claude-crap 0.3.7 → 0.4.0

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 (100) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +74 -7
  3. package/dist/adapters/common.d.ts +1 -1
  4. package/dist/adapters/common.d.ts.map +1 -1
  5. package/dist/adapters/common.js +1 -1
  6. package/dist/adapters/common.js.map +1 -1
  7. package/dist/adapters/dart-analyzer.d.ts +41 -0
  8. package/dist/adapters/dart-analyzer.d.ts.map +1 -0
  9. package/dist/adapters/dart-analyzer.js +120 -0
  10. package/dist/adapters/dart-analyzer.js.map +1 -0
  11. package/dist/adapters/dotnet-format.d.ts +35 -0
  12. package/dist/adapters/dotnet-format.d.ts.map +1 -0
  13. package/dist/adapters/dotnet-format.js +96 -0
  14. package/dist/adapters/dotnet-format.js.map +1 -0
  15. package/dist/adapters/index.d.ts +2 -0
  16. package/dist/adapters/index.d.ts.map +1 -1
  17. package/dist/adapters/index.js +8 -0
  18. package/dist/adapters/index.js.map +1 -1
  19. package/dist/crap-config.d.ts +4 -0
  20. package/dist/crap-config.d.ts.map +1 -1
  21. package/dist/crap-config.js +51 -28
  22. package/dist/crap-config.js.map +1 -1
  23. package/dist/dashboard/file-detail.d.ts.map +1 -1
  24. package/dist/dashboard/file-detail.js.map +1 -1
  25. package/dist/dashboard/server.d.ts +2 -0
  26. package/dist/dashboard/server.d.ts.map +1 -1
  27. package/dist/dashboard/server.js +7 -12
  28. package/dist/dashboard/server.js.map +1 -1
  29. package/dist/index.js +89 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/workspace-walker.d.ts +4 -1
  32. package/dist/metrics/workspace-walker.d.ts.map +1 -1
  33. package/dist/metrics/workspace-walker.js +12 -28
  34. package/dist/metrics/workspace-walker.js.map +1 -1
  35. package/dist/monorepo/project-map.d.ts +112 -0
  36. package/dist/monorepo/project-map.d.ts.map +1 -0
  37. package/dist/monorepo/project-map.js +384 -0
  38. package/dist/monorepo/project-map.js.map +1 -0
  39. package/dist/scanner/auto-scan.d.ts +1 -0
  40. package/dist/scanner/auto-scan.d.ts.map +1 -1
  41. package/dist/scanner/auto-scan.js +14 -5
  42. package/dist/scanner/auto-scan.js.map +1 -1
  43. package/dist/scanner/bootstrap.d.ts +1 -1
  44. package/dist/scanner/bootstrap.d.ts.map +1 -1
  45. package/dist/scanner/bootstrap.js +15 -1
  46. package/dist/scanner/bootstrap.js.map +1 -1
  47. package/dist/scanner/complexity-scanner.d.ts +2 -0
  48. package/dist/scanner/complexity-scanner.d.ts.map +1 -1
  49. package/dist/scanner/complexity-scanner.js +11 -26
  50. package/dist/scanner/complexity-scanner.js.map +1 -1
  51. package/dist/scanner/detector.d.ts +24 -4
  52. package/dist/scanner/detector.d.ts.map +1 -1
  53. package/dist/scanner/detector.js +110 -10
  54. package/dist/scanner/detector.js.map +1 -1
  55. package/dist/scanner/runner.d.ts +4 -1
  56. package/dist/scanner/runner.d.ts.map +1 -1
  57. package/dist/scanner/runner.js +25 -3
  58. package/dist/scanner/runner.js.map +1 -1
  59. package/dist/schemas/tool-schemas.d.ts +16 -1
  60. package/dist/schemas/tool-schemas.d.ts.map +1 -1
  61. package/dist/schemas/tool-schemas.js +16 -1
  62. package/dist/schemas/tool-schemas.js.map +1 -1
  63. package/dist/shared/exclusions.d.ts +53 -0
  64. package/dist/shared/exclusions.d.ts.map +1 -0
  65. package/dist/shared/exclusions.js +126 -0
  66. package/dist/shared/exclusions.js.map +1 -0
  67. package/package.json +3 -1
  68. package/plugin/.claude-plugin/plugin.json +1 -1
  69. package/plugin/CLAUDE.md +37 -0
  70. package/plugin/bundle/mcp-server.mjs +762 -144
  71. package/plugin/bundle/mcp-server.mjs.map +4 -4
  72. package/plugin/package-lock.json +15 -2
  73. package/plugin/package.json +2 -1
  74. package/scripts/bundle-plugin.mjs +2 -1
  75. package/src/adapters/common.ts +1 -1
  76. package/src/adapters/dart-analyzer.ts +161 -0
  77. package/src/adapters/dotnet-format.ts +125 -0
  78. package/src/adapters/index.ts +8 -0
  79. package/src/crap-config.ts +78 -18
  80. package/src/dashboard/file-detail.ts +0 -2
  81. package/src/dashboard/server.ts +9 -10
  82. package/src/index.ts +103 -5
  83. package/src/metrics/workspace-walker.ts +15 -27
  84. package/src/monorepo/project-map.ts +476 -0
  85. package/src/scanner/auto-scan.ts +17 -6
  86. package/src/scanner/bootstrap.ts +18 -1
  87. package/src/scanner/complexity-scanner.ts +15 -26
  88. package/src/scanner/detector.ts +119 -10
  89. package/src/scanner/runner.ts +25 -2
  90. package/src/schemas/tool-schemas.ts +17 -1
  91. package/src/shared/exclusions.ts +156 -0
  92. package/src/tests/adapters/dispatch.test.ts +2 -2
  93. package/src/tests/auto-scan.test.ts +2 -2
  94. package/src/tests/boot-monorepo.test.ts +804 -0
  95. package/src/tests/boot-scanner-detection.test.ts +692 -0
  96. package/src/tests/boot-single-project.test.ts +780 -0
  97. package/src/tests/exclusions.test.ts +117 -0
  98. package/src/tests/integration/mcp-server.integration.test.ts +2 -1
  99. package/src/tests/project-map.test.ts +302 -0
  100. package/src/tests/scanner-detector.test.ts +31 -11
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Monorepo project discovery and project-map generation.
3
+ *
4
+ * At plugin boot the host can call {@link discoverProjectMap} to walk
5
+ * a workspace, detect every sub-project's language/framework, pick the
6
+ * right scanner, and probe whether that scanner binary is available on
7
+ * the host PATH. The resulting {@link ProjectMap} is optionally written
8
+ * to `.claude-crap/projects.json` via {@link persistProjectMap} so
9
+ * subsequent boot cycles can skip the discovery work by calling
10
+ * {@link loadProjectMap} first.
11
+ *
12
+ * Detection priority per sub-directory (first match wins):
13
+ * pubspec.yaml → dart
14
+ * tsconfig.json + package.json → typescript
15
+ * package.json (no tsconfig) → javascript
16
+ * pyproject.toml / setup.py / requirements.txt → python
17
+ * pom.xml / build.gradle* → java
18
+ * *.csproj / *.sln / Directory.Build.props → csharp
19
+ * (none of the above) → unknown
20
+ *
21
+ * Scanner mapping:
22
+ * typescript / javascript → eslint
23
+ * python → bandit
24
+ * java / csharp → semgrep
25
+ * dart → dart_analyze
26
+ * unknown → null
27
+ *
28
+ * @module monorepo/project-map
29
+ */
30
+
31
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
32
+ import { promises as fs } from "node:fs";
33
+ import { join, basename, resolve } from "node:path";
34
+ import { execFile } from "node:child_process";
35
+
36
+ // ── Types ──────────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Detected language / platform for a workspace sub-project.
40
+ * Mirrors the set supported by the tree-sitter engine plus Dart.
41
+ */
42
+ export type ProjectType =
43
+ | "typescript"
44
+ | "javascript"
45
+ | "python"
46
+ | "java"
47
+ | "csharp"
48
+ | "dart"
49
+ | "unknown";
50
+
51
+ /**
52
+ * A discovered sub-project within a monorepo workspace.
53
+ */
54
+ export interface ProjectEntry {
55
+ /** Human-readable name — the directory's basename (e.g. "www", "mobile"). */
56
+ readonly name: string;
57
+ /** Relative path from the workspace root (e.g. "apps/www"). */
58
+ readonly path: string;
59
+ /** Detected project type based on marker files. */
60
+ readonly type: ProjectType;
61
+ /** Recommended scanner name, or null when the type is unknown. */
62
+ readonly scanner: string | null;
63
+ /** Whether the scanner binary is reachable on the system PATH. */
64
+ readonly scannerAvailable: boolean;
65
+ }
66
+
67
+ /**
68
+ * Complete snapshot of a workspace's sub-project layout.
69
+ */
70
+ export interface ProjectMap {
71
+ /** ISO 8601 timestamp when this map was generated. */
72
+ readonly generatedAt: string;
73
+ /** Absolute path to the workspace root that was scanned. */
74
+ readonly workspaceRoot: string;
75
+ /** True when at least one sub-project was discovered. */
76
+ readonly isMonorepo: boolean;
77
+ /** Discovered sub-projects. Empty for single-project workspaces. */
78
+ readonly projects: ProjectEntry[];
79
+ }
80
+
81
+ // ── Internal constants ─────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * First-level directories that conventionally contain sub-projects in
85
+ * popular monorepo layouts (Nx, Turborepo, Rush, Lerna, custom).
86
+ */
87
+ const MONOREPO_DIRS = ["apps", "packages", "libs", "modules", "services"] as const;
88
+
89
+ /**
90
+ * Scanner recommended for each project type. `null` means no scanner
91
+ * mapping is defined for the type (only "unknown" falls here).
92
+ */
93
+ const SCANNER_FOR_TYPE: Record<ProjectType, string | null> = {
94
+ typescript: "eslint",
95
+ javascript: "eslint",
96
+ python: "bandit",
97
+ java: "semgrep",
98
+ csharp: "dotnet_format",
99
+ dart: "dart_analyze",
100
+ unknown: null,
101
+ };
102
+
103
+ /**
104
+ * The binary name to probe for each scanner. Binary availability is
105
+ * checked with `which` via `execFile`, the same approach used in
106
+ * `scanner/detector.ts`.
107
+ */
108
+ const BINARY_FOR_SCANNER: Record<string, string> = {
109
+ eslint: "eslint",
110
+ bandit: "bandit",
111
+ semgrep: "semgrep",
112
+ dart_analyze: "dart",
113
+ dotnet_format: "dotnet",
114
+ };
115
+
116
+ // ── Binary probe ───────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Resolve whether the given binary name is reachable on the system
120
+ * PATH. Uses `which` via `execFile` with a short timeout so boot
121
+ * latency stays bounded.
122
+ *
123
+ * @param binaryName The executable name to look up (e.g. "eslint").
124
+ * @returns True when `which` exits with code 0.
125
+ */
126
+ function probeBinary(binaryName: string): Promise<boolean> {
127
+ return new Promise((resolve) => {
128
+ execFile("which", [binaryName], { timeout: 5_000 }, (err) => {
129
+ resolve(err === null);
130
+ });
131
+ });
132
+ }
133
+
134
+ // ── Project type detection ─────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Detect the dominant project type of a single directory by inspecting
138
+ * well-known marker files. Checks are ordered so the most specific
139
+ * signal wins (pubspec.yaml before tsconfig.json, tsconfig.json before
140
+ * bare package.json, etc.).
141
+ *
142
+ * The function is synchronous because it only performs `existsSync` and
143
+ * one `readdirSync` call (for C# extension scanning), keeping the
144
+ * discovery loop fast and free of unnecessary Promise allocation.
145
+ *
146
+ * @param dir Absolute path to the directory to inspect.
147
+ * @returns The detected {@link ProjectType}.
148
+ */
149
+ function detectProjectType(dir: string): ProjectType {
150
+ const has = (file: string): boolean => existsSync(join(dir, file));
151
+
152
+ // Dart / Flutter — check before JS because some Flutter projects also
153
+ // have a package.json for web sub-packages.
154
+ if (has("pubspec.yaml")) return "dart";
155
+
156
+ // JS / TS — tsconfig.json implies TypeScript superset.
157
+ if (has("package.json")) {
158
+ if (has("tsconfig.json")) return "typescript";
159
+ return "javascript";
160
+ }
161
+
162
+ // Python
163
+ if (has("pyproject.toml") || has("setup.py") || has("requirements.txt")) {
164
+ return "python";
165
+ }
166
+
167
+ // Java (Gradle and Maven)
168
+ if (has("pom.xml") || has("build.gradle") || has("build.gradle.kts")) {
169
+ return "java";
170
+ }
171
+
172
+ // C# — check the well-known single-file marker first, then scan for
173
+ // per-project extension files (.csproj / .sln) at this level only.
174
+ if (has("Directory.Build.props")) return "csharp";
175
+ try {
176
+ const entries = readdirSync(dir);
177
+ if (entries.some((e) => e.endsWith(".csproj") || e.endsWith(".sln"))) {
178
+ return "csharp";
179
+ }
180
+ } catch {
181
+ // Permission error or symlink loop — treat as unknown.
182
+ }
183
+
184
+ return "unknown";
185
+ }
186
+
187
+ // ── Subdirectory collection ────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Normalise an npm workspaces value (which can be a plain string array
191
+ * or an object `{ packages: string[] }`) into a flat array of patterns.
192
+ *
193
+ * @param workspaces The raw value of `package.json#workspaces`.
194
+ * @returns Array of workspace glob patterns (may be empty).
195
+ */
196
+ function extractWorkspacePatterns(workspaces: unknown): string[] {
197
+ if (Array.isArray(workspaces)) {
198
+ return workspaces.filter((v): v is string => typeof v === "string");
199
+ }
200
+ if (
201
+ workspaces !== null &&
202
+ typeof workspaces === "object" &&
203
+ "packages" in workspaces &&
204
+ Array.isArray((workspaces as { packages: unknown }).packages)
205
+ ) {
206
+ return ((workspaces as { packages: unknown[] }).packages).filter(
207
+ (v): v is string => typeof v === "string",
208
+ );
209
+ }
210
+ return [];
211
+ }
212
+
213
+ /**
214
+ * Expand a single workspace glob pattern into matching absolute paths.
215
+ *
216
+ * Only supports the common `dir/*` form (one trailing `*`) and plain
217
+ * paths (no glob at all). Full glob engines are deliberately avoided to
218
+ * keep the module dependency-free.
219
+ *
220
+ * @param workspaceRoot Absolute workspace root.
221
+ * @param pattern A workspace pattern such as `"packages/*"` or `"apps/web"`.
222
+ * @returns Absolute paths of matching directories that exist on disk.
223
+ */
224
+ function expandWorkspacePattern(
225
+ workspaceRoot: string,
226
+ pattern: string,
227
+ ): string[] {
228
+ if (pattern.endsWith("/*")) {
229
+ // Glob: list one level of the parent directory.
230
+ const parentDir = join(workspaceRoot, pattern.slice(0, -2));
231
+ try {
232
+ const entries = readdirSync(parentDir, { withFileTypes: true });
233
+ return entries
234
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
235
+ .map((e) => join(parentDir, e.name));
236
+ } catch {
237
+ return [];
238
+ }
239
+ }
240
+
241
+ // Plain path — verify it exists and is a directory.
242
+ const full = resolve(workspaceRoot, pattern);
243
+ try {
244
+ const entries = readdirSync(full, { withFileTypes: true });
245
+ // readdirSync succeeds only for directories; if we got here it exists.
246
+ void entries; // suppress unused-variable lint
247
+ return [full];
248
+ } catch {
249
+ return [];
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Collect all candidate sub-project absolute paths for the given
255
+ * workspace root. Sources (deduplicated):
256
+ *
257
+ * 1. npm `workspaces` field in root `package.json` (both array and
258
+ * object-with-packages formats; glob patterns are expanded one
259
+ * level deep with `readdirSync`).
260
+ * 2. One-level-deep subdirectories of conventional monorepo folders
261
+ * (`apps/`, `packages/`, `libs/`, `modules/`, `services/`).
262
+ *
263
+ * Hidden directories (names starting with `.`) are always skipped.
264
+ *
265
+ * @param workspaceRoot Absolute path to the workspace root.
266
+ * @returns De-duplicated set of absolute sub-project paths.
267
+ */
268
+ function collectSubdirectories(
269
+ workspaceRoot: string,
270
+ extraDirs?: ReadonlyArray<string>,
271
+ ): Set<string> {
272
+ const subdirs = new Set<string>();
273
+
274
+ // 1. npm workspaces
275
+ const pkgPath = join(workspaceRoot, "package.json");
276
+ if (existsSync(pkgPath)) {
277
+ try {
278
+ const raw = readFileSync(pkgPath, "utf-8");
279
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
280
+ const patterns = extractWorkspacePatterns(pkg["workspaces"]);
281
+ for (const pattern of patterns) {
282
+ for (const absPath of expandWorkspacePattern(workspaceRoot, pattern)) {
283
+ subdirs.add(absPath);
284
+ }
285
+ }
286
+ } catch {
287
+ // Malformed JSON or read error — skip npm workspaces source.
288
+ }
289
+ }
290
+
291
+ // 2. User-configured projectDirs from .claude-crap.json (highest priority).
292
+ // These can be parent directories scanned one level deep (e.g. "apps")
293
+ // or direct project paths (e.g. "tools/cli").
294
+ if (extraDirs && extraDirs.length > 0) {
295
+ for (const dir of extraDirs) {
296
+ const absDir = resolve(workspaceRoot, dir);
297
+ if (!existsSync(absDir)) continue;
298
+
299
+ // If the directory itself has a project marker, treat it as a project.
300
+ const hasMarker = PROJECT_MARKERS.some((m) => existsSync(join(absDir, m)));
301
+ if (hasMarker) {
302
+ subdirs.add(absDir);
303
+ continue;
304
+ }
305
+
306
+ // Otherwise scan one level deep (it's a parent directory like "apps").
307
+ try {
308
+ const entries = readdirSync(absDir, { withFileTypes: true });
309
+ for (const entry of entries) {
310
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
311
+ subdirs.add(join(absDir, entry.name));
312
+ }
313
+ }
314
+ } catch {
315
+ // Not readable — skip.
316
+ }
317
+ }
318
+ }
319
+
320
+ // 3. Conventional monorepo directories scanned one level deep.
321
+ // Skipped for directories already covered by user config.
322
+ const configuredDirNames = new Set(extraDirs?.map((d) => d.split("/")[0]) ?? []);
323
+ for (const dir of MONOREPO_DIRS) {
324
+ if (configuredDirNames.has(dir)) continue; // User config takes precedence
325
+ const parentDir = join(workspaceRoot, dir);
326
+ try {
327
+ const entries = readdirSync(parentDir, { withFileTypes: true });
328
+ for (const entry of entries) {
329
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
330
+ subdirs.add(join(parentDir, entry.name));
331
+ }
332
+ }
333
+ } catch {
334
+ // Directory absent — skip.
335
+ }
336
+ }
337
+
338
+ return subdirs;
339
+ }
340
+
341
+ /** Files that indicate a directory is a project root. */
342
+ const PROJECT_MARKERS = [
343
+ "package.json", "pubspec.yaml", "pyproject.toml", "setup.py",
344
+ "pom.xml", "build.gradle", "build.gradle.kts", "Directory.Build.props",
345
+ ];
346
+
347
+ // ── Public API ─────────────────────────────────────────────────────────────
348
+
349
+ /**
350
+ * Discover all sub-projects in the given workspace and return a fully
351
+ * populated {@link ProjectMap}.
352
+ *
353
+ * The function:
354
+ * 1. Reads `package.json#workspaces` (supports both array and object
355
+ * `{ packages: [...] }` formats) and resolves glob patterns.
356
+ * 2. Also scans `apps/`, `packages/`, `libs/`, `modules/`, and
357
+ * `services/` one level deep (mirrors `detectMonorepoScanners`).
358
+ * 3. De-duplicates the collected paths.
359
+ * 4. Detects the project type for each subdirectory.
360
+ * 5. Maps project type to a recommended scanner.
361
+ * 6. Probes scanner binary availability via `which`.
362
+ *
363
+ * When no sub-projects are found (single-project workspace) `projects`
364
+ * is an empty array and `isMonorepo` is false.
365
+ *
366
+ * @param workspaceRoot Absolute path to the workspace root.
367
+ * @returns The generated {@link ProjectMap}.
368
+ */
369
+ export async function discoverProjectMap(
370
+ workspaceRoot: string,
371
+ options?: { projectDirs?: ReadonlyArray<string> },
372
+ ): Promise<ProjectMap> {
373
+ const subdirs = collectSubdirectories(workspaceRoot, options?.projectDirs);
374
+
375
+ // Cache binary probe results so each unique scanner is only probed once.
376
+ const binaryCache = new Map<string, Promise<boolean>>();
377
+
378
+ const probeScanner = (scanner: string): Promise<boolean> => {
379
+ const binaryName = BINARY_FOR_SCANNER[scanner];
380
+ if (binaryName === undefined) return Promise.resolve(false);
381
+
382
+ const cached = binaryCache.get(scanner);
383
+ if (cached !== undefined) return cached;
384
+
385
+ const probe = probeBinary(binaryName);
386
+ binaryCache.set(scanner, probe);
387
+ return probe;
388
+ };
389
+
390
+ const projectEntries = await Promise.all(
391
+ [...subdirs].map(async (absPath): Promise<ProjectEntry> => {
392
+ const relPath = absPath.replace(workspaceRoot + "/", "");
393
+ const type = detectProjectType(absPath);
394
+ const scanner = SCANNER_FOR_TYPE[type];
395
+ const scannerAvailable =
396
+ scanner !== null ? await probeScanner(scanner) : false;
397
+
398
+ return {
399
+ name: basename(absPath),
400
+ path: relPath,
401
+ type,
402
+ scanner,
403
+ scannerAvailable,
404
+ };
405
+ }),
406
+ );
407
+
408
+ // Sort deterministically by relative path so the output is stable
409
+ // across re-runs regardless of readdirSync ordering.
410
+ projectEntries.sort((a, b) => a.path.localeCompare(b.path));
411
+
412
+ return {
413
+ generatedAt: new Date().toISOString(),
414
+ workspaceRoot,
415
+ isMonorepo: projectEntries.length > 0,
416
+ projects: projectEntries,
417
+ };
418
+ }
419
+
420
+ /**
421
+ * Write the project map to `.claude-crap/projects.json` under the given
422
+ * workspace root. The `.claude-crap/` directory is created if it does
423
+ * not already exist.
424
+ *
425
+ * The file is written atomically via `fs.writeFile` (Node's default
426
+ * behaviour on POSIX is to truncate-and-rewrite, which is safe for the
427
+ * sizes expected here).
428
+ *
429
+ * @param map The {@link ProjectMap} to serialise.
430
+ * @param workspaceRoot Absolute path to the workspace root.
431
+ */
432
+ export async function persistProjectMap(
433
+ map: ProjectMap,
434
+ workspaceRoot: string,
435
+ ): Promise<void> {
436
+ const dir = join(workspaceRoot, ".claude-crap");
437
+ await fs.mkdir(dir, { recursive: true });
438
+ const filePath = join(dir, "projects.json");
439
+ await fs.writeFile(filePath, JSON.stringify(map, null, 2) + "\n", "utf-8");
440
+ }
441
+
442
+ /**
443
+ * Read a previously persisted {@link ProjectMap} from
444
+ * `.claude-crap/projects.json`. Returns `null` when the file is absent
445
+ * or cannot be parsed, so callers can fall back to
446
+ * {@link discoverProjectMap} without special-casing errors.
447
+ *
448
+ * This function is intentionally synchronous so it can be called during
449
+ * plugin boot before the async event loop is fully initialised.
450
+ *
451
+ * @param workspaceRoot Absolute path to the workspace root.
452
+ * @returns The cached {@link ProjectMap}, or `null`.
453
+ */
454
+ export function loadProjectMap(workspaceRoot: string): ProjectMap | null {
455
+ const filePath = join(workspaceRoot, ".claude-crap", "projects.json");
456
+ if (!existsSync(filePath)) return null;
457
+
458
+ try {
459
+ const raw = readFileSync(filePath, "utf-8");
460
+ const parsed = JSON.parse(raw) as ProjectMap;
461
+
462
+ // Minimal structural validation — guard against truncated writes.
463
+ if (
464
+ typeof parsed.generatedAt !== "string" ||
465
+ typeof parsed.workspaceRoot !== "string" ||
466
+ typeof parsed.isMonorepo !== "boolean" ||
467
+ !Array.isArray(parsed.projects)
468
+ ) {
469
+ return null;
470
+ }
471
+
472
+ return parsed;
473
+ } catch {
474
+ return null;
475
+ }
476
+ }
@@ -22,7 +22,7 @@
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
28
  import { scanComplexity, type ComplexityScanResult } from "./complexity-scanner.js";
