claude-crap 0.3.8 → 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 (64) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +69 -27
  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/dotnet-format.d.ts +35 -0
  8. package/dist/adapters/dotnet-format.d.ts.map +1 -0
  9. package/dist/adapters/dotnet-format.js +96 -0
  10. package/dist/adapters/dotnet-format.js.map +1 -0
  11. package/dist/adapters/index.d.ts +1 -0
  12. package/dist/adapters/index.d.ts.map +1 -1
  13. package/dist/adapters/index.js +4 -0
  14. package/dist/adapters/index.js.map +1 -1
  15. package/dist/crap-config.d.ts +2 -0
  16. package/dist/crap-config.d.ts.map +1 -1
  17. package/dist/crap-config.js +19 -4
  18. package/dist/crap-config.js.map +1 -1
  19. package/dist/dashboard/server.js +1 -1
  20. package/dist/index.js +74 -5
  21. package/dist/index.js.map +1 -1
  22. package/dist/monorepo/project-map.d.ts +112 -0
  23. package/dist/monorepo/project-map.d.ts.map +1 -0
  24. package/dist/monorepo/project-map.js +384 -0
  25. package/dist/monorepo/project-map.js.map +1 -0
  26. package/dist/scanner/bootstrap.d.ts.map +1 -1
  27. package/dist/scanner/bootstrap.js +6 -1
  28. package/dist/scanner/bootstrap.js.map +1 -1
  29. package/dist/scanner/detector.d.ts.map +1 -1
  30. package/dist/scanner/detector.js +7 -2
  31. package/dist/scanner/detector.js.map +1 -1
  32. package/dist/scanner/runner.d.ts.map +1 -1
  33. package/dist/scanner/runner.js +13 -0
  34. package/dist/scanner/runner.js.map +1 -1
  35. package/dist/schemas/tool-schemas.d.ts +16 -1
  36. package/dist/schemas/tool-schemas.d.ts.map +1 -1
  37. package/dist/schemas/tool-schemas.js +16 -1
  38. package/dist/schemas/tool-schemas.js.map +1 -1
  39. package/package.json +1 -1
  40. package/plugin/.claude-plugin/plugin.json +1 -1
  41. package/plugin/CLAUDE.md +37 -0
  42. package/plugin/bundle/mcp-server.mjs +395 -29
  43. package/plugin/bundle/mcp-server.mjs.map +4 -4
  44. package/plugin/package-lock.json +2 -2
  45. package/plugin/package.json +1 -1
  46. package/src/adapters/common.ts +1 -1
  47. package/src/adapters/dotnet-format.ts +125 -0
  48. package/src/adapters/index.ts +4 -0
  49. package/src/crap-config.ts +27 -4
  50. package/src/dashboard/server.ts +1 -1
  51. package/src/index.ts +88 -5
  52. package/src/monorepo/project-map.ts +476 -0
  53. package/src/scanner/bootstrap.ts +7 -1
  54. package/src/scanner/detector.ts +7 -2
  55. package/src/scanner/runner.ts +13 -0
  56. package/src/schemas/tool-schemas.ts +17 -1
  57. package/src/tests/adapters/dispatch.test.ts +1 -1
  58. package/src/tests/auto-scan.test.ts +2 -2
  59. package/src/tests/boot-monorepo.test.ts +804 -0
  60. package/src/tests/boot-scanner-detection.test.ts +692 -0
  61. package/src/tests/boot-single-project.test.ts +780 -0
  62. package/src/tests/integration/mcp-server.integration.test.ts +2 -1
  63. package/src/tests/project-map.test.ts +302 -0
  64. package/src/tests/scanner-detector.test.ts +4 -4
@@ -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
+ }
@@ -270,13 +270,19 @@ function getRecommendation(projectType: ProjectType): ScannerRecommendation {
270
270
  "pip install bandit (or: pipx install bandit, poetry add --group dev bandit)",
271
271
  };
272
272
  case "java":
273
- case "csharp":
274
273
  return {
275
274
  scanner: "semgrep",
276
275
  canAutoInstall: false,
277
276
  installInstructions:
278
277
  "brew install semgrep (or: pip install semgrep, pipx install semgrep)",
279
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
+ };
280
286
  case "dart":
281
287
  return {
282
288
  scanner: "dart_analyze",
@@ -108,6 +108,11 @@ const SCANNER_SIGNALS: Record<KnownScanner, ScannerSignals> = {
108
108
  packageJsonKeys: [],
109
109
  binaryNames: ["dart"],
110
110
  },
111
+ dotnet_format: {
112
+ configFiles: [],
113
+ packageJsonKeys: [],
114
+ binaryNames: ["dotnet"],
115
+ },
111
116
  };
