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
package/src/index.ts
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* claude-crap MCP server — entrypoint.
|
|
4
|
+
*
|
|
5
|
+
* Transport: stdio. The server is launched by `.mcp.json` with the
|
|
6
|
+
* arguments `--transport stdio` and it never opens sockets or listens
|
|
7
|
+
* on the network: all communication with Claude Code happens over
|
|
8
|
+
* stdin/stdout as JSON-RPC messages.
|
|
9
|
+
*
|
|
10
|
+
* What this file wires together:
|
|
11
|
+
*
|
|
12
|
+
* Tools:
|
|
13
|
+
* - compute_crap (CRAP index for one function)
|
|
14
|
+
* - compute_tdr (Technical Debt Ratio for a scope)
|
|
15
|
+
* - analyze_file_ast (tree-sitter AST metrics for a source file)
|
|
16
|
+
* - ingest_sarif (normalize + dedupe an external SARIF report)
|
|
17
|
+
* - ingest_scanner_output (route Semgrep/ESLint/Bandit/Stryker native output through an adapter and persist the normalized SARIF)
|
|
18
|
+
* - require_test_harness (check that a production source file has a matching test)
|
|
19
|
+
* - score_project (aggregate the workspace into Maintainability / Reliability / Security / Overall ratings)
|
|
20
|
+
*
|
|
21
|
+
* Resources:
|
|
22
|
+
* - sonar://metrics/current (live CRAP / TDR / rating snapshot)
|
|
23
|
+
* - sonar://reports/latest.sarif (last consolidated SARIF document)
|
|
24
|
+
*
|
|
25
|
+
* The handlers delegate to pure engines in `./metrics`, `./ast` and
|
|
26
|
+
* `./sarif`, so the index file stays focused on routing and
|
|
27
|
+
* cross-cutting concerns (configuration, logging, error boundaries).
|
|
28
|
+
*
|
|
29
|
+
* @module index
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
33
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
34
|
+
import {
|
|
35
|
+
CallToolRequestSchema,
|
|
36
|
+
ListToolsRequestSchema,
|
|
37
|
+
ListResourcesRequestSchema,
|
|
38
|
+
ReadResourceRequestSchema,
|
|
39
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
40
|
+
import pino from "pino";
|
|
41
|
+
|
|
42
|
+
import { adaptScannerOutput, type KnownScanner } from "./adapters/index.js";
|
|
43
|
+
import { TreeSitterEngine } from "./ast/tree-sitter-engine.js";
|
|
44
|
+
import type { SupportedLanguage } from "./ast/language-config.js";
|
|
45
|
+
import { loadConfig, type CrapConfig } from "./config.js";
|
|
46
|
+
import { startDashboard, type DashboardHandle } from "./dashboard/server.js";
|
|
47
|
+
import { computeCrap } from "./metrics/crap.js";
|
|
48
|
+
import {
|
|
49
|
+
computeProjectScore,
|
|
50
|
+
renderProjectScoreMarkdown,
|
|
51
|
+
type ProjectScore,
|
|
52
|
+
} from "./metrics/score.js";
|
|
53
|
+
import { computeTdr, classifyTdr } from "./metrics/tdr.js";
|
|
54
|
+
import { estimateWorkspaceLoc } from "./metrics/workspace-walker.js";
|
|
55
|
+
import { SarifStore, type PersistedSarif } from "./sarif/sarif-store.js";
|
|
56
|
+
import { validateSarifDocument } from "./sarif/sarif-validator.js";
|
|
57
|
+
import { loadCrapConfig, CrapConfigError } from "./crap-config.js";
|
|
58
|
+
import { findTestFile } from "./tools/test-harness.js";
|
|
59
|
+
import { resolveWithinWorkspace } from "./workspace-guard.js";
|
|
60
|
+
import {
|
|
61
|
+
computeCrapSchema,
|
|
62
|
+
computeTdrSchema,
|
|
63
|
+
analyzeFileAstSchema,
|
|
64
|
+
ingestSarifSchema,
|
|
65
|
+
ingestScannerOutputSchema,
|
|
66
|
+
requireTestHarnessSchema,
|
|
67
|
+
scoreProjectSchema,
|
|
68
|
+
} from "./schemas/tool-schemas.js";
|
|
69
|
+
|
|
70
|
+
// IMPORTANT: the MCP stdio transport uses stdout for JSON-RPC framing.
|
|
71
|
+
// Anything the server logs MUST go to stderr (fd 2) to avoid corrupting
|
|
72
|
+
// the wire format. We configure pino explicitly to write to fd 2.
|
|
73
|
+
const logger = pino(
|
|
74
|
+
{ level: process.env.CLAUDE_CRAP_LOG_LEVEL ?? "info" },
|
|
75
|
+
pino.destination(2),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Server bootstrap. Loads configuration, instantiates the long-lived
|
|
80
|
+
* engines (tree-sitter, SARIF store), registers tool and resource
|
|
81
|
+
* handlers, and connects the stdio transport. Exits with a non-zero code
|
|
82
|
+
* on fatal startup errors so that Claude Code surfaces the failure to
|
|
83
|
+
* the user instead of silently running without the plugin.
|
|
84
|
+
*/
|
|
85
|
+
async function main(): Promise<void> {
|
|
86
|
+
const config = loadConfig();
|
|
87
|
+
logger.info(
|
|
88
|
+
{ config: { ...config, pluginRoot: "<redacted>" } },
|
|
89
|
+
"claude-crap MCP server starting",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Long-lived engines. Created once at boot and reused for every call.
|
|
93
|
+
const astEngine = new TreeSitterEngine();
|
|
94
|
+
const sarifStore = new SarifStore({
|
|
95
|
+
workspaceRoot: config.pluginRoot,
|
|
96
|
+
outputDir: config.sarifOutputDir,
|
|
97
|
+
});
|
|
98
|
+
await sarifStore.loadLatest();
|
|
99
|
+
logger.info(
|
|
100
|
+
{ findings: sarifStore.size(), path: sarifStore.consolidatedReportPath },
|
|
101
|
+
"SARIF store ready",
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Try to start the local Vue.js dashboard. Failures here are
|
|
105
|
+
// intentionally non-fatal — the MCP server still works without it.
|
|
106
|
+
let dashboard: DashboardHandle | null = null;
|
|
107
|
+
try {
|
|
108
|
+
dashboard = await startDashboard({
|
|
109
|
+
config,
|
|
110
|
+
sarifStore,
|
|
111
|
+
workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot),
|
|
112
|
+
logger,
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
logger.warn(
|
|
116
|
+
{ err: (err as Error).message, port: config.dashboardPort },
|
|
117
|
+
"claude-crap dashboard failed to start — continuing without it",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
// Make sure the dashboard is closed when the process exits so the TCP
|
|
121
|
+
// port is freed promptly. SIGINT/SIGTERM may arrive from Claude Code's
|
|
122
|
+
// MCP supervisor, from a developer hitting Ctrl-C, or from the test
|
|
123
|
+
// harness in our integration suite.
|
|
124
|
+
//
|
|
125
|
+
// IMPORTANT: installing a custom signal handler overrides Node's
|
|
126
|
+
// default (which exits the process), so we have to call
|
|
127
|
+
// `process.exit()` ourselves once cleanup finishes. Without this the
|
|
128
|
+
// MCP stdio transport would keep reading stdin forever and the
|
|
129
|
+
// Fastify dashboard would keep its listener open, leaving the whole
|
|
130
|
+
// process alive even after SIGTERM.
|
|
131
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
132
|
+
process.once(signal, () => {
|
|
133
|
+
void (async () => {
|
|
134
|
+
try {
|
|
135
|
+
await dashboard?.close();
|
|
136
|
+
} catch {
|
|
137
|
+
/* best effort — dashboard may already be down */
|
|
138
|
+
}
|
|
139
|
+
// 130 is the conventional exit code for SIGINT, 143 for SIGTERM.
|
|
140
|
+
const exitCode = signal === "SIGINT" ? 130 : 143;
|
|
141
|
+
process.exit(exitCode);
|
|
142
|
+
})();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const server = new Server(
|
|
147
|
+
{
|
|
148
|
+
name: "claude-crap",
|
|
149
|
+
version: "0.1.0",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
capabilities: {
|
|
153
|
+
tools: {},
|
|
154
|
+
resources: {},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// ------------------------------------------------------------------
|
|
160
|
+
// Tools — declaration (list)
|
|
161
|
+
// ------------------------------------------------------------------
|
|
162
|
+
// The tool list is what the LLM sees when it introspects the server.
|
|
163
|
+
// Keep the descriptions short, imperative and fact-based.
|
|
164
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
165
|
+
tools: [
|
|
166
|
+
{
|
|
167
|
+
name: "compute_crap",
|
|
168
|
+
description:
|
|
169
|
+
"Compute the CRAP (Change Risk Anti-Patterns) index for a function and block when the score exceeds the configured threshold.",
|
|
170
|
+
inputSchema: computeCrapSchema,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "compute_tdr",
|
|
174
|
+
description:
|
|
175
|
+
"Compute the Technical Debt Ratio for a scope and return the maintainability rating (A..E).",
|
|
176
|
+
inputSchema: computeTdrSchema,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "analyze_file_ast",
|
|
180
|
+
description:
|
|
181
|
+
"Analyze a source file with tree-sitter and return deterministic metrics (LOC, cyclomatic complexity, function topology).",
|
|
182
|
+
inputSchema: analyzeFileAstSchema,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "ingest_sarif",
|
|
186
|
+
description:
|
|
187
|
+
"Ingest a raw SARIF 2.1.0 report from an external scanner (Semgrep, ESLint, Bandit, ...), deduplicate it, and persist the consolidated view.",
|
|
188
|
+
inputSchema: ingestSarifSchema,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "ingest_scanner_output",
|
|
192
|
+
description:
|
|
193
|
+
"Ingest a scanner's native output (Semgrep, ESLint, Bandit, Stryker), route it through the matching adapter, enrich each finding with an effort estimate, and persist the normalized SARIF report.",
|
|
194
|
+
inputSchema: ingestScannerOutputSchema,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "require_test_harness",
|
|
198
|
+
description:
|
|
199
|
+
"Check whether a production source file has an accompanying test file. Required by the Golden Rule before any functional code is written.",
|
|
200
|
+
inputSchema: requireTestHarnessSchema,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "score_project",
|
|
204
|
+
description:
|
|
205
|
+
"Aggregate the project score across Maintainability, Reliability, Security and Overall, returning a chat-friendly Markdown summary, the structured JSON, the local dashboard URL, and the consolidated SARIF report path.",
|
|
206
|
+
inputSchema: scoreProjectSchema,
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
// ------------------------------------------------------------------
|
|
212
|
+
// Tools — call dispatch
|
|
213
|
+
// ------------------------------------------------------------------
|
|
214
|
+
// The MCP SDK has already validated `args` against the tool's JSON
|
|
215
|
+
// Schema by the time this handler runs, so we cast to the expected
|
|
216
|
+
// shape without re-validating. Each branch delegates to a pure engine.
|
|
217
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
218
|
+
const { name, arguments: args } = request.params;
|
|
219
|
+
logger.info({ tool: name }, "Tool call received");
|
|
220
|
+
|
|
221
|
+
switch (name) {
|
|
222
|
+
case "compute_crap": {
|
|
223
|
+
const typed = args as {
|
|
224
|
+
cyclomaticComplexity: number;
|
|
225
|
+
coveragePercent: number;
|
|
226
|
+
functionName: string;
|
|
227
|
+
filePath: string;
|
|
228
|
+
};
|
|
229
|
+
const result = computeCrap(
|
|
230
|
+
{ cyclomaticComplexity: typed.cyclomaticComplexity, coveragePercent: typed.coveragePercent },
|
|
231
|
+
config.crapThreshold,
|
|
232
|
+
);
|
|
233
|
+
return {
|
|
234
|
+
content: [
|
|
235
|
+
{
|
|
236
|
+
type: "text",
|
|
237
|
+
text: JSON.stringify(
|
|
238
|
+
{ tool: "compute_crap", function: typed.functionName, file: typed.filePath, ...result },
|
|
239
|
+
null,
|
|
240
|
+
2,
|
|
241
|
+
),
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
// Setting isError=true tells the LLM this call should be treated
|
|
245
|
+
// as a failure, which pushes it toward corrective action rather
|
|
246
|
+
// than assuming the score was acceptable.
|
|
247
|
+
isError: result.exceedsThreshold,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case "compute_tdr": {
|
|
252
|
+
const typed = args as {
|
|
253
|
+
remediationMinutes: number;
|
|
254
|
+
totalLinesOfCode: number;
|
|
255
|
+
scope: "project" | "module" | "file";
|
|
256
|
+
};
|
|
257
|
+
const result = computeTdr({
|
|
258
|
+
remediationMinutes: typed.remediationMinutes,
|
|
259
|
+
totalLinesOfCode: typed.totalLinesOfCode,
|
|
260
|
+
minutesPerLoc: config.minutesPerLoc,
|
|
261
|
+
});
|
|
262
|
+
return {
|
|
263
|
+
content: [
|
|
264
|
+
{
|
|
265
|
+
type: "text",
|
|
266
|
+
text: JSON.stringify({ tool: "compute_tdr", scope: typed.scope, ...result }, null, 2),
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "analyze_file_ast": {
|
|
273
|
+
const typed = args as { filePath: string; language: SupportedLanguage };
|
|
274
|
+
const absolutePath = resolveWithinWorkspace(config.pluginRoot, typed.filePath);
|
|
275
|
+
try {
|
|
276
|
+
const metrics = await astEngine.analyzeFile({
|
|
277
|
+
filePath: absolutePath,
|
|
278
|
+
language: typed.language,
|
|
279
|
+
});
|
|
280
|
+
return {
|
|
281
|
+
content: [
|
|
282
|
+
{
|
|
283
|
+
type: "text",
|
|
284
|
+
text: JSON.stringify({ tool: "analyze_file_ast", ...metrics }, null, 2),
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
};
|
|
288
|
+
} catch (err) {
|
|
289
|
+
logger.error(
|
|
290
|
+
{ err, filePath: absolutePath, language: typed.language },
|
|
291
|
+
"analyze_file_ast failed",
|
|
292
|
+
);
|
|
293
|
+
return {
|
|
294
|
+
content: [
|
|
295
|
+
{
|
|
296
|
+
type: "text",
|
|
297
|
+
text: JSON.stringify(
|
|
298
|
+
{
|
|
299
|
+
tool: "analyze_file_ast",
|
|
300
|
+
status: "error",
|
|
301
|
+
message: (err as Error).message,
|
|
302
|
+
filePath: typed.filePath,
|
|
303
|
+
language: typed.language,
|
|
304
|
+
},
|
|
305
|
+
null,
|
|
306
|
+
2,
|
|
307
|
+
),
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
isError: true,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case "score_project": {
|
|
316
|
+
const typed = (args ?? {}) as { format?: "markdown" | "json" | "both" };
|
|
317
|
+
const format = typed.format ?? "both";
|
|
318
|
+
try {
|
|
319
|
+
const workspace = await estimateWorkspaceLoc(config.pluginRoot);
|
|
320
|
+
const score: ProjectScore = computeProjectScore({
|
|
321
|
+
workspaceRoot: config.pluginRoot,
|
|
322
|
+
minutesPerLoc: config.minutesPerLoc,
|
|
323
|
+
tdrMaxRating: config.tdrMaxRating,
|
|
324
|
+
workspace: { physicalLoc: workspace.physicalLoc, fileCount: workspace.fileCount },
|
|
325
|
+
sarifStore,
|
|
326
|
+
dashboardUrl: dashboard?.url ?? null,
|
|
327
|
+
sarifReportPath: sarifStore.consolidatedReportPath,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const blocks: Array<{ type: "text"; text: string }> = [];
|
|
331
|
+
if (format === "markdown" || format === "both") {
|
|
332
|
+
blocks.push({ type: "text", text: renderProjectScoreMarkdown(score) });
|
|
333
|
+
}
|
|
334
|
+
if (format === "json" || format === "both") {
|
|
335
|
+
blocks.push({ type: "text", text: JSON.stringify(score, null, 2) });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Respect the workspace strictness setting: only `strict`
|
|
339
|
+
// mode should flag a failing project as an MCP tool error
|
|
340
|
+
// and push the agent toward remediation. In `warn` and
|
|
341
|
+
// `advisory` modes the Stop hook lets the task close, so
|
|
342
|
+
// `score_project` must stay consistent and return the
|
|
343
|
+
// score as plain content.
|
|
344
|
+
const strictness = safeLoadStrictness(config.pluginRoot, logger);
|
|
345
|
+
const shouldFlagError = strictness === "strict" && !score.overall.passes;
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
content: blocks,
|
|
349
|
+
isError: shouldFlagError,
|
|
350
|
+
};
|
|
351
|
+
} catch (err) {
|
|
352
|
+
logger.error({ err }, "score_project failed");
|
|
353
|
+
return {
|
|
354
|
+
content: [
|
|
355
|
+
{
|
|
356
|
+
type: "text",
|
|
357
|
+
text: JSON.stringify(
|
|
358
|
+
{ tool: "score_project", status: "error", message: (err as Error).message },
|
|
359
|
+
null,
|
|
360
|
+
2,
|
|
361
|
+
),
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
isError: true,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case "require_test_harness": {
|
|
370
|
+
const typed = args as { filePath: string };
|
|
371
|
+
const absolutePath = resolveWithinWorkspace(config.pluginRoot, typed.filePath);
|
|
372
|
+
try {
|
|
373
|
+
const resolution = await findTestFile(config.pluginRoot, absolutePath);
|
|
374
|
+
const hasTest = resolution.testFile !== null;
|
|
375
|
+
return {
|
|
376
|
+
content: [
|
|
377
|
+
{
|
|
378
|
+
type: "text",
|
|
379
|
+
text: JSON.stringify(
|
|
380
|
+
{
|
|
381
|
+
tool: "require_test_harness",
|
|
382
|
+
filePath: typed.filePath,
|
|
383
|
+
hasTest,
|
|
384
|
+
isTestFile: resolution.isTestFile,
|
|
385
|
+
testFile: resolution.testFile,
|
|
386
|
+
candidates: resolution.candidates,
|
|
387
|
+
...(hasTest
|
|
388
|
+
? {}
|
|
389
|
+
: {
|
|
390
|
+
corrective:
|
|
391
|
+
"No test file found. Per the CLAUDE.md Golden Rule, create a characterization " +
|
|
392
|
+
"test at one of the candidate paths before writing any functional code for this file.",
|
|
393
|
+
}),
|
|
394
|
+
},
|
|
395
|
+
null,
|
|
396
|
+
2,
|
|
397
|
+
),
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
// The Golden Rule says "no code without a test", so the absence
|
|
401
|
+
// of a test is a blocking condition. Surface it as an error.
|
|
402
|
+
isError: !hasTest,
|
|
403
|
+
};
|
|
404
|
+
} catch (err) {
|
|
405
|
+
logger.error({ err, filePath: absolutePath }, "require_test_harness failed");
|
|
406
|
+
return {
|
|
407
|
+
content: [
|
|
408
|
+
{
|
|
409
|
+
type: "text",
|
|
410
|
+
text: JSON.stringify(
|
|
411
|
+
{
|
|
412
|
+
tool: "require_test_harness",
|
|
413
|
+
status: "error",
|
|
414
|
+
message: (err as Error).message,
|
|
415
|
+
filePath: typed.filePath,
|
|
416
|
+
},
|
|
417
|
+
null,
|
|
418
|
+
2,
|
|
419
|
+
),
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
isError: true,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
case "ingest_scanner_output": {
|
|
428
|
+
const typed = args as { scanner: KnownScanner; rawOutput: unknown };
|
|
429
|
+
try {
|
|
430
|
+
const adapted = adaptScannerOutput(typed.scanner, typed.rawOutput);
|
|
431
|
+
// F-A05-01: validate the adapter's output against the same
|
|
432
|
+
// schema used by `ingest_sarif`. Adapters are internal and
|
|
433
|
+
// should already emit conformant documents, but this catches
|
|
434
|
+
// regressions before they reach the store or the dashboard.
|
|
435
|
+
validateSarifDocument(adapted.document);
|
|
436
|
+
const stats = sarifStore.ingestRun(adapted.document, adapted.sourceTool);
|
|
437
|
+
await sarifStore.persist();
|
|
438
|
+
return {
|
|
439
|
+
content: [
|
|
440
|
+
{
|
|
441
|
+
type: "text",
|
|
442
|
+
text: JSON.stringify(
|
|
443
|
+
{
|
|
444
|
+
tool: "ingest_scanner_output",
|
|
445
|
+
status: "accepted",
|
|
446
|
+
scanner: typed.scanner,
|
|
447
|
+
findingsParsed: adapted.findingCount,
|
|
448
|
+
totalEffortMinutes: adapted.totalEffortMinutes,
|
|
449
|
+
accepted: stats.accepted,
|
|
450
|
+
duplicates: stats.duplicates,
|
|
451
|
+
total: stats.total,
|
|
452
|
+
storeSize: sarifStore.size(),
|
|
453
|
+
reportPath: sarifStore.consolidatedReportPath,
|
|
454
|
+
},
|
|
455
|
+
null,
|
|
456
|
+
2,
|
|
457
|
+
),
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
};
|
|
461
|
+
} catch (err) {
|
|
462
|
+
logger.error({ err, scanner: typed.scanner }, "ingest_scanner_output failed");
|
|
463
|
+
return {
|
|
464
|
+
content: [
|
|
465
|
+
{
|
|
466
|
+
type: "text",
|
|
467
|
+
text: JSON.stringify(
|
|
468
|
+
{
|
|
469
|
+
tool: "ingest_scanner_output",
|
|
470
|
+
status: "error",
|
|
471
|
+
scanner: typed.scanner,
|
|
472
|
+
message: (err as Error).message,
|
|
473
|
+
},
|
|
474
|
+
null,
|
|
475
|
+
2,
|
|
476
|
+
),
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
isError: true,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
case "ingest_sarif": {
|
|
485
|
+
const typed = args as { sarifDocument: PersistedSarif; sourceTool: string };
|
|
486
|
+
try {
|
|
487
|
+
// F-A05-01: validate the caller-supplied document against a
|
|
488
|
+
// minimal SARIF 2.1.0 schema BEFORE touching the store. The
|
|
489
|
+
// MCP SDK already validated the outer tool-call shape, but
|
|
490
|
+
// the inner `sarifDocument` is declared as `type: "object"`
|
|
491
|
+
// in tool-schemas.ts and would otherwise flow through
|
|
492
|
+
// un-checked.
|
|
493
|
+
validateSarifDocument(typed.sarifDocument);
|
|
494
|
+
const stats = sarifStore.ingestRun(typed.sarifDocument, typed.sourceTool);
|
|
495
|
+
await sarifStore.persist();
|
|
496
|
+
return {
|
|
497
|
+
content: [
|
|
498
|
+
{
|
|
499
|
+
type: "text",
|
|
500
|
+
text: JSON.stringify(
|
|
501
|
+
{
|
|
502
|
+
tool: "ingest_sarif",
|
|
503
|
+
status: "accepted",
|
|
504
|
+
sourceTool: typed.sourceTool,
|
|
505
|
+
accepted: stats.accepted,
|
|
506
|
+
duplicates: stats.duplicates,
|
|
507
|
+
total: stats.total,
|
|
508
|
+
storeSize: sarifStore.size(),
|
|
509
|
+
reportPath: sarifStore.consolidatedReportPath,
|
|
510
|
+
},
|
|
511
|
+
null,
|
|
512
|
+
2,
|
|
513
|
+
),
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
};
|
|
517
|
+
} catch (err) {
|
|
518
|
+
logger.error({ err, sourceTool: typed.sourceTool }, "ingest_sarif failed");
|
|
519
|
+
return {
|
|
520
|
+
content: [
|
|
521
|
+
{
|
|
522
|
+
type: "text",
|
|
523
|
+
text: JSON.stringify(
|
|
524
|
+
{ tool: "ingest_sarif", status: "error", message: (err as Error).message },
|
|
525
|
+
null,
|
|
526
|
+
2,
|
|
527
|
+
),
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
isError: true,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
default:
|
|
536
|
+
throw new Error(`[claude-crap] Unknown tool: ${name}`);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// ------------------------------------------------------------------
|
|
541
|
+
// Resources — topology and reports
|
|
542
|
+
// ------------------------------------------------------------------
|
|
543
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
544
|
+
resources: [
|
|
545
|
+
{
|
|
546
|
+
uri: "sonar://metrics/current",
|
|
547
|
+
name: "Current project metrics",
|
|
548
|
+
mimeType: "application/json",
|
|
549
|
+
description: "Snapshot of CRAP, TDR, and Reliability / Security ratings.",
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
uri: "sonar://reports/latest.sarif",
|
|
553
|
+
name: "Latest consolidated SARIF 2.1.0 report",
|
|
554
|
+
mimeType: "application/sarif+json",
|
|
555
|
+
description: "Unified SARIF document produced by the most recent Stop quality-gate run.",
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
}));
|
|
559
|
+
|
|
560
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
561
|
+
const { uri } = request.params;
|
|
562
|
+
if (uri === "sonar://reports/latest.sarif") {
|
|
563
|
+
const doc = sarifStore.toSarifDocument();
|
|
564
|
+
return {
|
|
565
|
+
contents: [
|
|
566
|
+
{
|
|
567
|
+
uri,
|
|
568
|
+
mimeType: "application/sarif+json",
|
|
569
|
+
text: JSON.stringify(doc, null, 2),
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
if (uri === "sonar://metrics/current") {
|
|
575
|
+
const snapshot = await buildMetricsSnapshot(config, sarifStore);
|
|
576
|
+
return {
|
|
577
|
+
contents: [
|
|
578
|
+
{
|
|
579
|
+
uri,
|
|
580
|
+
mimeType: "application/json",
|
|
581
|
+
text: JSON.stringify(snapshot, null, 2),
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
throw new Error(`[claude-crap] Unknown resource URI: ${uri}`);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const transport = new StdioServerTransport();
|
|
590
|
+
await server.connect(transport);
|
|
591
|
+
logger.info("claude-crap MCP server ready (stdio)");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Load the workspace strictness without letting a busted config
|
|
596
|
+
* file take down the `score_project` tool. On any loader error we
|
|
597
|
+
* log to stderr via pino and fall back to `"strict"` so the tool
|
|
598
|
+
* stays useful. This is the MCP-server-side counterpart to the
|
|
599
|
+
* `resolveStrictness` helper in `hooks/stop-quality-gate.mjs`.
|
|
600
|
+
*
|
|
601
|
+
* @param workspaceRoot Absolute path the loader should probe for
|
|
602
|
+
* `.claude-crap.json`.
|
|
603
|
+
* @param logger Pino logger used to surface recoverable
|
|
604
|
+
* config errors.
|
|
605
|
+
* @returns The resolved strictness, or `"strict"` on
|
|
606
|
+
* error.
|
|
607
|
+
*/
|
|
608
|
+
function safeLoadStrictness(
|
|
609
|
+
workspaceRoot: string,
|
|
610
|
+
logger: import("pino").Logger,
|
|
611
|
+
): "strict" | "warn" | "advisory" {
|
|
612
|
+
try {
|
|
613
|
+
return loadCrapConfig({ workspaceRoot }).strictness;
|
|
614
|
+
} catch (err) {
|
|
615
|
+
if (err instanceof CrapConfigError) {
|
|
616
|
+
logger.warn(
|
|
617
|
+
{ err: err.message },
|
|
618
|
+
"score_project: invalid sonar config, falling back to strict",
|
|
619
|
+
);
|
|
620
|
+
return "strict";
|
|
621
|
+
}
|
|
622
|
+
throw err;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Build a lightweight metrics snapshot that the LLM can read through
|
|
628
|
+
* the `sonar://metrics/current` resource. This is intentionally thin
|
|
629
|
+
* and side-effect free: it derives everything from the in-memory
|
|
630
|
+
* SARIF store without walking the workspace. Callers that need a
|
|
631
|
+
* full scoring payload (with a real LOC walk and the A..E grades per
|
|
632
|
+
* dimension) should invoke the `score_project` tool, which uses the
|
|
633
|
+
* bounded workspace walker and the `metrics/score.ts` engine.
|
|
634
|
+
*
|
|
635
|
+
* @param config Fully resolved server configuration.
|
|
636
|
+
* @param sarifStore Live SARIF store used to read the latest findings.
|
|
637
|
+
*/
|
|
638
|
+
async function buildMetricsSnapshot(
|
|
639
|
+
config: CrapConfig,
|
|
640
|
+
sarifStore: SarifStore,
|
|
641
|
+
): Promise<Record<string, unknown>> {
|
|
642
|
+
const findings = sarifStore.list();
|
|
643
|
+
const totalRemediationMinutes = findings.reduce((sum, f) => {
|
|
644
|
+
const effort = f.properties?.["effortMinutes"];
|
|
645
|
+
return typeof effort === "number" ? sum + effort : sum;
|
|
646
|
+
}, 0);
|
|
647
|
+
|
|
648
|
+
// Cheap LOC approximation derived from the SARIF report: assume
|
|
649
|
+
// ~100 physical lines per file we have at least one finding in.
|
|
650
|
+
// This keeps the resource read lock-free and synchronous-feeling;
|
|
651
|
+
// the `score_project` tool is the authoritative path when a real
|
|
652
|
+
// workspace walk is required.
|
|
653
|
+
const uniqueFiles = new Set(findings.map((f) => f.location.uri));
|
|
654
|
+
const approxLoc = Math.max(uniqueFiles.size * 100, 1);
|
|
655
|
+
|
|
656
|
+
const tdrPercent =
|
|
657
|
+
totalRemediationMinutes / (config.minutesPerLoc * approxLoc) * 100;
|
|
658
|
+
const rating = classifyTdr(Number.isFinite(tdrPercent) ? tdrPercent : 0);
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
generatedAt: new Date().toISOString(),
|
|
662
|
+
config: {
|
|
663
|
+
crapThreshold: config.crapThreshold,
|
|
664
|
+
tdrMaxRating: config.tdrMaxRating,
|
|
665
|
+
minutesPerLoc: config.minutesPerLoc,
|
|
666
|
+
},
|
|
667
|
+
sarif: {
|
|
668
|
+
reportPath: sarifStore.consolidatedReportPath,
|
|
669
|
+
findings: findings.length,
|
|
670
|
+
files: uniqueFiles.size,
|
|
671
|
+
tools: Array.from(new Set(findings.map((f) => f.sourceTool))),
|
|
672
|
+
},
|
|
673
|
+
tdrApprox: {
|
|
674
|
+
percent: Number(tdrPercent.toFixed(4)),
|
|
675
|
+
rating,
|
|
676
|
+
remediationMinutes: totalRemediationMinutes,
|
|
677
|
+
approxLinesOfCode: approxLoc,
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Top-level await would be cleaner, but we keep main() + .catch() so
|
|
683
|
+
// any error during async bootstrap (engine init, store load) surfaces as
|
|
684
|
+
// a non-zero exit code visible to Claude Code's MCP diagnostics.
|
|
685
|
+
main().catch((err) => {
|
|
686
|
+
// Fatal errors go to stderr to avoid corrupting the JSON-RPC channel
|
|
687
|
+
// on stdout. We use `process.stderr.write` rather than `console.error`
|
|
688
|
+
// so that no lint suppression is needed and so that no buffering layer
|
|
689
|
+
// can swallow the message. A non-zero exit code causes Claude Code to
|
|
690
|
+
// surface the failure in its MCP-server diagnostics.
|
|
691
|
+
process.stderr.write(`[claude-crap] fatal error during startup: ${String(err)}\n`);
|
|
692
|
+
if (err instanceof Error && err.stack) {
|
|
693
|
+
process.stderr.write(err.stack + "\n");
|
|
694
|
+
}
|
|
695
|
+
process.exit(1);
|
|
696
|
+
});
|