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
package/plugin/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -57,7 +57,9 @@ import { validateSarifDocument } from "./sarif/sarif-validator.js";
|
|
|
57
57
|
import { loadCrapConfig, CrapConfigError } from "./crap-config.js";
|
|
58
58
|
import { findTestFile } from "./tools/test-harness.js";
|
|
59
59
|
import { resolveWithinWorkspace } from "./workspace-guard.js";
|
|
60
|
+
import { autoScan } from "./scanner/auto-scan.js";
|
|
60
61
|
import {
|
|
62
|
+
autoScanSchema,
|
|
61
63
|
computeCrapSchema,
|
|
62
64
|
computeTdrSchema,
|
|
63
65
|
analyzeFileAstSchema,
|
|
@@ -205,6 +207,12 @@ async function main(): Promise<void> {
|
|
|
205
207
|
"Aggregate the project score across Maintainability, Reliability, Security and Overall, returning a chat-friendly Markdown summary, the structured JSON, the local dashboard URL, and the consolidated SARIF report path.",
|
|
206
208
|
inputSchema: scoreProjectSchema,
|
|
207
209
|
},
|
|
210
|
+
{
|
|
211
|
+
name: "auto_scan",
|
|
212
|
+
description:
|
|
213
|
+
"Auto-detect available scanners (ESLint, Semgrep, Bandit, Stryker) in the workspace, run them, and ingest findings into the SARIF store.",
|
|
214
|
+
inputSchema: autoScanSchema,
|
|
215
|
+
},
|
|
208
216
|
],
|
|
209
217
|
}));
|
|
210
218
|
|
|
@@ -532,6 +540,35 @@ async function main(): Promise<void> {
|
|
|
532
540
|
}
|
|
533
541
|
}
|
|
534
542
|
|
|
543
|
+
case "auto_scan": {
|
|
544
|
+
logger.info({ tool: "auto_scan" }, "Tool call received");
|
|
545
|
+
try {
|
|
546
|
+
const result = await autoScan(config.pluginRoot, sarifStore, logger);
|
|
547
|
+
const markdown = renderAutoScanMarkdown(result);
|
|
548
|
+
return {
|
|
549
|
+
content: [
|
|
550
|
+
{ type: "text", text: markdown },
|
|
551
|
+
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
552
|
+
],
|
|
553
|
+
};
|
|
554
|
+
} catch (err) {
|
|
555
|
+
logger.error({ err }, "auto_scan failed");
|
|
556
|
+
return {
|
|
557
|
+
content: [
|
|
558
|
+
{
|
|
559
|
+
type: "text",
|
|
560
|
+
text: JSON.stringify(
|
|
561
|
+
{ tool: "auto_scan", status: "error", message: (err as Error).message },
|
|
562
|
+
null,
|
|
563
|
+
2,
|
|
564
|
+
),
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
isError: true,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
535
572
|
default:
|
|
536
573
|
throw new Error(`[claude-crap] Unknown tool: ${name}`);
|
|
537
574
|
}
|
|
@@ -589,6 +626,67 @@ async function main(): Promise<void> {
|
|
|
589
626
|
const transport = new StdioServerTransport();
|
|
590
627
|
await server.connect(transport);
|
|
591
628
|
logger.info("claude-crap MCP server ready (stdio)");
|
|
629
|
+
|
|
630
|
+
// Fire-and-forget: auto-scan runs in background, doesn't block tool calls.
|
|
631
|
+
// If the agent calls score_project before scanning finishes, it gets
|
|
632
|
+
// whatever is in the SARIF store so far. The next call after completion
|
|
633
|
+
// reflects all findings.
|
|
634
|
+
autoScan(config.pluginRoot, sarifStore, logger)
|
|
635
|
+
.then((result) => {
|
|
636
|
+
const scanners = result.results
|
|
637
|
+
.filter((r) => r.success)
|
|
638
|
+
.map((r) => r.scanner);
|
|
639
|
+
logger.info(
|
|
640
|
+
{
|
|
641
|
+
scannersRun: scanners,
|
|
642
|
+
totalFindings: result.totalFindings,
|
|
643
|
+
durationMs: result.totalDurationMs,
|
|
644
|
+
},
|
|
645
|
+
"auto-scan completed",
|
|
646
|
+
);
|
|
647
|
+
})
|
|
648
|
+
.catch((err) => {
|
|
649
|
+
logger.warn(
|
|
650
|
+
{ err: (err as Error).message },
|
|
651
|
+
"auto-scan failed — continuing without it",
|
|
652
|
+
);
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Render a human-readable Markdown summary of an auto-scan result.
|
|
658
|
+
*/
|
|
659
|
+
function renderAutoScanMarkdown(result: import("./scanner/auto-scan.js").AutoScanResult): string {
|
|
660
|
+
const lines: string[] = ["## claude-crap :: auto-scan results\n"];
|
|
661
|
+
|
|
662
|
+
// Detection summary
|
|
663
|
+
lines.push("### Detected scanners\n");
|
|
664
|
+
lines.push("| Scanner | Available | Reason |");
|
|
665
|
+
lines.push("| ------- | :-------: | ------ |");
|
|
666
|
+
for (const d of result.detected) {
|
|
667
|
+
lines.push(`| ${d.scanner} | ${d.available ? "yes" : "no"} | ${d.reason} |`);
|
|
668
|
+
}
|
|
669
|
+
lines.push("");
|
|
670
|
+
|
|
671
|
+
// Execution results
|
|
672
|
+
if (result.results.length > 0) {
|
|
673
|
+
lines.push("### Execution results\n");
|
|
674
|
+
lines.push("| Scanner | Status | Findings | Duration |");
|
|
675
|
+
lines.push("| ------- | :----: | :------: | -------: |");
|
|
676
|
+
for (const r of result.results) {
|
|
677
|
+
const status = r.success ? "ok" : "failed";
|
|
678
|
+
const duration = `${(r.durationMs / 1000).toFixed(1)}s`;
|
|
679
|
+
lines.push(`| ${r.scanner} | ${status} | ${r.findingsIngested} | ${duration} |`);
|
|
680
|
+
}
|
|
681
|
+
lines.push("");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Summary
|
|
685
|
+
lines.push(
|
|
686
|
+
`**Total findings ingested:** ${result.totalFindings} in ${(result.totalDurationMs / 1000).toFixed(1)}s`,
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
return lines.join("\n");
|
|
592
690
|
}
|
|
593
691
|
|
|
594
692
|
/**
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator: detect available scanners, run them, and ingest results.
|
|
3
|
+
*
|
|
4
|
+
* This module ties together the detector, runner, and adapter pipeline
|
|
5
|
+
* into a single `autoScan()` function that:
|
|
6
|
+
*
|
|
7
|
+
* 1. Probes the workspace for available scanners
|
|
8
|
+
* 2. Executes detected scanners in parallel
|
|
9
|
+
* 3. Routes each scanner's output through its adapter
|
|
10
|
+
* 4. Ingests the normalized SARIF into the store
|
|
11
|
+
*
|
|
12
|
+
* The function is designed to be called:
|
|
13
|
+
* - At MCP server boot (fire-and-forget, non-blocking)
|
|
14
|
+
* - On demand via the `auto_scan` MCP tool
|
|
15
|
+
*
|
|
16
|
+
* Failures in individual scanners are logged and skipped — a broken
|
|
17
|
+
* Semgrep install should not prevent ESLint from running.
|
|
18
|
+
*
|
|
19
|
+
* @module scanner/auto-scan
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Logger } from "pino";
|
|
23
|
+
import { detectScanners, type ScannerDetection } from "./detector.js";
|
|
24
|
+
import { runScanner, type ScannerRunResult } from "./runner.js";
|
|
25
|
+
import { adaptScannerOutput, type KnownScanner } from "../adapters/index.js";
|
|
26
|
+
import type { SarifStore } from "../sarif/sarif-store.js";
|
|
27
|
+
|
|
28
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Per-scanner result within the auto-scan summary.
|
|
32
|
+
*/
|
|
33
|
+
export interface ScannerResult {
|
|
34
|
+
scanner: KnownScanner;
|
|
35
|
+
success: boolean;
|
|
36
|
+
findingsIngested: number;
|
|
37
|
+
durationMs: number;
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Complete result of an auto-scan run.
|
|
43
|
+
*/
|
|
44
|
+
export interface AutoScanResult {
|
|
45
|
+
/** Detection results for all four scanners. */
|
|
46
|
+
detected: ScannerDetection[];
|
|
47
|
+
/** Execution + ingestion results for scanners that were available. */
|
|
48
|
+
results: ScannerResult[];
|
|
49
|
+
/** Total findings ingested across all scanners. */
|
|
50
|
+
totalFindings: number;
|
|
51
|
+
/** Wall-clock time for the entire auto-scan. */
|
|
52
|
+
totalDurationMs: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Orchestrator ───────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ingest a single scanner's raw output through its adapter and into
|
|
59
|
+
* the SARIF store. Returns the number of accepted findings.
|
|
60
|
+
*/
|
|
61
|
+
function ingestScannerRun(
|
|
62
|
+
scanner: KnownScanner,
|
|
63
|
+
rawOutput: string,
|
|
64
|
+
sarifStore: SarifStore,
|
|
65
|
+
): { accepted: number } {
|
|
66
|
+
// Parse the raw output — adapters accept string or object
|
|
67
|
+
let parsed: unknown;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(rawOutput);
|
|
70
|
+
} catch {
|
|
71
|
+
// Semgrep outputs SARIF as a string, others are JSON.
|
|
72
|
+
// If parsing fails, pass the raw string to the adapter.
|
|
73
|
+
parsed = rawOutput;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const adapted = adaptScannerOutput(scanner, parsed);
|
|
77
|
+
const stats = sarifStore.ingestRun(adapted.document, adapted.sourceTool);
|
|
78
|
+
return { accepted: stats.accepted };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Auto-detect, run, and ingest all available scanners.
|
|
83
|
+
*
|
|
84
|
+
* @param workspaceRoot Absolute path to the project root.
|
|
85
|
+
* @param sarifStore Live SARIF store to ingest findings into.
|
|
86
|
+
* @param logger Pino logger for progress and error reporting.
|
|
87
|
+
* @returns Summary of what was detected, run, and ingested.
|
|
88
|
+
*/
|
|
89
|
+
export async function autoScan(
|
|
90
|
+
workspaceRoot: string,
|
|
91
|
+
sarifStore: SarifStore,
|
|
92
|
+
logger: Logger,
|
|
93
|
+
): Promise<AutoScanResult> {
|
|
94
|
+
const start = Date.now();
|
|
95
|
+
|
|
96
|
+
// 1. Detect available scanners
|
|
97
|
+
const detected = await detectScanners(workspaceRoot);
|
|
98
|
+
const available = detected.filter((d) => d.available);
|
|
99
|
+
|
|
100
|
+
logger.info(
|
|
101
|
+
{
|
|
102
|
+
detected: detected.map((d) => `${d.scanner}:${d.available}`),
|
|
103
|
+
available: available.length,
|
|
104
|
+
},
|
|
105
|
+
"auto-scan: detection complete",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (available.length === 0) {
|
|
109
|
+
return {
|
|
110
|
+
detected,
|
|
111
|
+
results: [],
|
|
112
|
+
totalFindings: 0,
|
|
113
|
+
totalDurationMs: Date.now() - start,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Run all available scanners in parallel
|
|
118
|
+
const runResults = await Promise.allSettled(
|
|
119
|
+
available.map((d) => runScanner(d.scanner, workspaceRoot)),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// 3. Ingest results
|
|
123
|
+
const results: ScannerResult[] = [];
|
|
124
|
+
let totalFindings = 0;
|
|
125
|
+
let persistNeeded = false;
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < available.length; i++) {
|
|
128
|
+
const detection = available[i]!;
|
|
129
|
+
const settled = runResults[i]!;
|
|
130
|
+
|
|
131
|
+
if (settled.status === "rejected") {
|
|
132
|
+
const error = String(settled.reason);
|
|
133
|
+
logger.warn(
|
|
134
|
+
{ scanner: detection.scanner, error },
|
|
135
|
+
"auto-scan: scanner execution rejected",
|
|
136
|
+
);
|
|
137
|
+
results.push({
|
|
138
|
+
scanner: detection.scanner,
|
|
139
|
+
success: false,
|
|
140
|
+
findingsIngested: 0,
|
|
141
|
+
durationMs: 0,
|
|
142
|
+
error,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const runResult: ScannerRunResult = settled.value;
|
|
148
|
+
|
|
149
|
+
if (!runResult.success) {
|
|
150
|
+
logger.warn(
|
|
151
|
+
{ scanner: runResult.scanner, error: runResult.error },
|
|
152
|
+
"auto-scan: scanner returned failure",
|
|
153
|
+
);
|
|
154
|
+
results.push({
|
|
155
|
+
scanner: runResult.scanner,
|
|
156
|
+
success: false,
|
|
157
|
+
findingsIngested: 0,
|
|
158
|
+
durationMs: runResult.durationMs,
|
|
159
|
+
error: runResult.error ?? "unknown error",
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Ingest through adapter pipeline
|
|
165
|
+
try {
|
|
166
|
+
const { accepted } = ingestScannerRun(
|
|
167
|
+
runResult.scanner,
|
|
168
|
+
runResult.rawOutput,
|
|
169
|
+
sarifStore,
|
|
170
|
+
);
|
|
171
|
+
totalFindings += accepted;
|
|
172
|
+
persistNeeded = true;
|
|
173
|
+
|
|
174
|
+
logger.info(
|
|
175
|
+
{ scanner: runResult.scanner, accepted, durationMs: runResult.durationMs },
|
|
176
|
+
"auto-scan: scanner ingested",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
results.push({
|
|
180
|
+
scanner: runResult.scanner,
|
|
181
|
+
success: true,
|
|
182
|
+
findingsIngested: accepted,
|
|
183
|
+
durationMs: runResult.durationMs,
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const error = (err as Error).message;
|
|
187
|
+
logger.warn(
|
|
188
|
+
{ scanner: runResult.scanner, error },
|
|
189
|
+
"auto-scan: adapter/ingestion failed",
|
|
190
|
+
);
|
|
191
|
+
results.push({
|
|
192
|
+
scanner: runResult.scanner,
|
|
193
|
+
success: false,
|
|
194
|
+
findingsIngested: 0,
|
|
195
|
+
durationMs: runResult.durationMs,
|
|
196
|
+
error,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 4. Persist consolidated SARIF if anything was ingested
|
|
202
|
+
if (persistNeeded) {
|
|
203
|
+
await sarifStore.persist();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
detected,
|
|
208
|
+
results,
|
|
209
|
+
totalFindings,
|
|
210
|
+
totalDurationMs: Date.now() - start,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-detect which scanners are available in the current workspace.
|
|
3
|
+
*
|
|
4
|
+
* For each of the four supported scanners (ESLint, Semgrep, Bandit,
|
|
5
|
+
* Stryker) the detector probes three signal layers in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. Config file existence (fastest — a single `fs.stat`)
|
|
8
|
+
* 2. Package.json dependency (for JS-ecosystem scanners)
|
|
9
|
+
* 3. Binary availability via `which` (slowest — spawns a child process)
|
|
10
|
+
*
|
|
11
|
+
* Detection short-circuits on the first hit, so a project that has an
|
|
12
|
+
* `eslint.config.mjs` will never shell out to `which eslint`.
|
|
13
|
+
*
|
|
14
|
+
* The module is side-effect-free beyond filesystem reads and one
|
|
15
|
+
* `child_process.execFile` per binary probe.
|
|
16
|
+
*
|
|
17
|
+
* @module scanner/detector
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import type { KnownScanner } from "../adapters/common.js";
|
|
24
|
+
|
|
25
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Result of probing a single scanner's availability.
|
|
29
|
+
*/
|
|
30
|
+
export interface ScannerDetection {
|
|
31
|
+
/** Which scanner was probed. */
|
|
32
|
+
scanner: KnownScanner;
|
|
33
|
+
/** Whether the scanner is available and can be executed. */
|
|
34
|
+
available: boolean;
|
|
35
|
+
/** Human-readable reason for the verdict. */
|
|
36
|
+
reason: string;
|
|
37
|
+
/** Path to the config file that triggered detection, if any. */
|
|
38
|
+
configPath?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Detection signals ──────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Config file globs and package.json keys per scanner. Order matters:
|
|
45
|
+
* the first matching config file short-circuits further probes.
|
|
46
|
+
*/
|
|
47
|
+
interface ScannerSignals {
|
|
48
|
+
configFiles: string[];
|
|
49
|
+
packageJsonKeys: string[];
|
|
50
|
+
binaryNames: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SCANNER_SIGNALS: Record<KnownScanner, ScannerSignals> = {
|
|
54
|
+
eslint: {
|
|
55
|
+
configFiles: [
|
|
56
|
+
"eslint.config.js",
|
|
57
|
+
"eslint.config.mjs",
|
|
58
|
+
"eslint.config.cjs",
|
|
59
|
+
"eslint.config.ts",
|
|
60
|
+
"eslint.config.mts",
|
|
61
|
+
"eslint.config.cts",
|
|
62
|
+
".eslintrc.js",
|
|
63
|
+
".eslintrc.cjs",
|
|
64
|
+
".eslintrc.yaml",
|
|
65
|
+
".eslintrc.yml",
|
|
66
|
+
".eslintrc.json",
|
|
67
|
+
],
|
|
68
|
+
packageJsonKeys: ["eslint"],
|
|
69
|
+
binaryNames: ["eslint"],
|
|
70
|
+
},
|
|
71
|
+
semgrep: {
|
|
72
|
+
configFiles: [
|
|
73
|
+
".semgrep.yml",
|
|
74
|
+
".semgrep.yaml",
|
|
75
|
+
".semgrep.json",
|
|
76
|
+
],
|
|
77
|
+
packageJsonKeys: [],
|
|
78
|
+
binaryNames: ["semgrep"],
|
|
79
|
+
},
|
|
80
|
+
bandit: {
|
|
81
|
+
configFiles: [
|
|
82
|
+
".bandit",
|
|
83
|
+
"bandit.yaml",
|
|
84
|
+
"bandit.yml",
|
|
85
|
+
],
|
|
86
|
+
packageJsonKeys: [],
|
|
87
|
+
binaryNames: ["bandit"],
|
|
88
|
+
},
|
|
89
|
+
stryker: {
|
|
90
|
+
configFiles: [
|
|
91
|
+
"stryker.conf.js",
|
|
92
|
+
"stryker.conf.mjs",
|
|
93
|
+
"stryker.conf.cjs",
|
|
94
|
+
"stryker.conf.json",
|
|
95
|
+
".strykerrc",
|
|
96
|
+
".strykerrc.json",
|
|
97
|
+
],
|
|
98
|
+
packageJsonKeys: ["@stryker-mutator/core"],
|
|
99
|
+
binaryNames: ["stryker"],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ── Probes ──────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if any of the scanner's config files exist in the workspace.
|
|
107
|
+
*/
|
|
108
|
+
function probeConfigFiles(
|
|
109
|
+
workspaceRoot: string,
|
|
110
|
+
scanner: KnownScanner,
|
|
111
|
+
): { found: boolean; path?: string } {
|
|
112
|
+
const signals = SCANNER_SIGNALS[scanner];
|
|
113
|
+
for (const file of signals.configFiles) {
|
|
114
|
+
const fullPath = join(workspaceRoot, file);
|
|
115
|
+
if (existsSync(fullPath)) {
|
|
116
|
+
return { found: true, path: fullPath };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { found: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if the scanner appears in package.json deps or devDeps.
|
|
124
|
+
*/
|
|
125
|
+
function probePackageJson(
|
|
126
|
+
workspaceRoot: string,
|
|
127
|
+
scanner: KnownScanner,
|
|
128
|
+
): boolean {
|
|
129
|
+
const signals = SCANNER_SIGNALS[scanner];
|
|
130
|
+
if (signals.packageJsonKeys.length === 0) return false;
|
|
131
|
+
|
|
132
|
+
const pkgPath = join(workspaceRoot, "package.json");
|
|
133
|
+
if (!existsSync(pkgPath)) return false;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
137
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
138
|
+
const deps = {
|
|
139
|
+
...(typeof pkg.dependencies === "object" && pkg.dependencies !== null
|
|
140
|
+
? (pkg.dependencies as Record<string, string>)
|
|
141
|
+
: {}),
|
|
142
|
+
...(typeof pkg.devDependencies === "object" && pkg.devDependencies !== null
|
|
143
|
+
? (pkg.devDependencies as Record<string, string>)
|
|
144
|
+
: {}),
|
|
145
|
+
};
|
|
146
|
+
return signals.packageJsonKeys.some((key) => key in deps);
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if a binary is available on PATH via `which`.
|
|
154
|
+
*/
|
|
155
|
+
function probeBinary(binaryName: string): Promise<boolean> {
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
execFile("which", [binaryName], { timeout: 5_000 }, (err) => {
|
|
158
|
+
resolve(err === null);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Detect which of the four supported scanners are available in the
|
|
167
|
+
* given workspace. Probes config files, package.json, and binary
|
|
168
|
+
* availability in order, short-circuiting on first match.
|
|
169
|
+
*
|
|
170
|
+
* @param workspaceRoot Absolute path to the project root.
|
|
171
|
+
* @returns One {@link ScannerDetection} per known scanner.
|
|
172
|
+
*/
|
|
173
|
+
export async function detectScanners(
|
|
174
|
+
workspaceRoot: string,
|
|
175
|
+
): Promise<ScannerDetection[]> {
|
|
176
|
+
const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker"];
|
|
177
|
+
|
|
178
|
+
const results = await Promise.all(
|
|
179
|
+
scanners.map(async (scanner): Promise<ScannerDetection> => {
|
|
180
|
+
// 1. Config file probe (fastest)
|
|
181
|
+
const configProbe = probeConfigFiles(workspaceRoot, scanner);
|
|
182
|
+
if (configProbe.found && configProbe.path) {
|
|
183
|
+
return {
|
|
184
|
+
scanner,
|
|
185
|
+
available: true,
|
|
186
|
+
reason: `config file found: ${configProbe.path.replace(workspaceRoot + "/", "")}`,
|
|
187
|
+
configPath: configProbe.path,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 2. Package.json probe
|
|
192
|
+
if (probePackageJson(workspaceRoot, scanner)) {
|
|
193
|
+
return {
|
|
194
|
+
scanner,
|
|
195
|
+
available: true,
|
|
196
|
+
reason: `found in package.json dependencies`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 3. Binary probe (slowest)
|
|
201
|
+
const signals = SCANNER_SIGNALS[scanner];
|
|
202
|
+
for (const bin of signals.binaryNames) {
|
|
203
|
+
if (await probeBinary(bin)) {
|
|
204
|
+
return {
|
|
205
|
+
scanner,
|
|
206
|
+
available: true,
|
|
207
|
+
reason: `binary "${bin}" found on PATH`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
scanner,
|
|
214
|
+
available: false,
|
|
215
|
+
reason: "no config file, package.json entry, or binary found",
|
|
216
|
+
};
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Exported for testing
|
|
224
|
+
export { SCANNER_SIGNALS };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public SDK entry point for the scanner auto-detection and execution
|
|
3
|
+
* pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { autoScan, detectScanners } from "claude-crap/scanner";
|
|
9
|
+
*
|
|
10
|
+
* // Full pipeline: detect → run → ingest
|
|
11
|
+
* const result = await autoScan(workspaceRoot, sarifStore, logger);
|
|
12
|
+
*
|
|
13
|
+
* // Detection only (no execution)
|
|
14
|
+
* const detections = await detectScanners(workspaceRoot);
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @module scanner
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { detectScanners, type ScannerDetection } from "./detector.js";
|
|
21
|
+
export { runScanner, type ScannerRunResult } from "./runner.js";
|
|
22
|
+
export { autoScan, type AutoScanResult, type ScannerResult } from "./auto-scan.js";
|