@@ -97,17 +97,28 @@ export async function autoScan(
97
97
  workspaceRoot: string,
98
98
  sarifStore: SarifStore,
99
99
  logger: Logger,
100
- options?: { engine?: TreeSitterEngine; cyclomaticMax?: number },
100
+ options?: { engine?: TreeSitterEngine; cyclomaticMax?: number; exclude?: ReadonlyArray<string> },
101
101
  ): Promise<AutoScanResult> {
102
102
  const start = Date.now();
103
103
 
104
- // 1. Detect available scanners
104
+ // 1. Detect available scanners (root + monorepo subdirs)
105
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
+
106
116
  const available = detected.filter((d) => d.available);
107
117
 
108
118
  logger.info(
109
119
  {
110
120
  detected: detected.map((d) => `${d.scanner}:${d.available}`),
121
+ monorepo: monorepoDetected.length,
111
122
  available: available.length,
112
123
  },
113
124
  "auto-scan: detection complete",
@@ -162,9 +173,9 @@ export async function autoScan(
162
173
  };
163
174
  }
164
175
 
165
- // 2. Run all available scanners in parallel
176
+ // 2. Run all available scanners in parallel (each from its detected workingDir)
166
177
  const runResults = await Promise.allSettled(
167
- available.map((d) => runScanner(d.scanner, workspaceRoot)),
178
+ available.map((d) => runScanner(d.scanner, workspaceRoot, d.workingDir ? { workingDir: d.workingDir } : undefined)),
168
179
  );
169
180
 
170
181
  // 3. Ingest results
@@ -259,7 +270,7 @@ export async function autoScan(
259
270
  workspaceRoot,
260
271
  options.engine,
261
272
  sarifStore,
262
- { cyclomaticMax: options.cyclomaticMax ?? 15 },
273
+ { cyclomaticMax: options.cyclomaticMax ?? 15, ...(options.exclude ? { exclude: options.exclude } : {}) },
263
274
  logger,
264
275
  );
265
276
  totalFindings += complexityScan.violations;
@@ -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
 
@@ -266,13 +270,26 @@ function getRecommendation(projectType: ProjectType): ScannerRecommendation {
266
270
  "pip install bandit (or: pipx install bandit, poetry add --group dev bandit)",
267
271
  };
268
272
  case "java":
269
- case "csharp":
270
273
  return {
271
274
  scanner: "semgrep",
272
275
  canAutoInstall: false,
273
276
  installInstructions:
274
277
  "brew install semgrep (or: pip install semgrep, pipx install semgrep)",
275
278
  };
279
+ case "csharp":
280
+ return {
281
+ scanner: "dotnet_format",
282
+ canAutoInstall: false,
283
+ installInstructions:
284
+ "Install the .NET SDK: https://dotnet.microsoft.com/download",
285
+ };
286
+ case "dart":
287
+ return {
288
+ scanner: "dart_analyze",
289
+ canAutoInstall: false,
290
+ installInstructions:
291
+ "Install the Dart SDK: https://dart.dev/get-dart (or Flutter SDK which includes Dart)",
292
+ };
276
293
  case "unknown":
277
294
  return {
278
295
  scanner: "semgrep",
@@ -24,28 +24,11 @@ import type { Logger } from "pino";
24
24
  import { TreeSitterEngine } from "../ast/tree-sitter-engine.js";
25
25
  import { detectLanguageFromPath } from "../ast/language-config.js";
26
26
  import { wrapResultsInSarif, estimateEffortMinutes } from "../adapters/common.js";
27
+ import { createExclusionFilter } from "../shared/exclusions.js";
27
28
  import type { SarifStore } from "../sarif/sarif-store.js";
28
29
  import type { SarifLevel } from "../sarif/sarif-builder.js";
29
30
 
30
- // ── Constants ─────────────────────────────────────────────────────
31
-
32
- /** Directories that should never be scanned. Mirrors `workspace-walker.ts`. */
33
- const SKIP_DIRS: ReadonlySet<string> = new Set([
34
- "node_modules",
35
- ".git",
36
- "dist",
37
- "build",
38
- "out",
39
- "target",
40
- ".venv",
41
- "venv",
42
- "__pycache__",
43
- ".cache",
44
- ".next",
45
- ".nuxt",
46
- ".claude-crap",
47
- ".codesight",
48
- ]);
31
+ // Directory exclusions are now centralized in src/shared/exclusions.ts.
49
32
 
50
33
  /** Hard cap on files to prevent unbounded analysis. */
51
34
  const MAX_FILES = 20_000;
@@ -74,6 +57,8 @@ export interface ComplexityScanResult {
74
57
  export interface ComplexityScanConfig {
75
58
  /** Maximum cyclomatic complexity allowed per function. */
76
59
  readonly cyclomaticMax: number;
60
+ /** User-defined exclusion patterns from .claude-crap.json. */
61
+ readonly exclude?: ReadonlyArray<string>;
77
62
  }
78
63
 
79
64
  // ── Scanner ───────────────────────────────────────────────────────
@@ -105,7 +90,8 @@ export async function scanComplexity(
105
90
  const errorThreshold = threshold * 2;
106
91
 
107
92
  // 1. Collect supported source files
108
- const files = await collectSourceFiles(workspaceRoot);
93
+ const filter = createExclusionFilter(config.exclude);
94
+ const files = await collectSourceFiles(workspaceRoot, filter);
109
95
  logger.info(
110
96
  { fileCount: files.length, threshold },
111
97
  "complexity-scanner: starting analysis",
@@ -193,10 +179,13 @@ export async function scanComplexity(
193
179
 
194
180
  /**
195
181
  * Collect source files from the workspace that the tree-sitter engine
196
- * can analyze. Skips directories in `SKIP_DIRS` and hidden directories.
197
- * Only returns files whose extension maps to a supported language.
182
+ * can analyze. Uses the shared exclusion filter for directory and file
183
+ * filtering. Only returns files whose extension maps to a supported language.
198
184
  */
199
- async function collectSourceFiles(workspaceRoot: string): Promise<string[]> {
185
+ async function collectSourceFiles(
186
+ workspaceRoot: string,
187
+ filter: import("../shared/exclusions.js").ExclusionFilter,
188
+ ): Promise<string[]> {
200
189
  const files: string[] = [];
201
190
  let truncated = false;
202
191
 
@@ -210,16 +199,16 @@ async function collectSourceFiles(workspaceRoot: string): Promise<string[]> {
210
199
  }
211
200
  for (const entry of entries) {
212
201
  if (truncated) return;
213
- if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
214
202
  const full = join(dir, entry.name);
215
203
  if (entry.isDirectory()) {
216
- if (SKIP_DIRS.has(entry.name)) continue;
204
+ if (filter.shouldSkipDir(entry.name)) continue;
217
205
  await walk(full);
218
206
  continue;
219
207
  }
220
208
  if (!entry.isFile()) continue;
221
- // Only include files the tree-sitter engine can parse
222
209
  if (!detectLanguageFromPath(entry.name)) continue;
210
+ const relPath = relative(workspaceRoot, full);
211
+ if (filter.shouldSkipFile(relPath, entry.name)) continue;
223
212
  files.push(full);
224
213
  if (files.length >= MAX_FILES) {
225
214
  truncated = true;