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
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
+ });