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,151 @@
1
+ // @ts-check
2
+ /**
3
+ * Shared I/O helpers for every claude-crap hook script.
4
+ *
5
+ * Every hook in the plugin shares the same stdin/stdout/stderr contract
6
+ * with Claude Code (see https://code.claude.com/docs/en/hooks):
7
+ *
8
+ * - stdin : JSON payload with `session_id`, `tool_name`, `tool_input`,
9
+ * `tool_response`, `hook_event_name`, ...
10
+ * - stdout : Free-form text, stored in the hooks transcript.
11
+ * - stderr : Injected into the agent's context when the hook exits
12
+ * with code 2 (blocking) or captured for diagnostics when
13
+ * the hook exits with any non-zero code.
14
+ *
15
+ * This module factors that contract out so individual hooks can focus
16
+ * on their rules and not re-implement stdin parsing or error framing.
17
+ *
18
+ * @module hooks/lib/hook-io
19
+ */
20
+
21
+ /**
22
+ * Exit codes accepted by Claude Code hooks.
23
+ *
24
+ * - `ALLOW` (0) — hook passed, tool call proceeds.
25
+ * - `BLOCK` (2) — hook blocks the tool call; stderr goes to the agent.
26
+ * - `INTERNAL` (1) — hook itself errored; fail-open semantics applied.
27
+ */
28
+ export const ExitCodes = Object.freeze({
29
+ ALLOW: 0,
30
+ INTERNAL: 1,
31
+ BLOCK: 2,
32
+ });
33
+
34
+ /**
35
+ * Read stdin to EOF and parse it as JSON.
36
+ *
37
+ * @returns {Promise<object>} The parsed JSON payload.
38
+ * @throws When stdin is empty or not valid JSON.
39
+ */
40
+ export async function readStdinJson() {
41
+ /** @type {Buffer[]} */
42
+ const chunks = [];
43
+ for await (const chunk of process.stdin) {
44
+ chunks.push(/** @type {Buffer} */ (chunk));
45
+ }
46
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
47
+ if (!raw) {
48
+ throw new Error("stdin was empty — claude-crap hook expected a JSON payload");
49
+ }
50
+ try {
51
+ return JSON.parse(raw);
52
+ } catch (err) {
53
+ throw new Error(`stdin is not valid JSON: ${/** @type {Error} */ (err).message}`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Render a blocking message framed in the same box style used by the
59
+ * PreToolUse hook. The agent sees this text on stderr when a blocking
60
+ * hook exits with code 2, so it must be imperative and corrective.
61
+ *
62
+ * @param {Object} opts
63
+ * @param {string} opts.title - Short hook identifier (e.g. `"PostToolUse"`, `"Stop gate"`).
64
+ * @param {string} opts.ruleId - Stable rule identifier.
65
+ * @param {string} opts.tool - The tool call being evaluated (or `"-"`).
66
+ * @param {string} opts.reason - The corrective message for the agent.
67
+ * @returns {string} A multi-line formatted box.
68
+ */
69
+ export function formatBlockingMessage({ title, ruleId, tool, reason }) {
70
+ return [
71
+ `╭─ claude-crap :: ${title} BLOCKED ───────────────────────────────`,
72
+ `│ rule : ${ruleId}`,
73
+ `│ tool : ${tool}`,
74
+ `│`,
75
+ ...reason.split("\n").map((line) => `│ ${line}`),
76
+ `╰──────────────────────────────────────────────────────────────────`,
77
+ ].join("\n");
78
+ }
79
+
80
+ /**
81
+ * Render a non-blocking warning. PostToolUse emits these on stderr; the
82
+ * LLM reads them but the tool call is not aborted. The format is
83
+ * intentionally different from a block so the agent can tell them apart.
84
+ *
85
+ * @param {Object} opts
86
+ * @param {string} opts.title
87
+ * @param {string} opts.ruleId
88
+ * @param {string} opts.tool
89
+ * @param {string} opts.reason
90
+ * @returns {string}
91
+ */
92
+ export function formatWarningMessage({ title, ruleId, tool, reason }) {
93
+ return [
94
+ `┌─ claude-crap :: ${title} WARNING ───────────────────────────────`,
95
+ `│ rule : ${ruleId}`,
96
+ `│ tool : ${tool}`,
97
+ `│`,
98
+ ...reason.split("\n").map((line) => `│ ${line}`),
99
+ `└──────────────────────────────────────────────────────────────────`,
100
+ ].join("\n");
101
+ }
102
+
103
+ /**
104
+ * Print a blocking message to stderr and exit with code 2.
105
+ * Does not return.
106
+ *
107
+ * @param {Object} opts
108
+ * @param {string} opts.title
109
+ * @param {string} opts.ruleId
110
+ * @param {string} opts.tool
111
+ * @param {string} opts.reason
112
+ * @returns {never}
113
+ */
114
+ export function blockAndExit(opts) {
115
+ process.stderr.write(formatBlockingMessage(opts) + "\n");
116
+ process.exit(ExitCodes.BLOCK);
117
+ }
118
+
119
+ /**
120
+ * Print a warning to stderr without blocking. Returns so the caller
121
+ * can keep processing additional findings.
122
+ *
123
+ * @param {Object} opts
124
+ * @param {string} opts.title
125
+ * @param {string} opts.ruleId
126
+ * @param {string} opts.tool
127
+ * @param {string} opts.reason
128
+ */
129
+ export function warnNonBlocking(opts) {
130
+ process.stderr.write(formatWarningMessage(opts) + "\n");
131
+ }
132
+
133
+ /**
134
+ * Wrap a hook's `main` function in a uniform failure harness. When the
135
+ * user code throws, we log the error to stderr and exit with the
136
+ * INTERNAL code — the user is never deadlocked by a broken hook.
137
+ *
138
+ * @param {string} hookName Human-readable hook identifier for logs.
139
+ * @param {() => Promise<void>} fn Async entrypoint.
140
+ */
141
+ export async function runHook(hookName, fn) {
142
+ try {
143
+ await fn();
144
+ } catch (err) {
145
+ process.stderr.write(
146
+ `[claude-crap] ${hookName}: internal error: ${/** @type {Error} */ (err).message}\n` +
147
+ `[claude-crap] falling back to permissive mode (fail-open).\n`,
148
+ );
149
+ process.exit(ExitCodes.INTERNAL);
150
+ }
151
+ }
@@ -0,0 +1,329 @@
1
+ // @ts-check
2
+ /**
3
+ * Shared quality-gate evaluator used by the Stop and SubagentStop hooks.
4
+ *
5
+ * The evaluator is a pure function of:
6
+ *
7
+ * - the current claude-crap configuration (read from env),
8
+ * - the consolidated SARIF file on disk (optional — a missing file
9
+ * is treated as "no findings yet"),
10
+ * - a coarse workspace LOC count (bounded walk of the project tree).
11
+ *
12
+ * It produces a structured verdict with one `failures[]` entry per
13
+ * violated policy. The calling hook then decides whether to block
14
+ * (exit 2) or allow (exit 0) based on whether any failures were found.
15
+ *
16
+ * Engine imports (CRAP / TDR letter classification) come from the
17
+ * esbuild-produced bundle at `plugin/bundle/tdr-engine.mjs`, so the
18
+ * math stays in one place and the hook cannot drift from the server.
19
+ *
20
+ * @module hooks/lib/quality-gate
21
+ */
22
+
23
+ import { promises as fs } from "node:fs";
24
+ import { resolve, join, isAbsolute } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { dirname } from "node:path";
27
+
28
+ // Import the TDR engines from the bundled MCP server. The relative path
29
+ // resolves from `hooks/lib/` up to `plugin/bundle/`. Requires the
30
+ // plugin to have been built at least once via `npm run build:plugin`.
31
+ const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
32
+ const TDR_ENGINE_PATH = resolve(HOOK_DIR, "..", "..", "bundle", "tdr-engine.mjs");
33
+
34
+ /**
35
+ * @typedef {"A" | "B" | "C" | "D" | "E"} MaintainabilityRating
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} QualityGateConfig
40
+ * @property {string} workspaceRoot Absolute path to the project root.
41
+ * @property {string} sarifReportPath Absolute path to the consolidated SARIF file.
42
+ * @property {number} crapThreshold
43
+ * @property {MaintainabilityRating} tdrMaxRating
44
+ * @property {number} minutesPerLoc
45
+ */
46
+
47
+ /**
48
+ * @typedef {Object} GateFailure
49
+ * @property {string} ruleId
50
+ * @property {string} message
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} GateVerdict
55
+ * @property {boolean} passed
56
+ * @property {GateFailure[]} failures
57
+ * @property {Object} summary
58
+ * @property {number} summary.totalFindings
59
+ * @property {number} summary.errorFindings
60
+ * @property {number} summary.warningFindings
61
+ * @property {number} summary.noteFindings
62
+ * @property {number} summary.remediationMinutes
63
+ * @property {number} summary.physicalLoc
64
+ * @property {number} summary.tdrPercent
65
+ * @property {MaintainabilityRating} summary.tdrRating
66
+ * @property {string[]} summary.toolsSeen
67
+ */
68
+
69
+ /**
70
+ * Load the claude-crap configuration from environment variables. Hooks
71
+ * read the same `CLAUDE_PLUGIN_OPTION_*` family of variables that the
72
+ * MCP server uses, so the two always agree.
73
+ *
74
+ * @returns {QualityGateConfig}
75
+ */
76
+ export function loadQualityGateConfig() {
77
+ const workspaceRoot = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
78
+ const sarifOutputDir =
79
+ process.env.CLAUDE_PLUGIN_OPTION_SARIF_OUTPUT_DIR ?? ".claude-crap/reports";
80
+ const sarifReportDir = isAbsolute(sarifOutputDir)
81
+ ? sarifOutputDir
82
+ : resolve(workspaceRoot, sarifOutputDir);
83
+ const sarifReportPath = join(sarifReportDir, "latest.sarif");
84
+
85
+ const crapThreshold = Number(process.env.CLAUDE_PLUGIN_OPTION_CRAP_THRESHOLD ?? 30);
86
+ const tdrMaxRating = /** @type {MaintainabilityRating} */ (
87
+ process.env.CLAUDE_PLUGIN_OPTION_TDR_MAINTAINABILITY_MAX_RATING ?? "C"
88
+ );
89
+ const minutesPerLoc = Number(process.env.CLAUDE_PLUGIN_OPTION_MINUTES_PER_LINE_OF_CODE ?? 30);
90
+
91
+ return { workspaceRoot, sarifReportPath, crapThreshold, tdrMaxRating, minutesPerLoc };
92
+ }
93
+
94
+ /**
95
+ * Read and parse the consolidated SARIF file. Returns an empty findings
96
+ * list when the file does not exist (fresh workspace, no gate runs yet).
97
+ *
98
+ * @param {string} path Absolute path to the SARIF file.
99
+ * @returns {Promise<{findings: Array<{ruleId: string, level: string, uri: string, effortMinutes: number, sourceTool: string}>, toolsSeen: Set<string>}>}
100
+ */
101
+ async function readConsolidatedFindings(path) {
102
+ const findings = [];
103
+ const toolsSeen = new Set();
104
+ let raw;
105
+ try {
106
+ raw = await fs.readFile(path, "utf8");
107
+ } catch (err) {
108
+ const error = /** @type {NodeJS.ErrnoException} */ (err);
109
+ if (error.code === "ENOENT") return { findings, toolsSeen };
110
+ throw error;
111
+ }
112
+ const doc = JSON.parse(raw);
113
+ if (!doc || !Array.isArray(doc.runs)) return { findings, toolsSeen };
114
+
115
+ for (const run of doc.runs) {
116
+ const results = Array.isArray(run.results) ? run.results : [];
117
+ for (const result of results) {
118
+ const loc = result.locations?.[0]?.physicalLocation;
119
+ const uri = loc?.artifactLocation?.uri ?? "<unknown>";
120
+ const effort =
121
+ typeof result.properties?.effortMinutes === "number"
122
+ ? result.properties.effortMinutes
123
+ : 0;
124
+ const sourceTool =
125
+ typeof result.properties?.sourceTool === "string"
126
+ ? result.properties.sourceTool
127
+ : run.tool?.driver?.name ?? "unknown";
128
+ findings.push({
129
+ ruleId: String(result.ruleId ?? "unknown"),
130
+ level: String(result.level ?? "warning"),
131
+ uri,
132
+ effortMinutes: effort,
133
+ sourceTool,
134
+ });
135
+ toolsSeen.add(sourceTool);
136
+ }
137
+ }
138
+ return { findings, toolsSeen };
139
+ }
140
+
141
+ /**
142
+ * Directories we do not descend into when estimating workspace LOC. These
143
+ * are either dependency caches, build artifacts, or VCS metadata that the
144
+ * TDR policy is not meant to penalize.
145
+ */
146
+ const LOC_WALK_SKIP_DIRS = new Set([
147
+ "node_modules",
148
+ ".git",
149
+ "dist",
150
+ "build",
151
+ "out",
152
+ "target",
153
+ ".venv",
154
+ "venv",
155
+ "__pycache__",
156
+ ".cache",
157
+ ".next",
158
+ ".nuxt",
159
+ ".claude-crap",
160
+ ".codesight",
161
+ ]);
162
+
163
+ /**
164
+ * Extensions we treat as "code" for the LOC count. Anything else is
165
+ * ignored by the walker.
166
+ */
167
+ const LOC_CODE_EXTENSIONS = new Set([
168
+ ".ts",
169
+ ".tsx",
170
+ ".mts",
171
+ ".cts",
172
+ ".js",
173
+ ".jsx",
174
+ ".mjs",
175
+ ".cjs",
176
+ ".py",
177
+ ".java",
178
+ ".cs",
179
+ ".go",
180
+ ".rs",
181
+ ".rb",
182
+ ".php",
183
+ ".swift",
184
+ ".kt",
185
+ ".scala",
186
+ ".dart",
187
+ ".vue",
188
+ ]);
189
+
190
+ /**
191
+ * Maximum number of files the walker will open before giving up. Protects
192
+ * against pathological repos where the walk would dominate the hook's
193
+ * 120-second budget. When hit, we return the partial count and warn.
194
+ */
195
+ const MAX_FILES_WALKED = 20_000;
196
+
197
+ /**
198
+ * Count physical lines of code across the workspace. Skips dependency
199
+ * and build directories and never follows symlinks. Returns the total
200
+ * physical LOC and the number of files it actually read.
201
+ *
202
+ * @param {string} workspaceRoot
203
+ * @returns {Promise<{physicalLoc: number, filesWalked: number, truncated: boolean}>}
204
+ */
205
+ export async function estimateWorkspaceLoc(workspaceRoot) {
206
+ let physicalLoc = 0;
207
+ let filesWalked = 0;
208
+ let truncated = false;
209
+
210
+ /** @param {string} dir */
211
+ async function walk(dir) {
212
+ if (truncated) return;
213
+ /** @type {import("node:fs").Dirent[]} */
214
+ let entries;
215
+ try {
216
+ entries = await fs.readdir(dir, { withFileTypes: true });
217
+ } catch {
218
+ return;
219
+ }
220
+ for (const entry of entries) {
221
+ if (truncated) return;
222
+ if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") {
223
+ // Hidden files are skipped except the plugin dir itself (tiny).
224
+ continue;
225
+ }
226
+ const full = join(dir, entry.name);
227
+ if (entry.isDirectory()) {
228
+ if (LOC_WALK_SKIP_DIRS.has(entry.name)) continue;
229
+ await walk(full);
230
+ continue;
231
+ }
232
+ if (!entry.isFile()) continue;
233
+ const lower = entry.name.toLowerCase();
234
+ const dot = lower.lastIndexOf(".");
235
+ if (dot < 0) continue;
236
+ const ext = lower.substring(dot);
237
+ if (!LOC_CODE_EXTENSIONS.has(ext)) continue;
238
+ filesWalked += 1;
239
+ if (filesWalked > MAX_FILES_WALKED) {
240
+ truncated = true;
241
+ return;
242
+ }
243
+ try {
244
+ const content = await fs.readFile(full, "utf8");
245
+ if (content.length > 0) {
246
+ // `split(/\r?\n/)` overcounts by 1 when the file ends in a
247
+ // newline, so we fix that here to match editor behavior.
248
+ const newlineCount = content.split(/\r?\n/).length;
249
+ physicalLoc += content.endsWith("\n") ? newlineCount - 1 : newlineCount;
250
+ }
251
+ } catch {
252
+ // Unreadable file (permissions, binary). Skip silently.
253
+ }
254
+ }
255
+ }
256
+
257
+ await walk(workspaceRoot);
258
+ return { physicalLoc, filesWalked, truncated };
259
+ }
260
+
261
+ /**
262
+ * Evaluate the quality gate against the current workspace state. Pure
263
+ * function of its inputs — perform side effects (stdout / stderr / exit)
264
+ * in the calling hook script.
265
+ *
266
+ * @param {QualityGateConfig} config
267
+ * @returns {Promise<GateVerdict>}
268
+ */
269
+ export async function evaluateQualityGate(config) {
270
+ const { classifyTdr, ratingIsWorseThan } = await import(TDR_ENGINE_PATH);
271
+
272
+ const { findings, toolsSeen } = await readConsolidatedFindings(config.sarifReportPath);
273
+ const { physicalLoc } = await estimateWorkspaceLoc(config.workspaceRoot);
274
+
275
+ const remediationMinutes = findings.reduce((sum, f) => sum + f.effortMinutes, 0);
276
+ const errorFindings = findings.filter((f) => f.level === "error").length;
277
+ const warningFindings = findings.filter((f) => f.level === "warning").length;
278
+ const noteFindings = findings.filter((f) => f.level === "note").length;
279
+
280
+ // Guard against divide-by-zero on a truly empty workspace.
281
+ const safeLoc = Math.max(physicalLoc, 1);
282
+ const developmentCost = config.minutesPerLoc * safeLoc;
283
+ const tdrPercent = (remediationMinutes / developmentCost) * 100;
284
+ const tdrRating = /** @type {MaintainabilityRating} */ (classifyTdr(tdrPercent));
285
+
286
+ /** @type {GateFailure[]} */
287
+ const failures = [];
288
+
289
+ // Policy 1 — TDR rating must not exceed the configured tolerance.
290
+ if (ratingIsWorseThan(tdrRating, config.tdrMaxRating)) {
291
+ failures.push({
292
+ ruleId: "SONAR-GATE-TDR",
293
+ message:
294
+ `Maintainability rating ${tdrRating} is worse than the policy limit ` +
295
+ `${config.tdrMaxRating}. Current TDR = ${tdrPercent.toFixed(2)}% ` +
296
+ `(${remediationMinutes} min of remediation over ${physicalLoc} LOC). ` +
297
+ `Corrective action: resolve enough findings to reduce the TDR below the bracket for ` +
298
+ `rating ${config.tdrMaxRating}, then re-run the Stop hook.`,
299
+ });
300
+ }
301
+
302
+ // Policy 2 — no error-level findings may survive the gate.
303
+ if (errorFindings > 0) {
304
+ failures.push({
305
+ ruleId: "SONAR-GATE-ERRORS",
306
+ message:
307
+ `The consolidated SARIF report contains ${errorFindings} finding(s) at level "error". ` +
308
+ `CLAUDE.md forbids closing a task with unresolved reliability or security errors. ` +
309
+ `Corrective action: open 'sonar://reports/latest.sarif' via the MCP server and fix every ` +
310
+ `error-level finding before retrying the Stop hook.`,
311
+ });
312
+ }
313
+
314
+ return {
315
+ passed: failures.length === 0,
316
+ failures,
317
+ summary: {
318
+ totalFindings: findings.length,
319
+ errorFindings,
320
+ warningFindings,
321
+ noteFindings,
322
+ remediationMinutes,
323
+ physicalLoc,
324
+ tdrPercent: Number(tdrPercent.toFixed(4)),
325
+ tdrRating,
326
+ toolsSeen: Array.from(toolsSeen),
327
+ },
328
+ };
329
+ }
@@ -0,0 +1,152 @@
1
+ // @ts-check
2
+ /**
3
+ * Deterministic test-file resolver.
4
+ *
5
+ * Given a production source file (e.g. `src/foo/bar.ts`), this module
6
+ * enumerates the conventional locations where a matching test file
7
+ * would live and returns the first existing match — or `null` when
8
+ * none of the candidates exist.
9
+ *
10
+ * The resolver is a pure function of the filesystem; it never reads
11
+ * file contents, never invokes a test runner, and never calls the
12
+ * MCP server. Fast enough to run inside a hook's 15-second budget.
13
+ *
14
+ * Supported conventions (in priority order):
15
+ *
16
+ * 1. Sibling `<base>.test.<ext>` / `<base>.spec.<ext>`
17
+ * 2. Dedicated `__tests__/<base>.test.<ext>` directory
18
+ * 3. Mirror tree under `tests/`, `test/`, or `__tests__/` at the repo root
19
+ * 4. Python `test_<base>.py` variant
20
+ *
21
+ * New conventions can be added by extending the `candidatePaths` helper.
22
+ *
23
+ * @module hooks/lib/test-harness
24
+ */
25
+
26
+ import { promises as fs } from "node:fs";
27
+ import { dirname, extname, basename, join, relative, resolve, sep } from "node:path";
28
+
29
+ /**
30
+ * Result returned by {@link findTestFile}.
31
+ *
32
+ * @typedef {Object} TestFileResolution
33
+ * @property {string | null} testFile Absolute path to the first matching test file, or `null`.
34
+ * @property {string[]} candidates Absolute paths of every location the resolver tried.
35
+ * @property {boolean} isTestFile `true` if the input itself is a test file (resolver was a no-op).
36
+ */
37
+
38
+ const TEST_SUFFIX_PATTERN = /\.(test|spec)\./;
39
+
40
+ /**
41
+ * Return `true` if the given path is already a test file. Useful so the
42
+ * PostToolUse hook does not ask "where is the test for your test file?".
43
+ *
44
+ * @param {string} filePath Absolute or relative source path.
45
+ * @returns {boolean}
46
+ */
47
+ export function isTestFile(filePath) {
48
+ const base = basename(filePath);
49
+ if (TEST_SUFFIX_PATTERN.test(base)) return true;
50
+ if (base.startsWith("test_") && base.endsWith(".py")) return true;
51
+ const parts = filePath.split(sep);
52
+ return parts.includes("__tests__") || parts.includes("tests") || parts.includes("test");
53
+ }
54
+
55
+ /**
56
+ * Enumerate every plausible test file path for a given production source
57
+ * file. Does not touch the filesystem — the caller is expected to probe
58
+ * existence separately (see {@link findTestFile}).
59
+ *
60
+ * Supported conventions, in the order they are probed:
61
+ *
62
+ * 1. Sibling `<base>.test.<ext>` / `<base>.spec.<ext>`
63
+ * 2. Sibling `__tests__/<base>.test.<ext>`
64
+ * 3. Mirror tree under `tests/`, `test/`, or `__tests__/` at the
65
+ * workspace root (e.g. `tests/src/foo/bar.test.ts`)
66
+ * 4. Nearest-ancestor flat test directory: walk up from the source
67
+ * file's directory toward the workspace root, and at every
68
+ * ancestor check for `tests/<base>.test.<ext>`. Matches layouts
69
+ * where tests live in a single flat directory near the source.
70
+ * 5. Python-specific variants.
71
+ *
72
+ * @param {string} workspaceRoot Absolute path to the workspace root.
73
+ * @param {string} filePath Absolute path to the production file.
74
+ * @returns {string[]} Ordered list of absolute candidate paths.
75
+ */
76
+ export function candidatePaths(workspaceRoot, filePath) {
77
+ const absSource = resolve(filePath);
78
+ const ext = extname(absSource);
79
+ const base = basename(absSource, ext);
80
+ const dir = dirname(absSource);
81
+ const absWorkspace = resolve(workspaceRoot);
82
+ const relFromRoot = relative(absWorkspace, absSource);
83
+ const relDir = dirname(relFromRoot);
84
+
85
+ const candidates = new Set();
86
+
87
+ // 1. Sibling <base>.test.<ext> / <base>.spec.<ext>
88
+ candidates.add(join(dir, `${base}.test${ext}`));
89
+ candidates.add(join(dir, `${base}.spec${ext}`));
90
+
91
+ // 2. Sibling __tests__/<base>.test.<ext>
92
+ candidates.add(join(dir, "__tests__", `${base}.test${ext}`));
93
+ candidates.add(join(dir, "__tests__", `${base}.spec${ext}`));
94
+
95
+ // 3. Mirror tree under tests/, test/, __tests__ at the repo root.
96
+ for (const testRoot of ["tests", "test", "__tests__"]) {
97
+ candidates.add(join(absWorkspace, testRoot, relDir, `${base}.test${ext}`));
98
+ candidates.add(join(absWorkspace, testRoot, relDir, `${base}.spec${ext}`));
99
+ candidates.add(join(absWorkspace, testRoot, relDir, `${base}${ext}`));
100
+ }
101
+
102
+ // 4. Nearest-ancestor flat test directory. Walk up from the source
103
+ // directory to the workspace root, probing for a flat test layout
104
+ // at each ancestor level.
105
+ let current = dir;
106
+ while (current.length >= absWorkspace.length) {
107
+ for (const testRoot of ["tests", "test", "__tests__"]) {
108
+ candidates.add(join(current, testRoot, `${base}.test${ext}`));
109
+ candidates.add(join(current, testRoot, `${base}.spec${ext}`));
110
+ candidates.add(join(current, testRoot, `${base}${ext}`));
111
+ }
112
+ if (current === absWorkspace) break;
113
+ const parent = dirname(current);
114
+ if (parent === current) break;
115
+ current = parent;
116
+ }
117
+
118
+ // 5. Python: sibling test_<base>.py, and tests/test_<base>.py.
119
+ if (ext === ".py") {
120
+ candidates.add(join(dir, `test_${base}.py`));
121
+ candidates.add(join(absWorkspace, "tests", `test_${base}.py`));
122
+ candidates.add(join(absWorkspace, "tests", relDir, `test_${base}.py`));
123
+ }
124
+
125
+ return Array.from(candidates);
126
+ }
127
+
128
+ /**
129
+ * Probe the filesystem and return the first candidate that exists, or
130
+ * `null` when none of them do. Returns early with `isTestFile: true` if
131
+ * the input path is already a test file.
132
+ *
133
+ * @param {string} workspaceRoot Absolute path to the workspace root.
134
+ * @param {string} filePath Absolute or workspace-relative path.
135
+ * @returns {Promise<TestFileResolution>}
136
+ */
137
+ export async function findTestFile(workspaceRoot, filePath) {
138
+ const absolute = resolve(filePath);
139
+ if (isTestFile(absolute)) {
140
+ return { testFile: absolute, candidates: [absolute], isTestFile: true };
141
+ }
142
+ const candidates = candidatePaths(workspaceRoot, absolute);
143
+ for (const candidate of candidates) {
144
+ try {
145
+ await fs.access(candidate);
146
+ return { testFile: candidate, candidates, isTestFile: false };
147
+ } catch {
148
+ // Probe next candidate.
149
+ }
150
+ }
151
+ return { testFile: null, candidates, isTestFile: false };
152
+ }