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,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
|
+
}
|