claude-crap 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/adapters/common.d.ts +1 -1
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +1 -1
- package/dist/adapters/common.js.map +1 -1
- package/dist/adapters/dart-analyzer.d.ts +41 -0
- package/dist/adapters/dart-analyzer.d.ts.map +1 -0
- package/dist/adapters/dart-analyzer.js +120 -0
- package/dist/adapters/dart-analyzer.js.map +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +4 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/crap-config.d.ts +2 -0
- package/dist/crap-config.d.ts.map +1 -1
- package/dist/crap-config.js +36 -28
- package/dist/crap-config.js.map +1 -1
- package/dist/dashboard/file-detail.d.ts +77 -0
- package/dist/dashboard/file-detail.d.ts.map +1 -0
- package/dist/dashboard/file-detail.js +120 -0
- package/dist/dashboard/file-detail.js.map +1 -0
- package/dist/dashboard/server.d.ts +5 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +103 -1
- package/dist/dashboard/server.js.map +1 -1
- package/dist/index.js +36 -4
- package/dist/index.js.map +1 -1
- package/dist/metrics/workspace-walker.d.ts +4 -1
- package/dist/metrics/workspace-walker.d.ts.map +1 -1
- package/dist/metrics/workspace-walker.js +12 -28
- package/dist/metrics/workspace-walker.js.map +1 -1
- package/dist/scanner/auto-scan.d.ts +9 -1
- package/dist/scanner/auto-scan.d.ts.map +1 -1
- package/dist/scanner/auto-scan.js +27 -5
- package/dist/scanner/auto-scan.js.map +1 -1
- package/dist/scanner/bootstrap.d.ts +1 -1
- package/dist/scanner/bootstrap.d.ts.map +1 -1
- package/dist/scanner/bootstrap.js +9 -0
- package/dist/scanner/bootstrap.js.map +1 -1
- package/dist/scanner/complexity-scanner.d.ts +56 -0
- package/dist/scanner/complexity-scanner.d.ts.map +1 -0
- package/dist/scanner/complexity-scanner.js +161 -0
- package/dist/scanner/complexity-scanner.js.map +1 -0
- package/dist/scanner/detector.d.ts +24 -4
- package/dist/scanner/detector.d.ts.map +1 -1
- package/dist/scanner/detector.js +105 -10
- package/dist/scanner/detector.js.map +1 -1
- package/dist/scanner/runner.d.ts +4 -1
- package/dist/scanner/runner.d.ts.map +1 -1
- package/dist/scanner/runner.js +12 -3
- package/dist/scanner/runner.js.map +1 -1
- package/dist/schemas/tool-schemas.d.ts +1 -1
- package/dist/schemas/tool-schemas.js +1 -1
- package/dist/schemas/tool-schemas.js.map +1 -1
- package/dist/shared/exclusions.d.ts +53 -0
- package/dist/shared/exclusions.d.ts.map +1 -0
- package/dist/shared/exclusions.js +126 -0
- package/dist/shared/exclusions.js.map +1 -0
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bundle/dashboard/public/index.html +432 -12
- package/plugin/bundle/mcp-server.mjs +747 -137
- package/plugin/bundle/mcp-server.mjs.map +4 -4
- package/plugin/package-lock.json +15 -2
- package/plugin/package.json +2 -1
- package/scripts/bundle-plugin.mjs +2 -1
- package/src/adapters/common.ts +1 -1
- package/src/adapters/dart-analyzer.ts +161 -0
- package/src/adapters/index.ts +4 -0
- package/src/crap-config.ts +55 -18
- package/src/dashboard/file-detail.ts +195 -0
- package/src/dashboard/public/index.html +432 -12
- package/src/dashboard/server.ts +140 -1
- package/src/index.ts +37 -4
- package/src/metrics/workspace-walker.ts +15 -27
- package/src/scanner/auto-scan.ts +41 -4
- package/src/scanner/bootstrap.ts +11 -0
- package/src/scanner/complexity-scanner.ts +222 -0
- package/src/scanner/detector.ts +114 -10
- package/src/scanner/runner.ts +12 -2
- package/src/schemas/tool-schemas.ts +1 -1
- package/src/shared/exclusions.ts +156 -0
- package/src/tests/adapters/dispatch.test.ts +2 -2
- package/src/tests/auto-scan.test.ts +2 -2
- package/src/tests/complexity-scanner.test.ts +263 -0
- package/src/tests/exclusions.test.ts +117 -0
- package/src/tests/file-detail-api.test.ts +258 -0
- package/src/tests/scanner-detector.test.ts +31 -11
package/src/scanner/runner.ts
CHANGED
|
@@ -91,6 +91,13 @@ function getScannerCommand(
|
|
|
91
91
|
nonZeroIsNormal: false,
|
|
92
92
|
outputFile: join(workspaceRoot, "reports", "mutation", "mutation.json"),
|
|
93
93
|
};
|
|
94
|
+
case "dart_analyze":
|
|
95
|
+
return {
|
|
96
|
+
command: "dart",
|
|
97
|
+
args: ["analyze", "--format=json", "."],
|
|
98
|
+
timeoutMs: 120_000,
|
|
99
|
+
nonZeroIsNormal: true, // exits 3 when findings exist
|
|
100
|
+
};
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
103
|
|
|
@@ -101,21 +108,24 @@ function getScannerCommand(
|
|
|
101
108
|
*
|
|
102
109
|
* @param scanner Which scanner to run.
|
|
103
110
|
* @param workspaceRoot Absolute path to the project root (used as cwd).
|
|
111
|
+
* @param options Optional overrides.
|
|
104
112
|
* @returns A {@link ScannerRunResult} with stdout or file output.
|
|
105
113
|
*/
|
|
106
114
|
export function runScanner(
|
|
107
115
|
scanner: KnownScanner,
|
|
108
116
|
workspaceRoot: string,
|
|
117
|
+
options?: { workingDir?: string },
|
|
109
118
|
): Promise<ScannerRunResult> {
|
|
110
119
|
const start = Date.now();
|
|
111
|
-
const
|
|
120
|
+
const cwd = options?.workingDir ?? workspaceRoot;
|
|
121
|
+
const cmd = getScannerCommand(scanner, cwd);
|
|
112
122
|
|
|
113
123
|
return new Promise((resolve) => {
|
|
114
124
|
execFile(
|
|
115
125
|
cmd.command,
|
|
116
126
|
cmd.args,
|
|
117
127
|
{
|
|
118
|
-
cwd
|
|
128
|
+
cwd,
|
|
119
129
|
timeout: cmd.timeoutMs,
|
|
120
130
|
maxBuffer: 50 * 1024 * 1024, // 50 MB — large codebases produce verbose output
|
|
121
131
|
env: { ...process.env, FORCE_COLOR: "0" }, // suppress ANSI in output
|
|
@@ -181,7 +181,7 @@ export const ingestScannerOutputSchema = {
|
|
|
181
181
|
properties: {
|
|
182
182
|
scanner: {
|
|
183
183
|
type: "string",
|
|
184
|
-
enum: ["semgrep", "eslint", "bandit", "stryker"],
|
|
184
|
+
enum: ["semgrep", "eslint", "bandit", "stryker", "dart_analyze"],
|
|
185
185
|
description: "Identifier of the producing scanner.",
|
|
186
186
|
},
|
|
187
187
|
rawOutput: {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized file and directory exclusion system.
|
|
3
|
+
*
|
|
4
|
+
* Every filesystem walker in the codebase (workspace-walker,
|
|
5
|
+
* complexity-scanner, dashboard file-detail) imports from this module
|
|
6
|
+
* instead of maintaining its own `SKIP_DIRS` constant. This
|
|
7
|
+
* guarantees all subsystems agree on what to exclude.
|
|
8
|
+
*
|
|
9
|
+
* User-configurable exclusions from `.claude-crap.json` are layered
|
|
10
|
+
* on top of the defaults via {@link createExclusionFilter}.
|
|
11
|
+
*
|
|
12
|
+
* @module shared/exclusions
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import picomatch from "picomatch";
|
|
16
|
+
|
|
17
|
+
// ── Default exclusions ──────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Directories excluded by name at any depth. A walker that encounters
|
|
21
|
+
* a directory entry whose name is in this set should skip the entire
|
|
22
|
+
* subtree. The set covers package managers, VCS, build outputs for
|
|
23
|
+
* all major frameworks, language-specific caches, and plugin state.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_SKIP_DIRS: ReadonlySet<string> = new Set([
|
|
26
|
+
// Package managers / vendored deps
|
|
27
|
+
"node_modules",
|
|
28
|
+
"vendor",
|
|
29
|
+
|
|
30
|
+
// Version control
|
|
31
|
+
".git",
|
|
32
|
+
|
|
33
|
+
// Build outputs (general)
|
|
34
|
+
"dist",
|
|
35
|
+
"build",
|
|
36
|
+
"bundle",
|
|
37
|
+
"out",
|
|
38
|
+
"target",
|
|
39
|
+
"coverage",
|
|
40
|
+
|
|
41
|
+
// Framework build outputs
|
|
42
|
+
".next", // Next.js
|
|
43
|
+
".nuxt", // Nuxt 2
|
|
44
|
+
".output", // Nuxt 3
|
|
45
|
+
".vercel", // Vercel
|
|
46
|
+
".svelte-kit", // SvelteKit
|
|
47
|
+
".astro", // Astro
|
|
48
|
+
".angular", // Angular
|
|
49
|
+
".turbo", // Turborepo
|
|
50
|
+
".parcel-cache",// Parcel
|
|
51
|
+
".expo", // Expo / React Native
|
|
52
|
+
|
|
53
|
+
// Language-specific caches
|
|
54
|
+
".venv",
|
|
55
|
+
"venv",
|
|
56
|
+
"__pycache__",
|
|
57
|
+
".cache",
|
|
58
|
+
".dart_tool", // Dart / Flutter
|
|
59
|
+
".gradle", // Gradle
|
|
60
|
+
|
|
61
|
+
// IDE state
|
|
62
|
+
".idea",
|
|
63
|
+
|
|
64
|
+
// Plugin state
|
|
65
|
+
".claude-crap",
|
|
66
|
+
".codesight",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Filename-level glob patterns that match generated or minified files
|
|
71
|
+
* regardless of which directory they live in. Matched against the
|
|
72
|
+
* bare filename (not the full path).
|
|
73
|
+
*/
|
|
74
|
+
export const DEFAULT_SKIP_PATTERNS: ReadonlyArray<string> = [
|
|
75
|
+
"*.min.js",
|
|
76
|
+
"*.min.css",
|
|
77
|
+
"*.min.mjs",
|
|
78
|
+
"*.min.cjs",
|
|
79
|
+
"*.bundle.js",
|
|
80
|
+
"*.chunk.js",
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// ── Exclusion filter ────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Stateless, pre-compiled filter that every filesystem walker uses
|
|
87
|
+
* to decide whether to skip a directory or file.
|
|
88
|
+
*/
|
|
89
|
+
export interface ExclusionFilter {
|
|
90
|
+
/** Returns `true` when the directory should be skipped entirely. */
|
|
91
|
+
shouldSkipDir(dirName: string): boolean;
|
|
92
|
+
/** Returns `true` when the file should be excluded from analysis. */
|
|
93
|
+
shouldSkipFile(relativePath: string, fileName: string): boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create an {@link ExclusionFilter} that combines the built-in
|
|
98
|
+
* defaults with optional user-defined patterns from `.claude-crap.json`.
|
|
99
|
+
*
|
|
100
|
+
* User patterns follow `.gitignore`-style conventions:
|
|
101
|
+
* - `apps/legacy/` → trailing `/` means directory exclusion
|
|
102
|
+
* - `*.proto.ts` → glob matched against workspace-relative path
|
|
103
|
+
* - `src/generated/**` → path-prefix glob
|
|
104
|
+
*
|
|
105
|
+
* Picomatch matchers are compiled once at construction, so per-file
|
|
106
|
+
* checks are O(1) set lookups plus O(n) matcher calls where n is
|
|
107
|
+
* the small number of user patterns (typically < 20).
|
|
108
|
+
*
|
|
109
|
+
* @param userExclusions Optional patterns from `.claude-crap.json`.
|
|
110
|
+
*/
|
|
111
|
+
export function createExclusionFilter(
|
|
112
|
+
userExclusions?: ReadonlyArray<string>,
|
|
113
|
+
): ExclusionFilter {
|
|
114
|
+
// Split user patterns into directory exclusions and file globs
|
|
115
|
+
const extraDirs = new Set<string>();
|
|
116
|
+
const fileGlobs: string[] = [];
|
|
117
|
+
|
|
118
|
+
for (const pattern of userExclusions ?? []) {
|
|
119
|
+
if (pattern.endsWith("/")) {
|
|
120
|
+
// Directory exclusion — strip trailing slash
|
|
121
|
+
extraDirs.add(pattern.slice(0, -1));
|
|
122
|
+
} else {
|
|
123
|
+
fileGlobs.push(pattern);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Compile filename-level matchers once
|
|
128
|
+
const defaultFileMatchers = DEFAULT_SKIP_PATTERNS.map((p) =>
|
|
129
|
+
picomatch(p, { dot: true }),
|
|
130
|
+
);
|
|
131
|
+
const userFileMatchers = fileGlobs.map((p) =>
|
|
132
|
+
picomatch(p, { dot: true }),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
shouldSkipDir(dirName: string): boolean {
|
|
137
|
+
// Hidden directories are always skipped except .claude-plugin
|
|
138
|
+
if (dirName.startsWith(".") && dirName !== ".claude-plugin") {
|
|
139
|
+
return DEFAULT_SKIP_DIRS.has(dirName) || true;
|
|
140
|
+
}
|
|
141
|
+
return DEFAULT_SKIP_DIRS.has(dirName) || extraDirs.has(dirName);
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
shouldSkipFile(relativePath: string, fileName: string): boolean {
|
|
145
|
+
// Check filename against default minified/bundled patterns
|
|
146
|
+
for (const matcher of defaultFileMatchers) {
|
|
147
|
+
if (matcher(fileName)) return true;
|
|
148
|
+
}
|
|
149
|
+
// Check against user-defined globs (matched on relative path)
|
|
150
|
+
for (const matcher of userFileMatchers) {
|
|
151
|
+
if (matcher(relativePath) || matcher(fileName)) return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -94,7 +94,7 @@ describe("adaptScannerOutput", () => {
|
|
|
94
94
|
assert.throws(() => adaptScannerOutput(scanner, {}));
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
it("KNOWN_SCANNERS is frozen and contains
|
|
98
|
-
assert.deepEqual([...KNOWN_SCANNERS].sort(), ["bandit", "eslint", "semgrep", "stryker"]);
|
|
97
|
+
it("KNOWN_SCANNERS is frozen and contains all supported names", () => {
|
|
98
|
+
assert.deepEqual([...KNOWN_SCANNERS].sort(), ["bandit", "dart_analyze", "eslint", "semgrep", "stryker"]);
|
|
99
99
|
});
|
|
100
100
|
});
|
|
@@ -34,7 +34,7 @@ describe("autoScan", () => {
|
|
|
34
34
|
outputDir: join(dir, ".claude-crap/reports"),
|
|
35
35
|
});
|
|
36
36
|
const result = await autoScan(dir, store, logger);
|
|
37
|
-
assert.equal(result.detected.length,
|
|
37
|
+
assert.equal(result.detected.length, 5);
|
|
38
38
|
assert.ok(result.totalDurationMs >= 0);
|
|
39
39
|
// No scanners available means no results
|
|
40
40
|
// (unless the host has scanner binaries installed)
|
|
@@ -129,7 +129,7 @@ describe("autoScan", () => {
|
|
|
129
129
|
const result = await autoScan(dir, store, logger);
|
|
130
130
|
|
|
131
131
|
const scannerNames = result.detected.map((d) => d.scanner).sort();
|
|
132
|
-
assert.deepEqual(scannerNames, ["bandit", "eslint", "semgrep", "stryker"]);
|
|
132
|
+
assert.deepEqual(scannerNames, ["bandit", "dart_analyze", "eslint", "semgrep", "stryker"]);
|
|
133
133
|
} finally {
|
|
134
134
|
rmSync(dir, { recursive: true, force: true });
|
|
135
135
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the cyclomatic complexity scanner.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that the scanner walks a workspace, analyzes source
|
|
5
|
+
* files with tree-sitter, and emits SARIF findings for functions whose
|
|
6
|
+
* cyclomatic complexity exceeds the configured threshold.
|
|
7
|
+
*
|
|
8
|
+
* @module tests/complexity-scanner.test
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import pino from "pino";
|
|
17
|
+
|
|
18
|
+
import { scanComplexity } from "../scanner/complexity-scanner.js";
|
|
19
|
+
import { TreeSitterEngine } from "../ast/tree-sitter-engine.js";
|
|
20
|
+
import { SarifStore } from "../sarif/sarif-store.js";
|
|
21
|
+
|
|
22
|
+
const logger = pino({ level: "silent" });
|
|
23
|
+
|
|
24
|
+
function makeTmpDir(): string {
|
|
25
|
+
return mkdtempSync(join(tmpdir(), "crap-complexity-"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Simple function: CC = 1 (straight-line). */
|
|
29
|
+
const SIMPLE_TS = `
|
|
30
|
+
export function greet(name: string): string {
|
|
31
|
+
return "hello " + name;
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
/** Function with CC = 5: 4 if-branches + 1 baseline. */
|
|
36
|
+
const COMPLEX_TS = `
|
|
37
|
+
export function classify(x: number): string {
|
|
38
|
+
if (x < 0) return "negative";
|
|
39
|
+
if (x === 0) return "zero";
|
|
40
|
+
if (x < 10) return "small";
|
|
41
|
+
if (x < 100) return "medium";
|
|
42
|
+
return "large";
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extremely complex function: many branches to push CC well above 30.
|
|
48
|
+
* Each if/else-if adds +1, plus boolean operators.
|
|
49
|
+
*/
|
|
50
|
+
const EXTREME_TS = `
|
|
51
|
+
export function extremelyComplex(a: number, b: number, c: number): string {
|
|
52
|
+
if (a > 0) { return "a1"; }
|
|
53
|
+
if (a > 1) { return "a2"; }
|
|
54
|
+
if (a > 2) { return "a3"; }
|
|
55
|
+
if (a > 3) { return "a4"; }
|
|
56
|
+
if (a > 4) { return "a5"; }
|
|
57
|
+
if (a > 5) { return "a6"; }
|
|
58
|
+
if (a > 6) { return "a7"; }
|
|
59
|
+
if (a > 7) { return "a8"; }
|
|
60
|
+
if (a > 8) { return "a9"; }
|
|
61
|
+
if (a > 9) { return "a10"; }
|
|
62
|
+
if (b > 0) { return "b1"; }
|
|
63
|
+
if (b > 1) { return "b2"; }
|
|
64
|
+
if (b > 2) { return "b3"; }
|
|
65
|
+
if (b > 3) { return "b4"; }
|
|
66
|
+
if (b > 4) { return "b5"; }
|
|
67
|
+
if (b > 5) { return "b6"; }
|
|
68
|
+
if (b > 6) { return "b7"; }
|
|
69
|
+
if (b > 7) { return "b8"; }
|
|
70
|
+
if (b > 8) { return "b9"; }
|
|
71
|
+
if (b > 9) { return "b10"; }
|
|
72
|
+
if (c > 0) { return "c1"; }
|
|
73
|
+
if (c > 1) { return "c2"; }
|
|
74
|
+
if (c > 2) { return "c3"; }
|
|
75
|
+
if (c > 3) { return "c4"; }
|
|
76
|
+
if (c > 4) { return "c5"; }
|
|
77
|
+
if (c > 5) { return "c6"; }
|
|
78
|
+
if (c > 6) { return "c7"; }
|
|
79
|
+
if (c > 7) { return "c8"; }
|
|
80
|
+
if (c > 8) { return "c9"; }
|
|
81
|
+
if (c > 9) { return "c10"; }
|
|
82
|
+
return "default";
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
describe("scanComplexity", () => {
|
|
87
|
+
const engine = new TreeSitterEngine();
|
|
88
|
+
|
|
89
|
+
it("returns zero violations for an empty workspace", async () => {
|
|
90
|
+
const dir = makeTmpDir();
|
|
91
|
+
try {
|
|
92
|
+
const store = new SarifStore({
|
|
93
|
+
workspaceRoot: dir,
|
|
94
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
95
|
+
});
|
|
96
|
+
const result = await scanComplexity(
|
|
97
|
+
dir, engine, store, { cyclomaticMax: 15 }, logger,
|
|
98
|
+
);
|
|
99
|
+
assert.equal(result.violations, 0);
|
|
100
|
+
assert.equal(result.filesScanned, 0);
|
|
101
|
+
assert.equal(result.functionsAnalyzed, 0);
|
|
102
|
+
assert.ok(result.durationMs >= 0);
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("finds no violations when all functions are below threshold", async () => {
|
|
109
|
+
const dir = makeTmpDir();
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(join(dir, "simple.ts"), SIMPLE_TS);
|
|
112
|
+
const store = new SarifStore({
|
|
113
|
+
workspaceRoot: dir,
|
|
114
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
115
|
+
});
|
|
116
|
+
const result = await scanComplexity(
|
|
117
|
+
dir, engine, store, { cyclomaticMax: 15 }, logger,
|
|
118
|
+
);
|
|
119
|
+
assert.equal(result.violations, 0);
|
|
120
|
+
assert.equal(result.filesScanned, 1);
|
|
121
|
+
assert.ok(result.functionsAnalyzed >= 1);
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("flags functions above cyclomaticMax as warnings", async () => {
|
|
128
|
+
const dir = makeTmpDir();
|
|
129
|
+
try {
|
|
130
|
+
writeFileSync(join(dir, "complex.ts"), COMPLEX_TS);
|
|
131
|
+
const store = new SarifStore({
|
|
132
|
+
workspaceRoot: dir,
|
|
133
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
134
|
+
});
|
|
135
|
+
// Set threshold to 3 so the CC=5 function triggers
|
|
136
|
+
const result = await scanComplexity(
|
|
137
|
+
dir, engine, store, { cyclomaticMax: 3 }, logger,
|
|
138
|
+
);
|
|
139
|
+
assert.equal(result.violations, 1);
|
|
140
|
+
|
|
141
|
+
// Verify the finding is in the SARIF store
|
|
142
|
+
const findings = store.list();
|
|
143
|
+
const complexityFindings = findings.filter(
|
|
144
|
+
(f) => f.ruleId === "complexity/cyclomatic-max",
|
|
145
|
+
);
|
|
146
|
+
assert.equal(complexityFindings.length, 1);
|
|
147
|
+
assert.equal(complexityFindings[0]!.level, "warning");
|
|
148
|
+
assert.equal(complexityFindings[0]!.sourceTool, "complexity");
|
|
149
|
+
assert.ok(complexityFindings[0]!.message.includes("classify"));
|
|
150
|
+
} finally {
|
|
151
|
+
rmSync(dir, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("emits error level for functions at >= 2x threshold", async () => {
|
|
156
|
+
const dir = makeTmpDir();
|
|
157
|
+
try {
|
|
158
|
+
writeFileSync(join(dir, "extreme.ts"), EXTREME_TS);
|
|
159
|
+
const store = new SarifStore({
|
|
160
|
+
workspaceRoot: dir,
|
|
161
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
162
|
+
});
|
|
163
|
+
// threshold=15, CC=31 → 31 >= 30 (2x15) → error
|
|
164
|
+
const result = await scanComplexity(
|
|
165
|
+
dir, engine, store, { cyclomaticMax: 15 }, logger,
|
|
166
|
+
);
|
|
167
|
+
assert.equal(result.violations, 1);
|
|
168
|
+
|
|
169
|
+
const findings = store.list();
|
|
170
|
+
const complexityFindings = findings.filter(
|
|
171
|
+
(f) => f.ruleId === "complexity/cyclomatic-max",
|
|
172
|
+
);
|
|
173
|
+
assert.equal(complexityFindings.length, 1);
|
|
174
|
+
assert.equal(complexityFindings[0]!.level, "error");
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("skips directories in SKIP_DIRS", async () => {
|
|
181
|
+
const dir = makeTmpDir();
|
|
182
|
+
try {
|
|
183
|
+
// Put a complex file inside node_modules — should be skipped
|
|
184
|
+
const nmDir = join(dir, "node_modules", "pkg");
|
|
185
|
+
mkdirSync(nmDir, { recursive: true });
|
|
186
|
+
writeFileSync(join(nmDir, "index.ts"), COMPLEX_TS);
|
|
187
|
+
|
|
188
|
+
const store = new SarifStore({
|
|
189
|
+
workspaceRoot: dir,
|
|
190
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
191
|
+
});
|
|
192
|
+
const result = await scanComplexity(
|
|
193
|
+
dir, engine, store, { cyclomaticMax: 3 }, logger,
|
|
194
|
+
);
|
|
195
|
+
assert.equal(result.filesScanned, 0);
|
|
196
|
+
assert.equal(result.violations, 0);
|
|
197
|
+
} finally {
|
|
198
|
+
rmSync(dir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("skips unsupported file extensions", async () => {
|
|
203
|
+
const dir = makeTmpDir();
|
|
204
|
+
try {
|
|
205
|
+
writeFileSync(join(dir, "data.json"), '{"key": "value"}');
|
|
206
|
+
writeFileSync(join(dir, "readme.md"), "# Hello");
|
|
207
|
+
writeFileSync(join(dir, "styles.css"), "body {}");
|
|
208
|
+
|
|
209
|
+
const store = new SarifStore({
|
|
210
|
+
workspaceRoot: dir,
|
|
211
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
212
|
+
});
|
|
213
|
+
const result = await scanComplexity(
|
|
214
|
+
dir, engine, store, { cyclomaticMax: 15 }, logger,
|
|
215
|
+
);
|
|
216
|
+
assert.equal(result.filesScanned, 0);
|
|
217
|
+
} finally {
|
|
218
|
+
rmSync(dir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("respects custom cyclomaticMax threshold", async () => {
|
|
223
|
+
const dir = makeTmpDir();
|
|
224
|
+
try {
|
|
225
|
+
writeFileSync(join(dir, "complex.ts"), COMPLEX_TS);
|
|
226
|
+
const store = new SarifStore({
|
|
227
|
+
workspaceRoot: dir,
|
|
228
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
229
|
+
});
|
|
230
|
+
// CC=5, threshold=10 → no violation
|
|
231
|
+
const result = await scanComplexity(
|
|
232
|
+
dir, engine, store, { cyclomaticMax: 10 }, logger,
|
|
233
|
+
);
|
|
234
|
+
assert.equal(result.violations, 0);
|
|
235
|
+
} finally {
|
|
236
|
+
rmSync(dir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("sets effortMinutes and cyclomaticComplexity in finding properties", async () => {
|
|
241
|
+
const dir = makeTmpDir();
|
|
242
|
+
try {
|
|
243
|
+
writeFileSync(join(dir, "complex.ts"), COMPLEX_TS);
|
|
244
|
+
const store = new SarifStore({
|
|
245
|
+
workspaceRoot: dir,
|
|
246
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
247
|
+
});
|
|
248
|
+
const result = await scanComplexity(
|
|
249
|
+
dir, engine, store, { cyclomaticMax: 3 }, logger,
|
|
250
|
+
);
|
|
251
|
+
assert.equal(result.violations, 1);
|
|
252
|
+
|
|
253
|
+
const findings = store.list();
|
|
254
|
+
const finding = findings.find((f) => f.ruleId === "complexity/cyclomatic-max");
|
|
255
|
+
assert.ok(finding);
|
|
256
|
+
assert.equal(typeof finding.properties?.effortMinutes, "number");
|
|
257
|
+
assert.ok((finding.properties?.effortMinutes as number) > 0);
|
|
258
|
+
assert.equal(typeof finding.properties?.cyclomaticComplexity, "number");
|
|
259
|
+
} finally {
|
|
260
|
+
rmSync(dir, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SKIP_DIRS,
|
|
6
|
+
DEFAULT_SKIP_PATTERNS,
|
|
7
|
+
createExclusionFilter,
|
|
8
|
+
} from "../shared/exclusions.js";
|
|
9
|
+
|
|
10
|
+
describe("DEFAULT_SKIP_DIRS", () => {
|
|
11
|
+
it("includes core directories", () => {
|
|
12
|
+
for (const dir of ["node_modules", ".git", "dist", "build", "bundle", "out", "target", "coverage", "vendor"]) {
|
|
13
|
+
assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing: ${dir}`);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("includes framework build outputs", () => {
|
|
18
|
+
for (const dir of [".next", ".nuxt", ".output", ".vercel", ".svelte-kit", ".astro", ".angular", ".turbo", ".parcel-cache", ".expo"]) {
|
|
19
|
+
assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing: ${dir}`);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("includes language-specific caches", () => {
|
|
24
|
+
for (const dir of [".venv", "venv", "__pycache__", ".cache", ".dart_tool", ".gradle"]) {
|
|
25
|
+
assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing: ${dir}`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("includes plugin state dirs", () => {
|
|
30
|
+
for (const dir of [".claude-crap", ".codesight"]) {
|
|
31
|
+
assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing: ${dir}`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("DEFAULT_SKIP_PATTERNS", () => {
|
|
37
|
+
it("includes minified and bundled file patterns", () => {
|
|
38
|
+
const patterns = new Set(DEFAULT_SKIP_PATTERNS);
|
|
39
|
+
assert.ok(patterns.has("*.min.js"));
|
|
40
|
+
assert.ok(patterns.has("*.min.css"));
|
|
41
|
+
assert.ok(patterns.has("*.bundle.js"));
|
|
42
|
+
assert.ok(patterns.has("*.chunk.js"));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("createExclusionFilter", () => {
|
|
47
|
+
describe("shouldSkipDir", () => {
|
|
48
|
+
it("skips default directories", () => {
|
|
49
|
+
const filter = createExclusionFilter();
|
|
50
|
+
assert.equal(filter.shouldSkipDir("node_modules"), true);
|
|
51
|
+
assert.equal(filter.shouldSkipDir("dist"), true);
|
|
52
|
+
assert.equal(filter.shouldSkipDir("bundle"), true);
|
|
53
|
+
assert.equal(filter.shouldSkipDir(".next"), true);
|
|
54
|
+
assert.equal(filter.shouldSkipDir(".dart_tool"), true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("allows normal directories", () => {
|
|
58
|
+
const filter = createExclusionFilter();
|
|
59
|
+
assert.equal(filter.shouldSkipDir("src"), false);
|
|
60
|
+
assert.equal(filter.shouldSkipDir("lib"), false);
|
|
61
|
+
assert.equal(filter.shouldSkipDir("apps"), false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("skips hidden directories except .claude-plugin", () => {
|
|
65
|
+
const filter = createExclusionFilter();
|
|
66
|
+
assert.equal(filter.shouldSkipDir(".hidden"), true);
|
|
67
|
+
assert.equal(filter.shouldSkipDir(".secret"), true);
|
|
68
|
+
assert.equal(filter.shouldSkipDir(".claude-plugin"), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("respects user directory exclusions with trailing slash", () => {
|
|
72
|
+
const filter = createExclusionFilter(["legacy/", "generated/"]);
|
|
73
|
+
assert.equal(filter.shouldSkipDir("legacy"), true);
|
|
74
|
+
assert.equal(filter.shouldSkipDir("generated"), true);
|
|
75
|
+
assert.equal(filter.shouldSkipDir("src"), false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("shouldSkipFile", () => {
|
|
80
|
+
it("skips default minified patterns", () => {
|
|
81
|
+
const filter = createExclusionFilter();
|
|
82
|
+
assert.equal(filter.shouldSkipFile("lib/app.min.js", "app.min.js"), true);
|
|
83
|
+
assert.equal(filter.shouldSkipFile("styles/main.min.css", "main.min.css"), true);
|
|
84
|
+
assert.equal(filter.shouldSkipFile("lib/vendor.bundle.js", "vendor.bundle.js"), true);
|
|
85
|
+
assert.equal(filter.shouldSkipFile("lib/0.chunk.js", "0.chunk.js"), true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("allows normal source files", () => {
|
|
89
|
+
const filter = createExclusionFilter();
|
|
90
|
+
assert.equal(filter.shouldSkipFile("src/index.ts", "index.ts"), false);
|
|
91
|
+
assert.equal(filter.shouldSkipFile("lib/utils.js", "utils.js"), false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("applies user glob patterns to filenames", () => {
|
|
95
|
+
const filter = createExclusionFilter(["*.proto.ts"]);
|
|
96
|
+
assert.equal(filter.shouldSkipFile("src/api/service.proto.ts", "service.proto.ts"), true);
|
|
97
|
+
assert.equal(filter.shouldSkipFile("src/api/service.ts", "service.ts"), false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("applies user glob patterns to relative paths", () => {
|
|
101
|
+
const filter = createExclusionFilter(["src/generated/**"]);
|
|
102
|
+
assert.equal(filter.shouldSkipFile("src/generated/types.ts", "types.ts"), true);
|
|
103
|
+
assert.equal(filter.shouldSkipFile("src/real/types.ts", "types.ts"), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("works with empty user exclusions", () => {
|
|
107
|
+
const filter = createExclusionFilter([]);
|
|
108
|
+
assert.equal(filter.shouldSkipFile("src/index.ts", "index.ts"), false);
|
|
109
|
+
assert.equal(filter.shouldSkipFile("lib/app.min.js", "app.min.js"), true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("works with undefined user exclusions", () => {
|
|
113
|
+
const filter = createExclusionFilter();
|
|
114
|
+
assert.equal(filter.shouldSkipFile("src/index.ts", "index.ts"), false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|