claude-crap 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/CHANGELOG.md +308 -0
  2. package/LICENSE +21 -0
  3. package/README.md +550 -0
  4. package/bin/claude-crap.mjs +141 -0
  5. package/dist/adapters/bandit.d.ts +48 -0
  6. package/dist/adapters/bandit.d.ts.map +1 -0
  7. package/dist/adapters/bandit.js +145 -0
  8. package/dist/adapters/bandit.js.map +1 -0
  9. package/dist/adapters/common.d.ts +73 -0
  10. package/dist/adapters/common.d.ts.map +1 -0
  11. package/dist/adapters/common.js +78 -0
  12. package/dist/adapters/common.js.map +1 -0
  13. package/dist/adapters/eslint.d.ts +52 -0
  14. package/dist/adapters/eslint.d.ts.map +1 -0
  15. package/dist/adapters/eslint.js +142 -0
  16. package/dist/adapters/eslint.js.map +1 -0
  17. package/dist/adapters/index.d.ts +47 -0
  18. package/dist/adapters/index.d.ts.map +1 -0
  19. package/dist/adapters/index.js +64 -0
  20. package/dist/adapters/index.js.map +1 -0
  21. package/dist/adapters/semgrep.d.ts +30 -0
  22. package/dist/adapters/semgrep.d.ts.map +1 -0
  23. package/dist/adapters/semgrep.js +130 -0
  24. package/dist/adapters/semgrep.js.map +1 -0
  25. package/dist/adapters/stryker.d.ts +55 -0
  26. package/dist/adapters/stryker.d.ts.map +1 -0
  27. package/dist/adapters/stryker.js +165 -0
  28. package/dist/adapters/stryker.js.map +1 -0
  29. package/dist/ast/cyclomatic.d.ts +48 -0
  30. package/dist/ast/cyclomatic.d.ts.map +1 -0
  31. package/dist/ast/cyclomatic.js +106 -0
  32. package/dist/ast/cyclomatic.js.map +1 -0
  33. package/dist/ast/index.d.ts +26 -0
  34. package/dist/ast/index.d.ts.map +1 -0
  35. package/dist/ast/index.js +23 -0
  36. package/dist/ast/index.js.map +1 -0
  37. package/dist/ast/language-config.d.ts +70 -0
  38. package/dist/ast/language-config.d.ts.map +1 -0
  39. package/dist/ast/language-config.js +192 -0
  40. package/dist/ast/language-config.js.map +1 -0
  41. package/dist/ast/tree-sitter-engine.d.ts +133 -0
  42. package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
  43. package/dist/ast/tree-sitter-engine.js +270 -0
  44. package/dist/ast/tree-sitter-engine.js.map +1 -0
  45. package/dist/config.d.ts +57 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +78 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/crap-config.d.ts +97 -0
  50. package/dist/crap-config.d.ts.map +1 -0
  51. package/dist/crap-config.js +144 -0
  52. package/dist/crap-config.js.map +1 -0
  53. package/dist/dashboard/server.d.ts +65 -0
  54. package/dist/dashboard/server.d.ts.map +1 -0
  55. package/dist/dashboard/server.js +147 -0
  56. package/dist/dashboard/server.js.map +1 -0
  57. package/dist/index.d.ts +32 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +574 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/metrics/crap.d.ts +71 -0
  62. package/dist/metrics/crap.d.ts.map +1 -0
  63. package/dist/metrics/crap.js +67 -0
  64. package/dist/metrics/crap.js.map +1 -0
  65. package/dist/metrics/index.d.ts +31 -0
  66. package/dist/metrics/index.d.ts.map +1 -0
  67. package/dist/metrics/index.js +27 -0
  68. package/dist/metrics/index.js.map +1 -0
  69. package/dist/metrics/score.d.ts +143 -0
  70. package/dist/metrics/score.d.ts.map +1 -0
  71. package/dist/metrics/score.js +224 -0
  72. package/dist/metrics/score.js.map +1 -0
  73. package/dist/metrics/tdr.d.ts +106 -0
  74. package/dist/metrics/tdr.d.ts.map +1 -0
  75. package/dist/metrics/tdr.js +117 -0
  76. package/dist/metrics/tdr.js.map +1 -0
  77. package/dist/metrics/workspace-walker.d.ts +43 -0
  78. package/dist/metrics/workspace-walker.d.ts.map +1 -0
  79. package/dist/metrics/workspace-walker.js +137 -0
  80. package/dist/metrics/workspace-walker.js.map +1 -0
  81. package/dist/sarif/index.d.ts +21 -0
  82. package/dist/sarif/index.d.ts.map +1 -0
  83. package/dist/sarif/index.js +19 -0
  84. package/dist/sarif/index.js.map +1 -0
  85. package/dist/sarif/sarif-builder.d.ts +128 -0
  86. package/dist/sarif/sarif-builder.d.ts.map +1 -0
  87. package/dist/sarif/sarif-builder.js +79 -0
  88. package/dist/sarif/sarif-builder.js.map +1 -0
  89. package/dist/sarif/sarif-store.d.ts +205 -0
  90. package/dist/sarif/sarif-store.d.ts.map +1 -0
  91. package/dist/sarif/sarif-store.js +246 -0
  92. package/dist/sarif/sarif-store.js.map +1 -0
  93. package/dist/sarif/sarif-validator.d.ts +45 -0
  94. package/dist/sarif/sarif-validator.d.ts.map +1 -0
  95. package/dist/sarif/sarif-validator.js +138 -0
  96. package/dist/sarif/sarif-validator.js.map +1 -0
  97. package/dist/schemas/tool-schemas.d.ts +216 -0
  98. package/dist/schemas/tool-schemas.d.ts.map +1 -0
  99. package/dist/schemas/tool-schemas.js +208 -0
  100. package/dist/schemas/tool-schemas.js.map +1 -0
  101. package/dist/sdk.d.ts +45 -0
  102. package/dist/sdk.d.ts.map +1 -0
  103. package/dist/sdk.js +44 -0
  104. package/dist/sdk.js.map +1 -0
  105. package/dist/tools/index.d.ts +24 -0
  106. package/dist/tools/index.d.ts.map +1 -0
  107. package/dist/tools/index.js +23 -0
  108. package/dist/tools/index.js.map +1 -0
  109. package/dist/tools/test-harness.d.ts +75 -0
  110. package/dist/tools/test-harness.d.ts.map +1 -0
  111. package/dist/tools/test-harness.js +137 -0
  112. package/dist/tools/test-harness.js.map +1 -0
  113. package/dist/workspace-guard.d.ts +53 -0
  114. package/dist/workspace-guard.d.ts.map +1 -0
  115. package/dist/workspace-guard.js +61 -0
  116. package/dist/workspace-guard.js.map +1 -0
  117. package/package.json +133 -0
  118. package/plugin/.claude-plugin/plugin.json +29 -0
  119. package/plugin/.mcp.json +18 -0
  120. package/plugin/CLAUDE.md +143 -0
  121. package/plugin/bundle/dashboard/public/index.html +368 -0
  122. package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
  123. package/plugin/bundle/mcp-server.mjs +8718 -0
  124. package/plugin/bundle/mcp-server.mjs.map +7 -0
  125. package/plugin/bundle/tdr-engine.mjs +50 -0
  126. package/plugin/bundle/tdr-engine.mjs.map +7 -0
  127. package/plugin/hooks/hooks.json +62 -0
  128. package/plugin/hooks/lib/crap-config.mjs +152 -0
  129. package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
  130. package/plugin/hooks/lib/hook-io.mjs +151 -0
  131. package/plugin/hooks/lib/quality-gate.mjs +329 -0
  132. package/plugin/hooks/lib/test-harness.mjs +152 -0
  133. package/plugin/hooks/post-tool-use.mjs +245 -0
  134. package/plugin/hooks/pre-tool-use.mjs +290 -0
  135. package/plugin/hooks/session-start.mjs +109 -0
  136. package/plugin/hooks/stop-quality-gate.mjs +226 -0
  137. package/plugin/package.json +18 -0
  138. package/plugin/skills/adopt/SKILL.md +74 -0
  139. package/plugin/skills/analyze/SKILL.md +77 -0
  140. package/plugin/skills/check-test/SKILL.md +50 -0
  141. package/plugin/skills/score/SKILL.md +31 -0
  142. package/scripts/bug-report.mjs +328 -0
  143. package/scripts/build-fast.mjs +130 -0
  144. package/scripts/bundle-plugin.mjs +74 -0
  145. package/scripts/doctor.mjs +320 -0
  146. package/scripts/install.mjs +192 -0
  147. package/scripts/lib/cli-ui.mjs +122 -0
  148. package/scripts/postinstall.mjs +127 -0
  149. package/scripts/run-tests.mjs +95 -0
  150. package/scripts/status.mjs +110 -0
  151. package/scripts/uninstall.mjs +72 -0
  152. package/src/adapters/bandit.ts +191 -0
  153. package/src/adapters/common.ts +133 -0
  154. package/src/adapters/eslint.ts +187 -0
  155. package/src/adapters/index.ts +78 -0
  156. package/src/adapters/semgrep.ts +150 -0
  157. package/src/adapters/stryker.ts +218 -0
  158. package/src/ast/cyclomatic.ts +131 -0
  159. package/src/ast/index.ts +33 -0
  160. package/src/ast/language-config.ts +231 -0
  161. package/src/ast/tree-sitter-engine.ts +385 -0
  162. package/src/config.ts +109 -0
  163. package/src/crap-config.ts +196 -0
  164. package/src/dashboard/public/index.html +368 -0
  165. package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
  166. package/src/dashboard/server.ts +205 -0
  167. package/src/index.ts +696 -0
  168. package/src/metrics/crap.ts +101 -0
  169. package/src/metrics/index.ts +51 -0
  170. package/src/metrics/score.ts +329 -0
  171. package/src/metrics/tdr.ts +155 -0
  172. package/src/metrics/workspace-walker.ts +146 -0
  173. package/src/sarif/index.ts +31 -0
  174. package/src/sarif/sarif-builder.ts +139 -0
  175. package/src/sarif/sarif-store.ts +347 -0
  176. package/src/sarif/sarif-validator.ts +145 -0
  177. package/src/schemas/tool-schemas.ts +225 -0
  178. package/src/sdk.ts +110 -0
  179. package/src/tests/adapters/bandit.test.ts +111 -0
  180. package/src/tests/adapters/dispatch.test.ts +100 -0
  181. package/src/tests/adapters/eslint.test.ts +138 -0
  182. package/src/tests/adapters/semgrep.test.ts +125 -0
  183. package/src/tests/adapters/stryker.test.ts +103 -0
  184. package/src/tests/crap-config.test.ts +228 -0
  185. package/src/tests/crap.test.ts +59 -0
  186. package/src/tests/cyclomatic.test.ts +87 -0
  187. package/src/tests/dashboard-http.test.ts +108 -0
  188. package/src/tests/dashboard-integrity.test.ts +128 -0
  189. package/src/tests/integration/mcp-server.integration.test.ts +352 -0
  190. package/src/tests/pre-tool-use-hook.test.ts +178 -0
  191. package/src/tests/sarif-store.test.ts +241 -0
  192. package/src/tests/sarif-validator.test.ts +164 -0
  193. package/src/tests/score.test.ts +260 -0
  194. package/src/tests/skills-frontmatter.test.ts +172 -0
  195. package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
  196. package/src/tests/tdr.test.ts +86 -0
  197. package/src/tests/test-harness.test.ts +153 -0
  198. package/src/tests/workspace-guard.test.ts +111 -0
  199. package/src/tools/index.ts +24 -0
  200. package/src/tools/test-harness.ts +158 -0
  201. package/src/workspace-guard.ts +64 -0
  202. package/tsconfig.json +27 -0
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Tree-sitter based AST analysis engine.
3
+ *
4
+ * This module wraps `web-tree-sitter` (the WASM build of tree-sitter) to
5
+ * parse source files and extract deterministic per-function metrics. The
6
+ * WASM variant is used instead of the native bindings so that `npm install`
7
+ * never has to invoke a C compiler — matching the plugin's "zero install
8
+ * friction" promise.
9
+ *
10
+ * The engine is lazy:
11
+ *
12
+ * - `web-tree-sitter` is initialized only on first use.
13
+ * - Grammar WASM files are loaded on demand and cached per language.
14
+ *
15
+ * This keeps MCP server startup fast (crucial because Claude Code will
16
+ * spin the server up and tear it down across sessions).
17
+ *
18
+ * Usage:
19
+ *
20
+ * ```ts
21
+ * const engine = new TreeSitterEngine();
22
+ * const result = await engine.analyzeFile({
23
+ * filePath: "src/foo.ts",
24
+ * language: "typescript",
25
+ * });
26
+ * console.log(result.functions);
27
+ * ```
28
+ *
29
+ * @module ast/tree-sitter-engine
30
+ */
31
+
32
+ import { promises as fs } from "node:fs";
33
+ import { createRequire } from "node:module";
34
+ import { dirname, join, resolve } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ import { computeCyclomaticComplexity, type AstNode } from "./cyclomatic.js";
38
+ import { LANGUAGE_TABLE, type LanguageConfig, type SupportedLanguage } from "./language-config.js";
39
+
40
+ /**
41
+ * Minimal typed view of the `web-tree-sitter` Parser class and its
42
+ * static helpers. The npm package uses `export = Parser` (a CommonJS
43
+ * default export), so under ESM interop `await import('web-tree-sitter')`
44
+ * returns `{ default: Parser }` where `Parser`:
45
+ *
46
+ * - is a constructable class (`new Parser()`)
47
+ * - exposes `static init(moduleOptions)` to bootstrap the WASM runtime
48
+ * - exposes the nested `Parser.Language.load(pathOrBytes)` static
49
+ * to load a grammar from a `.wasm` file or a raw byte buffer
50
+ *
51
+ * We model that surface here so downstream consumers of this engine are
52
+ * not forced to import `web-tree-sitter` types directly.
53
+ */
54
+ interface ParserInstance {
55
+ setLanguage(language: unknown): void;
56
+ parse(source: string): { rootNode: AstNode };
57
+ }
58
+
59
+ interface ParserCtor {
60
+ new (): ParserInstance;
61
+ init(options?: { locateFile?: (name: string) => string }): Promise<void>;
62
+ Language: { load(path: string | Uint8Array): Promise<unknown> };
63
+ }
64
+
65
+ /**
66
+ * Per-function metrics returned by the engine.
67
+ */
68
+ export interface FunctionMetrics {
69
+ /** Human-readable function name, or `"<anonymous>"` when not available. */
70
+ readonly name: string;
71
+ /** 1-based line where the function body starts. */
72
+ readonly startLine: number;
73
+ /** 1-based line where the function body ends. */
74
+ readonly endLine: number;
75
+ /** McCabe cyclomatic complexity (always ≥ 1). */
76
+ readonly cyclomaticComplexity: number;
77
+ /** Physical lines of code covered by the function (endLine - startLine + 1). */
78
+ readonly lineCount: number;
79
+ }
80
+
81
+ /**
82
+ * File-level metrics returned by the engine.
83
+ */
84
+ export interface FileMetrics {
85
+ /** File path that was analyzed, echoed from the request for traceability. */
86
+ readonly filePath: string;
87
+ /** Language the file was parsed as. */
88
+ readonly language: SupportedLanguage;
89
+ /** Total physical lines in the file, including blanks and comments. */
90
+ readonly physicalLoc: number;
91
+ /** Physical lines that contain at least one non-whitespace character. */
92
+ readonly logicalLoc: number;
93
+ /** Per-function metrics sorted by starting line. */
94
+ readonly functions: ReadonlyArray<FunctionMetrics>;
95
+ }
96
+
97
+ /**
98
+ * Request accepted by {@link TreeSitterEngine.analyzeFile}.
99
+ */
100
+ export interface AnalyzeFileRequest {
101
+ readonly filePath: string;
102
+ readonly language: SupportedLanguage;
103
+ }
104
+
105
+ /**
106
+ * Options accepted by the engine constructor. All fields are optional and
107
+ * safe defaults are used when omitted.
108
+ */
109
+ export interface TreeSitterEngineOptions {
110
+ /**
111
+ * Directory where the language grammar WASM files live (one per
112
+ * language, e.g. `tree-sitter-typescript.wasm`). Defaults to the
113
+ * `tree-sitter-wasms/out` directory inside `node_modules`.
114
+ */
115
+ readonly grammarsDir?: string;
116
+ /**
117
+ * Directory where the `web-tree-sitter` runtime WASM (`tree-sitter.wasm`)
118
+ * lives. This is a different package from the grammars — the runtime
119
+ * ships with `web-tree-sitter` itself. Defaults to that package's
120
+ * install directory inside `node_modules`.
121
+ */
122
+ readonly runtimeDir?: string;
123
+ /**
124
+ * Override the WASM loader for tests. Receives the grammar filename
125
+ * and must return the raw bytes.
126
+ */
127
+ readonly loadGrammar?: (wasmPath: string) => Promise<Uint8Array>;
128
+ }
129
+
130
+ /**
131
+ * High-level AST engine. Instances are meant to be long-lived — create
132
+ * one at server startup and reuse it for every analysis request.
133
+ */
134
+ export class TreeSitterEngine {
135
+ private parserCtor: ParserCtor | null = null;
136
+ private readonly loadedLanguages = new Map<SupportedLanguage, unknown>();
137
+ private readonly grammarsDir: string;
138
+ private readonly runtimeDir: string;
139
+ private readonly loadGrammar: (wasmPath: string) => Promise<Uint8Array>;
140
+ private initPromise: Promise<void> | null = null;
141
+
142
+ constructor(options: TreeSitterEngineOptions = {}) {
143
+ this.grammarsDir = options.grammarsDir ?? resolveDefaultGrammarsDir();
144
+ this.runtimeDir = options.runtimeDir ?? resolveDefaultRuntimeDir();
145
+ this.loadGrammar = options.loadGrammar ?? ((path) => fs.readFile(path));
146
+ }
147
+
148
+ /**
149
+ * Analyze a source file and return per-function and file-level metrics.
150
+ *
151
+ * @param req The analysis request.
152
+ * @returns A {@link FileMetrics} snapshot ready to be serialized.
153
+ * @throws When the file cannot be read or the grammar cannot be loaded.
154
+ */
155
+ async analyzeFile(req: AnalyzeFileRequest): Promise<FileMetrics> {
156
+ const languageConfig = LANGUAGE_TABLE[req.language];
157
+ if (!languageConfig) {
158
+ throw new Error(`[tree-sitter-engine] Unsupported language: ${req.language}`);
159
+ }
160
+
161
+ const source = await fs.readFile(req.filePath, "utf8");
162
+ const parser = await this.ensureParserFor(languageConfig);
163
+ const tree = parser.parse(source);
164
+
165
+ const functions = collectFunctionMetrics(tree.rootNode, languageConfig);
166
+ const { physicalLoc, logicalLoc } = countLines(source);
167
+
168
+ return {
169
+ filePath: req.filePath,
170
+ language: languageConfig.id,
171
+ physicalLoc,
172
+ logicalLoc,
173
+ functions,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Ensure a parser with the requested language grammar bound is ready.
179
+ * Both the Parser class and the grammar are initialized lazily and
180
+ * cached on first use.
181
+ *
182
+ * @param config Language configuration for the requested grammar.
183
+ * @returns A fresh parser instance configured for the language.
184
+ */
185
+ private async ensureParserFor(config: LanguageConfig): Promise<ParserInstance> {
186
+ if (!this.parserCtor) {
187
+ if (!this.initPromise) {
188
+ this.initPromise = this.initParserModule();
189
+ }
190
+ await this.initPromise;
191
+ }
192
+ const Parser = this.parserCtor;
193
+ if (!Parser) {
194
+ throw new Error("[tree-sitter-engine] Parser class failed to initialize");
195
+ }
196
+
197
+ let language = this.loadedLanguages.get(config.id);
198
+ if (!language) {
199
+ const wasmPath = join(this.grammarsDir, config.wasmName);
200
+ const bytes = await this.loadGrammar(wasmPath);
201
+ language = await Parser.Language.load(bytes);
202
+ this.loadedLanguages.set(config.id, language);
203
+ }
204
+
205
+ const parser = new Parser();
206
+ parser.setLanguage(language);
207
+ return parser;
208
+ }
209
+
210
+ /**
211
+ * Import and initialize `web-tree-sitter`. Isolated in its own method
212
+ * so the dynamic import runs exactly once per engine instance.
213
+ *
214
+ * `web-tree-sitter` uses `export = Parser` so under ESM interop the
215
+ * Parser class arrives on the `default` property of the imported
216
+ * namespace. `Parser.init()` is a STATIC method on the class, not a
217
+ * top-level module function.
218
+ */
219
+ private async initParserModule(): Promise<void> {
220
+ const imported = (await import("web-tree-sitter")) as { default: ParserCtor };
221
+ const Parser = imported.default;
222
+ if (!Parser || typeof Parser.init !== "function") {
223
+ throw new Error(
224
+ "[tree-sitter-engine] web-tree-sitter did not expose the expected Parser class",
225
+ );
226
+ }
227
+ // Emscripten calls `locateFile` to resolve the runtime WASM during
228
+ // `Parser.init()`. The runtime file (`tree-sitter.wasm`) lives inside
229
+ // the `web-tree-sitter` package itself, NOT alongside the grammars,
230
+ // so we route requests for that exact filename to `runtimeDir`.
231
+ // Anything else falls back to `grammarsDir` for the per-language
232
+ // grammar files loaded later by `Parser.Language.load()`.
233
+ await Parser.init({
234
+ locateFile: (name: string) =>
235
+ name === "tree-sitter.wasm"
236
+ ? join(this.runtimeDir, name)
237
+ : join(this.grammarsDir, name),
238
+ });
239
+ this.parserCtor = Parser;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Resolve the default grammar directory to `tree-sitter-wasms/out` inside
245
+ * `node_modules`. Uses `createRequire` so the lookup works regardless of
246
+ * whether the caller is running from source (`tsx`) or from the built
247
+ * `dist/` directory.
248
+ */
249
+ function resolveDefaultGrammarsDir(): string {
250
+ try {
251
+ const requireFromHere = createRequire(import.meta.url);
252
+ // `tree-sitter-wasms` exposes its grammar files under `out/`.
253
+ const pkgJsonPath = requireFromHere.resolve("tree-sitter-wasms/package.json");
254
+ return join(dirname(pkgJsonPath), "out");
255
+ } catch {
256
+ // Fall back to a sibling `grammars/` directory if the npm package
257
+ // is not installed — useful for repo-local grammars.
258
+ const here = dirname(fileURLToPath(import.meta.url));
259
+ return resolve(here, "..", "..", "grammars");
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Resolve the default runtime directory to the `web-tree-sitter` package
265
+ * root inside `node_modules`. The runtime WASM (`tree-sitter.wasm`) ships
266
+ * with the `web-tree-sitter` package itself rather than with the grammar
267
+ * package, so we have to look it up separately from the grammars.
268
+ */
269
+ function resolveDefaultRuntimeDir(): string {
270
+ try {
271
+ const requireFromHere = createRequire(import.meta.url);
272
+ const pkgJsonPath = requireFromHere.resolve("web-tree-sitter/package.json");
273
+ return dirname(pkgJsonPath);
274
+ } catch {
275
+ // Fall back to the grammars directory — better than nothing if
276
+ // someone is running with a custom layout.
277
+ return resolveDefaultGrammarsDir();
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Walk the top-level AST and collect metrics for every function node,
283
+ * including nested functions. The caller gets a flat, line-sorted list.
284
+ *
285
+ * @param root AST root node returned by tree-sitter.
286
+ * @param languageConfig Language tables to classify nodes.
287
+ * @returns Flat list of function metrics sorted by start line.
288
+ */
289
+ function collectFunctionMetrics(
290
+ root: AstNode,
291
+ languageConfig: LanguageConfig,
292
+ ): ReadonlyArray<FunctionMetrics> {
293
+ const out: FunctionMetrics[] = [];
294
+
295
+ function visit(node: AstNode): void {
296
+ if (languageConfig.functionNodeTypes.has(node.type)) {
297
+ out.push(buildFunctionMetrics(node, languageConfig));
298
+ // Intentionally continue the walk so nested functions are also
299
+ // reported. Each nested function's complexity is computed against
300
+ // its own subtree; cyclomatic.ts skips nested functions during
301
+ // the walk so the parent's score is not inflated.
302
+ }
303
+ for (let i = 0; i < node.childCount; i++) {
304
+ const child = node.child(i);
305
+ if (child) visit(child);
306
+ }
307
+ }
308
+
309
+ visit(root);
310
+ out.sort((a, b) => a.startLine - b.startLine);
311
+ return out;
312
+ }
313
+
314
+ /**
315
+ * Build a {@link FunctionMetrics} record for a single function node.
316
+ */
317
+ function buildFunctionMetrics(node: AstNode, languageConfig: LanguageConfig): FunctionMetrics {
318
+ const name = extractFunctionName(node, languageConfig);
319
+ const position = extractPosition(node);
320
+ const complexity = computeCyclomaticComplexity(node, languageConfig);
321
+ return {
322
+ name,
323
+ startLine: position.startLine,
324
+ endLine: position.endLine,
325
+ cyclomaticComplexity: complexity,
326
+ lineCount: position.endLine - position.startLine + 1,
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Pull the function name out of a function node. Tree-sitter exposes a
332
+ * `childForFieldName` accessor on its real nodes; we feature-detect it
333
+ * because our minimal {@link AstNode} contract does not require it.
334
+ */
335
+ function extractFunctionName(node: AstNode, languageConfig: LanguageConfig): string {
336
+ const anyNode = node as AstNode & {
337
+ childForFieldName?: (field: string) => AstNode | null;
338
+ };
339
+ if (typeof anyNode.childForFieldName === "function") {
340
+ for (const field of languageConfig.nameFieldCandidates) {
341
+ const nameNode = anyNode.childForFieldName(field);
342
+ if (nameNode && nameNode.text) return nameNode.text;
343
+ }
344
+ }
345
+ return "<anonymous>";
346
+ }
347
+
348
+ /**
349
+ * Extract 1-based start/end line numbers for a node. Tree-sitter nodes
350
+ * expose `startPosition` and `endPosition` with zero-based rows.
351
+ */
352
+ function extractPosition(node: AstNode): { startLine: number; endLine: number } {
353
+ const anyNode = node as AstNode & {
354
+ startPosition?: { row: number };
355
+ endPosition?: { row: number };
356
+ };
357
+ const startRow = anyNode.startPosition?.row ?? 0;
358
+ const endRow = anyNode.endPosition?.row ?? startRow;
359
+ return { startLine: startRow + 1, endLine: endRow + 1 };
360
+ }
361
+
362
+ /**
363
+ * Count physical and logical lines of code in a raw source string.
364
+ *
365
+ * - **Physical LOC**: number of newline-separated lines, matching how
366
+ * most IDEs report file length.
367
+ * - **Logical LOC**: number of lines that contain at least one
368
+ * non-whitespace character.
369
+ *
370
+ * Comment detection is intentionally NOT done here; comment stripping
371
+ * requires language-aware parsing and the caller can derive a comment
372
+ * ratio from the AST node list if needed.
373
+ *
374
+ * @param source Raw source text.
375
+ * @returns An object with `physicalLoc` and `logicalLoc`.
376
+ */
377
+ function countLines(source: string): { physicalLoc: number; logicalLoc: number } {
378
+ if (source.length === 0) return { physicalLoc: 0, logicalLoc: 0 };
379
+ const lines = source.split(/\r?\n/);
380
+ let logical = 0;
381
+ for (const line of lines) {
382
+ if (line.trim().length > 0) logical += 1;
383
+ }
384
+ return { physicalLoc: lines.length, logicalLoc: logical };
385
+ }
package/src/config.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Deterministic configuration loader for the claude-crap MCP server.
3
+ *
4
+ * Every tunable knob is read from environment variables that are injected
5
+ * by `.mcp.json` at server startup. Those variables are themselves derived
6
+ * from the `CLAUDE_PLUGIN_OPTION_*` values defined in the plugin manifest,
7
+ * which means the configuration chain is:
8
+ *
9
+ * user settings → plugin.json "options" → .mcp.json "env" → this file
10
+ *
11
+ * If any environment variable is missing or empty, a safe default is used,
12
+ * but the loader NEVER invents stochastic values and NEVER performs I/O.
13
+ * This module is the single source of truth for runtime configuration.
14
+ *
15
+ * @module config
16
+ */
17
+
18
+ /**
19
+ * Maintainability rating letter grades used throughout claude-crap.
20
+ *
21
+ * The ordering is strict: A is best, E is worst. Callers that need to
22
+ * compare two ratings should use {@link ratingToRank} from `metrics/tdr.ts`
23
+ * rather than comparing the letters directly.
24
+ */
25
+ export type MaintainabilityRating = "A" | "B" | "C" | "D" | "E";
26
+
27
+ /**
28
+ * Fully resolved configuration object consumed by every subsystem of the
29
+ * MCP server. Fields are `readonly` so that downstream code cannot mutate
30
+ * configuration at runtime — any change must go through a server restart.
31
+ */
32
+ export interface CrapConfig {
33
+ /** Absolute path to the plugin root on disk. Defaults to `process.cwd()`. */
34
+ readonly pluginRoot: string;
35
+ /** Directory (relative to the workspace) where consolidated SARIF reports are written. */
36
+ readonly sarifOutputDir: string;
37
+ /** Hard block threshold for the CRAP index. Functions above this fail the Stop quality gate. */
38
+ readonly crapThreshold: number;
39
+ /** Maximum cyclomatic complexity allowed per function before warnings fire. */
40
+ readonly cyclomaticMax: number;
41
+ /** Highest (worst) maintainability rating the project is allowed to hold. */
42
+ readonly tdrMaxRating: MaintainabilityRating;
43
+ /** Assumed development cost per line of code, in minutes. Used as the TDR denominator. */
44
+ readonly minutesPerLoc: number;
45
+ /** Local TCP port the Vue.js dashboard will bind to. */
46
+ readonly dashboardPort: number;
47
+ }
48
+
49
+ /**
50
+ * Parse a numeric environment variable, falling back to `fallback` when the
51
+ * variable is undefined or empty. Throws if the value is present but not a
52
+ * finite number — we prefer a loud startup failure over silently using a
53
+ * wrong threshold.
54
+ *
55
+ * @param name Environment variable name, used only for the error message.
56
+ * @param raw Raw value read from `process.env`.
57
+ * @param fallback Default value used when `raw` is undefined/empty.
58
+ * @returns The parsed number.
59
+ * @throws When `raw` is present but not a finite number.
60
+ */
61
+ function parseNumber(name: string, raw: string | undefined, fallback: number): number {
62
+ if (raw === undefined || raw === "") return fallback;
63
+ const value = Number(raw);
64
+ if (!Number.isFinite(value)) {
65
+ throw new Error(`[claude-crap] Env ${name}="${raw}" is not a finite number`);
66
+ }
67
+ return value;
68
+ }
69
+
70
+ /**
71
+ * Parse a maintainability rating from an environment variable. Accepts any
72
+ * casing (`a`, `A`, ` a ` all become `"A"`). Throws on invalid letters so
73
+ * the server refuses to start rather than running with an unknown policy.
74
+ *
75
+ * @param raw Raw value read from `process.env`.
76
+ * @param fallback Default rating used when `raw` is undefined.
77
+ * @returns A validated {@link MaintainabilityRating}.
78
+ * @throws When `raw` is a non-empty string that is not A..E.
79
+ */
80
+ function parseRating(raw: string | undefined, fallback: MaintainabilityRating): MaintainabilityRating {
81
+ if (!raw) return fallback;
82
+ const normalized = raw.trim().toUpperCase();
83
+ if (!["A", "B", "C", "D", "E"].includes(normalized)) {
84
+ throw new Error(`[claude-crap] TDR_MAX_RATING="${raw}" must be one of A, B, C, D, E`);
85
+ }
86
+ return normalized as MaintainabilityRating;
87
+ }
88
+
89
+ /**
90
+ * Build the complete {@link CrapConfig} from the current process environment.
91
+ *
92
+ * This should be called exactly once at server startup. Subsequent callers
93
+ * that need configuration should accept a `CrapConfig` parameter instead
94
+ * of re-reading from `process.env`, so that tests can inject custom values.
95
+ *
96
+ * @returns A fully validated, immutable configuration object.
97
+ * @throws When any environment variable is present but malformed.
98
+ */
99
+ export function loadConfig(): CrapConfig {
100
+ return {
101
+ pluginRoot: process.env.CLAUDE_CRAP_PLUGIN_ROOT ?? process.cwd(),
102
+ sarifOutputDir: process.env.CLAUDE_CRAP_SARIF_OUTPUT_DIR ?? ".claude-crap/reports",
103
+ crapThreshold: parseNumber("CLAUDE_CRAP_CRAP_THRESHOLD", process.env.CLAUDE_CRAP_CRAP_THRESHOLD, 30),
104
+ cyclomaticMax: parseNumber("CLAUDE_CRAP_CYCLOMATIC_MAX", process.env.CLAUDE_CRAP_CYCLOMATIC_MAX, 15),
105
+ tdrMaxRating: parseRating(process.env.CLAUDE_CRAP_TDR_MAX_RATING, "C"),
106
+ minutesPerLoc: parseNumber("CLAUDE_CRAP_MINUTES_PER_LOC", process.env.CLAUDE_CRAP_MINUTES_PER_LOC, 30),
107
+ dashboardPort: parseNumber("CLAUDE_CRAP_DASHBOARD_PORT", process.env.CLAUDE_CRAP_DASHBOARD_PORT, 5117),
108
+ };
109
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Workspace-level sonar configuration loader.
3
+ *
4
+ * Every subsystem that can be made stricter or looser (the Stop
5
+ * quality gate, the `score_project` tool's `isError` flag) consults
6
+ * this loader to decide how hard to push back when a policy fails.
7
+ * Teams adopt claude-crap in stages:
8
+ *
9
+ * - `strict` (default) — the Stop hook exits 2 on any policy
10
+ * failure and the `score_project` tool returns `isError: true`.
11
+ * Matches the current, hard-coded behavior.
12
+ * - `warn` — the Stop hook exits 0 but writes the
13
+ * full verdict to stdout so the agent still sees every failing
14
+ * rule in its hook transcript. `score_project.isError` stays
15
+ * false even on a failing project.
16
+ * - `advisory` — the Stop hook exits 0 and writes a
17
+ * single-line summary. Minimal pressure on the agent.
18
+ *
19
+ * The loader resolves the `strictness` value in strict priority
20
+ * order so a team's committed default can be overridden per-session
21
+ * without editing the file:
22
+ *
23
+ * 1. `CLAUDE_CRAP_STRICTNESS` environment variable
24
+ * 2. `.claude-crap.json` at the workspace root
25
+ * 3. Hardcoded default `"strict"` (zero behavior change for
26
+ * installs that never create the file)
27
+ *
28
+ * The loader is intentionally tiny — it does a single synchronous
29
+ * file read, one optional env probe, and validates the string
30
+ * against the enum. A hook script can call it from inside its
31
+ * 15-second budget without breaking a sweat.
32
+ *
33
+ * @module crap-config
34
+ */
35
+
36
+ import { readFileSync } from "node:fs";
37
+ import { join } from "node:path";
38
+
39
+ /**
40
+ * Exhaustive list of valid strictness values. Keep this in sync with
41
+ * the `Strictness` type below — the tuple is `as const` so TypeScript
42
+ * derives the union from the same source of truth.
43
+ */
44
+ export const STRICTNESS_VALUES = ["strict", "warn", "advisory"] as const;
45
+
46
+ /**
47
+ * Union of valid strictness values. Used by every consumer of
48
+ * {@link CrapConfig} to branch on the mode without dealing with
49
+ * arbitrary strings.
50
+ */
51
+ export type Strictness = (typeof STRICTNESS_VALUES)[number];
52
+
53
+ /**
54
+ * Hardcoded default used when neither the environment variable nor
55
+ * `.claude-crap.json` provides a value. Chosen as `"strict"` so the
56
+ * plugin's hard-failing Stop gate stays the default experience.
57
+ */
58
+ export const DEFAULT_STRICTNESS: Strictness = "strict";
59
+
60
+ /**
61
+ * Thrown by {@link loadCrapConfig} when the configuration is
62
+ * rejected. Callers in the hook layer fall back to the default on a
63
+ * throw so a busted config never deadlocks the user, while callers
64
+ * in the MCP server surface the error verbatim.
65
+ */
66
+ export class CrapConfigError extends Error {
67
+ constructor(message: string) {
68
+ super(message);
69
+ this.name = "CrapConfigError";
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Structure of the resolved sonar configuration returned by
75
+ * {@link loadCrapConfig}. The shape is deliberately minimal for
76
+ * v0.1.0; future releases may add threshold overrides under the
77
+ * same `.claude-crap.json` file.
78
+ */
79
+ export interface CrapConfig {
80
+ /** Final strictness, after env override, file, and default fallback. */
81
+ readonly strictness: Strictness;
82
+ /** Where the strictness value actually came from. Useful for diagnostics. */
83
+ readonly strictnessSource: "env" | "file" | "default";
84
+ }
85
+
86
+ /**
87
+ * Options accepted by {@link loadCrapConfig}. The only required
88
+ * field is the workspace root the loader should search for
89
+ * `.claude-crap.json`.
90
+ */
91
+ export interface LoadCrapConfigOptions {
92
+ /**
93
+ * Absolute path to the workspace root. The loader reads
94
+ * `.claude-crap.json` from this directory only — it does not
95
+ * walk parent directories.
96
+ */
97
+ readonly workspaceRoot: string;
98
+ }
99
+
100
+ /**
101
+ * Resolve the effective sonar configuration for a given workspace
102
+ * root. Pure function except for the one synchronous file read on
103
+ * `<workspaceRoot>/.claude-crap.json` and the two env lookups.
104
+ *
105
+ * @param options Search options. Only `workspaceRoot` is required.
106
+ * @returns The resolved {@link CrapConfig}.
107
+ * @throws {@link CrapConfigError} on any invalid input.
108
+ */
109
+ export function loadCrapConfig(options: LoadCrapConfigOptions): CrapConfig {
110
+ const envRaw = process.env["CLAUDE_CRAP_STRICTNESS"];
111
+ if (typeof envRaw === "string" && envRaw.trim() !== "") {
112
+ const normalized = envRaw.trim().toLowerCase();
113
+ if (!isStrictness(normalized)) {
114
+ throw new CrapConfigError(
115
+ `[crap-config] CLAUDE_CRAP_STRICTNESS="${envRaw}" is not a valid strictness. ` +
116
+ `Expected one of: ${STRICTNESS_VALUES.join(", ")}.`,
117
+ );
118
+ }
119
+ return { strictness: normalized, strictnessSource: "env" };
120
+ }
121
+
122
+ const fromFile = readFromFile(options.workspaceRoot);
123
+ if (fromFile) return { strictness: fromFile, strictnessSource: "file" };
124
+
125
+ return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default" };
126
+ }
127
+
128
+ /**
129
+ * Attempt to read and validate `.claude-crap.json` at the
130
+ * workspace root. Returns `null` when the file is missing (which
131
+ * is the common case for fresh installs). Throws
132
+ * {@link CrapConfigError} on any other failure mode — a malformed
133
+ * JSON file, a non-object root, a missing or wrong-type
134
+ * `strictness` field, or an unknown enum value — so the caller
135
+ * cannot accidentally drop into the default on a typo.
136
+ *
137
+ * @param workspaceRoot Absolute workspace root.
138
+ * @returns The validated strictness, or `null` when no
139
+ * file is present.
140
+ */
141
+ function readFromFile(workspaceRoot: string): Strictness | null {
142
+ const filePath = join(workspaceRoot, ".claude-crap.json");
143
+ let raw: string;
144
+ try {
145
+ raw = readFileSync(filePath, "utf8");
146
+ } catch (err) {
147
+ const error = err as NodeJS.ErrnoException;
148
+ if (error.code === "ENOENT") return null;
149
+ throw new CrapConfigError(
150
+ `[crap-config] Failed to read ${filePath}: ${error.message}`,
151
+ );
152
+ }
153
+
154
+ let parsed: unknown;
155
+ try {
156
+ parsed = JSON.parse(raw);
157
+ } catch (err) {
158
+ throw new CrapConfigError(
159
+ `[crap-config] ${filePath} is not valid JSON: ${(err as Error).message}`,
160
+ );
161
+ }
162
+
163
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
164
+ throw new CrapConfigError(
165
+ `[crap-config] ${filePath} must be a JSON object at the top level`,
166
+ );
167
+ }
168
+ const doc = parsed as Record<string, unknown>;
169
+ if (!("strictness" in doc)) return null;
170
+
171
+ const value = doc["strictness"];
172
+ if (typeof value !== "string") {
173
+ throw new CrapConfigError(
174
+ `[crap-config] ${filePath}: 'strictness' must be a string, got ${typeof value}`,
175
+ );
176
+ }
177
+ const normalized = value.trim().toLowerCase();
178
+ if (!isStrictness(normalized)) {
179
+ throw new CrapConfigError(
180
+ `[crap-config] ${filePath}: 'strictness' is "${value}"; ` +
181
+ `expected one of ${STRICTNESS_VALUES.join(", ")}.`,
182
+ );
183
+ }
184
+ return normalized;
185
+ }
186
+
187
+ /**
188
+ * Runtime type guard for the {@link Strictness} union. Lets callers
189
+ * narrow an arbitrary string to the union without casting.
190
+ *
191
+ * @param value Arbitrary string.
192
+ * @returns `true` when `value` is a recognized strictness.
193
+ */
194
+ function isStrictness(value: string): value is Strictness {
195
+ return (STRICTNESS_VALUES as ReadonlyArray<string>).includes(value);
196
+ }