claude-crap 0.1.2 → 0.2.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 +35 -0
- package/README.md +43 -23
- package/dist/index.js +79 -1
- package/dist/index.js.map +1 -1
- package/dist/scanner/auto-scan.d.ts +57 -0
- package/dist/scanner/auto-scan.d.ts.map +1 -0
- package/dist/scanner/auto-scan.js +138 -0
- package/dist/scanner/auto-scan.js.map +1 -0
- package/dist/scanner/detector.d.ts +53 -0
- package/dist/scanner/detector.d.ts.map +1 -0
- package/dist/scanner/detector.js +173 -0
- package/dist/scanner/detector.js.map +1 -0
- package/dist/scanner/index.d.ts +22 -0
- package/dist/scanner/index.d.ts.map +1 -0
- package/dist/scanner/index.js +22 -0
- package/dist/scanner/index.js.map +1 -0
- package/dist/scanner/runner.d.ts +59 -0
- package/dist/scanner/runner.d.ts.map +1 -0
- package/dist/scanner/runner.js +159 -0
- package/dist/scanner/runner.js.map +1 -0
- package/dist/schemas/tool-schemas.d.ts +11 -0
- package/dist/schemas/tool-schemas.d.ts.map +1 -1
- package/dist/schemas/tool-schemas.js +11 -0
- package/dist/schemas/tool-schemas.js.map +1 -1
- package/package.json +5 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bundle/mcp-server.mjs +452 -0
- package/plugin/bundle/mcp-server.mjs.map +4 -4
- package/plugin/package.json +1 -1
- package/src/index.ts +98 -0
- package/src/scanner/auto-scan.ts +212 -0
- package/src/scanner/detector.ts +224 -0
- package/src/scanner/index.ts +22 -0
- package/src/scanner/runner.ts +212 -0
- package/src/schemas/tool-schemas.ts +13 -0
- package/src/tests/auto-scan.test.ts +137 -0
- package/src/tests/integration/mcp-server.integration.test.ts +2 -1
- package/src/tests/scanner-detector.test.ts +181 -0
- package/src/tests/scanner-runner.test.ts +63 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute a single scanner CLI and capture its raw output.
|
|
3
|
+
*
|
|
4
|
+
* Each scanner has a fixed invocation that produces the format its
|
|
5
|
+
* adapter expects:
|
|
6
|
+
*
|
|
7
|
+
* - ESLint → `npx eslint -f json .` (JSON array)
|
|
8
|
+
* - Semgrep → `semgrep --sarif --quiet .` (SARIF 2.1.0)
|
|
9
|
+
* - Bandit → `bandit -f json -r . -q` (JSON object)
|
|
10
|
+
* - Stryker → `npx stryker run` then read `reports/mutation/mutation.json`
|
|
11
|
+
*
|
|
12
|
+
* ESLint and Bandit exit non-zero when findings exist — that is
|
|
13
|
+
* expected, not an error. The runner captures stdout regardless of
|
|
14
|
+
* exit code for those scanners.
|
|
15
|
+
*
|
|
16
|
+
* Stryker is special: it writes to a file instead of stdout, so we
|
|
17
|
+
* read the output file after the process exits.
|
|
18
|
+
*
|
|
19
|
+
* @module scanner/runner
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import type { KnownScanner } from "../adapters/common.js";
|
|
26
|
+
|
|
27
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Result of executing a single scanner.
|
|
31
|
+
*/
|
|
32
|
+
export interface ScannerRunResult {
|
|
33
|
+
/** Which scanner was executed. */
|
|
34
|
+
scanner: KnownScanner;
|
|
35
|
+
/** Whether execution completed and produced parseable output. */
|
|
36
|
+
success: boolean;
|
|
37
|
+
/** The scanner's raw output (stdout or file contents). */
|
|
38
|
+
rawOutput: string;
|
|
39
|
+
/** Error message when `success` is false. */
|
|
40
|
+
error?: string;
|
|
41
|
+
/** Wall-clock execution time in milliseconds. */
|
|
42
|
+
durationMs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Scanner command definitions ────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
interface ScannerCommand {
|
|
48
|
+
/** Binary or npx command. */
|
|
49
|
+
command: string;
|
|
50
|
+
/** CLI arguments. */
|
|
51
|
+
args: string[];
|
|
52
|
+
/** Maximum execution time in ms. */
|
|
53
|
+
timeoutMs: number;
|
|
54
|
+
/** If true, non-zero exit is expected when findings exist. */
|
|
55
|
+
nonZeroIsNormal: boolean;
|
|
56
|
+
/** If set, read output from this file instead of stdout. */
|
|
57
|
+
outputFile?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getScannerCommand(
|
|
61
|
+
scanner: KnownScanner,
|
|
62
|
+
workspaceRoot: string,
|
|
63
|
+
): ScannerCommand {
|
|
64
|
+
switch (scanner) {
|
|
65
|
+
case "eslint":
|
|
66
|
+
return {
|
|
67
|
+
command: "npx",
|
|
68
|
+
args: ["eslint", "-f", "json", "."],
|
|
69
|
+
timeoutMs: 120_000,
|
|
70
|
+
nonZeroIsNormal: true,
|
|
71
|
+
};
|
|
72
|
+
case "semgrep":
|
|
73
|
+
return {
|
|
74
|
+
command: "semgrep",
|
|
75
|
+
args: ["--sarif", "--quiet", "."],
|
|
76
|
+
timeoutMs: 120_000,
|
|
77
|
+
nonZeroIsNormal: false,
|
|
78
|
+
};
|
|
79
|
+
case "bandit":
|
|
80
|
+
return {
|
|
81
|
+
command: "bandit",
|
|
82
|
+
args: ["-f", "json", "-r", ".", "-q"],
|
|
83
|
+
timeoutMs: 120_000,
|
|
84
|
+
nonZeroIsNormal: true,
|
|
85
|
+
};
|
|
86
|
+
case "stryker":
|
|
87
|
+
return {
|
|
88
|
+
command: "npx",
|
|
89
|
+
args: ["stryker", "run"],
|
|
90
|
+
timeoutMs: 300_000,
|
|
91
|
+
nonZeroIsNormal: false,
|
|
92
|
+
outputFile: join(workspaceRoot, "reports", "mutation", "mutation.json"),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Execute a scanner CLI and return its raw output.
|
|
101
|
+
*
|
|
102
|
+
* @param scanner Which scanner to run.
|
|
103
|
+
* @param workspaceRoot Absolute path to the project root (used as cwd).
|
|
104
|
+
* @returns A {@link ScannerRunResult} with stdout or file output.
|
|
105
|
+
*/
|
|
106
|
+
export function runScanner(
|
|
107
|
+
scanner: KnownScanner,
|
|
108
|
+
workspaceRoot: string,
|
|
109
|
+
): Promise<ScannerRunResult> {
|
|
110
|
+
const start = Date.now();
|
|
111
|
+
const cmd = getScannerCommand(scanner, workspaceRoot);
|
|
112
|
+
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
execFile(
|
|
115
|
+
cmd.command,
|
|
116
|
+
cmd.args,
|
|
117
|
+
{
|
|
118
|
+
cwd: workspaceRoot,
|
|
119
|
+
timeout: cmd.timeoutMs,
|
|
120
|
+
maxBuffer: 50 * 1024 * 1024, // 50 MB — large codebases produce verbose output
|
|
121
|
+
env: { ...process.env, FORCE_COLOR: "0" }, // suppress ANSI in output
|
|
122
|
+
},
|
|
123
|
+
(err, stdout, stderr) => {
|
|
124
|
+
const durationMs = Date.now() - start;
|
|
125
|
+
|
|
126
|
+
// For scanners where non-zero exit means "findings exist",
|
|
127
|
+
// we still have valid output in stdout.
|
|
128
|
+
if (err && !cmd.nonZeroIsNormal) {
|
|
129
|
+
// Stryker: check if the output file was written despite the error
|
|
130
|
+
if (cmd.outputFile && existsSync(cmd.outputFile)) {
|
|
131
|
+
try {
|
|
132
|
+
const fileOutput = readFileSync(cmd.outputFile, "utf-8");
|
|
133
|
+
resolve({
|
|
134
|
+
scanner,
|
|
135
|
+
success: true,
|
|
136
|
+
rawOutput: fileOutput,
|
|
137
|
+
durationMs,
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
} catch {
|
|
141
|
+
// Fall through to error path
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
resolve({
|
|
146
|
+
scanner,
|
|
147
|
+
success: false,
|
|
148
|
+
rawOutput: "",
|
|
149
|
+
error: stderr || (err as Error).message,
|
|
150
|
+
durationMs,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// For file-based output (Stryker), read from file
|
|
156
|
+
if (cmd.outputFile) {
|
|
157
|
+
if (existsSync(cmd.outputFile)) {
|
|
158
|
+
try {
|
|
159
|
+
const fileOutput = readFileSync(cmd.outputFile, "utf-8");
|
|
160
|
+
resolve({
|
|
161
|
+
scanner,
|
|
162
|
+
success: true,
|
|
163
|
+
rawOutput: fileOutput,
|
|
164
|
+
durationMs,
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
} catch (readErr) {
|
|
168
|
+
resolve({
|
|
169
|
+
scanner,
|
|
170
|
+
success: false,
|
|
171
|
+
rawOutput: "",
|
|
172
|
+
error: `Failed to read output file: ${(readErr as Error).message}`,
|
|
173
|
+
durationMs,
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
resolve({
|
|
179
|
+
scanner,
|
|
180
|
+
success: false,
|
|
181
|
+
rawOutput: "",
|
|
182
|
+
error: `Scanner completed but output file not found: ${cmd.outputFile}`,
|
|
183
|
+
durationMs,
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Stdout-based output
|
|
189
|
+
const output = stdout.trim();
|
|
190
|
+
if (!output) {
|
|
191
|
+
resolve({
|
|
192
|
+
scanner,
|
|
193
|
+
success: true,
|
|
194
|
+
rawOutput: "[]", // ESLint returns empty when no files match
|
|
195
|
+
durationMs,
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
resolve({
|
|
201
|
+
scanner,
|
|
202
|
+
success: true,
|
|
203
|
+
rawOutput: output,
|
|
204
|
+
durationMs,
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Exported for testing
|
|
212
|
+
export { getScannerCommand, type ScannerCommand };
|
|
@@ -199,6 +199,19 @@ export const ingestScannerOutputSchema = {
|
|
|
199
199
|
* from an external scanner, deduplicates against the internal store, and
|
|
200
200
|
* normalizes the output into claude-crap's canonical format.
|
|
201
201
|
*/
|
|
202
|
+
/**
|
|
203
|
+
* Schema for the `auto_scan` tool. Auto-detects available scanners
|
|
204
|
+
* in the workspace, runs them, and ingests findings into the SARIF store.
|
|
205
|
+
*/
|
|
206
|
+
export const autoScanSchema = {
|
|
207
|
+
type: "object",
|
|
208
|
+
description:
|
|
209
|
+
"Auto-detect available scanners (ESLint, Semgrep, Bandit, Stryker) in the workspace, execute them, and ingest findings into the SARIF store. Returns detection results, per-scanner execution stats, and total findings ingested. Call this to populate findings without manual scanner invocation.",
|
|
210
|
+
properties: {},
|
|
211
|
+
required: [],
|
|
212
|
+
additionalProperties: false,
|
|
213
|
+
} as const;
|
|
214
|
+
|
|
202
215
|
export const ingestSarifSchema = {
|
|
203
216
|
type: "object",
|
|
204
217
|
description:
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the auto-scan orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the orchestration logic: detection → run → ingest.
|
|
5
|
+
* They use a real (temporary) workspace with config files to trigger
|
|
6
|
+
* detection, but scanner execution will fail (binaries aren't installed
|
|
7
|
+
* in the test environment). This tests the graceful-failure path.
|
|
8
|
+
*
|
|
9
|
+
* @module tests/auto-scan.test
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import pino from "pino";
|
|
18
|
+
|
|
19
|
+
import { autoScan, type AutoScanResult } from "../scanner/auto-scan.js";
|
|
20
|
+
import { SarifStore } from "../sarif/sarif-store.js";
|
|
21
|
+
|
|
22
|
+
const logger = pino({ level: "silent" });
|
|
23
|
+
|
|
24
|
+
function makeTmpDir(): string {
|
|
25
|
+
return mkdtempSync(join(tmpdir(), "crap-autoscan-"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("autoScan", () => {
|
|
29
|
+
it("returns empty results when no scanners are detected", async () => {
|
|
30
|
+
const dir = makeTmpDir();
|
|
31
|
+
try {
|
|
32
|
+
const store = new SarifStore({
|
|
33
|
+
workspaceRoot: dir,
|
|
34
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
35
|
+
});
|
|
36
|
+
const result = await autoScan(dir, store, logger);
|
|
37
|
+
assert.equal(result.detected.length, 4);
|
|
38
|
+
assert.ok(result.totalDurationMs >= 0);
|
|
39
|
+
// No scanners available means no results
|
|
40
|
+
// (unless the host has scanner binaries installed)
|
|
41
|
+
assert.ok(Array.isArray(result.results));
|
|
42
|
+
assert.equal(typeof result.totalFindings, "number");
|
|
43
|
+
} finally {
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("detects scanners from config files in workspace", async () => {
|
|
49
|
+
const dir = makeTmpDir();
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(join(dir, "eslint.config.mjs"), "export default [];");
|
|
52
|
+
writeFileSync(join(dir, ".semgrep.yml"), "rules: []");
|
|
53
|
+
|
|
54
|
+
const store = new SarifStore({
|
|
55
|
+
workspaceRoot: dir,
|
|
56
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
57
|
+
});
|
|
58
|
+
const result = await autoScan(dir, store, logger);
|
|
59
|
+
|
|
60
|
+
// ESLint and Semgrep should be detected
|
|
61
|
+
const eslintDetection = result.detected.find((d) => d.scanner === "eslint");
|
|
62
|
+
const semgrepDetection = result.detected.find((d) => d.scanner === "semgrep");
|
|
63
|
+
assert.ok(eslintDetection);
|
|
64
|
+
assert.equal(eslintDetection.available, true);
|
|
65
|
+
assert.ok(semgrepDetection);
|
|
66
|
+
assert.equal(semgrepDetection.available, true);
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns correct structure even when all scanners fail", async () => {
|
|
73
|
+
const dir = makeTmpDir();
|
|
74
|
+
try {
|
|
75
|
+
// Create config files so scanners are detected but will fail to run
|
|
76
|
+
// (the temp dir isn't a real project)
|
|
77
|
+
writeFileSync(join(dir, "eslint.config.mjs"), "export default [];");
|
|
78
|
+
|
|
79
|
+
const store = new SarifStore({
|
|
80
|
+
workspaceRoot: dir,
|
|
81
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
82
|
+
});
|
|
83
|
+
const result = await autoScan(dir, store, logger);
|
|
84
|
+
|
|
85
|
+
// Structure must be valid regardless of scanner success
|
|
86
|
+
assert.ok(Array.isArray(result.detected));
|
|
87
|
+
assert.ok(Array.isArray(result.results));
|
|
88
|
+
assert.equal(typeof result.totalFindings, "number");
|
|
89
|
+
assert.equal(typeof result.totalDurationMs, "number");
|
|
90
|
+
|
|
91
|
+
// eslint was detected, so it should appear in results
|
|
92
|
+
const eslintResult = result.results.find((r) => r.scanner === "eslint");
|
|
93
|
+
if (eslintResult) {
|
|
94
|
+
assert.equal(typeof eslintResult.success, "boolean");
|
|
95
|
+
assert.equal(typeof eslintResult.durationMs, "number");
|
|
96
|
+
assert.equal(typeof eslintResult.findingsIngested, "number");
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("totalFindings sums findings across all successful scanners", async () => {
|
|
104
|
+
const dir = makeTmpDir();
|
|
105
|
+
try {
|
|
106
|
+
const store = new SarifStore({
|
|
107
|
+
workspaceRoot: dir,
|
|
108
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
109
|
+
});
|
|
110
|
+
const result = await autoScan(dir, store, logger);
|
|
111
|
+
|
|
112
|
+
// Sum of individual scanner findings should equal total
|
|
113
|
+
const sumFromResults = result.results
|
|
114
|
+
.filter((r) => r.success)
|
|
115
|
+
.reduce((sum, r) => sum + r.findingsIngested, 0);
|
|
116
|
+
assert.equal(result.totalFindings, sumFromResults);
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("result has one ScannerDetection per known scanner", async () => {
|
|
123
|
+
const dir = makeTmpDir();
|
|
124
|
+
try {
|
|
125
|
+
const store = new SarifStore({
|
|
126
|
+
workspaceRoot: dir,
|
|
127
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
128
|
+
});
|
|
129
|
+
const result = await autoScan(dir, store, logger);
|
|
130
|
+
|
|
131
|
+
const scannerNames = result.detected.map((d) => d.scanner).sort();
|
|
132
|
+
assert.deepEqual(scannerNames, ["bandit", "eslint", "semgrep", "stryker"]);
|
|
133
|
+
} finally {
|
|
134
|
+
rmSync(dir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -232,13 +232,14 @@ 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
|
|
235
|
+
it("exposes all eight tools via tools/list", async () => {
|
|
236
236
|
const response = await client!.request<{ result?: { tools?: Array<{ name: string }> } }>(
|
|
237
237
|
"tools/list",
|
|
238
238
|
);
|
|
239
239
|
const names = (response.result?.tools ?? []).map((t) => t.name).sort();
|
|
240
240
|
assert.deepEqual(names, [
|
|
241
241
|
"analyze_file_ast",
|
|
242
|
+
"auto_scan",
|
|
242
243
|
"compute_crap",
|
|
243
244
|
"compute_tdr",
|
|
244
245
|
"ingest_sarif",
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the scanner auto-detector.
|
|
3
|
+
*
|
|
4
|
+
* These tests probe the detection logic for each scanner type:
|
|
5
|
+
* config file detection, package.json dependency detection, and
|
|
6
|
+
* the fallback to binary availability.
|
|
7
|
+
*
|
|
8
|
+
* @module tests/scanner-detector.test
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
import { detectScanners, SCANNER_SIGNALS } from "../scanner/detector.js";
|
|
18
|
+
|
|
19
|
+
function makeTmpDir(): string {
|
|
20
|
+
return mkdtempSync(join(tmpdir(), "crap-detect-"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("detectScanners", () => {
|
|
24
|
+
it("detects eslint when eslint.config.mjs exists", async () => {
|
|
25
|
+
const dir = makeTmpDir();
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(join(dir, "eslint.config.mjs"), "export default [];");
|
|
28
|
+
const results = await detectScanners(dir);
|
|
29
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
30
|
+
assert.ok(eslint);
|
|
31
|
+
assert.equal(eslint.available, true);
|
|
32
|
+
assert.ok(eslint.reason.includes("eslint.config.mjs"));
|
|
33
|
+
assert.ok(eslint.configPath);
|
|
34
|
+
} finally {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("detects eslint from .eslintrc.json", async () => {
|
|
40
|
+
const dir = makeTmpDir();
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(join(dir, ".eslintrc.json"), "{}");
|
|
43
|
+
const results = await detectScanners(dir);
|
|
44
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
45
|
+
assert.ok(eslint);
|
|
46
|
+
assert.equal(eslint.available, true);
|
|
47
|
+
assert.ok(eslint.reason.includes(".eslintrc.json"));
|
|
48
|
+
} finally {
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("detects semgrep when .semgrep.yml exists", async () => {
|
|
54
|
+
const dir = makeTmpDir();
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(join(dir, ".semgrep.yml"), "rules: []");
|
|
57
|
+
const results = await detectScanners(dir);
|
|
58
|
+
const semgrep = results.find((r) => r.scanner === "semgrep");
|
|
59
|
+
assert.ok(semgrep);
|
|
60
|
+
assert.equal(semgrep.available, true);
|
|
61
|
+
assert.ok(semgrep.reason.includes(".semgrep.yml"));
|
|
62
|
+
} finally {
|
|
63
|
+
rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("detects bandit when .bandit config exists", async () => {
|
|
68
|
+
const dir = makeTmpDir();
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(join(dir, ".bandit"), "");
|
|
71
|
+
const results = await detectScanners(dir);
|
|
72
|
+
const bandit = results.find((r) => r.scanner === "bandit");
|
|
73
|
+
assert.ok(bandit);
|
|
74
|
+
assert.equal(bandit.available, true);
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("detects stryker when stryker.conf.js exists", async () => {
|
|
81
|
+
const dir = makeTmpDir();
|
|
82
|
+
try {
|
|
83
|
+
writeFileSync(join(dir, "stryker.conf.js"), "module.exports = {};");
|
|
84
|
+
const results = await detectScanners(dir);
|
|
85
|
+
const stryker = results.find((r) => r.scanner === "stryker");
|
|
86
|
+
assert.ok(stryker);
|
|
87
|
+
assert.equal(stryker.available, true);
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("detects eslint from package.json devDependencies", async () => {
|
|
94
|
+
const dir = makeTmpDir();
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(
|
|
97
|
+
join(dir, "package.json"),
|
|
98
|
+
JSON.stringify({ devDependencies: { eslint: "^9.0.0" } }),
|
|
99
|
+
);
|
|
100
|
+
const results = await detectScanners(dir);
|
|
101
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
102
|
+
assert.ok(eslint);
|
|
103
|
+
assert.equal(eslint.available, true);
|
|
104
|
+
assert.ok(eslint.reason.includes("package.json"));
|
|
105
|
+
} finally {
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("detects stryker from package.json @stryker-mutator/core", async () => {
|
|
111
|
+
const dir = makeTmpDir();
|
|
112
|
+
try {
|
|
113
|
+
writeFileSync(
|
|
114
|
+
join(dir, "package.json"),
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
devDependencies: { "@stryker-mutator/core": "^7.0.0" },
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
const results = await detectScanners(dir);
|
|
120
|
+
const stryker = results.find((r) => r.scanner === "stryker");
|
|
121
|
+
assert.ok(stryker);
|
|
122
|
+
assert.equal(stryker.available, true);
|
|
123
|
+
assert.ok(stryker.reason.includes("package.json"));
|
|
124
|
+
} finally {
|
|
125
|
+
rmSync(dir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns available:false for all scanners in an empty directory", async () => {
|
|
130
|
+
const dir = makeTmpDir();
|
|
131
|
+
try {
|
|
132
|
+
const results = await detectScanners(dir);
|
|
133
|
+
// Config and package.json probes will all fail.
|
|
134
|
+
// Binary probe results depend on the host — don't assert on those,
|
|
135
|
+
// but do assert the structure is correct.
|
|
136
|
+
assert.equal(results.length, 4);
|
|
137
|
+
for (const r of results) {
|
|
138
|
+
assert.ok(["eslint", "semgrep", "bandit", "stryker"].includes(r.scanner));
|
|
139
|
+
assert.equal(typeof r.available, "boolean");
|
|
140
|
+
assert.equal(typeof r.reason, "string");
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(dir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("handles malformed package.json gracefully", async () => {
|
|
148
|
+
const dir = makeTmpDir();
|
|
149
|
+
try {
|
|
150
|
+
writeFileSync(join(dir, "package.json"), "not json at all");
|
|
151
|
+
// Should not throw — just skip the package.json probe
|
|
152
|
+
const results = await detectScanners(dir);
|
|
153
|
+
assert.equal(results.length, 4);
|
|
154
|
+
} finally {
|
|
155
|
+
rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("short-circuits on config file — does not need binary", async () => {
|
|
160
|
+
const dir = makeTmpDir();
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(join(dir, "eslint.config.mjs"), "export default [];");
|
|
163
|
+
const results = await detectScanners(dir);
|
|
164
|
+
const eslint = results.find((r) => r.scanner === "eslint");
|
|
165
|
+
assert.ok(eslint);
|
|
166
|
+
assert.equal(eslint.available, true);
|
|
167
|
+
// Reason mentions config file, not binary
|
|
168
|
+
assert.ok(eslint.reason.includes("config file"));
|
|
169
|
+
assert.ok(!eslint.reason.includes("binary"));
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("SCANNER_SIGNALS covers all four scanners", () => {
|
|
176
|
+
assert.deepEqual(
|
|
177
|
+
Object.keys(SCANNER_SIGNALS).sort(),
|
|
178
|
+
["bandit", "eslint", "semgrep", "stryker"],
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the scanner runner.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the command definitions and error handling of the
|
|
5
|
+
* runner module. Actual scanner execution is not tested here — that
|
|
6
|
+
* requires the scanner binaries to be installed.
|
|
7
|
+
*
|
|
8
|
+
* @module tests/scanner-runner.test
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
|
|
14
|
+
import { getScannerCommand, type ScannerCommand } from "../scanner/runner.js";
|
|
15
|
+
|
|
16
|
+
describe("getScannerCommand", () => {
|
|
17
|
+
it("returns correct eslint command", () => {
|
|
18
|
+
const cmd = getScannerCommand("eslint", "/tmp/project");
|
|
19
|
+
assert.equal(cmd.command, "npx");
|
|
20
|
+
assert.deepEqual(cmd.args, ["eslint", "-f", "json", "."]);
|
|
21
|
+
assert.equal(cmd.nonZeroIsNormal, true);
|
|
22
|
+
assert.equal(cmd.outputFile, undefined);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns correct semgrep command", () => {
|
|
26
|
+
const cmd = getScannerCommand("semgrep", "/tmp/project");
|
|
27
|
+
assert.equal(cmd.command, "semgrep");
|
|
28
|
+
assert.deepEqual(cmd.args, ["--sarif", "--quiet", "."]);
|
|
29
|
+
assert.equal(cmd.nonZeroIsNormal, false);
|
|
30
|
+
assert.equal(cmd.outputFile, undefined);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns correct bandit command", () => {
|
|
34
|
+
const cmd = getScannerCommand("bandit", "/tmp/project");
|
|
35
|
+
assert.equal(cmd.command, "bandit");
|
|
36
|
+
assert.deepEqual(cmd.args, ["-f", "json", "-r", ".", "-q"]);
|
|
37
|
+
assert.equal(cmd.nonZeroIsNormal, true);
|
|
38
|
+
assert.equal(cmd.outputFile, undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns correct stryker command with output file", () => {
|
|
42
|
+
const cmd = getScannerCommand("stryker", "/tmp/project");
|
|
43
|
+
assert.equal(cmd.command, "npx");
|
|
44
|
+
assert.deepEqual(cmd.args, ["stryker", "run"]);
|
|
45
|
+
assert.equal(cmd.nonZeroIsNormal, false);
|
|
46
|
+
assert.ok(cmd.outputFile);
|
|
47
|
+
assert.ok(cmd.outputFile.includes("mutation.json"));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("all scanners have reasonable timeouts", () => {
|
|
51
|
+
for (const scanner of ["eslint", "semgrep", "bandit", "stryker"] as const) {
|
|
52
|
+
const cmd = getScannerCommand(scanner, "/tmp");
|
|
53
|
+
assert.ok(cmd.timeoutMs >= 60_000, `${scanner} timeout should be >= 60s`);
|
|
54
|
+
assert.ok(cmd.timeoutMs <= 300_000, `${scanner} timeout should be <= 300s`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("stryker has a longer timeout than other scanners", () => {
|
|
59
|
+
const stryker = getScannerCommand("stryker", "/tmp");
|
|
60
|
+
const eslint = getScannerCommand("eslint", "/tmp");
|
|
61
|
+
assert.ok(stryker.timeoutMs > eslint.timeoutMs);
|
|
62
|
+
});
|
|
63
|
+
});
|