claude-crap 0.1.2
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 +308 -0
- package/LICENSE +21 -0
- package/README.md +550 -0
- package/bin/claude-crap.mjs +141 -0
- package/dist/adapters/bandit.d.ts +48 -0
- package/dist/adapters/bandit.d.ts.map +1 -0
- package/dist/adapters/bandit.js +145 -0
- package/dist/adapters/bandit.js.map +1 -0
- package/dist/adapters/common.d.ts +73 -0
- package/dist/adapters/common.d.ts.map +1 -0
- package/dist/adapters/common.js +78 -0
- package/dist/adapters/common.js.map +1 -0
- package/dist/adapters/eslint.d.ts +52 -0
- package/dist/adapters/eslint.d.ts.map +1 -0
- package/dist/adapters/eslint.js +142 -0
- package/dist/adapters/eslint.js.map +1 -0
- package/dist/adapters/index.d.ts +47 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +64 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/semgrep.d.ts +30 -0
- package/dist/adapters/semgrep.d.ts.map +1 -0
- package/dist/adapters/semgrep.js +130 -0
- package/dist/adapters/semgrep.js.map +1 -0
- package/dist/adapters/stryker.d.ts +55 -0
- package/dist/adapters/stryker.d.ts.map +1 -0
- package/dist/adapters/stryker.js +165 -0
- package/dist/adapters/stryker.js.map +1 -0
- package/dist/ast/cyclomatic.d.ts +48 -0
- package/dist/ast/cyclomatic.d.ts.map +1 -0
- package/dist/ast/cyclomatic.js +106 -0
- package/dist/ast/cyclomatic.js.map +1 -0
- package/dist/ast/index.d.ts +26 -0
- package/dist/ast/index.d.ts.map +1 -0
- package/dist/ast/index.js +23 -0
- package/dist/ast/index.js.map +1 -0
- package/dist/ast/language-config.d.ts +70 -0
- package/dist/ast/language-config.d.ts.map +1 -0
- package/dist/ast/language-config.js +192 -0
- package/dist/ast/language-config.js.map +1 -0
- package/dist/ast/tree-sitter-engine.d.ts +133 -0
- package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
- package/dist/ast/tree-sitter-engine.js +270 -0
- package/dist/ast/tree-sitter-engine.js.map +1 -0
- package/dist/config.d.ts +57 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +78 -0
- package/dist/config.js.map +1 -0
- package/dist/crap-config.d.ts +97 -0
- package/dist/crap-config.d.ts.map +1 -0
- package/dist/crap-config.js +144 -0
- package/dist/crap-config.js.map +1 -0
- package/dist/dashboard/server.d.ts +65 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +147 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +574 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/crap.d.ts +71 -0
- package/dist/metrics/crap.d.ts.map +1 -0
- package/dist/metrics/crap.js +67 -0
- package/dist/metrics/crap.js.map +1 -0
- package/dist/metrics/index.d.ts +31 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +27 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/score.d.ts +143 -0
- package/dist/metrics/score.d.ts.map +1 -0
- package/dist/metrics/score.js +224 -0
- package/dist/metrics/score.js.map +1 -0
- package/dist/metrics/tdr.d.ts +106 -0
- package/dist/metrics/tdr.d.ts.map +1 -0
- package/dist/metrics/tdr.js +117 -0
- package/dist/metrics/tdr.js.map +1 -0
- package/dist/metrics/workspace-walker.d.ts +43 -0
- package/dist/metrics/workspace-walker.d.ts.map +1 -0
- package/dist/metrics/workspace-walker.js +137 -0
- package/dist/metrics/workspace-walker.js.map +1 -0
- package/dist/sarif/index.d.ts +21 -0
- package/dist/sarif/index.d.ts.map +1 -0
- package/dist/sarif/index.js +19 -0
- package/dist/sarif/index.js.map +1 -0
- package/dist/sarif/sarif-builder.d.ts +128 -0
- package/dist/sarif/sarif-builder.d.ts.map +1 -0
- package/dist/sarif/sarif-builder.js +79 -0
- package/dist/sarif/sarif-builder.js.map +1 -0
- package/dist/sarif/sarif-store.d.ts +205 -0
- package/dist/sarif/sarif-store.d.ts.map +1 -0
- package/dist/sarif/sarif-store.js +246 -0
- package/dist/sarif/sarif-store.js.map +1 -0
- package/dist/sarif/sarif-validator.d.ts +45 -0
- package/dist/sarif/sarif-validator.d.ts.map +1 -0
- package/dist/sarif/sarif-validator.js +138 -0
- package/dist/sarif/sarif-validator.js.map +1 -0
- package/dist/schemas/tool-schemas.d.ts +216 -0
- package/dist/schemas/tool-schemas.d.ts.map +1 -0
- package/dist/schemas/tool-schemas.js +208 -0
- package/dist/schemas/tool-schemas.js.map +1 -0
- package/dist/sdk.d.ts +45 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +44 -0
- package/dist/sdk.js.map +1 -0
- package/dist/tools/index.d.ts +24 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/test-harness.d.ts +75 -0
- package/dist/tools/test-harness.d.ts.map +1 -0
- package/dist/tools/test-harness.js +137 -0
- package/dist/tools/test-harness.js.map +1 -0
- package/dist/workspace-guard.d.ts +53 -0
- package/dist/workspace-guard.d.ts.map +1 -0
- package/dist/workspace-guard.js +61 -0
- package/dist/workspace-guard.js.map +1 -0
- package/package.json +133 -0
- package/plugin/.claude-plugin/plugin.json +29 -0
- package/plugin/.mcp.json +18 -0
- package/plugin/CLAUDE.md +143 -0
- package/plugin/bundle/dashboard/public/index.html +368 -0
- package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
- package/plugin/bundle/mcp-server.mjs +8718 -0
- package/plugin/bundle/mcp-server.mjs.map +7 -0
- package/plugin/bundle/tdr-engine.mjs +50 -0
- package/plugin/bundle/tdr-engine.mjs.map +7 -0
- package/plugin/hooks/hooks.json +62 -0
- package/plugin/hooks/lib/crap-config.mjs +152 -0
- package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
- package/plugin/hooks/lib/hook-io.mjs +151 -0
- package/plugin/hooks/lib/quality-gate.mjs +329 -0
- package/plugin/hooks/lib/test-harness.mjs +152 -0
- package/plugin/hooks/post-tool-use.mjs +245 -0
- package/plugin/hooks/pre-tool-use.mjs +290 -0
- package/plugin/hooks/session-start.mjs +109 -0
- package/plugin/hooks/stop-quality-gate.mjs +226 -0
- package/plugin/package.json +18 -0
- package/plugin/skills/adopt/SKILL.md +74 -0
- package/plugin/skills/analyze/SKILL.md +77 -0
- package/plugin/skills/check-test/SKILL.md +50 -0
- package/plugin/skills/score/SKILL.md +31 -0
- package/scripts/bug-report.mjs +328 -0
- package/scripts/build-fast.mjs +130 -0
- package/scripts/bundle-plugin.mjs +74 -0
- package/scripts/doctor.mjs +320 -0
- package/scripts/install.mjs +192 -0
- package/scripts/lib/cli-ui.mjs +122 -0
- package/scripts/postinstall.mjs +127 -0
- package/scripts/run-tests.mjs +95 -0
- package/scripts/status.mjs +110 -0
- package/scripts/uninstall.mjs +72 -0
- package/src/adapters/bandit.ts +191 -0
- package/src/adapters/common.ts +133 -0
- package/src/adapters/eslint.ts +187 -0
- package/src/adapters/index.ts +78 -0
- package/src/adapters/semgrep.ts +150 -0
- package/src/adapters/stryker.ts +218 -0
- package/src/ast/cyclomatic.ts +131 -0
- package/src/ast/index.ts +33 -0
- package/src/ast/language-config.ts +231 -0
- package/src/ast/tree-sitter-engine.ts +385 -0
- package/src/config.ts +109 -0
- package/src/crap-config.ts +196 -0
- package/src/dashboard/public/index.html +368 -0
- package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
- package/src/dashboard/server.ts +205 -0
- package/src/index.ts +696 -0
- package/src/metrics/crap.ts +101 -0
- package/src/metrics/index.ts +51 -0
- package/src/metrics/score.ts +329 -0
- package/src/metrics/tdr.ts +155 -0
- package/src/metrics/workspace-walker.ts +146 -0
- package/src/sarif/index.ts +31 -0
- package/src/sarif/sarif-builder.ts +139 -0
- package/src/sarif/sarif-store.ts +347 -0
- package/src/sarif/sarif-validator.ts +145 -0
- package/src/schemas/tool-schemas.ts +225 -0
- package/src/sdk.ts +110 -0
- package/src/tests/adapters/bandit.test.ts +111 -0
- package/src/tests/adapters/dispatch.test.ts +100 -0
- package/src/tests/adapters/eslint.test.ts +138 -0
- package/src/tests/adapters/semgrep.test.ts +125 -0
- package/src/tests/adapters/stryker.test.ts +103 -0
- package/src/tests/crap-config.test.ts +228 -0
- package/src/tests/crap.test.ts +59 -0
- package/src/tests/cyclomatic.test.ts +87 -0
- package/src/tests/dashboard-http.test.ts +108 -0
- package/src/tests/dashboard-integrity.test.ts +128 -0
- package/src/tests/integration/mcp-server.integration.test.ts +352 -0
- package/src/tests/pre-tool-use-hook.test.ts +178 -0
- package/src/tests/sarif-store.test.ts +241 -0
- package/src/tests/sarif-validator.test.ts +164 -0
- package/src/tests/score.test.ts +260 -0
- package/src/tests/skills-frontmatter.test.ts +172 -0
- package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
- package/src/tests/tdr.test.ts +86 -0
- package/src/tests/test-harness.test.ts +153 -0
- package/src/tests/workspace-guard.test.ts +111 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/test-harness.ts +158 -0
- package/src/workspace-guard.ts +64 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded workspace walker.
|
|
3
|
+
*
|
|
4
|
+
* Counts physical lines of code across a workspace, skipping directories
|
|
5
|
+
* that should not contribute to the Technical Debt Ratio (dependency
|
|
6
|
+
* caches, build artifacts, VCS metadata, etc.) and capping the file
|
|
7
|
+
* count to keep the walk well under the Stop hook's 120-second budget
|
|
8
|
+
* even on pathological repositories.
|
|
9
|
+
*
|
|
10
|
+
* This is the TypeScript twin of `hooks/lib/quality-gate.mjs#estimateWorkspaceLoc`.
|
|
11
|
+
* The two are independent so neither side has to import files from outside
|
|
12
|
+
* its own project tree.
|
|
13
|
+
*
|
|
14
|
+
* @module metrics/workspace-walker
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Result returned by {@link estimateWorkspaceLoc}.
|
|
22
|
+
*/
|
|
23
|
+
export interface WorkspaceWalkResult {
|
|
24
|
+
/** Total physical lines of code across every file the walker read. */
|
|
25
|
+
readonly physicalLoc: number;
|
|
26
|
+
/** Number of code files the walker visited. */
|
|
27
|
+
readonly fileCount: number;
|
|
28
|
+
/** `true` when the walker hit {@link MAX_FILES_WALKED} and stopped early. */
|
|
29
|
+
readonly truncated: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Directories that should never contribute to the LOC count. Dependency
|
|
34
|
+
* caches, build artifacts, VCS metadata, claude-crap's own state.
|
|
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
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extensions the walker treats as "code". Anything else is ignored,
|
|
55
|
+
* including markdown, JSON, YAML, lockfiles, and binaries.
|
|
56
|
+
*/
|
|
57
|
+
const CODE_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
58
|
+
".ts",
|
|
59
|
+
".tsx",
|
|
60
|
+
".mts",
|
|
61
|
+
".cts",
|
|
62
|
+
".js",
|
|
63
|
+
".jsx",
|
|
64
|
+
".mjs",
|
|
65
|
+
".cjs",
|
|
66
|
+
".py",
|
|
67
|
+
".java",
|
|
68
|
+
".cs",
|
|
69
|
+
".go",
|
|
70
|
+
".rs",
|
|
71
|
+
".rb",
|
|
72
|
+
".php",
|
|
73
|
+
".swift",
|
|
74
|
+
".kt",
|
|
75
|
+
".scala",
|
|
76
|
+
".dart",
|
|
77
|
+
".vue",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Hard cap on the number of files the walker will read. Protects against
|
|
82
|
+
* pathological repositories where the walk would otherwise dominate the
|
|
83
|
+
* Stop hook's budget. When hit, the walker returns the partial count
|
|
84
|
+
* with `truncated: true` and the caller may decide how to react.
|
|
85
|
+
*/
|
|
86
|
+
export const MAX_FILES_WALKED = 20_000;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Walk a workspace and return its physical LOC + file count. Never
|
|
90
|
+
* follows symbolic links. Skips hidden directories except `.claude-plugin`
|
|
91
|
+
* (which is tiny and contains the manifest).
|
|
92
|
+
*
|
|
93
|
+
* @param workspaceRoot Absolute path to the workspace root.
|
|
94
|
+
* @returns A {@link WorkspaceWalkResult} snapshot.
|
|
95
|
+
*/
|
|
96
|
+
export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<WorkspaceWalkResult> {
|
|
97
|
+
let physicalLoc = 0;
|
|
98
|
+
let fileCount = 0;
|
|
99
|
+
let truncated = false;
|
|
100
|
+
|
|
101
|
+
async function walk(dir: string): Promise<void> {
|
|
102
|
+
if (truncated) return;
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (truncated) return;
|
|
111
|
+
// Skip hidden files except the plugin manifest dir.
|
|
112
|
+
if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
|
|
113
|
+
const full = join(dir, entry.name);
|
|
114
|
+
if (entry.isDirectory()) {
|
|
115
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
116
|
+
await walk(full);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!entry.isFile()) continue;
|
|
120
|
+
const lower = entry.name.toLowerCase();
|
|
121
|
+
const dot = lower.lastIndexOf(".");
|
|
122
|
+
if (dot < 0) continue;
|
|
123
|
+
const ext = lower.substring(dot);
|
|
124
|
+
if (!CODE_EXTENSIONS.has(ext)) continue;
|
|
125
|
+
fileCount += 1;
|
|
126
|
+
if (fileCount > MAX_FILES_WALKED) {
|
|
127
|
+
truncated = true;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const content = await fs.readFile(full, "utf8");
|
|
132
|
+
if (content.length > 0) {
|
|
133
|
+
// Subtract 1 for trailing newline, matching what most editors
|
|
134
|
+
// report as the file's line count.
|
|
135
|
+
const lines = content.split(/\r?\n/).length;
|
|
136
|
+
physicalLoc += content.endsWith("\n") ? lines - 1 : lines;
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Unreadable file (permissions, binary). Skip silently.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await walk(workspaceRoot);
|
|
145
|
+
return { physicalLoc, fileCount, truncated };
|
|
146
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public SDK entry point for the SARIF 2.1.0 builder and store.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import {
|
|
8
|
+
* SarifStore,
|
|
9
|
+
* buildSarifDocument,
|
|
10
|
+
* type SarifFinding,
|
|
11
|
+
* type SarifLevel,
|
|
12
|
+
* } from "claude-crap/sarif";
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* @module sarif
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export { buildSarifDocument } from "./sarif-builder.js";
|
|
19
|
+
export type {
|
|
20
|
+
SarifFinding,
|
|
21
|
+
SarifLevel,
|
|
22
|
+
SarifLocation,
|
|
23
|
+
SarifToolInfo,
|
|
24
|
+
} from "./sarif-builder.js";
|
|
25
|
+
|
|
26
|
+
export { SarifStore } from "./sarif-store.js";
|
|
27
|
+
export type {
|
|
28
|
+
IngestedFinding,
|
|
29
|
+
PersistedSarif,
|
|
30
|
+
SarifStoreOptions,
|
|
31
|
+
} from "./sarif-store.js";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SARIF 2.1.0 document builder.
|
|
3
|
+
*
|
|
4
|
+
* Every report that leaves the MCP server on its way to the agent is
|
|
5
|
+
* normalized to SARIF 2.1.0 first. This module provides the typed
|
|
6
|
+
* helpers used to wrap raw findings in the canonical
|
|
7
|
+
* `tool → runs → results` taxonomy with exact file coordinates.
|
|
8
|
+
*
|
|
9
|
+
* Per-scanner adapters (Semgrep, ESLint, Bandit, Stryker) live under
|
|
10
|
+
* `src/adapters/` and call into `buildSarifDocument` through the
|
|
11
|
+
* `wrapResultsInSarif` helper in `src/adapters/common.ts`. The
|
|
12
|
+
* on-disk deduplication store lives in `./sarif-store.ts`.
|
|
13
|
+
*
|
|
14
|
+
* The SARIF 2.1.0 spec lives at:
|
|
15
|
+
* https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
16
|
+
*
|
|
17
|
+
* @module sarif/sarif-builder
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Severity levels supported by SARIF 2.1.0. They map 1:1 to the
|
|
22
|
+
* `result.level` field. `"error"` is the strongest, `"none"` is informational.
|
|
23
|
+
*/
|
|
24
|
+
export type SarifLevel = "none" | "note" | "warning" | "error";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Physical location of a finding inside a source artifact. `startLine` and
|
|
28
|
+
* `startColumn` are 1-based, matching the SARIF spec. `endLine` and
|
|
29
|
+
* `endColumn` are optional — omit them for point-like findings.
|
|
30
|
+
*/
|
|
31
|
+
export interface SarifLocation {
|
|
32
|
+
/** Artifact URI, typically a file path relative to the workspace root. */
|
|
33
|
+
readonly uri: string;
|
|
34
|
+
/** 1-based line number where the finding starts. */
|
|
35
|
+
readonly startLine: number;
|
|
36
|
+
/** 1-based column number where the finding starts. */
|
|
37
|
+
readonly startColumn: number;
|
|
38
|
+
/** Optional 1-based line number where the finding ends. */
|
|
39
|
+
readonly endLine?: number;
|
|
40
|
+
/** Optional 1-based column number where the finding ends. */
|
|
41
|
+
readonly endColumn?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A single finding ready to be embedded in a SARIF run. This is the
|
|
46
|
+
* internal shape used by claude-crap adapters; it is converted into the
|
|
47
|
+
* official SARIF `result` object by {@link buildSarifDocument}.
|
|
48
|
+
*/
|
|
49
|
+
export interface SarifFinding {
|
|
50
|
+
/** Stable rule identifier (e.g. `"SONAR-CRAP-001"`, `"semgrep.python.sqli"`). */
|
|
51
|
+
readonly ruleId: string;
|
|
52
|
+
/** Severity level for this finding. */
|
|
53
|
+
readonly level: SarifLevel;
|
|
54
|
+
/** Human-readable message describing the finding. */
|
|
55
|
+
readonly message: string;
|
|
56
|
+
/** Physical location where the finding was detected. */
|
|
57
|
+
readonly location: SarifLocation;
|
|
58
|
+
/** Optional extra metadata stored in the SARIF `properties` bag. */
|
|
59
|
+
readonly properties?: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Metadata describing the tool that produced a SARIF run. The `name` is
|
|
64
|
+
* required by the spec; `version` is strongly recommended so that dashboard
|
|
65
|
+
* diffs can distinguish between scanner releases.
|
|
66
|
+
*/
|
|
67
|
+
export interface SarifToolInfo {
|
|
68
|
+
/** Tool display name (e.g. `"claude-crap"`, `"semgrep"`). */
|
|
69
|
+
readonly name: string;
|
|
70
|
+
/** Tool semantic version. */
|
|
71
|
+
readonly version: string;
|
|
72
|
+
/** Optional URL pointing to the tool's documentation or home page. */
|
|
73
|
+
readonly informationUri?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build a minimal but valid SARIF 2.1.0 document from a list of findings.
|
|
78
|
+
*
|
|
79
|
+
* The returned object conforms to the SARIF JSON schema hosted at:
|
|
80
|
+
* https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json
|
|
81
|
+
*
|
|
82
|
+
* Rules are deduplicated by `ruleId` and emitted in the `tool.driver.rules`
|
|
83
|
+
* array so that downstream consumers (Claude Code, the dashboard, or any
|
|
84
|
+
* third-party SARIF viewer) can render a rule index.
|
|
85
|
+
*
|
|
86
|
+
* @param tool Metadata about the producing tool.
|
|
87
|
+
* @param findings Findings to include in the single run.
|
|
88
|
+
* @returns A SARIF 2.1.0 document literal (frozen by `as const`).
|
|
89
|
+
*/
|
|
90
|
+
export function buildSarifDocument(tool: SarifToolInfo, findings: ReadonlyArray<SarifFinding>) {
|
|
91
|
+
return {
|
|
92
|
+
$schema: "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
|
|
93
|
+
version: "2.1.0",
|
|
94
|
+
runs: [
|
|
95
|
+
{
|
|
96
|
+
tool: {
|
|
97
|
+
driver: {
|
|
98
|
+
name: tool.name,
|
|
99
|
+
version: tool.version,
|
|
100
|
+
informationUri: tool.informationUri ?? "https://github.com/local/claude-crap",
|
|
101
|
+
// Deduplicate rules by id while preserving insertion order so
|
|
102
|
+
// the emitted `rules` array matches the order findings appear.
|
|
103
|
+
rules: Array.from(
|
|
104
|
+
new Map(
|
|
105
|
+
findings.map((f) => [
|
|
106
|
+
f.ruleId,
|
|
107
|
+
{
|
|
108
|
+
id: f.ruleId,
|
|
109
|
+
shortDescription: { text: f.ruleId },
|
|
110
|
+
defaultConfiguration: { level: f.level },
|
|
111
|
+
},
|
|
112
|
+
]),
|
|
113
|
+
).values(),
|
|
114
|
+
),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
results: findings.map((f) => ({
|
|
118
|
+
ruleId: f.ruleId,
|
|
119
|
+
level: f.level,
|
|
120
|
+
message: { text: f.message },
|
|
121
|
+
locations: [
|
|
122
|
+
{
|
|
123
|
+
physicalLocation: {
|
|
124
|
+
artifactLocation: { uri: f.location.uri },
|
|
125
|
+
region: {
|
|
126
|
+
startLine: f.location.startLine,
|
|
127
|
+
startColumn: f.location.startColumn,
|
|
128
|
+
...(f.location.endLine !== undefined ? { endLine: f.location.endLine } : {}),
|
|
129
|
+
...(f.location.endColumn !== undefined ? { endColumn: f.location.endColumn } : {}),
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
...(f.properties ? { properties: f.properties } : {}),
|
|
135
|
+
})),
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
} as const;
|
|
139
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk SARIF 2.1.0 store with finding deduplication.
|
|
3
|
+
*
|
|
4
|
+
* The Stop quality gate and the `ingest_sarif` MCP tool both need a
|
|
5
|
+
* single, consolidated view of every finding produced across the current
|
|
6
|
+
* session. This module provides:
|
|
7
|
+
*
|
|
8
|
+
* - `loadLatest()` — read the consolidated SARIF document from disk,
|
|
9
|
+
* or return an empty seed when no report exists yet.
|
|
10
|
+
* - `ingestRun()` — merge a new SARIF run from an external scanner
|
|
11
|
+
* (Semgrep, ESLint, Bandit, Stryker, ...) into the
|
|
12
|
+
* in-memory store, deduplicating by
|
|
13
|
+
* `(ruleId, uri, startLine, startColumn)`.
|
|
14
|
+
* - `persist()` — atomically write the consolidated document back
|
|
15
|
+
* to disk so other processes (the dashboard) can
|
|
16
|
+
* read it.
|
|
17
|
+
*
|
|
18
|
+
* The store is intentionally simple: it does NOT attempt to preserve
|
|
19
|
+
* per-tool run separation inside the persisted file. Instead, every
|
|
20
|
+
* ingested run is flattened into a single `runs[0]` entry whose `tool.driver`
|
|
21
|
+
* is claude-crap itself, and the original scanner name is recorded on
|
|
22
|
+
* each finding via the `properties.sourceTool` field. This keeps the
|
|
23
|
+
* consolidated document easy to diff between sessions.
|
|
24
|
+
*
|
|
25
|
+
* @module sarif/sarif-store
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { promises as fs } from "node:fs";
|
|
29
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
30
|
+
|
|
31
|
+
import { buildSarifDocument, type SarifFinding, type SarifLevel } from "./sarif-builder.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The shape of a persisted SARIF 2.1.0 document, narrowed to the fields
|
|
35
|
+
* we actually read and write. The full spec has many more optional
|
|
36
|
+
* fields; we ignore them on read and do not emit them on write.
|
|
37
|
+
*/
|
|
38
|
+
export interface PersistedSarif {
|
|
39
|
+
readonly $schema?: string;
|
|
40
|
+
readonly version: "2.1.0";
|
|
41
|
+
readonly runs: ReadonlyArray<SarifRun>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SarifRun {
|
|
45
|
+
readonly tool: {
|
|
46
|
+
readonly driver: {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly version: string;
|
|
49
|
+
readonly informationUri?: string;
|
|
50
|
+
readonly rules?: ReadonlyArray<unknown>;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
readonly results: ReadonlyArray<SarifResult>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SarifResult {
|
|
57
|
+
readonly ruleId: string;
|
|
58
|
+
readonly level?: SarifLevel;
|
|
59
|
+
readonly message: { readonly text: string };
|
|
60
|
+
readonly locations?: ReadonlyArray<SarifResultLocation>;
|
|
61
|
+
readonly properties?: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface SarifResultLocation {
|
|
65
|
+
readonly physicalLocation?: {
|
|
66
|
+
readonly artifactLocation?: { readonly uri?: string };
|
|
67
|
+
readonly region?: {
|
|
68
|
+
readonly startLine?: number;
|
|
69
|
+
readonly startColumn?: number;
|
|
70
|
+
readonly endLine?: number;
|
|
71
|
+
readonly endColumn?: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Options accepted by the {@link SarifStore} constructor.
|
|
78
|
+
*/
|
|
79
|
+
export interface SarifStoreOptions {
|
|
80
|
+
/** Workspace root. Used to resolve relative `outputDir`. */
|
|
81
|
+
readonly workspaceRoot: string;
|
|
82
|
+
/** Directory (absolute or workspace-relative) where reports are written. */
|
|
83
|
+
readonly outputDir: string;
|
|
84
|
+
/** Filename for the consolidated SARIF document. Defaults to `latest.sarif`. */
|
|
85
|
+
readonly fileName?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A finding together with its deduplication key. Used internally and
|
|
90
|
+
* returned by {@link SarifStore.ingestRun} so callers can see which
|
|
91
|
+
* findings were accepted.
|
|
92
|
+
*/
|
|
93
|
+
export interface IngestedFinding extends SarifFinding {
|
|
94
|
+
/** Stable deduplication key, shape: `ruleId|uri|line|col`. */
|
|
95
|
+
readonly dedupKey: string;
|
|
96
|
+
/** Name of the scanner that produced the finding (propagated from `sourceTool`). */
|
|
97
|
+
readonly sourceTool: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* On-disk SARIF store.
|
|
102
|
+
*/
|
|
103
|
+
export class SarifStore {
|
|
104
|
+
private readonly filePath: string;
|
|
105
|
+
/** In-memory index of findings keyed by their dedup string. */
|
|
106
|
+
private readonly findings = new Map<string, IngestedFinding>();
|
|
107
|
+
/** Tool invocations we have already ingested, for telemetry. */
|
|
108
|
+
private toolInvocations = 0;
|
|
109
|
+
|
|
110
|
+
constructor(options: SarifStoreOptions) {
|
|
111
|
+
const dir = isAbsolute(options.outputDir)
|
|
112
|
+
? options.outputDir
|
|
113
|
+
: resolve(options.workspaceRoot, options.outputDir);
|
|
114
|
+
this.filePath = join(dir, options.fileName ?? "latest.sarif");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Absolute path to the consolidated SARIF file on disk.
|
|
119
|
+
*/
|
|
120
|
+
get consolidatedReportPath(): string {
|
|
121
|
+
return this.filePath;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load the consolidated document from disk into memory. If the file is
|
|
126
|
+
* missing, the store is initialized empty. Top-level parsing errors
|
|
127
|
+
* still throw (a file that is not valid JSON, or that declares a
|
|
128
|
+
* different SARIF version, is a real safety signal). However, once
|
|
129
|
+
* the document is parsed, malformed individual runs and results are
|
|
130
|
+
* tolerated: F-A08-01 showed that a single bad entry in `latest.sarif`
|
|
131
|
+
* could crash the MCP server on boot and persistently DoS the
|
|
132
|
+
* developer. Each run / result is wrapped in its own try/catch so a
|
|
133
|
+
* single bad entry logs to stderr and is dropped, but the rest of
|
|
134
|
+
* the file still loads.
|
|
135
|
+
*
|
|
136
|
+
* @throws When the file exists but is not valid SARIF 2.1.0 JSON.
|
|
137
|
+
*/
|
|
138
|
+
async loadLatest(): Promise<void> {
|
|
139
|
+
try {
|
|
140
|
+
const raw = await fs.readFile(this.filePath, "utf8");
|
|
141
|
+
const parsed = JSON.parse(raw) as PersistedSarif;
|
|
142
|
+
if (parsed.version !== "2.1.0") {
|
|
143
|
+
throw new Error(`Expected SARIF 2.1.0, got ${parsed.version}`);
|
|
144
|
+
}
|
|
145
|
+
this.findings.clear();
|
|
146
|
+
// Defensive against tampered / mis-generated files: `runs` must
|
|
147
|
+
// be an array. Anything else is dropped with a stderr warning.
|
|
148
|
+
if (!Array.isArray(parsed.runs)) {
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
`[sarif-store] ${this.filePath}: 'runs' is not an array, dropping entire document\n`,
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const run of parsed.runs) {
|
|
155
|
+
try {
|
|
156
|
+
if (!run || typeof run !== "object" || !Array.isArray(run.results)) {
|
|
157
|
+
process.stderr.write(
|
|
158
|
+
`[sarif-store] ${this.filePath}: skipping run with non-iterable 'results'\n`,
|
|
159
|
+
);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
for (const result of run.results) {
|
|
163
|
+
try {
|
|
164
|
+
const finding = hydrateFindingFromResult(result);
|
|
165
|
+
if (finding) this.findings.set(finding.dedupKey, finding);
|
|
166
|
+
} catch (entryErr) {
|
|
167
|
+
process.stderr.write(
|
|
168
|
+
`[sarif-store] ${this.filePath}: dropping malformed result: ${(entryErr as Error).message}\n`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch (runErr) {
|
|
173
|
+
process.stderr.write(
|
|
174
|
+
`[sarif-store] ${this.filePath}: dropping malformed run: ${(runErr as Error).message}\n`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const error = err as NodeJS.ErrnoException;
|
|
180
|
+
if (error.code === "ENOENT") {
|
|
181
|
+
// No report on disk yet — normal for a fresh workspace.
|
|
182
|
+
this.findings.clear();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
throw new Error(
|
|
186
|
+
`[sarif-store] Failed to load consolidated report at ${this.filePath}: ${error.message}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Merge a raw SARIF document (from any external scanner) into the
|
|
193
|
+
* store, deduplicating by `(ruleId, uri, startLine, startColumn)`. The
|
|
194
|
+
* last writer wins for the message and level fields — later ingestions
|
|
195
|
+
* can refine earlier ones.
|
|
196
|
+
*
|
|
197
|
+
* @param sarifDocument The raw SARIF document as received from the tool.
|
|
198
|
+
* @param sourceTool Stable identifier of the producing scanner.
|
|
199
|
+
* @returns Stats describing what was accepted.
|
|
200
|
+
*/
|
|
201
|
+
ingestRun(
|
|
202
|
+
sarifDocument: PersistedSarif,
|
|
203
|
+
sourceTool: string,
|
|
204
|
+
): { accepted: number; duplicates: number; total: number } {
|
|
205
|
+
if (sarifDocument.version !== "2.1.0") {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`[sarif-store] ingestRun received version ${sarifDocument.version}, expected 2.1.0`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.toolInvocations += 1;
|
|
212
|
+
let accepted = 0;
|
|
213
|
+
let duplicates = 0;
|
|
214
|
+
let total = 0;
|
|
215
|
+
|
|
216
|
+
for (const run of sarifDocument.runs) {
|
|
217
|
+
for (const result of run.results) {
|
|
218
|
+
total += 1;
|
|
219
|
+
const finding = hydrateFindingFromResult(result, sourceTool);
|
|
220
|
+
if (!finding) continue;
|
|
221
|
+
if (this.findings.has(finding.dedupKey)) {
|
|
222
|
+
duplicates += 1;
|
|
223
|
+
// Overwrite with the latest metadata so the consolidated view
|
|
224
|
+
// reflects the most recent scanner output for this location.
|
|
225
|
+
this.findings.set(finding.dedupKey, finding);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
this.findings.set(finding.dedupKey, finding);
|
|
229
|
+
accepted += 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { accepted, duplicates, total };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Snapshot all currently tracked findings as a plain array. Mostly
|
|
238
|
+
* useful for tests and for the dashboard API.
|
|
239
|
+
*/
|
|
240
|
+
list(): ReadonlyArray<IngestedFinding> {
|
|
241
|
+
return Array.from(this.findings.values());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Atomically write the consolidated document back to disk. Uses a
|
|
246
|
+
* temporary file and `rename` so concurrent readers never observe
|
|
247
|
+
* a half-written document.
|
|
248
|
+
*/
|
|
249
|
+
async persist(): Promise<void> {
|
|
250
|
+
const doc = this.toSarifDocument();
|
|
251
|
+
await fs.mkdir(dirname(this.filePath), { recursive: true });
|
|
252
|
+
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
253
|
+
await fs.writeFile(tmp, JSON.stringify(doc, null, 2), "utf8");
|
|
254
|
+
await fs.rename(tmp, this.filePath);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build the current consolidated SARIF document from the in-memory
|
|
259
|
+
* findings without touching disk.
|
|
260
|
+
*/
|
|
261
|
+
toSarifDocument() {
|
|
262
|
+
// Strip the store-only `dedupKey` and `sourceTool` fields before
|
|
263
|
+
// serializing, but keep `sourceTool` in the per-finding `properties`
|
|
264
|
+
// bag so consumers can still trace origin.
|
|
265
|
+
const findings: SarifFinding[] = Array.from(this.findings.values()).map((f) => ({
|
|
266
|
+
ruleId: f.ruleId,
|
|
267
|
+
level: f.level,
|
|
268
|
+
message: f.message,
|
|
269
|
+
location: f.location,
|
|
270
|
+
properties: {
|
|
271
|
+
...(f.properties ?? {}),
|
|
272
|
+
sourceTool: f.sourceTool,
|
|
273
|
+
},
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
return buildSarifDocument(
|
|
277
|
+
{
|
|
278
|
+
name: "claude-crap",
|
|
279
|
+
version: "0.1.0",
|
|
280
|
+
informationUri: "https://github.com/local/claude-crap",
|
|
281
|
+
},
|
|
282
|
+
findings,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Number of unique findings currently tracked.
|
|
288
|
+
*/
|
|
289
|
+
size(): number {
|
|
290
|
+
return this.findings.size;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Number of times `ingestRun` has been called on this instance.
|
|
295
|
+
*/
|
|
296
|
+
get invocationsCount(): number {
|
|
297
|
+
return this.toolInvocations;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Convert a raw SARIF `result` object into an {@link IngestedFinding}.
|
|
303
|
+
* Returns `null` when the result is malformed (missing ruleId, message,
|
|
304
|
+
* or physical location), since a finding without coordinates cannot be
|
|
305
|
+
* deduplicated and is therefore useless.
|
|
306
|
+
*
|
|
307
|
+
* @param result Raw SARIF `result` object from the scanner's document.
|
|
308
|
+
* @param sourceTool Optional scanner identifier. If omitted, we read it
|
|
309
|
+
* from `result.properties.sourceTool` (used when
|
|
310
|
+
* reloading a persisted report).
|
|
311
|
+
* @returns The hydrated finding, or `null` when invalid.
|
|
312
|
+
*/
|
|
313
|
+
function hydrateFindingFromResult(
|
|
314
|
+
result: SarifResult,
|
|
315
|
+
sourceTool?: string,
|
|
316
|
+
): IngestedFinding | null {
|
|
317
|
+
if (!result.ruleId || !result.message?.text) return null;
|
|
318
|
+
const loc = result.locations?.[0]?.physicalLocation;
|
|
319
|
+
const uri = loc?.artifactLocation?.uri;
|
|
320
|
+
const region = loc?.region;
|
|
321
|
+
if (!uri || region?.startLine === undefined || region.startColumn === undefined) return null;
|
|
322
|
+
|
|
323
|
+
const resolvedSourceTool =
|
|
324
|
+
sourceTool ??
|
|
325
|
+
(typeof result.properties?.sourceTool === "string"
|
|
326
|
+
? (result.properties.sourceTool as string)
|
|
327
|
+
: "unknown");
|
|
328
|
+
|
|
329
|
+
const level: SarifLevel = result.level ?? "warning";
|
|
330
|
+
const dedupKey = `${result.ruleId}|${uri}|${region.startLine}|${region.startColumn}`;
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
ruleId: result.ruleId,
|
|
334
|
+
level,
|
|
335
|
+
message: result.message.text,
|
|
336
|
+
location: {
|
|
337
|
+
uri,
|
|
338
|
+
startLine: region.startLine,
|
|
339
|
+
startColumn: region.startColumn,
|
|
340
|
+
...(region.endLine !== undefined ? { endLine: region.endLine } : {}),
|
|
341
|
+
...(region.endColumn !== undefined ? { endColumn: region.endColumn } : {}),
|
|
342
|
+
},
|
|
343
|
+
...(result.properties ? { properties: result.properties } : {}),
|
|
344
|
+
dedupKey,
|
|
345
|
+
sourceTool: resolvedSourceTool,
|
|
346
|
+
};
|
|
347
|
+
}
|