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
package/src/index.ts CHANGED
@@ -93,6 +93,18 @@ async function main(): Promise<void> {
93
93
  "claude-crap MCP server starting",
94
94
  );
95
95
 
96
+ // Load user-defined exclusions from .claude-crap.json (non-fatal).
97
+ let userExclusions: ReadonlyArray<string> = [];
98
+ try {
99
+ const crapConfig = loadCrapConfig({ workspaceRoot: config.pluginRoot });
100
+ userExclusions = crapConfig.exclude;
101
+ if (userExclusions.length > 0) {
102
+ logger.info({ exclude: userExclusions }, "user exclusions loaded from .claude-crap.json");
103
+ }
104
+ } catch {
105
+ // Non-fatal — use empty exclusions.
106
+ }
107
+
96
108
  // Long-lived engines. Created once at boot and reused for every call.
97
109
  const astEngine = new TreeSitterEngine();
98
110
  const sarifStore = new SarifStore({
@@ -112,8 +124,10 @@ async function main(): Promise<void> {
112
124
  dashboard = await startDashboard({
113
125
  config,
114
126
  sarifStore,
115
- workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot),
127
+ workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot, { exclude: userExclusions }),
116
128
  logger,
129
+ astEngine,
130
+ exclude: userExclusions,
117
131
  });
118
132
  } catch (err) {
119
133
  logger.warn(
@@ -332,7 +346,7 @@ async function main(): Promise<void> {
332
346
  const typed = (args ?? {}) as { format?: "markdown" | "json" | "both" };
333
347
  const format = typed.format ?? "both";
334
348
  try {
335
- const workspace = await estimateWorkspaceLoc(config.pluginRoot);
349
+ const workspace = await estimateWorkspaceLoc(config.pluginRoot, { exclude: userExclusions });
336
350
  const score: ProjectScore = computeProjectScore({
337
351
  workspaceRoot: config.pluginRoot,
338
352
  minutesPerLoc: config.minutesPerLoc,
@@ -581,7 +595,11 @@ async function main(): Promise<void> {
581
595
  case "auto_scan": {
582
596
  logger.info({ tool: "auto_scan" }, "Tool call received");
583
597
  try {
584
- const result = await autoScan(config.pluginRoot, sarifStore, logger);
598
+ const result = await autoScan(config.pluginRoot, sarifStore, logger, {
599
+ engine: astEngine,
600
+ cyclomaticMax: config.cyclomaticMax,
601
+ exclude: userExclusions,
602
+ });
585
603
  const markdown = renderAutoScanMarkdown(result);
586
604
  return {
587
605
  content: [
@@ -669,7 +687,11 @@ async function main(): Promise<void> {
669
687
  // If the agent calls score_project before scanning finishes, it gets
670
688
  // whatever is in the SARIF store so far. The next call after completion
671
689
  // reflects all findings.
672
- autoScan(config.pluginRoot, sarifStore, logger)
690
+ autoScan(config.pluginRoot, sarifStore, logger, {
691
+ engine: astEngine,
692
+ cyclomaticMax: config.cyclomaticMax,
693
+ exclude: userExclusions,
694
+ })
673
695
  .then((result) => {
674
696
  const scanners = result.results
675
697
  .filter((r) => r.success)
@@ -759,6 +781,17 @@ function renderAutoScanMarkdown(result: import("./scanner/auto-scan.js").AutoSca
759
781
  lines.push("");
760
782
  }
761
783
 
784
+ // Complexity scan
785
+ if (result.complexityScan) {
786
+ const cs = result.complexityScan;
787
+ lines.push("### Cyclomatic complexity scan\n");
788
+ lines.push(`- Files scanned: **${cs.filesScanned}**`);
789
+ lines.push(`- Functions analyzed: **${cs.functionsAnalyzed}**`);
790
+ lines.push(`- Violations: **${cs.violations}**`);
791
+ lines.push(`- Duration: ${(cs.durationMs / 1000).toFixed(1)}s`);
792
+ lines.push("");
793
+ }
794
+
762
795
  // Summary
763
796
  lines.push(
764
797
  `**Total findings ingested:** ${result.totalFindings} in ${(result.totalDurationMs / 1000).toFixed(1)}s`,
@@ -15,7 +15,9 @@
15
15
  */
16
16
 
17
17
  import { promises as fs } from "node:fs";
18
- import { join } from "node:path";
18
+ import { join, relative } from "node:path";
19
+
20
+ import { createExclusionFilter } from "../shared/exclusions.js";
19
21
 
20
22
  /**
21
23
  * Result returned by {@link estimateWorkspaceLoc}.
@@ -29,26 +31,9 @@ export interface WorkspaceWalkResult {
29
31
  readonly truncated: boolean;
30
32
  }
31
33
 
32
- /**
33
- * Directories that should never contribute to the LOC count. Dependency
34
- * caches, build artifacts, VCS metadata, claude-crap's own state.
35
- */
36
- const SKIP_DIRS: ReadonlySet<string> = new Set([
37
- "node_modules",
38
- ".git",
39
- "dist",
40
- "build",
41
- "out",
42
- "target",
43
- ".venv",
44
- "venv",
45
- "__pycache__",
46
- ".cache",
47
- ".next",
48
- ".nuxt",
49
- ".claude-crap",
50
- ".codesight",
51
- ]);
34
+ // Directory exclusions are now centralized in src/shared/exclusions.ts.
35
+ // The createExclusionFilter() factory is called once per walk with
36
+ // optional user-defined patterns from .claude-crap.json.
52
37
 
53
38
  /**
54
39
  * Extensions the walker treats as "code". Anything else is ignored,
@@ -91,9 +76,14 @@ export const MAX_FILES_WALKED = 20_000;
91
76
  * (which is tiny and contains the manifest).
92
77
  *
93
78
  * @param workspaceRoot Absolute path to the workspace root.
79
+ * @param options Optional settings including user-defined exclusion patterns.
94
80
  * @returns A {@link WorkspaceWalkResult} snapshot.
95
81
  */
96
- export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<WorkspaceWalkResult> {
82
+ export async function estimateWorkspaceLoc(
83
+ workspaceRoot: string,
84
+ options?: { exclude?: ReadonlyArray<string> },
85
+ ): Promise<WorkspaceWalkResult> {
86
+ const filter = createExclusionFilter(options?.exclude);
97
87
  let physicalLoc = 0;
98
88
  let fileCount = 0;
99
89
  let truncated = false;
@@ -108,11 +98,9 @@ export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<Works
108
98
  }
109
99
  for (const entry of entries) {
110
100
  if (truncated) return;
111
- // Skip hidden files except the plugin manifest dir.
112
- if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
113
101
  const full = join(dir, entry.name);
114
102
  if (entry.isDirectory()) {
115
- if (SKIP_DIRS.has(entry.name)) continue;
103
+ if (filter.shouldSkipDir(entry.name)) continue;
116
104
  await walk(full);
117
105
  continue;
118
106
  }
@@ -122,6 +110,8 @@ export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<Works
122
110
  if (dot < 0) continue;
123
111
  const ext = lower.substring(dot);
124
112
  if (!CODE_EXTENSIONS.has(ext)) continue;
113
+ const relPath = relative(workspaceRoot, full);
114
+ if (filter.shouldSkipFile(relPath, entry.name)) continue;
125
115
  fileCount += 1;
126
116
  if (fileCount > MAX_FILES_WALKED) {
127
117
  truncated = true;
@@ -130,8 +120,6 @@ export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<Works
130
120
  try {
131
121
  const content = await fs.readFile(full, "utf8");
132
122
  if (content.length > 0) {
133
- // Subtract 1 for trailing newline, matching what most editors
134
- // report as the file's line count.
135
123
  const lines = content.split(/\r?\n/).length;
136
124
  physicalLoc += content.endsWith("\n") ? lines - 1 : lines;
137
125
  }
@@ -22,10 +22,12 @@
22
22
  import { existsSync } from "node:fs";
23
23
  import { join } from "node:path";
24
24
  import type { Logger } from "pino";
25
- import { detectScanners, type ScannerDetection } from "./detector.js";
25
+ import { detectScanners, detectMonorepoScanners, type ScannerDetection } from "./detector.js";
26
26
  import { runScanner, type ScannerRunResult } from "./runner.js";
27
27
  import { bootstrapScanner } from "./bootstrap.js";
28
+ import { scanComplexity, type ComplexityScanResult } from "./complexity-scanner.js";
28
29
  import { adaptScannerOutput, type KnownScanner } from "../adapters/index.js";
30
+ import type { TreeSitterEngine } from "../ast/tree-sitter-engine.js";
29
31
  import type { SarifStore } from "../sarif/sarif-store.js";
30
32
 
31
33
  // ── Types ──────────────────────────────────────────────────────────
@@ -53,6 +55,8 @@ export interface AutoScanResult {
53
55
  totalFindings: number;
54
56
  /** Wall-clock time for the entire auto-scan. */
55
57
  totalDurationMs: number;
58
+ /** Result of the built-in cyclomatic complexity scan, when enabled. */
59
+ complexityScan?: ComplexityScanResult;
56
60
  }
57
61
 
58
62
  // ── Orchestrator ───────────────────────────────────────────────────
@@ -93,16 +97,28 @@ export async function autoScan(
93
97
  workspaceRoot: string,
94
98
  sarifStore: SarifStore,
95
99
  logger: Logger,
100
+ options?: { engine?: TreeSitterEngine; cyclomaticMax?: number; exclude?: ReadonlyArray<string> },
96
101
  ): Promise<AutoScanResult> {
97
102
  const start = Date.now();
98
103
 
99
- // 1. Detect available scanners
104
+ // 1. Detect available scanners (root + monorepo subdirs)
100
105
  const detected = await detectScanners(workspaceRoot);
106
+ const monorepoDetected = await detectMonorepoScanners(workspaceRoot);
107
+
108
+ // Merge monorepo detections — skip duplicates (same scanner already found at root)
109
+ const rootScannerSet = new Set(detected.filter((d) => d.available).map((d) => d.scanner));
110
+ for (const md of monorepoDetected) {
111
+ if (!rootScannerSet.has(md.scanner)) {
112
+ detected.push(md);
113
+ }
114
+ }
115
+
101
116
  const available = detected.filter((d) => d.available);
102
117
 
103
118
  logger.info(
104
119
  {
105
120
  detected: detected.map((d) => `${d.scanner}:${d.available}`),
121
+ monorepo: monorepoDetected.length,
106
122
  available: available.length,
107
123
  },
108
124
  "auto-scan: detection complete",
@@ -157,9 +173,9 @@ export async function autoScan(
157
173
  };
158
174
  }
159
175
 
160
- // 2. Run all available scanners in parallel
176
+ // 2. Run all available scanners in parallel (each from its detected workingDir)
161
177
  const runResults = await Promise.allSettled(
162
- available.map((d) => runScanner(d.scanner, workspaceRoot)),
178
+ available.map((d) => runScanner(d.scanner, workspaceRoot, d.workingDir ? { workingDir: d.workingDir } : undefined)),
163
179
  );
164
180
 
165
181
  // 3. Ingest results
@@ -246,10 +262,31 @@ export async function autoScan(
246
262
  await sarifStore.persist();
247
263
  }
248
264
 
265
+ // 5. Run built-in cyclomatic complexity scanner
266
+ let complexityScan: ComplexityScanResult | undefined;
267
+ if (options?.engine) {
268
+ try {
269
+ complexityScan = await scanComplexity(
270
+ workspaceRoot,
271
+ options.engine,
272
+ sarifStore,
273
+ { cyclomaticMax: options.cyclomaticMax ?? 15, ...(options.exclude ? { exclude: options.exclude } : {}) },
274
+ logger,
275
+ );
276
+ totalFindings += complexityScan.violations;
277
+ } catch (err) {
278
+ logger.warn(
279
+ { err: (err as Error).message },
280
+ "auto-scan: complexity scanner failed — continuing without it",
281
+ );
282
+ }
283
+ }
284
+
249
285
  return {
250
286
  detected,
251
287
  results,
252
288
  totalFindings,
253
289
  totalDurationMs: Date.now() - start,
290
+ ...(complexityScan ? { complexityScan } : {}),
254
291
  };
255
292
  }
@@ -44,6 +44,7 @@ export type ProjectType =
44
44
  | "python"
45
45
  | "java"
46
46
  | "csharp"
47
+ | "dart"
47
48
  | "unknown";
48
49
 
49
50
  /**
@@ -117,6 +118,9 @@ export function detectProjectType(workspaceRoot: string): ProjectType {
117
118
  // readdirSync can fail on permissions — fall through
118
119
  }
119
120
 
121
+ // Dart / Flutter detection
122
+ if (has("pubspec.yaml")) return "dart";
123
+
120
124
  return "unknown";
121
125
  }
122
126
 
@@ -273,6 +277,13 @@ function getRecommendation(projectType: ProjectType): ScannerRecommendation {
273
277
  installInstructions:
274
278
  "brew install semgrep (or: pip install semgrep, pipx install semgrep)",
275
279
  };
280
+ case "dart":
281
+ return {
282
+ scanner: "dart_analyze",
283
+ canAutoInstall: false,
284
+ installInstructions:
285
+ "Install the Dart SDK: https://dart.dev/get-dart (or Flutter SDK which includes Dart)",
286
+ };
276
287
  case "unknown":
277
288
  return {
278
289
  scanner: "semgrep",
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Cyclomatic complexity scanner.
3
+ *
4
+ * Walks the workspace, analyzes each supported source file with
5
+ * tree-sitter, and emits SARIF findings for functions whose cyclomatic
6
+ * complexity exceeds the configured threshold (`cyclomaticMax`).
7
+ *
8
+ * This scanner is an internal analyzer — it is NOT a "known scanner"
9
+ * in the `KnownScanner` union (eslint/semgrep/bandit/stryker). It
10
+ * bypasses the adapter pipeline and writes SARIF directly via
11
+ * `wrapResultsInSarif()` from the common adapter helpers.
12
+ *
13
+ * Severity mapping:
14
+ * - `warning` — CC > threshold but < 2× threshold
15
+ * - `error` — CC >= 2× threshold (aligns with CRAP index hard block at CC >= 30)
16
+ *
17
+ * @module scanner/complexity-scanner
18
+ */
19
+
20
+ import { promises as fs } from "node:fs";
21
+ import { join, relative } from "node:path";
22
+ import type { Logger } from "pino";
23
+
24
+ import { TreeSitterEngine } from "../ast/tree-sitter-engine.js";
25
+ import { detectLanguageFromPath } from "../ast/language-config.js";
26
+ import { wrapResultsInSarif, estimateEffortMinutes } from "../adapters/common.js";
27
+ import { createExclusionFilter } from "../shared/exclusions.js";
28
+ import type { SarifStore } from "../sarif/sarif-store.js";
29
+ import type { SarifLevel } from "../sarif/sarif-builder.js";
30
+
31
+ // Directory exclusions are now centralized in src/shared/exclusions.ts.
32
+
33
+ /** Hard cap on files to prevent unbounded analysis. */
34
+ const MAX_FILES = 20_000;
35
+
36
+ /** SARIF rule ID for cyclomatic complexity violations. */
37
+ const RULE_ID = "complexity/cyclomatic-max";
38
+
39
+ /** Source tool identifier used in SARIF properties. */
40
+ const SOURCE_TOOL = "complexity";
41
+
42
+ // ── Types ─────────────────────────────────────────────────────────
43
+
44
+ /** Result of a complexity scan run. */
45
+ export interface ComplexityScanResult {
46
+ /** Number of source files successfully analyzed. */
47
+ readonly filesScanned: number;
48
+ /** Total number of functions found across all files. */
49
+ readonly functionsAnalyzed: number;
50
+ /** Number of functions that exceeded the threshold. */
51
+ readonly violations: number;
52
+ /** Wall-clock time for the entire scan. */
53
+ readonly durationMs: number;
54
+ }
55
+
56
+ /** Configuration accepted by the scanner. */
57
+ export interface ComplexityScanConfig {
58
+ /** Maximum cyclomatic complexity allowed per function. */
59
+ readonly cyclomaticMax: number;
60
+ /** User-defined exclusion patterns from .claude-crap.json. */
61
+ readonly exclude?: ReadonlyArray<string>;
62
+ }
63
+
64
+ // ── Scanner ───────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Scan a workspace for cyclomatic complexity violations.
68
+ *
69
+ * Walks the directory tree, analyzes each source file with the
70
+ * tree-sitter engine, and emits SARIF findings for functions above
71
+ * the configured threshold. Findings are ingested into the provided
72
+ * `SarifStore` and persisted to disk.
73
+ *
74
+ * @param workspaceRoot Absolute path to the workspace root.
75
+ * @param engine Initialized tree-sitter engine instance.
76
+ * @param sarifStore Live SARIF store to ingest findings into.
77
+ * @param config Scanner configuration (threshold).
78
+ * @param logger Pino logger for progress and error reporting.
79
+ * @returns Summary of what was scanned and found.
80
+ */
81
+ export async function scanComplexity(
82
+ workspaceRoot: string,
83
+ engine: TreeSitterEngine,
84
+ sarifStore: SarifStore,
85
+ config: ComplexityScanConfig,
86
+ logger: Logger,
87
+ ): Promise<ComplexityScanResult> {
88
+ const start = Date.now();
89
+ const threshold = config.cyclomaticMax;
90
+ const errorThreshold = threshold * 2;
91
+
92
+ // 1. Collect supported source files
93
+ const filter = createExclusionFilter(config.exclude);
94
+ const files = await collectSourceFiles(workspaceRoot, filter);
95
+ logger.info(
96
+ { fileCount: files.length, threshold },
97
+ "complexity-scanner: starting analysis",
98
+ );
99
+
100
+ // 2. Analyze each file and collect violations
101
+ const sarifResults: object[] = [];
102
+ let filesScanned = 0;
103
+ let functionsAnalyzed = 0;
104
+ let violations = 0;
105
+
106
+ for (const filePath of files) {
107
+ const language = detectLanguageFromPath(filePath);
108
+ if (!language) continue;
109
+
110
+ try {
111
+ const metrics = await engine.analyzeFile({ filePath, language });
112
+ filesScanned += 1;
113
+ functionsAnalyzed += metrics.functions.length;
114
+
115
+ for (const fn of metrics.functions) {
116
+ if (fn.cyclomaticComplexity <= threshold) continue;
117
+
118
+ const level: SarifLevel =
119
+ fn.cyclomaticComplexity >= errorThreshold ? "error" : "warning";
120
+
121
+ const relPath = relative(workspaceRoot, filePath);
122
+
123
+ sarifResults.push({
124
+ ruleId: RULE_ID,
125
+ level,
126
+ message: {
127
+ text: `Function '${fn.name}' has cyclomatic complexity ${fn.cyclomaticComplexity} (threshold: ${threshold})`,
128
+ },
129
+ locations: [
130
+ {
131
+ physicalLocation: {
132
+ artifactLocation: { uri: relPath },
133
+ region: {
134
+ startLine: fn.startLine,
135
+ startColumn: 1,
136
+ endLine: fn.endLine,
137
+ endColumn: 1,
138
+ },
139
+ },
140
+ },
141
+ ],
142
+ properties: {
143
+ sourceTool: SOURCE_TOOL,
144
+ effortMinutes: estimateEffortMinutes(level),
145
+ cyclomaticComplexity: fn.cyclomaticComplexity,
146
+ },
147
+ });
148
+ violations += 1;
149
+ }
150
+ } catch (err) {
151
+ logger.warn(
152
+ { filePath, err: (err as Error).message },
153
+ "complexity-scanner: failed to analyze file, skipping",
154
+ );
155
+ }
156
+ }
157
+
158
+ // 3. Ingest findings into the SARIF store
159
+ if (sarifResults.length > 0) {
160
+ const document = wrapResultsInSarif(
161
+ SOURCE_TOOL as never,
162
+ "0.1.0",
163
+ sarifResults,
164
+ );
165
+ sarifStore.ingestRun(document, SOURCE_TOOL);
166
+ await sarifStore.persist();
167
+ }
168
+
169
+ const durationMs = Date.now() - start;
170
+ logger.info(
171
+ { filesScanned, functionsAnalyzed, violations, durationMs },
172
+ "complexity-scanner: analysis complete",
173
+ );
174
+
175
+ return { filesScanned, functionsAnalyzed, violations, durationMs };
176
+ }
177
+
178
+ // ── File walker ───────────────────────────────────────────────────
179
+
180
+ /**
181
+ * Collect source files from the workspace that the tree-sitter engine
182
+ * can analyze. Uses the shared exclusion filter for directory and file
183
+ * filtering. Only returns files whose extension maps to a supported language.
184
+ */
185
+ async function collectSourceFiles(
186
+ workspaceRoot: string,
187
+ filter: import("../shared/exclusions.js").ExclusionFilter,
188
+ ): Promise<string[]> {
189
+ const files: string[] = [];
190
+ let truncated = false;
191
+
192
+ async function walk(dir: string): Promise<void> {
193
+ if (truncated) return;
194
+ let entries;
195
+ try {
196
+ entries = await fs.readdir(dir, { withFileTypes: true });
197
+ } catch {
198
+ return;
199
+ }
200
+ for (const entry of entries) {
201
+ if (truncated) return;
202
+ const full = join(dir, entry.name);
203
+ if (entry.isDirectory()) {
204
+ if (filter.shouldSkipDir(entry.name)) continue;
205
+ await walk(full);
206
+ continue;
207
+ }
208
+ if (!entry.isFile()) continue;
209
+ if (!detectLanguageFromPath(entry.name)) continue;
210
+ const relPath = relative(workspaceRoot, full);
211
+ if (filter.shouldSkipFile(relPath, entry.name)) continue;
212
+ files.push(full);
213
+ if (files.length >= MAX_FILES) {
214
+ truncated = true;
215
+ return;
216
+ }
217
+ }
218
+ }
219
+
220
+ await walk(workspaceRoot);
221
+ return files;
222
+ }
@@ -17,8 +17,8 @@
17
17
  * @module scanner/detector
18
18
  */
19
19
 
20
- import { existsSync, readFileSync } from "node:fs";
21
- import { join } from "node:path";
20
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
21
+ import { join, resolve } from "node:path";
22
22
  import { execFile } from "node:child_process";
23
23
  import type { KnownScanner } from "../adapters/common.js";
24
24
 
@@ -36,6 +36,8 @@ export interface ScannerDetection {
36
36
  reason: string;
37
37
  /** Path to the config file that triggered detection, if any. */
38
38
  configPath?: string;
39
+ /** Working directory to run the scanner from (defaults to workspace root). */
40
+ workingDir?: string;
39
41
  }
40
42
 
41
43
  // ── Detection signals ──────────────────────────────────────────────
@@ -98,6 +100,14 @@ const SCANNER_SIGNALS: Record<KnownScanner, ScannerSignals> = {
98
100
  packageJsonKeys: ["@stryker-mutator/core"],
99
101
  binaryNames: ["stryker"],
100
102
  },
103
+ dart_analyze: {
104
+ configFiles: [
105
+ "analysis_options.yaml",
106
+ "pubspec.yaml",
107
+ ],
108
+ packageJsonKeys: [],
109
+ binaryNames: ["dart"],
110
+ },
101
111
  };
102
112
 
103
113
  // ── Probes ──────────────────────────────────────────────────────────
@@ -163,9 +173,9 @@ function probeBinary(binaryName: string): Promise<boolean> {
163
173
  // ── Public API ──────────────────────────────────────────────────────
164
174
 
165
175
  /**
166
- * Detect which of the four supported scanners are available in the
167
- * given workspace. Probes config files, package.json, and binary
168
- * availability in order, short-circuiting on first match.
176
+ * Detect which supported scanners are available in the given workspace.
177
+ * Probes config files, package.json, and binary availability in order,
178
+ * short-circuiting on first match.
169
179
  *
170
180
  * @param workspaceRoot Absolute path to the project root.
171
181
  * @returns One {@link ScannerDetection} per known scanner.
@@ -173,7 +183,7 @@ function probeBinary(binaryName: string): Promise<boolean> {
173
183
  export async function detectScanners(
174
184
  workspaceRoot: string,
175
185
  ): Promise<ScannerDetection[]> {
176
- const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker"];
186
+ const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
177
187
 
178
188
  const results = await Promise.all(
179
189
  scanners.map(async (scanner): Promise<ScannerDetection> => {
@@ -188,12 +198,18 @@ export async function detectScanners(
188
198
  };
189
199
  }
190
200
 
191
- // 2. Package.json probe
201
+ // 2. Package.json probe — declared in deps/devDeps, but is it
202
+ // actually installed? Check node_modules/.bin/ for the binary.
192
203
  if (probePackageJson(workspaceRoot, scanner)) {
204
+ const binName = SCANNER_SIGNALS[scanner].binaryNames[0];
205
+ const binPath = binName ? join(workspaceRoot, "node_modules", ".bin", binName) : null;
206
+ const installed = binPath !== null && existsSync(binPath);
193
207
  return {
194
208
  scanner,
195
- available: true,
196
- reason: `found in package.json dependencies`,
209
+ available: installed,
210
+ reason: installed
211
+ ? "found in package.json and installed"
212
+ : `found in package.json but not installed (run \`npm install\`)`,
197
213
  };
198
214
  }
199
215
 
@@ -220,5 +236,93 @@ export async function detectScanners(
220
236
  return results;
221
237
  }
222
238
 
239
+ // ── Monorepo subdirectory probing ────────────────────────────────
240
+
241
+ /**
242
+ * Common monorepo directory names that may contain workspace
243
+ * subdirectories. Checked one level deep only.
244
+ */
245
+ const MONOREPO_DIRS = ["apps", "packages", "libs", "modules", "services"];
246
+
247
+ /**
248
+ * Detect scanners in monorepo subdirectories. Probes first-level
249
+ * children of common monorepo directories (apps/, packages/, etc.)
250
+ * and npm workspaces for scanner config files. Returns detections
251
+ * with a `workingDir` pointing to the subdirectory.
252
+ *
253
+ * This catches e.g. `apps/mobile/pubspec.yaml` in a polyglot monorepo
254
+ * where the root-level detector only finds ESLint.
255
+ *
256
+ * @param workspaceRoot Absolute path to the project root.
257
+ * @returns Additional detections from subdirectories (may be empty).
258
+ */
259
+ export async function detectMonorepoScanners(
260
+ workspaceRoot: string,
261
+ ): Promise<ScannerDetection[]> {
262
+ const subdirs = new Set<string>();
263
+
264
+ // 1. Read npm workspaces from package.json
265
+ try {
266
+ const pkgPath = join(workspaceRoot, "package.json");
267
+ const raw = readFileSync(pkgPath, "utf-8");
268
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
269
+ if (Array.isArray(pkg.workspaces)) {
270
+ for (const ws of pkg.workspaces) {
271
+ if (typeof ws === "string" && !ws.includes("*")) {
272
+ const full = resolve(workspaceRoot, ws);
273
+ if (existsSync(full)) subdirs.add(full);
274
+ }
275
+ }
276
+ }
277
+ } catch {
278
+ // No package.json or not parseable — continue
279
+ }
280
+
281
+ // 2. Scan common monorepo directories one level deep
282
+ for (const dir of MONOREPO_DIRS) {
283
+ const full = join(workspaceRoot, dir);
284
+ try {
285
+ const entries = readdirSync(full, { withFileTypes: true });
286
+ for (const entry of entries) {
287
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
288
+ subdirs.add(join(full, entry.name));
289
+ }
290
+ }
291
+ } catch {
292
+ // Directory doesn't exist — skip
293
+ }
294
+ }
295
+
296
+ if (subdirs.size === 0) return [];
297
+
298
+ // 3. Probe each subdirectory for scanner config files
299
+ const detections: ScannerDetection[] = [];
300
+ const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
301
+
302
+ for (const subdir of subdirs) {
303
+ for (const scanner of scanners) {
304
+ const configProbe = probeConfigFiles(subdir, scanner);
305
+ if (!configProbe.found) continue;
306
+
307
+ // For dart_analyze, also verify the binary is on PATH
308
+ if (scanner === "dart_analyze") {
309
+ const hasBinary = await probeBinary("dart");
310
+ if (!hasBinary) continue;
311
+ }
312
+
313
+ const relDir = subdir.replace(workspaceRoot + "/", "");
314
+ detections.push({
315
+ scanner,
316
+ available: true,
317
+ reason: `config file found in ${relDir}/`,
318
+ ...(configProbe.path ? { configPath: configProbe.path } : {}),
319
+ workingDir: subdir,
320
+ });
321
+ }
322
+ }
323
+
324
+ return detections;
325
+ }
326
+
223
327
  // Exported for testing
224
- export { SCANNER_SIGNALS };
328
+ export { SCANNER_SIGNALS, MONOREPO_DIRS };