claude-crap 0.3.6 → 0.3.8
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/README.md +25 -0
- 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/dart-analyzer.d.ts +41 -0
- package/dist/adapters/dart-analyzer.d.ts.map +1 -0
- package/dist/adapters/dart-analyzer.js +120 -0
- package/dist/adapters/dart-analyzer.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 +36 -28
- package/dist/crap-config.js.map +1 -1
- package/dist/dashboard/file-detail.d.ts +77 -0
- package/dist/dashboard/file-detail.d.ts.map +1 -0
- package/dist/dashboard/file-detail.js +120 -0
- package/dist/dashboard/file-detail.js.map +1 -0
- package/dist/dashboard/server.d.ts +5 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +103 -1
- package/dist/dashboard/server.js.map +1 -1
- package/dist/index.js +36 -4
- package/dist/index.js.map +1 -1
- package/dist/metrics/workspace-walker.d.ts +4 -1
- package/dist/metrics/workspace-walker.d.ts.map +1 -1
- package/dist/metrics/workspace-walker.js +12 -28
- package/dist/metrics/workspace-walker.js.map +1 -1
- package/dist/scanner/auto-scan.d.ts +9 -1
- package/dist/scanner/auto-scan.d.ts.map +1 -1
- package/dist/scanner/auto-scan.js +27 -5
- package/dist/scanner/auto-scan.js.map +1 -1
- package/dist/scanner/bootstrap.d.ts +1 -1
- package/dist/scanner/bootstrap.d.ts.map +1 -1
- package/dist/scanner/bootstrap.js +9 -0
- package/dist/scanner/bootstrap.js.map +1 -1
- package/dist/scanner/complexity-scanner.d.ts +56 -0
- package/dist/scanner/complexity-scanner.d.ts.map +1 -0
- package/dist/scanner/complexity-scanner.js +161 -0
- package/dist/scanner/complexity-scanner.js.map +1 -0
- package/dist/scanner/detector.d.ts +24 -4
- package/dist/scanner/detector.d.ts.map +1 -1
- package/dist/scanner/detector.js +105 -10
- package/dist/scanner/detector.js.map +1 -1
- package/dist/scanner/runner.d.ts +4 -1
- package/dist/scanner/runner.d.ts.map +1 -1
- package/dist/scanner/runner.js +12 -3
- package/dist/scanner/runner.js.map +1 -1
- package/dist/schemas/tool-schemas.d.ts +1 -1
- package/dist/schemas/tool-schemas.js +1 -1
- package/dist/schemas/tool-schemas.js.map +1 -1
- package/dist/shared/exclusions.d.ts +53 -0
- package/dist/shared/exclusions.d.ts.map +1 -0
- package/dist/shared/exclusions.js +126 -0
- package/dist/shared/exclusions.js.map +1 -0
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bundle/dashboard/public/index.html +432 -12
- package/plugin/bundle/mcp-server.mjs +747 -137
- package/plugin/bundle/mcp-server.mjs.map +4 -4
- package/plugin/package-lock.json +15 -2
- package/plugin/package.json +2 -1
- package/scripts/bundle-plugin.mjs +2 -1
- package/src/adapters/common.ts +1 -1
- package/src/adapters/dart-analyzer.ts +161 -0
- package/src/adapters/index.ts +4 -0
- package/src/crap-config.ts +55 -18
- package/src/dashboard/file-detail.ts +195 -0
- package/src/dashboard/public/index.html +432 -12
- package/src/dashboard/server.ts +140 -1
- package/src/index.ts +37 -4
- package/src/metrics/workspace-walker.ts +15 -27
- package/src/scanner/auto-scan.ts +41 -4
- package/src/scanner/bootstrap.ts +11 -0
- package/src/scanner/complexity-scanner.ts +222 -0
- package/src/scanner/detector.ts +114 -10
- package/src/scanner/runner.ts +12 -2
- package/src/schemas/tool-schemas.ts +1 -1
- package/src/shared/exclusions.ts +156 -0
- package/src/tests/adapters/dispatch.test.ts +2 -2
- package/src/tests/auto-scan.test.ts +2 -2
- package/src/tests/complexity-scanner.test.ts +263 -0
- package/src/tests/exclusions.test.ts +117 -0
- package/src/tests/file-detail-api.test.ts +258 -0
- package/src/tests/scanner-detector.test.ts +31 -11
package/src/index.ts
CHANGED
|
@@ -93,6 +93,18 @@ async function main(): Promise<void> {
|
|
|
93
93
|
"claude-crap MCP server starting",
|
|
94
94
|
);
|
|
95
95
|
|
|
96
|
+
// Load user-defined exclusions from .claude-crap.json (non-fatal).
|
|
97
|
+
let userExclusions: ReadonlyArray<string> = [];
|
|
98
|
+
try {
|
|
99
|
+
const crapConfig = loadCrapConfig({ workspaceRoot: config.pluginRoot });
|
|
100
|
+
userExclusions = crapConfig.exclude;
|
|
101
|
+
if (userExclusions.length > 0) {
|
|
102
|
+
logger.info({ exclude: userExclusions }, "user exclusions loaded from .claude-crap.json");
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Non-fatal — use empty exclusions.
|
|
106
|
+
}
|
|
107
|
+
|
|
96
108
|
// Long-lived engines. Created once at boot and reused for every call.
|
|
97
109
|
const astEngine = new TreeSitterEngine();
|
|
98
110
|
const sarifStore = new SarifStore({
|
|
@@ -112,8 +124,10 @@ async function main(): Promise<void> {
|
|
|
112
124
|
dashboard = await startDashboard({
|
|
113
125
|
config,
|
|
114
126
|
sarifStore,
|
|
115
|
-
workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot),
|
|
127
|
+
workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot, { exclude: userExclusions }),
|
|
116
128
|
logger,
|
|
129
|
+
astEngine,
|
|
130
|
+
exclude: userExclusions,
|
|
117
131
|
});
|
|
118
132
|
} catch (err) {
|
|
119
133
|
logger.warn(
|
|
@@ -332,7 +346,7 @@ async function main(): Promise<void> {
|
|
|
332
346
|
const typed = (args ?? {}) as { format?: "markdown" | "json" | "both" };
|
|
333
347
|
const format = typed.format ?? "both";
|
|
334
348
|
try {
|
|
335
|
-
const workspace = await estimateWorkspaceLoc(config.pluginRoot);
|
|
349
|
+
const workspace = await estimateWorkspaceLoc(config.pluginRoot, { exclude: userExclusions });
|
|
336
350
|
const score: ProjectScore = computeProjectScore({
|
|
337
351
|
workspaceRoot: config.pluginRoot,
|
|
338
352
|
minutesPerLoc: config.minutesPerLoc,
|
|
@@ -581,7 +595,11 @@ async function main(): Promise<void> {
|
|
|
581
595
|
case "auto_scan": {
|
|
582
596
|
logger.info({ tool: "auto_scan" }, "Tool call received");
|
|
583
597
|
try {
|
|
584
|
-
const result = await autoScan(config.pluginRoot, sarifStore, logger
|
|
598
|
+
const result = await autoScan(config.pluginRoot, sarifStore, logger, {
|
|
599
|
+
engine: astEngine,
|
|
600
|
+
cyclomaticMax: config.cyclomaticMax,
|
|
601
|
+
exclude: userExclusions,
|
|
602
|
+
});
|
|
585
603
|
const markdown = renderAutoScanMarkdown(result);
|
|
586
604
|
return {
|
|
587
605
|
content: [
|
|
@@ -669,7 +687,11 @@ async function main(): Promise<void> {
|
|
|
669
687
|
// If the agent calls score_project before scanning finishes, it gets
|
|
670
688
|
// whatever is in the SARIF store so far. The next call after completion
|
|
671
689
|
// reflects all findings.
|
|
672
|
-
autoScan(config.pluginRoot, sarifStore, logger
|
|
690
|
+
autoScan(config.pluginRoot, sarifStore, logger, {
|
|
691
|
+
engine: astEngine,
|
|
692
|
+
cyclomaticMax: config.cyclomaticMax,
|
|
693
|
+
exclude: userExclusions,
|
|
694
|
+
})
|
|
673
695
|
.then((result) => {
|
|
674
696
|
const scanners = result.results
|
|
675
697
|
.filter((r) => r.success)
|
|
@@ -759,6 +781,17 @@ function renderAutoScanMarkdown(result: import("./scanner/auto-scan.js").AutoSca
|
|
|
759
781
|
lines.push("");
|
|
760
782
|
}
|
|
761
783
|
|
|
784
|
+
// Complexity scan
|
|
785
|
+
if (result.complexityScan) {
|
|
786
|
+
const cs = result.complexityScan;
|
|
787
|
+
lines.push("### Cyclomatic complexity scan\n");
|
|
788
|
+
lines.push(`- Files scanned: **${cs.filesScanned}**`);
|
|
789
|
+
lines.push(`- Functions analyzed: **${cs.functionsAnalyzed}**`);
|
|
790
|
+
lines.push(`- Violations: **${cs.violations}**`);
|
|
791
|
+
lines.push(`- Duration: ${(cs.durationMs / 1000).toFixed(1)}s`);
|
|
792
|
+
lines.push("");
|
|
793
|
+
}
|
|
794
|
+
|
|
762
795
|
// Summary
|
|
763
796
|
lines.push(
|
|
764
797
|
`**Total findings ingested:** ${result.totalFindings} in ${(result.totalDurationMs / 1000).toFixed(1)}s`,
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { promises as fs } from "node:fs";
|
|
18
|
-
import { join } from "node:path";
|
|
18
|
+
import { join, relative } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { createExclusionFilter } from "../shared/exclusions.js";
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Result returned by {@link estimateWorkspaceLoc}.
|
|
@@ -29,26 +31,9 @@ export interface WorkspaceWalkResult {
|
|
|
29
31
|
readonly truncated: boolean;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*/
|
|
36
|
-
const SKIP_DIRS: ReadonlySet<string> = new Set([
|
|
37
|
-
"node_modules",
|
|
38
|
-
".git",
|
|
39
|
-
"dist",
|
|
40
|
-
"build",
|
|
41
|
-
"out",
|
|
42
|
-
"target",
|
|
43
|
-
".venv",
|
|
44
|
-
"venv",
|
|
45
|
-
"__pycache__",
|
|
46
|
-
".cache",
|
|
47
|
-
".next",
|
|
48
|
-
".nuxt",
|
|
49
|
-
".claude-crap",
|
|
50
|
-
".codesight",
|
|
51
|
-
]);
|
|
34
|
+
// Directory exclusions are now centralized in src/shared/exclusions.ts.
|
|
35
|
+
// The createExclusionFilter() factory is called once per walk with
|
|
36
|
+
// optional user-defined patterns from .claude-crap.json.
|
|
52
37
|
|
|
53
38
|
/**
|
|
54
39
|
* Extensions the walker treats as "code". Anything else is ignored,
|
|
@@ -91,9 +76,14 @@ export const MAX_FILES_WALKED = 20_000;
|
|
|
91
76
|
* (which is tiny and contains the manifest).
|
|
92
77
|
*
|
|
93
78
|
* @param workspaceRoot Absolute path to the workspace root.
|
|
79
|
+
* @param options Optional settings including user-defined exclusion patterns.
|
|
94
80
|
* @returns A {@link WorkspaceWalkResult} snapshot.
|
|
95
81
|
*/
|
|
96
|
-
export async function estimateWorkspaceLoc(
|
|
82
|
+
export async function estimateWorkspaceLoc(
|
|
83
|
+
workspaceRoot: string,
|
|
84
|
+
options?: { exclude?: ReadonlyArray<string> },
|
|
85
|
+
): Promise<WorkspaceWalkResult> {
|
|
86
|
+
const filter = createExclusionFilter(options?.exclude);
|
|
97
87
|
let physicalLoc = 0;
|
|
98
88
|
let fileCount = 0;
|
|
99
89
|
let truncated = false;
|
|
@@ -108,11 +98,9 @@ export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<Works
|
|
|
108
98
|
}
|
|
109
99
|
for (const entry of entries) {
|
|
110
100
|
if (truncated) return;
|
|
111
|
-
// Skip hidden files except the plugin manifest dir.
|
|
112
|
-
if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
|
|
113
101
|
const full = join(dir, entry.name);
|
|
114
102
|
if (entry.isDirectory()) {
|
|
115
|
-
if (
|
|
103
|
+
if (filter.shouldSkipDir(entry.name)) continue;
|
|
116
104
|
await walk(full);
|
|
117
105
|
continue;
|
|
118
106
|
}
|
|
@@ -122,6 +110,8 @@ export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<Works
|
|
|
122
110
|
if (dot < 0) continue;
|
|
123
111
|
const ext = lower.substring(dot);
|
|
124
112
|
if (!CODE_EXTENSIONS.has(ext)) continue;
|
|
113
|
+
const relPath = relative(workspaceRoot, full);
|
|
114
|
+
if (filter.shouldSkipFile(relPath, entry.name)) continue;
|
|
125
115
|
fileCount += 1;
|
|
126
116
|
if (fileCount > MAX_FILES_WALKED) {
|
|
127
117
|
truncated = true;
|
|
@@ -130,8 +120,6 @@ export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<Works
|
|
|
130
120
|
try {
|
|
131
121
|
const content = await fs.readFile(full, "utf8");
|
|
132
122
|
if (content.length > 0) {
|
|
133
|
-
// Subtract 1 for trailing newline, matching what most editors
|
|
134
|
-
// report as the file's line count.
|
|
135
123
|
const lines = content.split(/\r?\n/).length;
|
|
136
124
|
physicalLoc += content.endsWith("\n") ? lines - 1 : lines;
|
|
137
125
|
}
|
package/src/scanner/auto-scan.ts
CHANGED
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
import { existsSync } from "node:fs";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import type { Logger } from "pino";
|
|
25
|
-
import { detectScanners, type ScannerDetection } from "./detector.js";
|
|
25
|
+
import { detectScanners, detectMonorepoScanners, type ScannerDetection } from "./detector.js";
|
|
26
26
|
import { runScanner, type ScannerRunResult } from "./runner.js";
|
|
27
27
|
import { bootstrapScanner } from "./bootstrap.js";
|
|
28
|
+
import { scanComplexity, type ComplexityScanResult } from "./complexity-scanner.js";
|
|
28
29
|
import { adaptScannerOutput, type KnownScanner } from "../adapters/index.js";
|
|
30
|
+
import type { TreeSitterEngine } from "../ast/tree-sitter-engine.js";
|
|
29
31
|
import type { SarifStore } from "../sarif/sarif-store.js";
|
|
30
32
|
|
|
31
33
|
// ── Types ──────────────────────────────────────────────────────────
|
|
@@ -53,6 +55,8 @@ export interface AutoScanResult {
|
|
|
53
55
|
totalFindings: number;
|
|
54
56
|
/** Wall-clock time for the entire auto-scan. */
|
|
55
57
|
totalDurationMs: number;
|
|
58
|
+
/** Result of the built-in cyclomatic complexity scan, when enabled. */
|
|
59
|
+
complexityScan?: ComplexityScanResult;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
// ── Orchestrator ───────────────────────────────────────────────────
|
|
@@ -93,16 +97,28 @@ export async function autoScan(
|
|
|
93
97
|
workspaceRoot: string,
|
|
94
98
|
sarifStore: SarifStore,
|
|
95
99
|
logger: Logger,
|
|
100
|
+
options?: { engine?: TreeSitterEngine; cyclomaticMax?: number; exclude?: ReadonlyArray<string> },
|
|
96
101
|
): Promise<AutoScanResult> {
|
|
97
102
|
const start = Date.now();
|
|
98
103
|
|
|
99
|
-
// 1. Detect available scanners
|
|
104
|
+
// 1. Detect available scanners (root + monorepo subdirs)
|
|
100
105
|
const detected = await detectScanners(workspaceRoot);
|
|
106
|
+
const monorepoDetected = await detectMonorepoScanners(workspaceRoot);
|
|
107
|
+
|
|
108
|
+
// Merge monorepo detections — skip duplicates (same scanner already found at root)
|
|
109
|
+
const rootScannerSet = new Set(detected.filter((d) => d.available).map((d) => d.scanner));
|
|
110
|
+
for (const md of monorepoDetected) {
|
|
111
|
+
if (!rootScannerSet.has(md.scanner)) {
|
|
112
|
+
detected.push(md);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
const available = detected.filter((d) => d.available);
|
|
102
117
|
|
|
103
118
|
logger.info(
|
|
104
119
|
{
|
|
105
120
|
detected: detected.map((d) => `${d.scanner}:${d.available}`),
|
|
121
|
+
monorepo: monorepoDetected.length,
|
|
106
122
|
available: available.length,
|
|
107
123
|
},
|
|
108
124
|
"auto-scan: detection complete",
|
|
@@ -157,9 +173,9 @@ export async function autoScan(
|
|
|
157
173
|
};
|
|
158
174
|
}
|
|
159
175
|
|
|
160
|
-
// 2. Run all available scanners in parallel
|
|
176
|
+
// 2. Run all available scanners in parallel (each from its detected workingDir)
|
|
161
177
|
const runResults = await Promise.allSettled(
|
|
162
|
-
available.map((d) => runScanner(d.scanner, workspaceRoot)),
|
|
178
|
+
available.map((d) => runScanner(d.scanner, workspaceRoot, d.workingDir ? { workingDir: d.workingDir } : undefined)),
|
|
163
179
|
);
|
|
164
180
|
|
|
165
181
|
// 3. Ingest results
|
|
@@ -246,10 +262,31 @@ export async function autoScan(
|
|
|
246
262
|
await sarifStore.persist();
|
|
247
263
|
}
|
|
248
264
|
|
|
265
|
+
// 5. Run built-in cyclomatic complexity scanner
|
|
266
|
+
let complexityScan: ComplexityScanResult | undefined;
|
|
267
|
+
if (options?.engine) {
|
|
268
|
+
try {
|
|
269
|
+
complexityScan = await scanComplexity(
|
|
270
|
+
workspaceRoot,
|
|
271
|
+
options.engine,
|
|
272
|
+
sarifStore,
|
|
273
|
+
{ cyclomaticMax: options.cyclomaticMax ?? 15, ...(options.exclude ? { exclude: options.exclude } : {}) },
|
|
274
|
+
logger,
|
|
275
|
+
);
|
|
276
|
+
totalFindings += complexityScan.violations;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
logger.warn(
|
|
279
|
+
{ err: (err as Error).message },
|
|
280
|
+
"auto-scan: complexity scanner failed — continuing without it",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
249
285
|
return {
|
|
250
286
|
detected,
|
|
251
287
|
results,
|
|
252
288
|
totalFindings,
|
|
253
289
|
totalDurationMs: Date.now() - start,
|
|
290
|
+
...(complexityScan ? { complexityScan } : {}),
|
|
254
291
|
};
|
|
255
292
|
}
|
package/src/scanner/bootstrap.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type ProjectType =
|
|
|
44
44
|
| "python"
|
|
45
45
|
| "java"
|
|
46
46
|
| "csharp"
|
|
47
|
+
| "dart"
|
|
47
48
|
| "unknown";
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -117,6 +118,9 @@ export function detectProjectType(workspaceRoot: string): ProjectType {
|
|
|
117
118
|
// readdirSync can fail on permissions — fall through
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
// Dart / Flutter detection
|
|
122
|
+
if (has("pubspec.yaml")) return "dart";
|
|
123
|
+
|
|
120
124
|
return "unknown";
|
|
121
125
|
}
|
|
122
126
|
|
|
@@ -273,6 +277,13 @@ function getRecommendation(projectType: ProjectType): ScannerRecommendation {
|
|
|
273
277
|
installInstructions:
|
|
274
278
|
"brew install semgrep (or: pip install semgrep, pipx install semgrep)",
|
|
275
279
|
};
|
|
280
|
+
case "dart":
|
|
281
|
+
return {
|
|
282
|
+
scanner: "dart_analyze",
|
|
283
|
+
canAutoInstall: false,
|
|
284
|
+
installInstructions:
|
|
285
|
+
"Install the Dart SDK: https://dart.dev/get-dart (or Flutter SDK which includes Dart)",
|
|
286
|
+
};
|
|
276
287
|
case "unknown":
|
|
277
288
|
return {
|
|
278
289
|
scanner: "semgrep",
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cyclomatic complexity scanner.
|
|
3
|
+
*
|
|
4
|
+
* Walks the workspace, analyzes each supported source file with
|
|
5
|
+
* tree-sitter, and emits SARIF findings for functions whose cyclomatic
|
|
6
|
+
* complexity exceeds the configured threshold (`cyclomaticMax`).
|
|
7
|
+
*
|
|
8
|
+
* This scanner is an internal analyzer — it is NOT a "known scanner"
|
|
9
|
+
* in the `KnownScanner` union (eslint/semgrep/bandit/stryker). It
|
|
10
|
+
* bypasses the adapter pipeline and writes SARIF directly via
|
|
11
|
+
* `wrapResultsInSarif()` from the common adapter helpers.
|
|
12
|
+
*
|
|
13
|
+
* Severity mapping:
|
|
14
|
+
* - `warning` — CC > threshold but < 2× threshold
|
|
15
|
+
* - `error` — CC >= 2× threshold (aligns with CRAP index hard block at CC >= 30)
|
|
16
|
+
*
|
|
17
|
+
* @module scanner/complexity-scanner
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { promises as fs } from "node:fs";
|
|
21
|
+
import { join, relative } from "node:path";
|
|
22
|
+
import type { Logger } from "pino";
|
|
23
|
+
|
|
24
|
+
import { TreeSitterEngine } from "../ast/tree-sitter-engine.js";
|
|
25
|
+
import { detectLanguageFromPath } from "../ast/language-config.js";
|
|
26
|
+
import { wrapResultsInSarif, estimateEffortMinutes } from "../adapters/common.js";
|
|
27
|
+
import { createExclusionFilter } from "../shared/exclusions.js";
|
|
28
|
+
import type { SarifStore } from "../sarif/sarif-store.js";
|
|
29
|
+
import type { SarifLevel } from "../sarif/sarif-builder.js";
|
|
30
|
+
|
|
31
|
+
// Directory exclusions are now centralized in src/shared/exclusions.ts.
|
|
32
|
+
|
|
33
|
+
/** Hard cap on files to prevent unbounded analysis. */
|
|
34
|
+
const MAX_FILES = 20_000;
|
|
35
|
+
|
|
36
|
+
/** SARIF rule ID for cyclomatic complexity violations. */
|
|
37
|
+
const RULE_ID = "complexity/cyclomatic-max";
|
|
38
|
+
|
|
39
|
+
/** Source tool identifier used in SARIF properties. */
|
|
40
|
+
const SOURCE_TOOL = "complexity";
|
|
41
|
+
|
|
42
|
+
// ── Types ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** Result of a complexity scan run. */
|
|
45
|
+
export interface ComplexityScanResult {
|
|
46
|
+
/** Number of source files successfully analyzed. */
|
|
47
|
+
readonly filesScanned: number;
|
|
48
|
+
/** Total number of functions found across all files. */
|
|
49
|
+
readonly functionsAnalyzed: number;
|
|
50
|
+
/** Number of functions that exceeded the threshold. */
|
|
51
|
+
readonly violations: number;
|
|
52
|
+
/** Wall-clock time for the entire scan. */
|
|
53
|
+
readonly durationMs: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Configuration accepted by the scanner. */
|
|
57
|
+
export interface ComplexityScanConfig {
|
|
58
|
+
/** Maximum cyclomatic complexity allowed per function. */
|
|
59
|
+
readonly cyclomaticMax: number;
|
|
60
|
+
/** User-defined exclusion patterns from .claude-crap.json. */
|
|
61
|
+
readonly exclude?: ReadonlyArray<string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Scanner ───────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Scan a workspace for cyclomatic complexity violations.
|
|
68
|
+
*
|
|
69
|
+
* Walks the directory tree, analyzes each source file with the
|
|
70
|
+
* tree-sitter engine, and emits SARIF findings for functions above
|
|
71
|
+
* the configured threshold. Findings are ingested into the provided
|
|
72
|
+
* `SarifStore` and persisted to disk.
|
|
73
|
+
*
|
|
74
|
+
* @param workspaceRoot Absolute path to the workspace root.
|
|
75
|
+
* @param engine Initialized tree-sitter engine instance.
|
|
76
|
+
* @param sarifStore Live SARIF store to ingest findings into.
|
|
77
|
+
* @param config Scanner configuration (threshold).
|
|
78
|
+
* @param logger Pino logger for progress and error reporting.
|
|
79
|
+
* @returns Summary of what was scanned and found.
|
|
80
|
+
*/
|
|
81
|
+
export async function scanComplexity(
|
|
82
|
+
workspaceRoot: string,
|
|
83
|
+
engine: TreeSitterEngine,
|
|
84
|
+
sarifStore: SarifStore,
|
|
85
|
+
config: ComplexityScanConfig,
|
|
86
|
+
logger: Logger,
|
|
87
|
+
): Promise<ComplexityScanResult> {
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
const threshold = config.cyclomaticMax;
|
|
90
|
+
const errorThreshold = threshold * 2;
|
|
91
|
+
|
|
92
|
+
// 1. Collect supported source files
|
|
93
|
+
const filter = createExclusionFilter(config.exclude);
|
|
94
|
+
const files = await collectSourceFiles(workspaceRoot, filter);
|
|
95
|
+
logger.info(
|
|
96
|
+
{ fileCount: files.length, threshold },
|
|
97
|
+
"complexity-scanner: starting analysis",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// 2. Analyze each file and collect violations
|
|
101
|
+
const sarifResults: object[] = [];
|
|
102
|
+
let filesScanned = 0;
|
|
103
|
+
let functionsAnalyzed = 0;
|
|
104
|
+
let violations = 0;
|
|
105
|
+
|
|
106
|
+
for (const filePath of files) {
|
|
107
|
+
const language = detectLanguageFromPath(filePath);
|
|
108
|
+
if (!language) continue;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const metrics = await engine.analyzeFile({ filePath, language });
|
|
112
|
+
filesScanned += 1;
|
|
113
|
+
functionsAnalyzed += metrics.functions.length;
|
|
114
|
+
|
|
115
|
+
for (const fn of metrics.functions) {
|
|
116
|
+
if (fn.cyclomaticComplexity <= threshold) continue;
|
|
117
|
+
|
|
118
|
+
const level: SarifLevel =
|
|
119
|
+
fn.cyclomaticComplexity >= errorThreshold ? "error" : "warning";
|
|
120
|
+
|
|
121
|
+
const relPath = relative(workspaceRoot, filePath);
|
|
122
|
+
|
|
123
|
+
sarifResults.push({
|
|
124
|
+
ruleId: RULE_ID,
|
|
125
|
+
level,
|
|
126
|
+
message: {
|
|
127
|
+
text: `Function '${fn.name}' has cyclomatic complexity ${fn.cyclomaticComplexity} (threshold: ${threshold})`,
|
|
128
|
+
},
|
|
129
|
+
locations: [
|
|
130
|
+
{
|
|
131
|
+
physicalLocation: {
|
|
132
|
+
artifactLocation: { uri: relPath },
|
|
133
|
+
region: {
|
|
134
|
+
startLine: fn.startLine,
|
|
135
|
+
startColumn: 1,
|
|
136
|
+
endLine: fn.endLine,
|
|
137
|
+
endColumn: 1,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
properties: {
|
|
143
|
+
sourceTool: SOURCE_TOOL,
|
|
144
|
+
effortMinutes: estimateEffortMinutes(level),
|
|
145
|
+
cyclomaticComplexity: fn.cyclomaticComplexity,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
violations += 1;
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
logger.warn(
|
|
152
|
+
{ filePath, err: (err as Error).message },
|
|
153
|
+
"complexity-scanner: failed to analyze file, skipping",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 3. Ingest findings into the SARIF store
|
|
159
|
+
if (sarifResults.length > 0) {
|
|
160
|
+
const document = wrapResultsInSarif(
|
|
161
|
+
SOURCE_TOOL as never,
|
|
162
|
+
"0.1.0",
|
|
163
|
+
sarifResults,
|
|
164
|
+
);
|
|
165
|
+
sarifStore.ingestRun(document, SOURCE_TOOL);
|
|
166
|
+
await sarifStore.persist();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const durationMs = Date.now() - start;
|
|
170
|
+
logger.info(
|
|
171
|
+
{ filesScanned, functionsAnalyzed, violations, durationMs },
|
|
172
|
+
"complexity-scanner: analysis complete",
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return { filesScanned, functionsAnalyzed, violations, durationMs };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── File walker ───────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Collect source files from the workspace that the tree-sitter engine
|
|
182
|
+
* can analyze. Uses the shared exclusion filter for directory and file
|
|
183
|
+
* filtering. Only returns files whose extension maps to a supported language.
|
|
184
|
+
*/
|
|
185
|
+
async function collectSourceFiles(
|
|
186
|
+
workspaceRoot: string,
|
|
187
|
+
filter: import("../shared/exclusions.js").ExclusionFilter,
|
|
188
|
+
): Promise<string[]> {
|
|
189
|
+
const files: string[] = [];
|
|
190
|
+
let truncated = false;
|
|
191
|
+
|
|
192
|
+
async function walk(dir: string): Promise<void> {
|
|
193
|
+
if (truncated) return;
|
|
194
|
+
let entries;
|
|
195
|
+
try {
|
|
196
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
197
|
+
} catch {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
if (truncated) return;
|
|
202
|
+
const full = join(dir, entry.name);
|
|
203
|
+
if (entry.isDirectory()) {
|
|
204
|
+
if (filter.shouldSkipDir(entry.name)) continue;
|
|
205
|
+
await walk(full);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!entry.isFile()) continue;
|
|
209
|
+
if (!detectLanguageFromPath(entry.name)) continue;
|
|
210
|
+
const relPath = relative(workspaceRoot, full);
|
|
211
|
+
if (filter.shouldSkipFile(relPath, entry.name)) continue;
|
|
212
|
+
files.push(full);
|
|
213
|
+
if (files.length >= MAX_FILES) {
|
|
214
|
+
truncated = true;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await walk(workspaceRoot);
|
|
221
|
+
return files;
|
|
222
|
+
}
|
package/src/scanner/detector.ts
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* @module scanner/detector
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
-
import { join } from "node:path";
|
|
20
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
22
|
import { execFile } from "node:child_process";
|
|
23
23
|
import type { KnownScanner } from "../adapters/common.js";
|
|
24
24
|
|
|
@@ -36,6 +36,8 @@ export interface ScannerDetection {
|
|
|
36
36
|
reason: string;
|
|
37
37
|
/** Path to the config file that triggered detection, if any. */
|
|
38
38
|
configPath?: string;
|
|
39
|
+
/** Working directory to run the scanner from (defaults to workspace root). */
|
|
40
|
+
workingDir?: string;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// ── Detection signals ──────────────────────────────────────────────
|
|
@@ -98,6 +100,14 @@ const SCANNER_SIGNALS: Record<KnownScanner, ScannerSignals> = {
|
|
|
98
100
|
packageJsonKeys: ["@stryker-mutator/core"],
|
|
99
101
|
binaryNames: ["stryker"],
|
|
100
102
|
},
|
|
103
|
+
dart_analyze: {
|
|
104
|
+
configFiles: [
|
|
105
|
+
"analysis_options.yaml",
|
|
106
|
+
"pubspec.yaml",
|
|
107
|
+
],
|
|
108
|
+
packageJsonKeys: [],
|
|
109
|
+
binaryNames: ["dart"],
|
|
110
|
+
},
|
|
101
111
|
};
|
|
102
112
|
|
|
103
113
|
// ── Probes ──────────────────────────────────────────────────────────
|
|
@@ -163,9 +173,9 @@ function probeBinary(binaryName: string): Promise<boolean> {
|
|
|
163
173
|
// ── Public API ──────────────────────────────────────────────────────
|
|
164
174
|
|
|
165
175
|
/**
|
|
166
|
-
* Detect which
|
|
167
|
-
*
|
|
168
|
-
*
|
|
176
|
+
* Detect which supported scanners are available in the given workspace.
|
|
177
|
+
* Probes config files, package.json, and binary availability in order,
|
|
178
|
+
* short-circuiting on first match.
|
|
169
179
|
*
|
|
170
180
|
* @param workspaceRoot Absolute path to the project root.
|
|
171
181
|
* @returns One {@link ScannerDetection} per known scanner.
|
|
@@ -173,7 +183,7 @@ function probeBinary(binaryName: string): Promise<boolean> {
|
|
|
173
183
|
export async function detectScanners(
|
|
174
184
|
workspaceRoot: string,
|
|
175
185
|
): Promise<ScannerDetection[]> {
|
|
176
|
-
const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker"];
|
|
186
|
+
const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
|
|
177
187
|
|
|
178
188
|
const results = await Promise.all(
|
|
179
189
|
scanners.map(async (scanner): Promise<ScannerDetection> => {
|
|
@@ -188,12 +198,18 @@ export async function detectScanners(
|
|
|
188
198
|
};
|
|
189
199
|
}
|
|
190
200
|
|
|
191
|
-
// 2. Package.json probe
|
|
201
|
+
// 2. Package.json probe — declared in deps/devDeps, but is it
|
|
202
|
+
// actually installed? Check node_modules/.bin/ for the binary.
|
|
192
203
|
if (probePackageJson(workspaceRoot, scanner)) {
|
|
204
|
+
const binName = SCANNER_SIGNALS[scanner].binaryNames[0];
|
|
205
|
+
const binPath = binName ? join(workspaceRoot, "node_modules", ".bin", binName) : null;
|
|
206
|
+
const installed = binPath !== null && existsSync(binPath);
|
|
193
207
|
return {
|
|
194
208
|
scanner,
|
|
195
|
-
available:
|
|
196
|
-
reason:
|
|
209
|
+
available: installed,
|
|
210
|
+
reason: installed
|
|
211
|
+
? "found in package.json and installed"
|
|
212
|
+
: `found in package.json but not installed (run \`npm install\`)`,
|
|
197
213
|
};
|
|
198
214
|
}
|
|
199
215
|
|
|
@@ -220,5 +236,93 @@ export async function detectScanners(
|
|
|
220
236
|
return results;
|
|
221
237
|
}
|
|
222
238
|
|
|
239
|
+
// ── Monorepo subdirectory probing ────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Common monorepo directory names that may contain workspace
|
|
243
|
+
* subdirectories. Checked one level deep only.
|
|
244
|
+
*/
|
|
245
|
+
const MONOREPO_DIRS = ["apps", "packages", "libs", "modules", "services"];
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Detect scanners in monorepo subdirectories. Probes first-level
|
|
249
|
+
* children of common monorepo directories (apps/, packages/, etc.)
|
|
250
|
+
* and npm workspaces for scanner config files. Returns detections
|
|
251
|
+
* with a `workingDir` pointing to the subdirectory.
|
|
252
|
+
*
|
|
253
|
+
* This catches e.g. `apps/mobile/pubspec.yaml` in a polyglot monorepo
|
|
254
|
+
* where the root-level detector only finds ESLint.
|
|
255
|
+
*
|
|
256
|
+
* @param workspaceRoot Absolute path to the project root.
|
|
257
|
+
* @returns Additional detections from subdirectories (may be empty).
|
|
258
|
+
*/
|
|
259
|
+
export async function detectMonorepoScanners(
|
|
260
|
+
workspaceRoot: string,
|
|
261
|
+
): Promise<ScannerDetection[]> {
|
|
262
|
+
const subdirs = new Set<string>();
|
|
263
|
+
|
|
264
|
+
// 1. Read npm workspaces from package.json
|
|
265
|
+
try {
|
|
266
|
+
const pkgPath = join(workspaceRoot, "package.json");
|
|
267
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
268
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
269
|
+
if (Array.isArray(pkg.workspaces)) {
|
|
270
|
+
for (const ws of pkg.workspaces) {
|
|
271
|
+
if (typeof ws === "string" && !ws.includes("*")) {
|
|
272
|
+
const full = resolve(workspaceRoot, ws);
|
|
273
|
+
if (existsSync(full)) subdirs.add(full);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// No package.json or not parseable — continue
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 2. Scan common monorepo directories one level deep
|
|
282
|
+
for (const dir of MONOREPO_DIRS) {
|
|
283
|
+
const full = join(workspaceRoot, dir);
|
|
284
|
+
try {
|
|
285
|
+
const entries = readdirSync(full, { withFileTypes: true });
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
288
|
+
subdirs.add(join(full, entry.name));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// Directory doesn't exist — skip
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (subdirs.size === 0) return [];
|
|
297
|
+
|
|
298
|
+
// 3. Probe each subdirectory for scanner config files
|
|
299
|
+
const detections: ScannerDetection[] = [];
|
|
300
|
+
const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
|
|
301
|
+
|
|
302
|
+
for (const subdir of subdirs) {
|
|
303
|
+
for (const scanner of scanners) {
|
|
304
|
+
const configProbe = probeConfigFiles(subdir, scanner);
|
|
305
|
+
if (!configProbe.found) continue;
|
|
306
|
+
|
|
307
|
+
// For dart_analyze, also verify the binary is on PATH
|
|
308
|
+
if (scanner === "dart_analyze") {
|
|
309
|
+
const hasBinary = await probeBinary("dart");
|
|
310
|
+
if (!hasBinary) continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const relDir = subdir.replace(workspaceRoot + "/", "");
|
|
314
|
+
detections.push({
|
|
315
|
+
scanner,
|
|
316
|
+
available: true,
|
|
317
|
+
reason: `config file found in ${relDir}/`,
|
|
318
|
+
...(configProbe.path ? { configPath: configProbe.path } : {}),
|
|
319
|
+
workingDir: subdir,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return detections;
|
|
325
|
+
}
|
|
326
|
+
|
|
223
327
|
// Exported for testing
|
|
224
|
-
export { SCANNER_SIGNALS };
|
|
328
|
+
export { SCANNER_SIGNALS, MONOREPO_DIRS };
|