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.
Files changed (202) hide show
  1. package/CHANGELOG.md +308 -0
  2. package/LICENSE +21 -0
  3. package/README.md +550 -0
  4. package/bin/claude-crap.mjs +141 -0
  5. package/dist/adapters/bandit.d.ts +48 -0
  6. package/dist/adapters/bandit.d.ts.map +1 -0
  7. package/dist/adapters/bandit.js +145 -0
  8. package/dist/adapters/bandit.js.map +1 -0
  9. package/dist/adapters/common.d.ts +73 -0
  10. package/dist/adapters/common.d.ts.map +1 -0
  11. package/dist/adapters/common.js +78 -0
  12. package/dist/adapters/common.js.map +1 -0
  13. package/dist/adapters/eslint.d.ts +52 -0
  14. package/dist/adapters/eslint.d.ts.map +1 -0
  15. package/dist/adapters/eslint.js +142 -0
  16. package/dist/adapters/eslint.js.map +1 -0
  17. package/dist/adapters/index.d.ts +47 -0
  18. package/dist/adapters/index.d.ts.map +1 -0
  19. package/dist/adapters/index.js +64 -0
  20. package/dist/adapters/index.js.map +1 -0
  21. package/dist/adapters/semgrep.d.ts +30 -0
  22. package/dist/adapters/semgrep.d.ts.map +1 -0
  23. package/dist/adapters/semgrep.js +130 -0
  24. package/dist/adapters/semgrep.js.map +1 -0
  25. package/dist/adapters/stryker.d.ts +55 -0
  26. package/dist/adapters/stryker.d.ts.map +1 -0
  27. package/dist/adapters/stryker.js +165 -0
  28. package/dist/adapters/stryker.js.map +1 -0
  29. package/dist/ast/cyclomatic.d.ts +48 -0
  30. package/dist/ast/cyclomatic.d.ts.map +1 -0
  31. package/dist/ast/cyclomatic.js +106 -0
  32. package/dist/ast/cyclomatic.js.map +1 -0
  33. package/dist/ast/index.d.ts +26 -0
  34. package/dist/ast/index.d.ts.map +1 -0
  35. package/dist/ast/index.js +23 -0
  36. package/dist/ast/index.js.map +1 -0
  37. package/dist/ast/language-config.d.ts +70 -0
  38. package/dist/ast/language-config.d.ts.map +1 -0
  39. package/dist/ast/language-config.js +192 -0
  40. package/dist/ast/language-config.js.map +1 -0
  41. package/dist/ast/tree-sitter-engine.d.ts +133 -0
  42. package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
  43. package/dist/ast/tree-sitter-engine.js +270 -0
  44. package/dist/ast/tree-sitter-engine.js.map +1 -0
  45. package/dist/config.d.ts +57 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +78 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/crap-config.d.ts +97 -0
  50. package/dist/crap-config.d.ts.map +1 -0
  51. package/dist/crap-config.js +144 -0
  52. package/dist/crap-config.js.map +1 -0
  53. package/dist/dashboard/server.d.ts +65 -0
  54. package/dist/dashboard/server.d.ts.map +1 -0
  55. package/dist/dashboard/server.js +147 -0
  56. package/dist/dashboard/server.js.map +1 -0
  57. package/dist/index.d.ts +32 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +574 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/metrics/crap.d.ts +71 -0
  62. package/dist/metrics/crap.d.ts.map +1 -0
  63. package/dist/metrics/crap.js +67 -0
  64. package/dist/metrics/crap.js.map +1 -0
  65. package/dist/metrics/index.d.ts +31 -0
  66. package/dist/metrics/index.d.ts.map +1 -0
  67. package/dist/metrics/index.js +27 -0
  68. package/dist/metrics/index.js.map +1 -0
  69. package/dist/metrics/score.d.ts +143 -0
  70. package/dist/metrics/score.d.ts.map +1 -0
  71. package/dist/metrics/score.js +224 -0
  72. package/dist/metrics/score.js.map +1 -0
  73. package/dist/metrics/tdr.d.ts +106 -0
  74. package/dist/metrics/tdr.d.ts.map +1 -0
  75. package/dist/metrics/tdr.js +117 -0
  76. package/dist/metrics/tdr.js.map +1 -0
  77. package/dist/metrics/workspace-walker.d.ts +43 -0
  78. package/dist/metrics/workspace-walker.d.ts.map +1 -0
  79. package/dist/metrics/workspace-walker.js +137 -0
  80. package/dist/metrics/workspace-walker.js.map +1 -0
  81. package/dist/sarif/index.d.ts +21 -0
  82. package/dist/sarif/index.d.ts.map +1 -0
  83. package/dist/sarif/index.js +19 -0
  84. package/dist/sarif/index.js.map +1 -0
  85. package/dist/sarif/sarif-builder.d.ts +128 -0
  86. package/dist/sarif/sarif-builder.d.ts.map +1 -0
  87. package/dist/sarif/sarif-builder.js +79 -0
  88. package/dist/sarif/sarif-builder.js.map +1 -0
  89. package/dist/sarif/sarif-store.d.ts +205 -0
  90. package/dist/sarif/sarif-store.d.ts.map +1 -0
  91. package/dist/sarif/sarif-store.js +246 -0
  92. package/dist/sarif/sarif-store.js.map +1 -0
  93. package/dist/sarif/sarif-validator.d.ts +45 -0
  94. package/dist/sarif/sarif-validator.d.ts.map +1 -0
  95. package/dist/sarif/sarif-validator.js +138 -0
  96. package/dist/sarif/sarif-validator.js.map +1 -0
  97. package/dist/schemas/tool-schemas.d.ts +216 -0
  98. package/dist/schemas/tool-schemas.d.ts.map +1 -0
  99. package/dist/schemas/tool-schemas.js +208 -0
  100. package/dist/schemas/tool-schemas.js.map +1 -0
  101. package/dist/sdk.d.ts +45 -0
  102. package/dist/sdk.d.ts.map +1 -0
  103. package/dist/sdk.js +44 -0
  104. package/dist/sdk.js.map +1 -0
  105. package/dist/tools/index.d.ts +24 -0
  106. package/dist/tools/index.d.ts.map +1 -0
  107. package/dist/tools/index.js +23 -0
  108. package/dist/tools/index.js.map +1 -0
  109. package/dist/tools/test-harness.d.ts +75 -0
  110. package/dist/tools/test-harness.d.ts.map +1 -0
  111. package/dist/tools/test-harness.js +137 -0
  112. package/dist/tools/test-harness.js.map +1 -0
  113. package/dist/workspace-guard.d.ts +53 -0
  114. package/dist/workspace-guard.d.ts.map +1 -0
  115. package/dist/workspace-guard.js +61 -0
  116. package/dist/workspace-guard.js.map +1 -0
  117. package/package.json +133 -0
  118. package/plugin/.claude-plugin/plugin.json +29 -0
  119. package/plugin/.mcp.json +18 -0
  120. package/plugin/CLAUDE.md +143 -0
  121. package/plugin/bundle/dashboard/public/index.html +368 -0
  122. package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
  123. package/plugin/bundle/mcp-server.mjs +8718 -0
  124. package/plugin/bundle/mcp-server.mjs.map +7 -0
  125. package/plugin/bundle/tdr-engine.mjs +50 -0
  126. package/plugin/bundle/tdr-engine.mjs.map +7 -0
  127. package/plugin/hooks/hooks.json +62 -0
  128. package/plugin/hooks/lib/crap-config.mjs +152 -0
  129. package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
  130. package/plugin/hooks/lib/hook-io.mjs +151 -0
  131. package/plugin/hooks/lib/quality-gate.mjs +329 -0
  132. package/plugin/hooks/lib/test-harness.mjs +152 -0
  133. package/plugin/hooks/post-tool-use.mjs +245 -0
  134. package/plugin/hooks/pre-tool-use.mjs +290 -0
  135. package/plugin/hooks/session-start.mjs +109 -0
  136. package/plugin/hooks/stop-quality-gate.mjs +226 -0
  137. package/plugin/package.json +18 -0
  138. package/plugin/skills/adopt/SKILL.md +74 -0
  139. package/plugin/skills/analyze/SKILL.md +77 -0
  140. package/plugin/skills/check-test/SKILL.md +50 -0
  141. package/plugin/skills/score/SKILL.md +31 -0
  142. package/scripts/bug-report.mjs +328 -0
  143. package/scripts/build-fast.mjs +130 -0
  144. package/scripts/bundle-plugin.mjs +74 -0
  145. package/scripts/doctor.mjs +320 -0
  146. package/scripts/install.mjs +192 -0
  147. package/scripts/lib/cli-ui.mjs +122 -0
  148. package/scripts/postinstall.mjs +127 -0
  149. package/scripts/run-tests.mjs +95 -0
  150. package/scripts/status.mjs +110 -0
  151. package/scripts/uninstall.mjs +72 -0
  152. package/src/adapters/bandit.ts +191 -0
  153. package/src/adapters/common.ts +133 -0
  154. package/src/adapters/eslint.ts +187 -0
  155. package/src/adapters/index.ts +78 -0
  156. package/src/adapters/semgrep.ts +150 -0
  157. package/src/adapters/stryker.ts +218 -0
  158. package/src/ast/cyclomatic.ts +131 -0
  159. package/src/ast/index.ts +33 -0
  160. package/src/ast/language-config.ts +231 -0
  161. package/src/ast/tree-sitter-engine.ts +385 -0
  162. package/src/config.ts +109 -0
  163. package/src/crap-config.ts +196 -0
  164. package/src/dashboard/public/index.html +368 -0
  165. package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
  166. package/src/dashboard/server.ts +205 -0
  167. package/src/index.ts +696 -0
  168. package/src/metrics/crap.ts +101 -0
  169. package/src/metrics/index.ts +51 -0
  170. package/src/metrics/score.ts +329 -0
  171. package/src/metrics/tdr.ts +155 -0
  172. package/src/metrics/workspace-walker.ts +146 -0
  173. package/src/sarif/index.ts +31 -0
  174. package/src/sarif/sarif-builder.ts +139 -0
  175. package/src/sarif/sarif-store.ts +347 -0
  176. package/src/sarif/sarif-validator.ts +145 -0
  177. package/src/schemas/tool-schemas.ts +225 -0
  178. package/src/sdk.ts +110 -0
  179. package/src/tests/adapters/bandit.test.ts +111 -0
  180. package/src/tests/adapters/dispatch.test.ts +100 -0
  181. package/src/tests/adapters/eslint.test.ts +138 -0
  182. package/src/tests/adapters/semgrep.test.ts +125 -0
  183. package/src/tests/adapters/stryker.test.ts +103 -0
  184. package/src/tests/crap-config.test.ts +228 -0
  185. package/src/tests/crap.test.ts +59 -0
  186. package/src/tests/cyclomatic.test.ts +87 -0
  187. package/src/tests/dashboard-http.test.ts +108 -0
  188. package/src/tests/dashboard-integrity.test.ts +128 -0
  189. package/src/tests/integration/mcp-server.integration.test.ts +352 -0
  190. package/src/tests/pre-tool-use-hook.test.ts +178 -0
  191. package/src/tests/sarif-store.test.ts +241 -0
  192. package/src/tests/sarif-validator.test.ts +164 -0
  193. package/src/tests/score.test.ts +260 -0
  194. package/src/tests/skills-frontmatter.test.ts +172 -0
  195. package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
  196. package/src/tests/tdr.test.ts +86 -0
  197. package/src/tests/test-harness.test.ts +153 -0
  198. package/src/tests/workspace-guard.test.ts +111 -0
  199. package/src/tools/index.ts +24 -0
  200. package/src/tools/test-harness.ts +158 -0
  201. package/src/workspace-guard.ts +64 -0
  202. 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
+ }