112
117
 
113
118
  // ── Probes ──────────────────────────────────────────────────────────
@@ -183,7 +188,7 @@ function probeBinary(binaryName: string): Promise<boolean> {
183
188
  export async function detectScanners(
184
189
  workspaceRoot: string,
185
190
  ): Promise<ScannerDetection[]> {
186
- const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
191
+ const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze", "dotnet_format"];
187
192
 
188
193
  const results = await Promise.all(
189
194
  scanners.map(async (scanner): Promise<ScannerDetection> => {
@@ -297,7 +302,7 @@ export async function detectMonorepoScanners(
297
302
 
298
303
  // 3. Probe each subdirectory for scanner config files
299
304
  const detections: ScannerDetection[] = [];
300
- const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
305
+ const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze", "dotnet_format"];
301
306
 
302
307
  for (const subdir of subdirs) {
303
308
  for (const scanner of scanners) {
@@ -98,6 +98,19 @@ function getScannerCommand(
98
98
  timeoutMs: 120_000,
99
99
  nonZeroIsNormal: true, // exits 3 when findings exist
100
100
  };
101
+ case "dotnet_format":
102
+ return {
103
+ command: "dotnet",
104
+ args: [
105
+ "format",
106
+ "--verify-no-changes",
107
+ "--report",
108
+ join(workspaceRoot, ".claude-crap", "dotnet-report.json"),
109
+ ],
110
+ timeoutMs: 120_000,
111
+ nonZeroIsNormal: true,
112
+ outputFile: join(workspaceRoot, ".claude-crap", "dotnet-report.json"),
113
+ };
101
114
  }
102
115
  }
103
116
 
@@ -134,11 +134,27 @@ export const scoreProjectSchema = {
134
134
  description:
135
135
  "Output format. `markdown` returns only the chat summary, `json` returns only the structured snapshot, `both` (default) returns both as separate content blocks.",
136
136
  },
137
+ scope: {
138
+ type: "string",
139
+ description: "Optional project name from the project map. When provided, the score is computed only for files within that project's subtree. Omit to score the entire workspace.",
140
+ },
137
141
  },
138
142
  required: [],
139
143
  additionalProperties: false,
140
144
  } as const;
141
145
 
146
+ /**
147
+ * Schema for the `list_projects` tool. Returns the discovered sub-projects
148
+ * in the workspace, or an empty list for single-project workspaces.
149
+ */
150
+ export const listProjectsSchema = {
151
+ type: "object",
152
+ description: "List all discovered sub-projects in the workspace. In a monorepo, returns each sub-project with its type, path, and recommended scanner. In a single-project workspace, returns an empty list.",
153
+ properties: {},
154
+ required: [],
155
+ additionalProperties: false,
156
+ } as const;
157
+
142
158
  /**
143
159
  * Schema for the `require_test_harness` tool. Checks whether a production
144
160
  * source file has an accompanying test file in any of the conventional
@@ -181,7 +197,7 @@ export const ingestScannerOutputSchema = {
181
197
  properties: {
182
198
  scanner: {
183
199
  type: "string",
184
- enum: ["semgrep", "eslint", "bandit", "stryker", "dart_analyze"],
200
+ enum: ["semgrep", "eslint", "bandit", "stryker", "dart_analyze", "dotnet_format"],
185
201
  description: "Identifier of the producing scanner.",
186
202
  },
187
203
  rawOutput: {
@@ -95,6 +95,6 @@ describe("adaptScannerOutput", () => {
95
95
  });
96
96
 
97
97
  it("KNOWN_SCANNERS is frozen and contains all supported names", () => {
98
- assert.deepEqual([...KNOWN_SCANNERS].sort(), ["bandit", "dart_analyze", "eslint", "semgrep", "stryker"]);
98
+ assert.deepEqual([...KNOWN_SCANNERS].sort(), ["bandit", "dart_analyze", "dotnet_format", "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, 5);
37
+ assert.equal(result.detected.length, 6);
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", "dart_analyze", "eslint", "semgrep", "stryker"]);
132
+ assert.deepEqual(scannerNames, ["bandit", "dart_analyze", "dotnet_format", "eslint", "semgrep", "stryker"]);
133
133
  } finally {
134
134
  rmSync(dir, { recursive: true, force: true });
135
135
  }