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.
Files changed (88) hide show
  1. package/README.md +25 -0
  2. package/dist/adapters/common.d.ts +1 -1
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +1 -1
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/adapters/dart-analyzer.d.ts +41 -0
  7. package/dist/adapters/dart-analyzer.d.ts.map +1 -0
  8. package/dist/adapters/dart-analyzer.js +120 -0
  9. package/dist/adapters/dart-analyzer.js.map +1 -0
  10. package/dist/adapters/index.d.ts +1 -0
  11. package/dist/adapters/index.d.ts.map +1 -1
  12. package/dist/adapters/index.js +4 -0
  13. package/dist/adapters/index.js.map +1 -1
  14. package/dist/crap-config.d.ts +2 -0
  15. package/dist/crap-config.d.ts.map +1 -1
  16. package/dist/crap-config.js +36 -28
  17. package/dist/crap-config.js.map +1 -1
  18. package/dist/dashboard/file-detail.d.ts +77 -0
  19. package/dist/dashboard/file-detail.d.ts.map +1 -0
  20. package/dist/dashboard/file-detail.js +120 -0
  21. package/dist/dashboard/file-detail.js.map +1 -0
  22. package/dist/dashboard/server.d.ts +5 -0
  23. package/dist/dashboard/server.d.ts.map +1 -1
  24. package/dist/dashboard/server.js +103 -1
  25. package/dist/dashboard/server.js.map +1 -1
  26. package/dist/index.js +36 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/metrics/workspace-walker.d.ts +4 -1
  29. package/dist/metrics/workspace-walker.d.ts.map +1 -1
  30. package/dist/metrics/workspace-walker.js +12 -28
  31. package/dist/metrics/workspace-walker.js.map +1 -1
  32. package/dist/scanner/auto-scan.d.ts +9 -1
  33. package/dist/scanner/auto-scan.d.ts.map +1 -1
  34. package/dist/scanner/auto-scan.js +27 -5
  35. package/dist/scanner/auto-scan.js.map +1 -1
  36. package/dist/scanner/bootstrap.d.ts +1 -1
  37. package/dist/scanner/bootstrap.d.ts.map +1 -1
  38. package/dist/scanner/bootstrap.js +9 -0
  39. package/dist/scanner/bootstrap.js.map +1 -1
  40. package/dist/scanner/complexity-scanner.d.ts +56 -0
  41. package/dist/scanner/complexity-scanner.d.ts.map +1 -0
  42. package/dist/scanner/complexity-scanner.js +161 -0
  43. package/dist/scanner/complexity-scanner.js.map +1 -0
  44. package/dist/scanner/detector.d.ts +24 -4
  45. package/dist/scanner/detector.d.ts.map +1 -1
  46. package/dist/scanner/detector.js +105 -10
  47. package/dist/scanner/detector.js.map +1 -1
  48. package/dist/scanner/runner.d.ts +4 -1
  49. package/dist/scanner/runner.d.ts.map +1 -1
  50. package/dist/scanner/runner.js +12 -3
  51. package/dist/scanner/runner.js.map +1 -1
  52. package/dist/schemas/tool-schemas.d.ts +1 -1
  53. package/dist/schemas/tool-schemas.js +1 -1
  54. package/dist/schemas/tool-schemas.js.map +1 -1
  55. package/dist/shared/exclusions.d.ts +53 -0
  56. package/dist/shared/exclusions.d.ts.map +1 -0
  57. package/dist/shared/exclusions.js +126 -0
  58. package/dist/shared/exclusions.js.map +1 -0
  59. package/package.json +3 -1
  60. package/plugin/.claude-plugin/plugin.json +1 -1
  61. package/plugin/bundle/dashboard/public/index.html +432 -12
  62. package/plugin/bundle/mcp-server.mjs +747 -137
  63. package/plugin/bundle/mcp-server.mjs.map +4 -4
  64. package/plugin/package-lock.json +15 -2
  65. package/plugin/package.json +2 -1
  66. package/scripts/bundle-plugin.mjs +2 -1
  67. package/src/adapters/common.ts +1 -1
  68. package/src/adapters/dart-analyzer.ts +161 -0
  69. package/src/adapters/index.ts +4 -0
  70. package/src/crap-config.ts +55 -18
  71. package/src/dashboard/file-detail.ts +195 -0
  72. package/src/dashboard/public/index.html +432 -12
  73. package/src/dashboard/server.ts +140 -1
  74. package/src/index.ts +37 -4
  75. package/src/metrics/workspace-walker.ts +15 -27
  76. package/src/scanner/auto-scan.ts +41 -4
  77. package/src/scanner/bootstrap.ts +11 -0
  78. package/src/scanner/complexity-scanner.ts +222 -0
  79. package/src/scanner/detector.ts +114 -10
  80. package/src/scanner/runner.ts +12 -2
  81. package/src/schemas/tool-schemas.ts +1 -1
  82. package/src/shared/exclusions.ts +156 -0
  83. package/src/tests/adapters/dispatch.test.ts +2 -2
  84. package/src/tests/auto-scan.test.ts +2 -2
  85. package/src/tests/complexity-scanner.test.ts +263 -0
  86. package/src/tests/exclusions.test.ts +117 -0
  87. package/src/tests/file-detail-api.test.ts +258 -0
  88. package/src/tests/scanner-detector.test.ts +31 -11
@@ -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 cmd = getScannerCommand(scanner, workspaceRoot);
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: workspaceRoot,
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 exactly the four supported names", () => {
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, 4);
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
+ });