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,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
+ });
@@ -232,7 +232,7 @@ describe("MCP server integration", { skip: !serverBuilt }, () => {
232
232
  assert.ok(child && !child.killed, "server child should be running");
233
233
  });
234
234
 
235
- it("exposes all nine tools via tools/list", async () => {
235
+ it("exposes all eleven tools via tools/list", async () => {
236
236
  const response = await client!.request<{ result?: { tools?: Array<{ name: string }> } }>(
237
237
  "tools/list",
238
238
  );
@@ -245,6 +245,7 @@ describe("MCP server integration", { skip: !serverBuilt }, () => {
245
245
  "compute_tdr",
246
246
  "ingest_sarif",
247
247
  "ingest_scanner_output",
248
+ "list_projects",
248
249
  "require_test_harness",
249
250
  "score_project",
250
251
  ]);
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Unit tests for the project-map discovery module.
3
+ *
4
+ * Covers workspace classification (single-project vs monorepo),
5
+ * per-project language detection across all supported project types,
6
+ * deduplication when a workspace appears in both npm workspaces and
7
+ * directory scan results, and the persist/load round-trip.
8
+ *
9
+ * Each test creates a fresh temporary directory tree and removes it in
10
+ * a `finally` block so failures leave no artefacts on disk.
11
+ *
12
+ * @module tests/project-map.test
13
+ */
14
+
15
+ import { describe, it } from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { tmpdir } from "node:os";
20
+
21
+ import {
22
+ discoverProjectMap,
23
+ persistProjectMap,
24
+ loadProjectMap,
25
+ type ProjectMap,
26
+ type ProjectEntry,
27
+ } from "../monorepo/project-map.js";
28
+
29
+ // ── Helpers ──────────────────────────────────────────────────────────
30
+
31
+ function makeTmpDir(): string {
32
+ return mkdtempSync(join(tmpdir(), "crap-projmap-"));
33
+ }
34
+
35
+ /**
36
+ * Write a file at `absPath`, creating all parent directories first.
37
+ */
38
+ function touch(absPath: string, content = ""): void {
39
+ mkdirSync(join(absPath, ".."), { recursive: true });
40
+ writeFileSync(absPath, content, "utf8");
41
+ }
42
+
43
+ /**
44
+ * Return the ProjectEntry for the given relative path, or throw if absent.
45
+ */
46
+ function findProject(map: ProjectMap, relPath: string): ProjectEntry {
47
+ const entry = map.projects.find((p) => p.path === relPath);
48
+ assert.ok(entry, `expected project at path "${relPath}" — found: ${map.projects.map((p) => p.path).join(", ")}`);
49
+ return entry;
50
+ }
51
+
52
+ // ── discoverProjectMap ────────────────────────────────────────────────
53
+
54
+ describe("discoverProjectMap", () => {
55
+ it("single-project workspace is not a monorepo and has no sub-projects", async () => {
56
+ const dir = makeTmpDir();
57
+ try {
58
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "my-app" }));
59
+ writeFileSync(join(dir, "tsconfig.json"), "{}");
60
+
61
+ const map = await discoverProjectMap(dir);
62
+
63
+ assert.equal(map.isMonorepo, false);
64
+ assert.deepEqual(map.projects, []);
65
+ assert.equal(map.workspaceRoot, dir);
66
+ assert.equal(typeof map.generatedAt, "string");
67
+ } finally {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ it("npm workspaces monorepo detects both sub-projects with correct types", async () => {
73
+ const dir = makeTmpDir();
74
+ try {
75
+ // Root manifest declares workspaces
76
+ writeFileSync(
77
+ join(dir, "package.json"),
78
+ JSON.stringify({ name: "root", workspaces: ["apps/web", "apps/api"] }),
79
+ );
80
+
81
+ // apps/web — TypeScript project
82
+ mkdirSync(join(dir, "apps", "web"), { recursive: true });
83
+ writeFileSync(join(dir, "apps", "web", "package.json"), JSON.stringify({ name: "web" }));
84
+ writeFileSync(join(dir, "apps", "web", "tsconfig.json"), "{}");
85
+
86
+ // apps/api — plain JavaScript project (no tsconfig)
87
+ mkdirSync(join(dir, "apps", "api"), { recursive: true });
88
+ writeFileSync(join(dir, "apps", "api", "package.json"), JSON.stringify({ name: "api" }));
89
+
90
+ const map = await discoverProjectMap(dir);
91
+
92
+ assert.equal(map.isMonorepo, true);
93
+ assert.equal(map.projects.length, 2);
94
+
95
+ const web = findProject(map, "apps/web");
96
+ assert.equal(web.type, "typescript");
97
+ assert.equal(web.name, "web");
98
+
99
+ const api = findProject(map, "apps/api");
100
+ assert.equal(api.type, "javascript");
101
+ assert.equal(api.name, "api");
102
+ } finally {
103
+ rmSync(dir, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ it("mixed monorepo discovers both an npm workspace and a Dart project", async () => {
108
+ const dir = makeTmpDir();
109
+ try {
110
+ // Root declares only apps/web in workspaces; apps/mobile is found by scan
111
+ writeFileSync(
112
+ join(dir, "package.json"),
113
+ JSON.stringify({ name: "root", workspaces: ["apps/web"] }),
114
+ );
115
+
116
+ // apps/web — TypeScript
117
+ mkdirSync(join(dir, "apps", "web"), { recursive: true });
118
+ writeFileSync(join(dir, "apps", "web", "package.json"), JSON.stringify({ name: "web" }));
119
+ writeFileSync(join(dir, "apps", "web", "tsconfig.json"), "{}");
120
+
121
+ // apps/mobile — Dart / Flutter (discovered via directory scan, not workspaces)
122
+ mkdirSync(join(dir, "apps", "mobile"), { recursive: true });
123
+ writeFileSync(
124
+ join(dir, "apps", "mobile", "pubspec.yaml"),
125
+ "name: mobile\nenvironment:\n sdk: '>=3.0.0 <4.0.0'\n",
126
+ );
127
+
128
+ const map = await discoverProjectMap(dir);
129
+
130
+ assert.equal(map.isMonorepo, true);
131
+
132
+ const web = findProject(map, "apps/web");
133
+ assert.equal(web.type, "typescript");
134
+
135
+ const mobile = findProject(map, "apps/mobile");
136
+ assert.equal(mobile.type, "dart");
137
+ } finally {
138
+ rmSync(dir, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ it("Python project is detected with type 'python' and scanner 'bandit'", async () => {
143
+ const dir = makeTmpDir();
144
+ try {
145
+ // Root with no workspaces — but has a sub-directory with Python signals
146
+ writeFileSync(
147
+ join(dir, "package.json"),
148
+ JSON.stringify({ name: "root", workspaces: ["apps/ml"] }),
149
+ );
150
+
151
+ mkdirSync(join(dir, "apps", "ml"), { recursive: true });
152
+ writeFileSync(
153
+ join(dir, "apps", "ml", "pyproject.toml"),
154
+ "[project]\nname = \"ml\"\n",
155
+ );
156
+
157
+ const map = await discoverProjectMap(dir);
158
+
159
+ const ml = findProject(map, "apps/ml");
160
+ assert.equal(ml.type, "python");
161
+ assert.equal(ml.scanner, "bandit");
162
+ } finally {
163
+ rmSync(dir, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ it("Java project is detected with type 'java' and scanner 'dotnet_format'", async () => {
168
+ const dir = makeTmpDir();
169
+ try {
170
+ writeFileSync(
171
+ join(dir, "package.json"),
172
+ JSON.stringify({ name: "root", workspaces: ["apps/backend"] }),
173
+ );
174
+
175
+ mkdirSync(join(dir, "apps", "backend"), { recursive: true });
176
+ writeFileSync(
177
+ join(dir, "apps", "backend", "pom.xml"),
178
+ "<project><modelVersion>4.0.0</modelVersion></project>",
179
+ );
180
+
181
+ const map = await discoverProjectMap(dir);
182
+
183
+ const backend = findProject(map, "apps/backend");
184
+ assert.equal(backend.type, "java");
185
+ assert.equal(backend.scanner, "semgrep");
186
+ } finally {
187
+ rmSync(dir, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ it("C# project is detected with type 'csharp' and scanner 'dotnet_format'", async () => {
192
+ const dir = makeTmpDir();
193
+ try {
194
+ writeFileSync(
195
+ join(dir, "package.json"),
196
+ JSON.stringify({ name: "root", workspaces: ["apps/api"] }),
197
+ );
198
+
199
+ mkdirSync(join(dir, "apps", "api"), { recursive: true });
200
+ writeFileSync(
201
+ join(dir, "apps", "api", "MyApp.csproj"),
202
+ "<Project Sdk=\"Microsoft.NET.Sdk\"></Project>",
203
+ );
204
+
205
+ const map = await discoverProjectMap(dir);
206
+
207
+ const api = findProject(map, "apps/api");
208
+ assert.equal(api.type, "csharp");
209
+ assert.equal(api.scanner, "dotnet_format");
210
+ } finally {
211
+ rmSync(dir, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ it("empty workspace returns isMonorepo false and an empty projects array", async () => {
216
+ const dir = makeTmpDir();
217
+ try {
218
+ const map = await discoverProjectMap(dir);
219
+
220
+ assert.equal(map.isMonorepo, false);
221
+ assert.deepEqual(map.projects, []);
222
+ } finally {
223
+ rmSync(dir, { recursive: true, force: true });
224
+ }
225
+ });
226
+
227
+ it("project listed in npm workspaces AND found by directory scan appears only once", async () => {
228
+ const dir = makeTmpDir();
229
+ try {
230
+ // apps/shared is declared in workspaces; it also lives inside apps/
231
+ // which the directory scanner would naturally traverse
232
+ writeFileSync(
233
+ join(dir, "package.json"),
234
+ JSON.stringify({ name: "root", workspaces: ["apps/shared"] }),
235
+ );
236
+
237
+ mkdirSync(join(dir, "apps", "shared"), { recursive: true });
238
+ writeFileSync(join(dir, "apps", "shared", "package.json"), JSON.stringify({ name: "shared" }));
239
+ writeFileSync(join(dir, "apps", "shared", "tsconfig.json"), "{}");
240
+
241
+ const map = await discoverProjectMap(dir);
242
+
243
+ const matches = map.projects.filter((p) => p.path === "apps/shared");
244
+ assert.equal(
245
+ matches.length,
246
+ 1,
247
+ `expected exactly 1 entry for "apps/shared", got ${matches.length}`,
248
+ );
249
+ } finally {
250
+ rmSync(dir, { recursive: true, force: true });
251
+ }
252
+ });
253
+ });
254
+
255
+ // ── persistProjectMap / loadProjectMap ───────────────────────────────
256
+
257
+ describe("persistProjectMap / loadProjectMap", () => {
258
+ it("persisted map round-trips through loadProjectMap with deep equality", async () => {
259
+ const dir = makeTmpDir();
260
+ try {
261
+ const original: ProjectMap = {
262
+ generatedAt: new Date().toISOString(),
263
+ workspaceRoot: dir,
264
+ isMonorepo: true,
265
+ projects: [
266
+ {
267
+ name: "web",
268
+ path: "apps/web",
269
+ type: "typescript",
270
+ scanner: "eslint",
271
+ scannerAvailable: true,
272
+ },
273
+ {
274
+ name: "ml",
275
+ path: "apps/ml",
276
+ type: "python",
277
+ scanner: "bandit",
278
+ scannerAvailable: false,
279
+ },
280
+ ],
281
+ };
282
+
283
+ await persistProjectMap(original, dir);
284
+ const loaded = loadProjectMap(dir);
285
+
286
+ assert.ok(loaded !== null, "loadProjectMap returned null after persist");
287
+ assert.deepEqual(loaded, original);
288
+ } finally {
289
+ rmSync(dir, { recursive: true, force: true });
290
+ }
291
+ });
292
+
293
+ it("loadProjectMap returns null when no persisted file exists", () => {
294
+ const dir = makeTmpDir();
295
+ try {
296
+ const result = loadProjectMap(dir);
297
+ assert.equal(result, null);
298
+ } finally {
299
+ rmSync(dir, { recursive: true, force: true });
300
+ }
301
+ });
302
+ });
@@ -90,24 +90,34 @@ describe("detectScanners", () => {
90
90
  }
91
91
  });
92
92
 
93
- it("detects eslint from package.json devDependencies", async () => {
93
+ it("detects eslint from package.json — not installed vs installed", async () => {
94
94
  const dir = makeTmpDir();
95
95
  try {
96
96
  writeFileSync(
97
97
  join(dir, "package.json"),
98
98
  JSON.stringify({ devDependencies: { eslint: "^9.0.0" } }),
99
99
  );
100
+ // Without node_modules/.bin/eslint — declared but not installed
100
101
  const results = await detectScanners(dir);
101
102
  const eslint = results.find((r) => r.scanner === "eslint");
102
103
  assert.ok(eslint);
103
- assert.equal(eslint.available, true);
104
- assert.ok(eslint.reason.includes("package.json"));
104
+ assert.equal(eslint.available, false);
105
+ assert.ok(eslint.reason.includes("not installed"));
106
+
107
+ // With binary present — installed
108
+ mkdirSync(join(dir, "node_modules", ".bin"), { recursive: true });
109
+ writeFileSync(join(dir, "node_modules", ".bin", "eslint"), "");
110
+ const results2 = await detectScanners(dir);
111
+ const eslint2 = results2.find((r) => r.scanner === "eslint");
112
+ assert.ok(eslint2);
113
+ assert.equal(eslint2.available, true);
114
+ assert.ok(eslint2.reason.includes("installed"));
105
115
  } finally {
106
116
  rmSync(dir, { recursive: true, force: true });
107
117
  }
108
118
  });
109
119
 
110
- it("detects stryker from package.json @stryker-mutator/core", async () => {
120
+ it("detects stryker from package.json — not installed vs installed", async () => {
111
121
  const dir = makeTmpDir();
112
122
  try {
113
123
  writeFileSync(
@@ -116,11 +126,21 @@ describe("detectScanners", () => {
116
126
  devDependencies: { "@stryker-mutator/core": "^7.0.0" },
117
127
  }),
118
128
  );
129
+ // Without binary — declared but not installed
119
130
  const results = await detectScanners(dir);
120
131
  const stryker = results.find((r) => r.scanner === "stryker");
121
132
  assert.ok(stryker);
122
- assert.equal(stryker.available, true);
123
- assert.ok(stryker.reason.includes("package.json"));
133
+ assert.equal(stryker.available, false);
134
+ assert.ok(stryker.reason.includes("not installed"));
135
+
136
+ // With binary present — installed
137
+ mkdirSync(join(dir, "node_modules", ".bin"), { recursive: true });
138
+ writeFileSync(join(dir, "node_modules", ".bin", "stryker"), "");
139
+ const results2 = await detectScanners(dir);
140
+ const stryker2 = results2.find((r) => r.scanner === "stryker");
141
+ assert.ok(stryker2);
142
+ assert.equal(stryker2.available, true);
143
+ assert.ok(stryker2.reason.includes("installed"));
124
144
  } finally {
125
145
  rmSync(dir, { recursive: true, force: true });
126
146
  }
@@ -133,9 +153,9 @@ describe("detectScanners", () => {
133
153
  // Config and package.json probes will all fail.
134
154
  // Binary probe results depend on the host — don't assert on those,
135
155
  // but do assert the structure is correct.
136
- assert.equal(results.length, 4);
156
+ assert.equal(results.length, 6);
137
157
  for (const r of results) {
138
- assert.ok(["eslint", "semgrep", "bandit", "stryker"].includes(r.scanner));
158
+ assert.ok(["eslint", "semgrep", "bandit", "stryker", "dart_analyze", "dotnet_format"].includes(r.scanner));
139
159
  assert.equal(typeof r.available, "boolean");
140
160
  assert.equal(typeof r.reason, "string");
141
161
  }
@@ -150,7 +170,7 @@ describe("detectScanners", () => {
150
170
  writeFileSync(join(dir, "package.json"), "not json at all");
151
171
  // Should not throw — just skip the package.json probe
152
172
  const results = await detectScanners(dir);
153
- assert.equal(results.length, 4);
173
+ assert.equal(results.length, 6);
154
174
  } finally {
155
175
  rmSync(dir, { recursive: true, force: true });
156
176
  }
@@ -172,10 +192,10 @@ describe("detectScanners", () => {
172
192
  }
173
193
  });
174
194
 
175
- it("SCANNER_SIGNALS covers all four scanners", () => {
195
+ it("SCANNER_SIGNALS covers all supported scanners", () => {
176
196
  assert.deepEqual(
177
197
  Object.keys(SCANNER_SIGNALS).sort(),
178
- ["bandit", "eslint", "semgrep", "stryker"],
198
+ ["bandit", "dart_analyze", "dotnet_format", "eslint", "semgrep", "stryker"],
179
199
  );
180
200
  });
181
201
  });