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.
- package/CHANGELOG.md +33 -0
- package/README.md +69 -27
- package/dist/adapters/common.d.ts +1 -1
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +1 -1
- package/dist/adapters/common.js.map +1 -1
- package/dist/adapters/dotnet-format.d.ts +35 -0
- package/dist/adapters/dotnet-format.d.ts.map +1 -0
- package/dist/adapters/dotnet-format.js +96 -0
- package/dist/adapters/dotnet-format.js.map +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +4 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/crap-config.d.ts +2 -0
- package/dist/crap-config.d.ts.map +1 -1
- package/dist/crap-config.js +19 -4
- package/dist/crap-config.js.map +1 -1
- package/dist/dashboard/server.js +1 -1
- package/dist/index.js +74 -5
- package/dist/index.js.map +1 -1
- package/dist/monorepo/project-map.d.ts +112 -0
- package/dist/monorepo/project-map.d.ts.map +1 -0
- package/dist/monorepo/project-map.js +384 -0
- package/dist/monorepo/project-map.js.map +1 -0
- package/dist/scanner/bootstrap.d.ts.map +1 -1
- package/dist/scanner/bootstrap.js +6 -1
- package/dist/scanner/bootstrap.js.map +1 -1
- package/dist/scanner/detector.d.ts.map +1 -1
- package/dist/scanner/detector.js +7 -2
- package/dist/scanner/detector.js.map +1 -1
- package/dist/scanner/runner.d.ts.map +1 -1
- package/dist/scanner/runner.js +13 -0
- package/dist/scanner/runner.js.map +1 -1
- package/dist/schemas/tool-schemas.d.ts +16 -1
- package/dist/schemas/tool-schemas.d.ts.map +1 -1
- package/dist/schemas/tool-schemas.js +16 -1
- package/dist/schemas/tool-schemas.js.map +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +37 -0
- package/plugin/bundle/mcp-server.mjs +395 -29
- package/plugin/bundle/mcp-server.mjs.map +4 -4
- package/plugin/package-lock.json +2 -2
- package/plugin/package.json +1 -1
- package/src/adapters/common.ts +1 -1
- package/src/adapters/dotnet-format.ts +125 -0
- package/src/adapters/index.ts +4 -0
- package/src/crap-config.ts +27 -4
- package/src/dashboard/server.ts +1 -1
- package/src/index.ts +88 -5
- package/src/monorepo/project-map.ts +476 -0
- package/src/scanner/bootstrap.ts +7 -1
- package/src/scanner/detector.ts +7 -2
- package/src/scanner/runner.ts +13 -0
- package/src/schemas/tool-schemas.ts +17 -1
- package/src/tests/adapters/dispatch.test.ts +1 -1
- package/src/tests/auto-scan.test.ts +2 -2
- package/src/tests/boot-monorepo.test.ts +804 -0
- package/src/tests/boot-scanner-detection.test.ts +692 -0
- package/src/tests/boot-single-project.test.ts +780 -0
- package/src/tests/integration/mcp-server.integration.test.ts +2 -1
- package/src/tests/project-map.test.ts +302 -0
- package/src/tests/scanner-detector.test.ts +4 -4
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comprehensive boot-time scanner detection tests.
|
|
3
|
+
*
|
|
4
|
+
* Exercises every detection layer (config file, package.json dependency,
|
|
5
|
+
* binary probe) for all six supported scanners, plus the monorepo
|
|
6
|
+
* subdirectory probing exported by detectMonorepoScanners.
|
|
7
|
+
*
|
|
8
|
+
* Test inventory
|
|
9
|
+
* ──────────────
|
|
10
|
+
* Config file detection : ESLint (×3), Semgrep, Bandit (×2), Stryker (×2), Dart (×2)
|
|
11
|
+
* package.json + binary : ESLint not-installed, ESLint installed, ESLint deps key,
|
|
12
|
+
* Stryker not-installed, Stryker installed
|
|
13
|
+
* Empty workspace : returns exactly 6 ScannerDetections
|
|
14
|
+
* Monorepo subdir probing : npm workspaces, Dart in subdir, multi-scanner multi-dir,
|
|
15
|
+
* apps/ directory scan, root+subdir dedup, hidden dir skip
|
|
16
|
+
* Signal coverage : all 6 scanners present, no empty signals
|
|
17
|
+
* Edge cases : malformed package.json, empty config file, multi-config dedup
|
|
18
|
+
*
|
|
19
|
+
* @module tests/boot-scanner-detection.test
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it } from "node:test";
|
|
23
|
+
import assert from "node:assert/strict";
|
|
24
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
detectScanners,
|
|
30
|
+
detectMonorepoScanners,
|
|
31
|
+
SCANNER_SIGNALS,
|
|
32
|
+
MONOREPO_DIRS,
|
|
33
|
+
} from "../scanner/detector.js";
|
|
34
|
+
import { KNOWN_SCANNERS } from "../adapters/common.js";
|
|
35
|
+
|
|
36
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function makeTmpDir(): string {
|
|
39
|
+
return mkdtempSync(join(tmpdir(), "crap-boot-detect-"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Create a directory (including parents) inside a temp root. */
|
|
43
|
+
function mkdir(base: string, ...segments: string[]): string {
|
|
44
|
+
const full = join(base, ...segments);
|
|
45
|
+
mkdirSync(full, { recursive: true });
|
|
46
|
+
return full;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Write a file, creating parent directories if needed. */
|
|
50
|
+
function touch(base: string, ...segments: string[]): void {
|
|
51
|
+
const full = join(base, ...segments);
|
|
52
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
53
|
+
writeFileSync(full, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Config file detection ─────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe("Config file detection — ESLint", () => {
|
|
59
|
+
it("detects eslint via eslint.config.mjs", async () => {
|
|
60
|
+
const dir = makeTmpDir();
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(join(dir, "eslint.config.mjs"), "export default [];");
|
|
63
|
+
const results = await detectScanners(dir);
|
|
64
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
65
|
+
assert.ok(eslint, "eslint detection missing");
|
|
66
|
+
assert.equal(eslint.available, true);
|
|
67
|
+
assert.ok(eslint.reason.includes("eslint.config.mjs"), `unexpected reason: ${eslint.reason}`);
|
|
68
|
+
assert.ok(eslint.configPath, "configPath should be set");
|
|
69
|
+
assert.ok(eslint.configPath!.endsWith("eslint.config.mjs"));
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("detects eslint via eslint.config.js", async () => {
|
|
76
|
+
const dir = makeTmpDir();
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(join(dir, "eslint.config.js"), "export default [];");
|
|
79
|
+
const results = await detectScanners(dir);
|
|
80
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
81
|
+
assert.ok(eslint);
|
|
82
|
+
assert.equal(eslint.available, true);
|
|
83
|
+
assert.ok(eslint.reason.includes("eslint.config.js"), `unexpected reason: ${eslint.reason}`);
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("detects eslint via legacy .eslintrc.json", async () => {
|
|
90
|
+
const dir = makeTmpDir();
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync(join(dir, ".eslintrc.json"), "{}");
|
|
93
|
+
const results = await detectScanners(dir);
|
|
94
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
95
|
+
assert.ok(eslint);
|
|
96
|
+
assert.equal(eslint.available, true);
|
|
97
|
+
assert.ok(eslint.reason.includes(".eslintrc.json"), `unexpected reason: ${eslint.reason}`);
|
|
98
|
+
assert.ok(eslint.configPath!.endsWith(".eslintrc.json"));
|
|
99
|
+
} finally {
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("Config file detection — Semgrep", () => {
|
|
106
|
+
it("detects semgrep via .semgrep.yml", async () => {
|
|
107
|
+
const dir = makeTmpDir();
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(join(dir, ".semgrep.yml"), "rules: []");
|
|
110
|
+
const results = await detectScanners(dir);
|
|
111
|
+
const semgrep = results.find((r) => r.scanner === "semgrep");
|
|
112
|
+
assert.ok(semgrep);
|
|
113
|
+
assert.equal(semgrep.available, true);
|
|
114
|
+
assert.ok(semgrep.reason.includes(".semgrep.yml"), `unexpected reason: ${semgrep.reason}`);
|
|
115
|
+
assert.ok(semgrep.configPath!.endsWith(".semgrep.yml"));
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("Config file detection — Bandit", () => {
|
|
123
|
+
it("detects bandit via .bandit", async () => {
|
|
124
|
+
const dir = makeTmpDir();
|
|
125
|
+
try {
|
|
126
|
+
writeFileSync(join(dir, ".bandit"), "[bandit]");
|
|
127
|
+
const results = await detectScanners(dir);
|
|
128
|
+
const bandit = results.find((r) => r.scanner === "bandit");
|
|
129
|
+
assert.ok(bandit);
|
|
130
|
+
assert.equal(bandit.available, true);
|
|
131
|
+
assert.ok(bandit.reason.includes(".bandit"), `unexpected reason: ${bandit.reason}`);
|
|
132
|
+
} finally {
|
|
133
|
+
rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("detects bandit via bandit.yaml", async () => {
|
|
138
|
+
const dir = makeTmpDir();
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(join(dir, "bandit.yaml"), "profiles: {}");
|
|
141
|
+
const results = await detectScanners(dir);
|
|
142
|
+
const bandit = results.find((r) => r.scanner === "bandit");
|
|
143
|
+
assert.ok(bandit);
|
|
144
|
+
assert.equal(bandit.available, true);
|
|
145
|
+
assert.ok(bandit.reason.includes("bandit.yaml"), `unexpected reason: ${bandit.reason}`);
|
|
146
|
+
} finally {
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("Config file detection — Stryker", () => {
|
|
153
|
+
it("detects stryker via stryker.conf.js", async () => {
|
|
154
|
+
const dir = makeTmpDir();
|
|
155
|
+
try {
|
|
156
|
+
writeFileSync(join(dir, "stryker.conf.js"), "module.exports = {};");
|
|
157
|
+
const results = await detectScanners(dir);
|
|
158
|
+
const stryker = results.find((r) => r.scanner === "stryker");
|
|
159
|
+
assert.ok(stryker);
|
|
160
|
+
assert.equal(stryker.available, true);
|
|
161
|
+
assert.ok(stryker.reason.includes("stryker.conf.js"), `unexpected reason: ${stryker.reason}`);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("detects stryker via .strykerrc.json", async () => {
|
|
168
|
+
const dir = makeTmpDir();
|
|
169
|
+
try {
|
|
170
|
+
writeFileSync(join(dir, ".strykerrc.json"), "{}");
|
|
171
|
+
const results = await detectScanners(dir);
|
|
172
|
+
const stryker = results.find((r) => r.scanner === "stryker");
|
|
173
|
+
assert.ok(stryker);
|
|
174
|
+
assert.equal(stryker.available, true);
|
|
175
|
+
assert.ok(stryker.reason.includes(".strykerrc.json"), `unexpected reason: ${stryker.reason}`);
|
|
176
|
+
} finally {
|
|
177
|
+
rmSync(dir, { recursive: true, force: true });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("Config file detection — Dart", () => {
|
|
183
|
+
it("detects dart_analyze via pubspec.yaml — config layer fires regardless of PATH", async () => {
|
|
184
|
+
const dir = makeTmpDir();
|
|
185
|
+
try {
|
|
186
|
+
writeFileSync(join(dir, "pubspec.yaml"), "name: my_app\nsdkversion: '>=3.0.0 <4.0.0'");
|
|
187
|
+
const results = await detectScanners(dir);
|
|
188
|
+
const dart = results.find((r) => r.scanner === "dart_analyze");
|
|
189
|
+
assert.ok(dart);
|
|
190
|
+
// Config probe short-circuits; available is true whether or not dart binary exists
|
|
191
|
+
assert.equal(dart.available, true);
|
|
192
|
+
assert.ok(dart.reason.includes("pubspec.yaml"), `unexpected reason: ${dart.reason}`);
|
|
193
|
+
} finally {
|
|
194
|
+
rmSync(dir, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("detects dart_analyze via analysis_options.yaml", async () => {
|
|
199
|
+
const dir = makeTmpDir();
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(join(dir, "analysis_options.yaml"), "analyzer:\n errors: {}");
|
|
202
|
+
const results = await detectScanners(dir);
|
|
203
|
+
const dart = results.find((r) => r.scanner === "dart_analyze");
|
|
204
|
+
assert.ok(dart);
|
|
205
|
+
assert.equal(dart.available, true);
|
|
206
|
+
assert.ok(
|
|
207
|
+
dart.reason.includes("analysis_options.yaml"),
|
|
208
|
+
`unexpected reason: ${dart.reason}`,
|
|
209
|
+
);
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(dir, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ── package.json + binary detection ──────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
describe("package.json detection + binary validation", () => {
|
|
219
|
+
it("ESLint in devDependencies — declared but not installed → available:false", async () => {
|
|
220
|
+
const dir = makeTmpDir();
|
|
221
|
+
try {
|
|
222
|
+
writeFileSync(
|
|
223
|
+
join(dir, "package.json"),
|
|
224
|
+
JSON.stringify({ devDependencies: { eslint: "^9.0.0" } }),
|
|
225
|
+
);
|
|
226
|
+
const results = await detectScanners(dir);
|
|
227
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
228
|
+
assert.ok(eslint);
|
|
229
|
+
assert.equal(eslint.available, false);
|
|
230
|
+
assert.ok(
|
|
231
|
+
eslint.reason.toLowerCase().includes("not installed"),
|
|
232
|
+
`expected "not installed" in reason, got: ${eslint.reason}`,
|
|
233
|
+
);
|
|
234
|
+
} finally {
|
|
235
|
+
rmSync(dir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("ESLint in devDependencies — binary present → available:true", async () => {
|
|
240
|
+
const dir = makeTmpDir();
|
|
241
|
+
try {
|
|
242
|
+
writeFileSync(
|
|
243
|
+
join(dir, "package.json"),
|
|
244
|
+
JSON.stringify({ devDependencies: { eslint: "^9.0.0" } }),
|
|
245
|
+
);
|
|
246
|
+
mkdir(dir, "node_modules", ".bin");
|
|
247
|
+
writeFileSync(join(dir, "node_modules", ".bin", "eslint"), "#!/usr/bin/env node");
|
|
248
|
+
|
|
249
|
+
const results = await detectScanners(dir);
|
|
250
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
251
|
+
assert.ok(eslint);
|
|
252
|
+
assert.equal(eslint.available, true);
|
|
253
|
+
assert.ok(
|
|
254
|
+
eslint.reason.includes("installed"),
|
|
255
|
+
`expected "installed" in reason, got: ${eslint.reason}`,
|
|
256
|
+
);
|
|
257
|
+
} finally {
|
|
258
|
+
rmSync(dir, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("ESLint in dependencies (not devDependencies) — still detected", async () => {
|
|
263
|
+
const dir = makeTmpDir();
|
|
264
|
+
try {
|
|
265
|
+
writeFileSync(
|
|
266
|
+
join(dir, "package.json"),
|
|
267
|
+
JSON.stringify({ dependencies: { eslint: "^9.0.0" } }),
|
|
268
|
+
);
|
|
269
|
+
mkdir(dir, "node_modules", ".bin");
|
|
270
|
+
writeFileSync(join(dir, "node_modules", ".bin", "eslint"), "#!/usr/bin/env node");
|
|
271
|
+
|
|
272
|
+
const results = await detectScanners(dir);
|
|
273
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
274
|
+
assert.ok(eslint);
|
|
275
|
+
assert.equal(eslint.available, true);
|
|
276
|
+
} finally {
|
|
277
|
+
rmSync(dir, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("Stryker via @stryker-mutator/core — declared but not installed → available:false", async () => {
|
|
282
|
+
const dir = makeTmpDir();
|
|
283
|
+
try {
|
|
284
|
+
writeFileSync(
|
|
285
|
+
join(dir, "package.json"),
|
|
286
|
+
JSON.stringify({ devDependencies: { "@stryker-mutator/core": "^7.0.0" } }),
|
|
287
|
+
);
|
|
288
|
+
const results = await detectScanners(dir);
|
|
289
|
+
const stryker = results.find((r) => r.scanner === "stryker");
|
|
290
|
+
assert.ok(stryker);
|
|
291
|
+
assert.equal(stryker.available, false);
|
|
292
|
+
assert.ok(
|
|
293
|
+
stryker.reason.toLowerCase().includes("not installed"),
|
|
294
|
+
`expected "not installed" in reason, got: ${stryker.reason}`,
|
|
295
|
+
);
|
|
296
|
+
} finally {
|
|
297
|
+
rmSync(dir, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("Stryker via @stryker-mutator/core — binary present → available:true", async () => {
|
|
302
|
+
const dir = makeTmpDir();
|
|
303
|
+
try {
|
|
304
|
+
writeFileSync(
|
|
305
|
+
join(dir, "package.json"),
|
|
306
|
+
JSON.stringify({ devDependencies: { "@stryker-mutator/core": "^7.0.0" } }),
|
|
307
|
+
);
|
|
308
|
+
mkdir(dir, "node_modules", ".bin");
|
|
309
|
+
writeFileSync(join(dir, "node_modules", ".bin", "stryker"), "#!/usr/bin/env node");
|
|
310
|
+
|
|
311
|
+
const results = await detectScanners(dir);
|
|
312
|
+
const stryker = results.find((r) => r.scanner === "stryker");
|
|
313
|
+
assert.ok(stryker);
|
|
314
|
+
assert.equal(stryker.available, true);
|
|
315
|
+
assert.ok(
|
|
316
|
+
stryker.reason.includes("installed"),
|
|
317
|
+
`expected "installed" in reason, got: ${stryker.reason}`,
|
|
318
|
+
);
|
|
319
|
+
} finally {
|
|
320
|
+
rmSync(dir, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ── Empty workspace ───────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
describe("Empty workspace", () => {
|
|
328
|
+
it("returns exactly 6 ScannerDetection objects for an empty directory", async () => {
|
|
329
|
+
const dir = makeTmpDir();
|
|
330
|
+
try {
|
|
331
|
+
const results = await detectScanners(dir);
|
|
332
|
+
assert.equal(results.length, 6, `expected 6 results, got ${results.length}`);
|
|
333
|
+
} finally {
|
|
334
|
+
rmSync(dir, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("every result has the correct shape (scanner, available, reason)", async () => {
|
|
339
|
+
const dir = makeTmpDir();
|
|
340
|
+
try {
|
|
341
|
+
const results = await detectScanners(dir);
|
|
342
|
+
const knownSet = new Set<string>(KNOWN_SCANNERS);
|
|
343
|
+
for (const r of results) {
|
|
344
|
+
assert.ok(knownSet.has(r.scanner), `unexpected scanner name: ${r.scanner}`);
|
|
345
|
+
assert.equal(typeof r.available, "boolean", `available must be boolean for ${r.scanner}`);
|
|
346
|
+
assert.ok(r.reason.length > 0, `reason must not be empty for ${r.scanner}`);
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
rmSync(dir, { recursive: true, force: true });
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("config-less scanners all return available:false in an isolated empty directory", async () => {
|
|
354
|
+
// We create a temp dir that is NOT in PATH, has no package.json, and no
|
|
355
|
+
// config files. The binary probe runs against the real PATH, so
|
|
356
|
+
// scanners that happen to be installed on the host machine may resolve
|
|
357
|
+
// as available:true — that is correct behaviour, not a test failure.
|
|
358
|
+
// What we can assert is that the "not found" reason is well-formed when
|
|
359
|
+
// a scanner is not available.
|
|
360
|
+
const dir = makeTmpDir();
|
|
361
|
+
try {
|
|
362
|
+
const results = await detectScanners(dir);
|
|
363
|
+
const unavailable = results.filter((r) => !r.available);
|
|
364
|
+
for (const r of unavailable) {
|
|
365
|
+
assert.ok(
|
|
366
|
+
r.reason.length > 0,
|
|
367
|
+
`unavailable scanner ${r.scanner} must have a non-empty reason`,
|
|
368
|
+
);
|
|
369
|
+
// configPath should not be set when scanner is not available
|
|
370
|
+
assert.equal(
|
|
371
|
+
r.configPath,
|
|
372
|
+
undefined,
|
|
373
|
+
`configPath should be undefined for unavailable scanner ${r.scanner}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
} finally {
|
|
377
|
+
rmSync(dir, { recursive: true, force: true });
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ── Monorepo subdirectory probing ─────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
describe("detectMonorepoScanners", () => {
|
|
385
|
+
it("npm workspaces + subdirectory eslint config — workingDir set correctly", async () => {
|
|
386
|
+
const dir = makeTmpDir();
|
|
387
|
+
try {
|
|
388
|
+
// Root workspace package.json pointing to apps/web
|
|
389
|
+
mkdir(dir, "apps", "web");
|
|
390
|
+
writeFileSync(
|
|
391
|
+
join(dir, "package.json"),
|
|
392
|
+
JSON.stringify({ name: "monorepo", workspaces: ["apps/web"] }),
|
|
393
|
+
);
|
|
394
|
+
writeFileSync(join(dir, "apps", "web", "eslint.config.mjs"), "export default [];");
|
|
395
|
+
|
|
396
|
+
const detections = await detectMonorepoScanners(dir);
|
|
397
|
+
const eslint = detections.find((d) => d.scanner === "eslint");
|
|
398
|
+
assert.ok(eslint, "eslint should be detected in apps/web via npm workspaces");
|
|
399
|
+
assert.equal(eslint.available, true);
|
|
400
|
+
assert.ok(eslint.workingDir, "workingDir must be set for monorepo detection");
|
|
401
|
+
assert.ok(
|
|
402
|
+
eslint.workingDir!.endsWith(join("apps", "web")),
|
|
403
|
+
`workingDir should end with apps/web, got: ${eslint.workingDir}`,
|
|
404
|
+
);
|
|
405
|
+
} finally {
|
|
406
|
+
rmSync(dir, { recursive: true, force: true });
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("Dart in apps/mobile — detected with workingDir when dart binary is on PATH", async () => {
|
|
411
|
+
const dir = makeTmpDir();
|
|
412
|
+
try {
|
|
413
|
+
mkdir(dir, "apps", "mobile");
|
|
414
|
+
writeFileSync(join(dir, "apps", "mobile", "pubspec.yaml"), "name: mobile_app");
|
|
415
|
+
|
|
416
|
+
const detections = await detectMonorepoScanners(dir);
|
|
417
|
+
// dart_analyze is only emitted when the dart binary is available.
|
|
418
|
+
// On CI machines without dart the detection is absent — that is correct.
|
|
419
|
+
const dart = detections.find((d) => d.scanner === "dart_analyze");
|
|
420
|
+
if (dart) {
|
|
421
|
+
assert.equal(dart.available, true);
|
|
422
|
+
assert.ok(dart.workingDir, "workingDir must be set");
|
|
423
|
+
assert.ok(
|
|
424
|
+
dart.workingDir!.endsWith(join("apps", "mobile")),
|
|
425
|
+
`workingDir should end with apps/mobile, got: ${dart.workingDir}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
// Whether or not dart is available, no other scanner should appear for mobile
|
|
429
|
+
const otherDetections = detections.filter(
|
|
430
|
+
(d) => d.scanner !== "dart_analyze",
|
|
431
|
+
);
|
|
432
|
+
assert.equal(
|
|
433
|
+
otherDetections.length,
|
|
434
|
+
0,
|
|
435
|
+
`unexpected detections: ${otherDetections.map((d) => d.scanner).join(", ")}`,
|
|
436
|
+
);
|
|
437
|
+
} finally {
|
|
438
|
+
rmSync(dir, { recursive: true, force: true });
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("multiple scanners in different subdirs — different workingDirs returned", async () => {
|
|
443
|
+
const dir = makeTmpDir();
|
|
444
|
+
try {
|
|
445
|
+
mkdir(dir, "apps", "web");
|
|
446
|
+
mkdir(dir, "apps", "mobile");
|
|
447
|
+
writeFileSync(join(dir, "apps", "web", "eslint.config.mjs"), "export default [];");
|
|
448
|
+
writeFileSync(join(dir, "apps", "mobile", "pubspec.yaml"), "name: mobile_app");
|
|
449
|
+
|
|
450
|
+
const detections = await detectMonorepoScanners(dir);
|
|
451
|
+
|
|
452
|
+
// ESLint in apps/web is always detectable (no binary requirement)
|
|
453
|
+
const eslint = detections.find((d) => d.scanner === "eslint");
|
|
454
|
+
assert.ok(eslint, "eslint should be detected in apps/web");
|
|
455
|
+
assert.ok(eslint.workingDir!.endsWith(join("apps", "web")));
|
|
456
|
+
|
|
457
|
+
// dart_analyze in apps/mobile only appears when dart binary is present
|
|
458
|
+
const dart = detections.find((d) => d.scanner === "dart_analyze");
|
|
459
|
+
if (dart) {
|
|
460
|
+
assert.ok(dart.workingDir!.endsWith(join("apps", "mobile")));
|
|
461
|
+
// The two detections must have different workingDirs
|
|
462
|
+
assert.notEqual(eslint.workingDir, dart.workingDir);
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
rmSync(dir, { recursive: true, force: true });
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("apps/ directory scan — no npm workspaces, two subdirs with configs both detected", async () => {
|
|
470
|
+
const dir = makeTmpDir();
|
|
471
|
+
try {
|
|
472
|
+
// No package.json — relies on MONOREPO_DIRS scanning
|
|
473
|
+
mkdir(dir, "apps", "frontend");
|
|
474
|
+
mkdir(dir, "apps", "backend");
|
|
475
|
+
writeFileSync(join(dir, "apps", "frontend", "eslint.config.mjs"), "export default [];");
|
|
476
|
+
writeFileSync(join(dir, "apps", "backend", ".semgrep.yml"), "rules: []");
|
|
477
|
+
|
|
478
|
+
const detections = await detectMonorepoScanners(dir);
|
|
479
|
+
const eslint = detections.find(
|
|
480
|
+
(d) => d.scanner === "eslint" && d.workingDir!.endsWith(join("apps", "frontend")),
|
|
481
|
+
);
|
|
482
|
+
const semgrep = detections.find(
|
|
483
|
+
(d) => d.scanner === "semgrep" && d.workingDir!.endsWith(join("apps", "backend")),
|
|
484
|
+
);
|
|
485
|
+
assert.ok(eslint, "eslint should be detected in apps/frontend");
|
|
486
|
+
assert.ok(semgrep, "semgrep should be detected in apps/backend");
|
|
487
|
+
} finally {
|
|
488
|
+
rmSync(dir, { recursive: true, force: true });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("root + subdir with same scanner — subdir detection is still returned", async () => {
|
|
493
|
+
// dedup between root and subdir is the auto-scan orchestrator's job,
|
|
494
|
+
// not the detector's. The detector must return the subdir detection.
|
|
495
|
+
const dir = makeTmpDir();
|
|
496
|
+
try {
|
|
497
|
+
writeFileSync(join(dir, "eslint.config.mjs"), "export default [];");
|
|
498
|
+
mkdir(dir, "apps", "web");
|
|
499
|
+
writeFileSync(join(dir, "apps", "web", "eslint.config.mjs"), "export default [];");
|
|
500
|
+
|
|
501
|
+
const detections = await detectMonorepoScanners(dir);
|
|
502
|
+
const eslintInWeb = detections.find(
|
|
503
|
+
(d) => d.scanner === "eslint" && d.workingDir!.endsWith(join("apps", "web")),
|
|
504
|
+
);
|
|
505
|
+
assert.ok(
|
|
506
|
+
eslintInWeb,
|
|
507
|
+
"detector should still emit the subdir detection even when root also has eslint",
|
|
508
|
+
);
|
|
509
|
+
} finally {
|
|
510
|
+
rmSync(dir, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("hidden directories under apps/ are skipped", async () => {
|
|
515
|
+
const dir = makeTmpDir();
|
|
516
|
+
try {
|
|
517
|
+
mkdir(dir, "apps", ".hidden");
|
|
518
|
+
writeFileSync(join(dir, "apps", ".hidden", "pubspec.yaml"), "name: hidden_app");
|
|
519
|
+
|
|
520
|
+
const detections = await detectMonorepoScanners(dir);
|
|
521
|
+
// .hidden should be ignored regardless of whether dart is on PATH
|
|
522
|
+
assert.equal(
|
|
523
|
+
detections.length,
|
|
524
|
+
0,
|
|
525
|
+
`expected 0 detections from hidden dir, got: ${detections.map((d) => d.scanner).join(", ")}`,
|
|
526
|
+
);
|
|
527
|
+
} finally {
|
|
528
|
+
rmSync(dir, { recursive: true, force: true });
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("returns empty array when workspace has no monorepo subdirs and no package.json workspaces", async () => {
|
|
533
|
+
const dir = makeTmpDir();
|
|
534
|
+
try {
|
|
535
|
+
// No apps/, packages/, libs/, modules/, services/, no package.json
|
|
536
|
+
const detections = await detectMonorepoScanners(dir);
|
|
537
|
+
assert.equal(detections.length, 0);
|
|
538
|
+
} finally {
|
|
539
|
+
rmSync(dir, { recursive: true, force: true });
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// ── Signal coverage ───────────────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
describe("SCANNER_SIGNALS coverage", () => {
|
|
547
|
+
it("has an entry for every KnownScanner", () => {
|
|
548
|
+
const signalKeys = Object.keys(SCANNER_SIGNALS).sort();
|
|
549
|
+
const knownKeys = [...KNOWN_SCANNERS].sort();
|
|
550
|
+
assert.deepEqual(
|
|
551
|
+
signalKeys,
|
|
552
|
+
knownKeys,
|
|
553
|
+
`SCANNER_SIGNALS keys (${signalKeys.join(", ")}) do not match KNOWN_SCANNERS (${knownKeys.join(", ")})`,
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("every scanner signal has at least configFiles or binaryNames defined", () => {
|
|
558
|
+
for (const [scanner, signals] of Object.entries(SCANNER_SIGNALS)) {
|
|
559
|
+
const hasConfig = signals.configFiles.length > 0;
|
|
560
|
+
const hasBinary = signals.binaryNames.length > 0;
|
|
561
|
+
assert.ok(
|
|
562
|
+
hasConfig || hasBinary,
|
|
563
|
+
`${scanner}: signals must have at least one configFile or binaryName — both are empty`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("MONOREPO_DIRS is a non-empty array of strings", () => {
|
|
569
|
+
assert.ok(Array.isArray(MONOREPO_DIRS), "MONOREPO_DIRS must be an array");
|
|
570
|
+
assert.ok(MONOREPO_DIRS.length > 0, "MONOREPO_DIRS must not be empty");
|
|
571
|
+
for (const d of MONOREPO_DIRS) {
|
|
572
|
+
assert.equal(typeof d, "string", `each MONOREPO_DIRS entry must be a string, got ${typeof d}`);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("MONOREPO_DIRS includes apps and packages", () => {
|
|
577
|
+
assert.ok(MONOREPO_DIRS.includes("apps"), 'MONOREPO_DIRS must include "apps"');
|
|
578
|
+
assert.ok(MONOREPO_DIRS.includes("packages"), 'MONOREPO_DIRS must include "packages"');
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// ── Edge cases ────────────────────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
describe("Edge cases", () => {
|
|
585
|
+
it("malformed package.json — no crash, eslint not detected from deps", async () => {
|
|
586
|
+
const dir = makeTmpDir();
|
|
587
|
+
try {
|
|
588
|
+
writeFileSync(join(dir, "package.json"), "not json at all");
|
|
589
|
+
// Should not throw — falls through to binary probe
|
|
590
|
+
const results = await detectScanners(dir);
|
|
591
|
+
assert.equal(results.length, 6, "must still return 6 results despite malformed package.json");
|
|
592
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
593
|
+
assert.ok(eslint);
|
|
594
|
+
// With malformed JSON, the package.json probe fails silently.
|
|
595
|
+
// If eslint is available it must be via PATH, not via deps.
|
|
596
|
+
if (!eslint.available) {
|
|
597
|
+
assert.ok(
|
|
598
|
+
eslint.reason.includes("no config file") || eslint.reason.includes("binary"),
|
|
599
|
+
`unexpected reason for failed eslint: ${eslint.reason}`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
} finally {
|
|
603
|
+
rmSync(dir, { recursive: true, force: true });
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("empty config file is still detected — existence is enough", async () => {
|
|
608
|
+
const dir = makeTmpDir();
|
|
609
|
+
try {
|
|
610
|
+
// Write a completely empty file (zero bytes)
|
|
611
|
+
writeFileSync(join(dir, ".semgrep.yml"), "");
|
|
612
|
+
const results = await detectScanners(dir);
|
|
613
|
+
const semgrep = results.find((r) => r.scanner === "semgrep");
|
|
614
|
+
assert.ok(semgrep);
|
|
615
|
+
assert.equal(
|
|
616
|
+
semgrep.available,
|
|
617
|
+
true,
|
|
618
|
+
"empty config file should still trigger config-file detection",
|
|
619
|
+
);
|
|
620
|
+
} finally {
|
|
621
|
+
rmSync(dir, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("multiple config files for same scanner — exactly one detection returned", async () => {
|
|
626
|
+
const dir = makeTmpDir();
|
|
627
|
+
try {
|
|
628
|
+
// Both eslint.config.js (earlier in signal list) and .eslintrc.json exist
|
|
629
|
+
writeFileSync(join(dir, "eslint.config.js"), "export default [];");
|
|
630
|
+
writeFileSync(join(dir, ".eslintrc.json"), "{}");
|
|
631
|
+
const results = await detectScanners(dir);
|
|
632
|
+
const eslintResults = results.filter((r) => r.scanner === "eslint");
|
|
633
|
+
assert.equal(
|
|
634
|
+
eslintResults.length,
|
|
635
|
+
1,
|
|
636
|
+
`expected exactly 1 eslint detection, got ${eslintResults.length}`,
|
|
637
|
+
);
|
|
638
|
+
// Should be the first one in the configFiles order (eslint.config.js)
|
|
639
|
+
assert.ok(eslintResults[0].configPath!.endsWith("eslint.config.js"));
|
|
640
|
+
} finally {
|
|
641
|
+
rmSync(dir, { recursive: true, force: true });
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("config-file detection short-circuits — reason mentions config file not binary", async () => {
|
|
646
|
+
const dir = makeTmpDir();
|
|
647
|
+
try {
|
|
648
|
+
writeFileSync(join(dir, "eslint.config.mjs"), "export default [];");
|
|
649
|
+
const results = await detectScanners(dir);
|
|
650
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
651
|
+
assert.ok(eslint);
|
|
652
|
+
assert.equal(eslint.available, true);
|
|
653
|
+
assert.ok(
|
|
654
|
+
eslint.reason.includes("config file"),
|
|
655
|
+
`reason should mention "config file", got: ${eslint.reason}`,
|
|
656
|
+
);
|
|
657
|
+
assert.ok(
|
|
658
|
+
!eslint.reason.includes("binary"),
|
|
659
|
+
`reason should not mention "binary" when short-circuited by config file, got: ${eslint.reason}`,
|
|
660
|
+
);
|
|
661
|
+
} finally {
|
|
662
|
+
rmSync(dir, { recursive: true, force: true });
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("dotnet_format has no configFiles — relies on binary probe only", () => {
|
|
667
|
+
const signals = SCANNER_SIGNALS["dotnet_format"];
|
|
668
|
+
assert.equal(
|
|
669
|
+
signals.configFiles.length,
|
|
670
|
+
0,
|
|
671
|
+
"dotnet_format should have no config files (binary-only scanner)",
|
|
672
|
+
);
|
|
673
|
+
assert.ok(
|
|
674
|
+
signals.binaryNames.length > 0,
|
|
675
|
+
"dotnet_format must have at least one binaryName",
|
|
676
|
+
);
|
|
677
|
+
assert.ok(signals.binaryNames.includes("dotnet"));
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("all 6 results share the same ordered scanner list across multiple calls", async () => {
|
|
681
|
+
const dir = makeTmpDir();
|
|
682
|
+
try {
|
|
683
|
+
const results1 = await detectScanners(dir);
|
|
684
|
+
const results2 = await detectScanners(dir);
|
|
685
|
+
const names1 = results1.map((r) => r.scanner);
|
|
686
|
+
const names2 = results2.map((r) => r.scanner);
|
|
687
|
+
assert.deepEqual(names1, names2, "scanner order must be deterministic");
|
|
688
|
+
} finally {
|
|
689
|
+
rmSync(dir, { recursive: true, force: true });
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
});
|