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,87 @@
1
+ /**
2
+ * Unit tests for the cyclomatic complexity walker.
3
+ *
4
+ * These tests use a hand-built mock AST that conforms to the minimal
5
+ * {@link AstNode} contract. That keeps the test hermetic — we do not
6
+ * have to load any WASM grammars or spin up the real tree-sitter engine.
7
+ *
8
+ * @module tests/cyclomatic.test
9
+ */
10
+
11
+ import { describe, it } from "node:test";
12
+ import assert from "node:assert/strict";
13
+
14
+ import { computeCyclomaticComplexity, type AstNode } from "../ast/cyclomatic.js";
15
+ import { LANGUAGE_TABLE } from "../ast/language-config.js";
16
+
17
+ /**
18
+ * Build a tiny AST node. Used only by the tests to construct synthetic
19
+ * trees without depending on tree-sitter. Both `type` and `children`
20
+ * must match what the walker expects; `text` is mostly for operator
21
+ * detection on `binary_expression` children.
22
+ */
23
+ function node(type: string, text = "", children: AstNode[] = []): AstNode {
24
+ return {
25
+ type,
26
+ text,
27
+ childCount: children.length,
28
+ child(index: number): AstNode | null {
29
+ return children[index] ?? null;
30
+ },
31
+ };
32
+ }
33
+
34
+ describe("computeCyclomaticComplexity", () => {
35
+ const ts = LANGUAGE_TABLE.typescript;
36
+ const python = LANGUAGE_TABLE.python;
37
+
38
+ it("returns 1 for a straight-line function", () => {
39
+ const root = node("function_declaration", "", [
40
+ node("return_statement", "return 1;"),
41
+ ]);
42
+ assert.equal(computeCyclomaticComplexity(root, ts), 1);
43
+ });
44
+
45
+ it("adds 1 per branching node", () => {
46
+ // Function with one `if` and one `for` → 1 + 2 = 3
47
+ const root = node("function_declaration", "", [
48
+ node("if_statement"),
49
+ node("for_statement"),
50
+ ]);
51
+ assert.equal(computeCyclomaticComplexity(root, ts), 3);
52
+ });
53
+
54
+ it("counts short-circuit operators", () => {
55
+ // Function with a binary_expression child whose operator is "&&"
56
+ const andOperator = node("&&", "&&");
57
+ const binary = node("binary_expression", "a && b", [
58
+ node("identifier", "a"),
59
+ andOperator,
60
+ node("identifier", "b"),
61
+ ]);
62
+ const root = node("function_declaration", "", [binary]);
63
+ assert.equal(computeCyclomaticComplexity(root, ts), 2);
64
+ });
65
+
66
+ it("does not count nested functions against the parent", () => {
67
+ // Outer function with one `if`, plus a nested function containing
68
+ // another `if`. The nested function's complexity must NOT bleed
69
+ // into the parent count (the parent should be 2, not 3).
70
+ const nestedIf = node("if_statement");
71
+ const nested = node("function_declaration", "", [nestedIf]);
72
+ const parentIf = node("if_statement");
73
+ const root = node("function_declaration", "", [parentIf, nested]);
74
+ assert.equal(computeCyclomaticComplexity(root, ts), 2);
75
+ });
76
+
77
+ it("recognizes python `and` and `or`", () => {
78
+ const andOp = node("and", "and");
79
+ const boolean = node("boolean_operator", "x and y", [
80
+ node("identifier", "x"),
81
+ andOp,
82
+ node("identifier", "y"),
83
+ ]);
84
+ const root = node("function_definition", "", [boolean]);
85
+ assert.equal(computeCyclomaticComplexity(root, python), 2);
86
+ });
87
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
4
+ import { promises as fs, statSync } from "node:fs";
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const HERE = dirname(fileURLToPath(import.meta.url));
11
+ const PLUGIN_ROOT = resolve(HERE, "..", "..");
12
+ const SERVER_ENTRY = process.env.SONAR_MCP_ENTRY
13
+ ? resolve(process.env.SONAR_MCP_ENTRY)
14
+ : join(PLUGIN_ROOT, "plugin", "bundle", "mcp-server.mjs");
15
+
16
+ let serverBuilt = false;
17
+ try {
18
+ statSync(SERVER_ENTRY);
19
+ serverBuilt = true;
20
+ } catch {
21
+ }
22
+
23
+ describe("dashboard HTTP characterization test", { skip: !serverBuilt }, () => {
24
+ let workspace = "";
25
+ let child: ChildProcessWithoutNullStreams | null = null;
26
+ let dashboardUrl = "";
27
+ let dashboardPromise: Promise<string>;
28
+
29
+ before(async () => {
30
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-dashboard-"));
31
+
32
+ const dashboardPort = 5700 + Math.floor(Math.random() * 300);
33
+
34
+ child = spawn(process.execPath, [SERVER_ENTRY, "--transport", "stdio"], {
35
+ stdio: ["pipe", "pipe", "pipe"],
36
+ env: {
37
+ ...process.env,
38
+ CLAUDE_CRAP_LOG_LEVEL: "info",
39
+ CLAUDE_CRAP_PLUGIN_ROOT: workspace,
40
+ CLAUDE_CRAP_DASHBOARD_PORT: String(dashboardPort),
41
+ },
42
+ });
43
+
44
+ dashboardPromise = new Promise((resolvePromise, rejectPromise) => {
45
+ let stderrBuffer = "";
46
+ child!.stderr.setEncoding("utf8");
47
+ child!.stderr.on("data", (chunk: string) => {
48
+ stderrBuffer += chunk;
49
+ const lines = stderrBuffer.split("\n");
50
+ stderrBuffer = lines.pop() ?? "";
51
+ for (const line of lines) {
52
+ if (!line.trim()) continue;
53
+ try {
54
+ const parsed = JSON.parse(line);
55
+ if (parsed.msg === "claude-crap dashboard listening" && parsed.url) {
56
+ resolvePromise(parsed.url);
57
+ }
58
+ } catch {
59
+ // ignore non-JSON or other logs
60
+ }
61
+ }
62
+ });
63
+ child!.on("error", rejectPromise);
64
+ setTimeout(() => rejectPromise(new Error("Timeout waiting for dashboard URL")), 5000);
65
+ });
66
+
67
+ // Send initialized handshake so MCP server allows the dashboard to hum along
68
+ const initReq = {
69
+ jsonrpc: "2.0",
70
+ id: 1,
71
+ method: "initialize",
72
+ params: {
73
+ protocolVersion: "2024-11-05",
74
+ capabilities: {},
75
+ clientInfo: { name: "integration-test", version: "0.0.1" },
76
+ },
77
+ };
78
+ child.stdin.write(JSON.stringify(initReq) + "\n");
79
+
80
+ dashboardUrl = await dashboardPromise;
81
+ });
82
+
83
+ after(async () => {
84
+ if (child && !child.killed) {
85
+ child.kill("SIGTERM");
86
+ let killed = false;
87
+ child.once("exit", () => { killed = true; });
88
+ await new Promise(r => setTimeout(r, 100)); // give it a moment
89
+ if (!killed) child.kill("SIGKILL");
90
+ }
91
+ if (workspace) await rm(workspace, { recursive: true, force: true });
92
+ });
93
+
94
+ it("fetches the dashboardUrl and asserts 200 OK + text/html", async () => {
95
+ assert.ok(dashboardUrl, "dashboardUrl missing");
96
+ const res = await fetch(dashboardUrl);
97
+ assert.equal(res.status, 200);
98
+ assert.match(res.headers.get("content-type") ?? "", /text\/html/);
99
+ });
100
+
101
+ it("fetches the dashboardUrl + /api/score and asserts 200 OK + JSON response", async () => {
102
+ const res = await fetch(dashboardUrl + "/api/score");
103
+ assert.equal(res.status, 200);
104
+ assert.match(res.headers.get("content-type") ?? "", /application\/json/);
105
+ const data = await res.json() as Record<string, unknown>;
106
+ assert.ok(data.overall);
107
+ });
108
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Supply-chain integrity tests for the local dashboard SPA.
3
+ *
4
+ * F-A03-01: the dashboard used to load Vue 3 from `unpkg.com` with NO
5
+ * Subresource Integrity attribute, while the surrounding HTML comment
6
+ * lied about it being "pinned". The fix bundles Vue under
7
+ * `src/dashboard/public/vendor/vue.global.prod.js` and rewrites the
8
+ * HTML to reference that local path. These tests pin both the
9
+ * characterization invariants (exactly one Vue runtime is loaded,
10
+ * dashboard HTML still contains the Vue entry point it expects) and
11
+ * the attack invariants (no external CDN reference anywhere in the
12
+ * file, no lingering "integrity hash is pinned" claim).
13
+ *
14
+ * @module tests/dashboard-integrity.test
15
+ */
16
+
17
+ import { describe, it } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { promises as fs } from "node:fs";
20
+ import { dirname, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+ const PUBLIC_DIR = resolve(HERE, "..", "dashboard", "public");
25
+ const INDEX_HTML = resolve(PUBLIC_DIR, "index.html");
26
+ const VENDOR_VUE = resolve(PUBLIC_DIR, "vendor", "vue.global.prod.js");
27
+
28
+ describe("dashboard HTML — characterization (still a Vue 3 SPA)", () => {
29
+ it("index.html exists and is non-trivial", async () => {
30
+ const stat = await fs.stat(INDEX_HTML);
31
+ assert.ok(stat.isFile());
32
+ assert.ok(stat.size > 1000, "index.html should not be empty");
33
+ });
34
+
35
+ it("still declares exactly one Vue runtime <script> tag", async () => {
36
+ const html = await fs.readFile(INDEX_HTML, "utf8");
37
+ const matches = html.match(/<script[^>]*vue\.global\.prod\.js[^>]*>/g) ?? [];
38
+ assert.equal(matches.length, 1, "exactly one Vue runtime script tag must be present");
39
+ });
40
+
41
+ it("still mounts the Vue app with createApp + #app (characterization)", async () => {
42
+ const html = await fs.readFile(INDEX_HTML, "utf8");
43
+ assert.match(html, /createApp\(\{/);
44
+ assert.match(html, /mount\("#app"\)/);
45
+ });
46
+ });
47
+
48
+ describe("dashboard HTML — F-A03-01 attack invariants", () => {
49
+ it("does NOT fetch Vue from any external http(s) CDN", async () => {
50
+ const html = await fs.readFile(INDEX_HTML, "utf8");
51
+ // Any <script src="http(s)://..."> that points at a Vue file is a CDN load.
52
+ const external = html.match(
53
+ /<script[^>]*src=["']https?:\/\/[^"']*vue[^"']*["'][^>]*>/gi,
54
+ );
55
+ assert.equal(
56
+ external,
57
+ null,
58
+ `the dashboard must not fetch Vue from a CDN (found: ${JSON.stringify(external)})`,
59
+ );
60
+ });
61
+
62
+ it("does NOT contain any <script> element sourced from unpkg.com", async () => {
63
+ const html = await fs.readFile(INDEX_HTML, "utf8");
64
+ // Only flag actual <script src="...unpkg.com..."> loads; documentation
65
+ // strings inside HTML comments (e.g. a pointer to where to refresh the
66
+ // vendored Vue file from) are explicitly allowed.
67
+ const externalScript = html.match(
68
+ /<script[^>]*src=["'][^"']*unpkg\.com[^"']*["'][^>]*>/gi,
69
+ );
70
+ assert.equal(
71
+ externalScript,
72
+ null,
73
+ `no <script> element may load from unpkg.com (found: ${JSON.stringify(externalScript)})`,
74
+ );
75
+ });
76
+
77
+ it("does NOT claim the integrity hash is pinned (old lying comment gone)", async () => {
78
+ const html = await fs.readFile(INDEX_HTML, "utf8");
79
+ // The pre-fix comment said: "The script integrity hash is pinned so
80
+ // an upstream CDN cannot silently swap the file." — that claim was
81
+ // false because the <script> tag had no `integrity=` attribute.
82
+ assert.equal(
83
+ html.includes("integrity hash is pinned"),
84
+ false,
85
+ "the old CDN integrity claim must not survive the bundle-locally fix",
86
+ );
87
+ });
88
+ });
89
+
90
+ describe("dashboard HTML — F-A03-01 vendored Vue runtime", () => {
91
+ it("bundles vue.global.prod.js under the vendor directory", async () => {
92
+ const stat = await fs.stat(VENDOR_VUE);
93
+ assert.ok(stat.isFile(), "vue.global.prod.js must exist under src/dashboard/public/vendor/");
94
+ // The production Vue 3 runtime is ~50-170 KB depending on version;
95
+ // a sanity lower bound catches an accidentally-empty or truncated
96
+ // vendor file.
97
+ assert.ok(
98
+ stat.size > 50_000,
99
+ `expected Vue runtime > 50KB, got ${stat.size} bytes`,
100
+ );
101
+ });
102
+
103
+ it("the vendored Vue file actually exports a Vue runtime", async () => {
104
+ const content = await fs.readFile(VENDOR_VUE, "utf8");
105
+ // The production global build exposes `Vue` on the window object and
106
+ // contains its own copyright banner; both are stable markers across
107
+ // the 3.x minor versions we care about.
108
+ assert.match(
109
+ content,
110
+ /Vue/,
111
+ "the vendored file does not look like a Vue runtime",
112
+ );
113
+ assert.match(
114
+ content,
115
+ /createApp/,
116
+ "the vendored Vue runtime must expose createApp",
117
+ );
118
+ });
119
+
120
+ it("index.html references the local vendor path", async () => {
121
+ const html = await fs.readFile(INDEX_HTML, "utf8");
122
+ assert.match(
123
+ html,
124
+ /src=["'](\.\/)?vendor\/vue\.global\.prod\.js["']/,
125
+ "index.html must reference vendor/vue.global.prod.js locally",
126
+ );
127
+ });
128
+ });
@@ -0,0 +1,352 @@
1
+ /**
2
+ * End-to-end integration tests for the compiled MCP server.
3
+ *
4
+ * Unlike the rest of the test suite (which exercises pure engine
5
+ * modules in isolation), these tests spawn the bundled MCP server
6
+ * (`plugin/bundle/mcp-server.mjs`) as a child process, speak
7
+ * JSON-RPC to it over stdio, and verify that the full server works
8
+ * when it is actually running:
9
+ *
10
+ * - `initialize` + `notifications/initialized` handshake
11
+ * - `tools/list` returns every registered tool with its schema
12
+ * - `tools/call compute_crap` returns the exact deterministic value
13
+ * the unit test produces (21.216 for CC=12, cov=60, threshold=30)
14
+ * - `tools/call score_project` returns a markdown + JSON summary
15
+ * and marks the project as passing its policy
16
+ * - `tools/call require_test_harness` returns `hasTest: true` for
17
+ * a file that has a matching test (crap.ts → tests/crap.test.ts)
18
+ * and `isError: true` / `hasTest: false` for a file that does not
19
+ * - `resources/list` + `resources/read` round-trip the two resources
20
+ *
21
+ * This suite exercises `src/index.ts` end-to-end on every `npm test`
22
+ * run, so the JSON-RPC wiring, the resource handlers, and the
23
+ * error-boundary shaping all stay covered even though every
24
+ * individual engine already has its own unit tests.
25
+ *
26
+ * The test skips cleanly when the bundle does not exist — that
27
+ * happens during `tsx`-based dev runs before `npm run build:plugin`
28
+ * has been run. `npm test` builds via postinstall or can be preceded
29
+ * by `npm run build:plugin`.
30
+ *
31
+ * @module tests/integration/mcp-server.integration.test
32
+ */
33
+
34
+ import { describe, it, before, after } from "node:test";
35
+ import assert from "node:assert/strict";
36
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
37
+ import { promises as fs, statSync } from "node:fs";
38
+ import { mkdtemp, rm } from "node:fs/promises";
39
+ import { tmpdir } from "node:os";
40
+ import { dirname, join, resolve } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+
43
+ // Resolve the plugin root from `import.meta.url` so the test works
44
+ // regardless of whether it is run via tsx or from `dist/tests/`.
45
+ const HERE = dirname(fileURLToPath(import.meta.url));
46
+ const PLUGIN_ROOT = resolve(HERE, "..", "..", "..");
47
+ const SERVER_ENTRY = process.env.SONAR_MCP_ENTRY
48
+ ? resolve(process.env.SONAR_MCP_ENTRY)
49
+ : join(PLUGIN_ROOT, "plugin", "bundle", "mcp-server.mjs");
50
+
51
+ // Synchronously probe for the compiled server entry at module load
52
+ // time so we can pass `{ skip }` to describe(). Passing an async
53
+ // callback to describe() causes node:test to race the test runner
54
+ // against the unawaited registration — we learned this the hard way.
55
+ let serverBuilt = false;
56
+ try {
57
+ statSync(SERVER_ENTRY);
58
+ serverBuilt = true;
59
+ } catch {
60
+ // dist/ is missing — this is normal on a fresh tsx-only dev loop
61
+ // and we'll skip the entire integration suite. `npm run build`
62
+ // (or the npm postinstall hook) will make it run next time.
63
+ }
64
+
65
+ /**
66
+ * Thin JSON-RPC client that writes newline-delimited frames to the
67
+ * MCP server's stdin and collects the responses from its stdout.
68
+ *
69
+ * We do not implement the full MCP SDK client because the surface we
70
+ * need is tiny: send a request, await the matching response, compare.
71
+ */
72
+ class StdioClient {
73
+ private readonly child: ChildProcessWithoutNullStreams;
74
+ private stdoutBuffer = "";
75
+ private readonly pending = new Map<number, (msg: unknown) => void>();
76
+ private nextId = 1;
77
+
78
+ constructor(child: ChildProcessWithoutNullStreams) {
79
+ this.child = child;
80
+ this.child.stdout.setEncoding("utf8");
81
+ this.child.stdout.on("data", (chunk: string) => this.onData(chunk));
82
+ }
83
+
84
+ private onData(chunk: string): void {
85
+ this.stdoutBuffer += chunk;
86
+ // Consume newline-delimited JSON frames out of the buffer.
87
+ let newlineIdx = this.stdoutBuffer.indexOf("\n");
88
+ while (newlineIdx !== -1) {
89
+ const line = this.stdoutBuffer.slice(0, newlineIdx).trim();
90
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIdx + 1);
91
+ newlineIdx = this.stdoutBuffer.indexOf("\n");
92
+ if (!line) continue;
93
+ let msg: unknown;
94
+ try {
95
+ msg = JSON.parse(line);
96
+ } catch {
97
+ continue; // ignore non-JSON garbage (there should not be any)
98
+ }
99
+ const id = (msg as { id?: number }).id;
100
+ if (typeof id === "number" && this.pending.has(id)) {
101
+ const resolver = this.pending.get(id);
102
+ this.pending.delete(id);
103
+ resolver?.(msg);
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Send a JSON-RPC notification (no response expected).
110
+ */
111
+ notify(method: string, params?: Record<string, unknown>): void {
112
+ const frame = { jsonrpc: "2.0", method, ...(params ? { params } : {}) };
113
+ this.child.stdin.write(JSON.stringify(frame) + "\n");
114
+ }
115
+
116
+ /**
117
+ * Send a JSON-RPC request and resolve with its response (or timeout).
118
+ */
119
+ request<T = unknown>(
120
+ method: string,
121
+ params: Record<string, unknown> = {},
122
+ timeoutMs = 10_000,
123
+ ): Promise<T> {
124
+ const id = this.nextId++;
125
+ return new Promise<T>((resolvePromise, rejectPromise) => {
126
+ const timer = setTimeout(() => {
127
+ this.pending.delete(id);
128
+ rejectPromise(new Error(`JSON-RPC timeout waiting for ${method}#${id}`));
129
+ }, timeoutMs);
130
+ this.pending.set(id, (msg) => {
131
+ clearTimeout(timer);
132
+ resolvePromise(msg as T);
133
+ });
134
+ const frame = { jsonrpc: "2.0", id, method, params };
135
+ this.child.stdin.write(JSON.stringify(frame) + "\n");
136
+ });
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Extract the plain `text` of the first content block in a tools/call
142
+ * response and parse it as JSON. Every claude-crap tool returns its
143
+ * primary payload as a JSON-stringified text block, so this is the
144
+ * standard way to read the data.
145
+ */
146
+ function parseFirstContentAsJson(response: unknown): Record<string, unknown> {
147
+ const r = response as {
148
+ result?: { content?: Array<{ type: string; text: string }> };
149
+ };
150
+ const first = r.result?.content?.[0];
151
+ assert.ok(first, "tool call returned no content");
152
+ assert.equal(first.type, "text");
153
+ return JSON.parse(first.text) as Record<string, unknown>;
154
+ }
155
+
156
+ describe("MCP server integration", { skip: !serverBuilt }, () => {
157
+ let workspace = "";
158
+ let child: ChildProcessWithoutNullStreams | null = null;
159
+ let client: StdioClient | null = null;
160
+ const capturedStderr: string[] = [];
161
+
162
+ before(async () => {
163
+ // Build a throwaway workspace that mirrors the shape of a real
164
+ // project. We populate it with a production source file that has
165
+ // a matching test and another that does not, so the
166
+ // `require_test_harness` assertions exercise both branches.
167
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-integ-"));
168
+ await fs.mkdir(join(workspace, "src"), { recursive: true });
169
+ await fs.writeFile(join(workspace, "src", "foo.ts"), "export const foo = 1;\n");
170
+ await fs.writeFile(join(workspace, "src", "foo.test.ts"), "// test\n");
171
+ await fs.writeFile(join(workspace, "src", "no-test.ts"), "export const bar = 2;\n");
172
+
173
+ // Pick a high-ish port to avoid clashing with the developer's own
174
+ // running plugin. The dashboard is best-effort, so a collision
175
+ // would still let the MCP server boot — but a clean port keeps
176
+ // the test output clean too.
177
+ const dashboardPort = 5200 + Math.floor(Math.random() * 300);
178
+
179
+ child = spawn(process.execPath, [SERVER_ENTRY, "--transport", "stdio"], {
180
+ stdio: ["pipe", "pipe", "pipe"],
181
+ env: {
182
+ ...process.env,
183
+ CLAUDE_CRAP_LOG_LEVEL: "error",
184
+ CLAUDE_CRAP_PLUGIN_ROOT: workspace,
185
+ CLAUDE_CRAP_DASHBOARD_PORT: String(dashboardPort),
186
+ },
187
+ });
188
+ child.stderr.setEncoding("utf8");
189
+ child.stderr.on("data", (chunk: string) => {
190
+ capturedStderr.push(chunk);
191
+ });
192
+ client = new StdioClient(child);
193
+
194
+ // The MCP protocol requires an initialize handshake before any
195
+ // other request. We send it and discard the result — the tool
196
+ // list test below re-asserts the negotiated protocol version.
197
+ await client.request("initialize", {
198
+ protocolVersion: "2024-11-05",
199
+ capabilities: {},
200
+ clientInfo: { name: "integration-test", version: "0.0.1" },
201
+ });
202
+ client.notify("notifications/initialized");
203
+ });
204
+
205
+ after(async () => {
206
+ if (child && !child.killed) {
207
+ // Await the child's actual `exit` event instead of sleeping
208
+ // with a fixed timer. node:test keeps the event loop alive as
209
+ // long as any spawned child is still referenced, so without
210
+ // this the test runner would hang for several seconds after
211
+ // the last assertion even though every test already passed.
212
+ // SIGKILL after 1.5 s is a safety net for pathological hangs.
213
+ const exited = new Promise<void>((resolvePromise) => {
214
+ child!.once("exit", () => resolvePromise());
215
+ });
216
+ child.kill("SIGTERM");
217
+ const killTimer = setTimeout(() => {
218
+ if (child && !child.killed) child.kill("SIGKILL");
219
+ }, 1500);
220
+ await exited;
221
+ clearTimeout(killTimer);
222
+ }
223
+ if (workspace) await rm(workspace, { recursive: true, force: true });
224
+ });
225
+
226
+ it("boots cleanly and returns a protocol version on initialize", async () => {
227
+ // The before() block already initialized. Re-init should fail or
228
+ // return the same version — either outcome tells us the server
229
+ // is alive. We just assert that our stdio client's state shows
230
+ // stdout/stdin are both open.
231
+ assert.ok(client, "client should be constructed");
232
+ assert.ok(child && !child.killed, "server child should be running");
233
+ });
234
+
235
+ it("exposes all seven tools via tools/list", async () => {
236
+ const response = await client!.request<{ result?: { tools?: Array<{ name: string }> } }>(
237
+ "tools/list",
238
+ );
239
+ const names = (response.result?.tools ?? []).map((t) => t.name).sort();
240
+ assert.deepEqual(names, [
241
+ "analyze_file_ast",
242
+ "compute_crap",
243
+ "compute_tdr",
244
+ "ingest_sarif",
245
+ "ingest_scanner_output",
246
+ "require_test_harness",
247
+ "score_project",
248
+ ]);
249
+ });
250
+
251
+ it("compute_crap returns the exact deterministic value (CC=12, cov=60 → 21.216)", async () => {
252
+ const response = await client!.request("tools/call", {
253
+ name: "compute_crap",
254
+ arguments: {
255
+ cyclomaticComplexity: 12,
256
+ coveragePercent: 60,
257
+ functionName: "foo",
258
+ filePath: "src/foo.ts",
259
+ },
260
+ });
261
+ const payload = parseFirstContentAsJson(response);
262
+ assert.equal(payload.crap, 21.216);
263
+ assert.equal(payload.exceedsThreshold, false);
264
+ // isError is only set on failure — should be absent or false here.
265
+ const isError = (response as { result?: { isError?: boolean } }).result?.isError;
266
+ assert.notEqual(isError, true);
267
+ });
268
+
269
+ it("score_project returns a markdown + json block and a dashboard URL", async () => {
270
+ const response = await client!.request<{
271
+ result?: { content?: Array<{ type: string; text: string }> };
272
+ }>("tools/call", { name: "score_project", arguments: { format: "both" } });
273
+ const blocks = response.result?.content ?? [];
274
+ assert.equal(blocks.length, 2, "expected markdown + json");
275
+ assert.match(blocks[0]?.text ?? "", /## claude-crap :: project score/);
276
+ assert.match(blocks[0]?.text ?? "", /\*\*Overall: A\*\*/);
277
+
278
+ const json = JSON.parse(blocks[1]?.text ?? "{}") as {
279
+ overall: { rating: string; passes: boolean };
280
+ location: { dashboardUrl: string | null };
281
+ loc: { physical: number; files: number };
282
+ };
283
+ assert.equal(json.overall.rating, "A");
284
+ assert.equal(json.overall.passes, true);
285
+ assert.ok(json.location.dashboardUrl?.startsWith("http://127.0.0.1:"));
286
+ assert.ok(json.loc.physical > 0);
287
+ assert.ok(json.loc.files > 0);
288
+ });
289
+
290
+ it("require_test_harness finds a sibling test for src/foo.ts", async () => {
291
+ const response = await client!.request("tools/call", {
292
+ name: "require_test_harness",
293
+ arguments: { filePath: "src/foo.ts" },
294
+ });
295
+ const payload = parseFirstContentAsJson(response);
296
+ assert.equal(payload.hasTest, true);
297
+ assert.ok(typeof payload.testFile === "string");
298
+ const isError = (response as { result?: { isError?: boolean } }).result?.isError;
299
+ assert.notEqual(isError, true);
300
+ });
301
+
302
+ it("require_test_harness flags src/no-test.ts as a Golden Rule violation", async () => {
303
+ const response = await client!.request<{
304
+ result?: { isError?: boolean; content?: Array<{ type: string; text: string }> };
305
+ }>("tools/call", {
306
+ name: "require_test_harness",
307
+ arguments: { filePath: "src/no-test.ts" },
308
+ });
309
+ assert.equal(response.result?.isError, true);
310
+ const payload = parseFirstContentAsJson(response);
311
+ assert.equal(payload.hasTest, false);
312
+ assert.ok(typeof payload.corrective === "string");
313
+ });
314
+
315
+ it("resources/list exposes both sonar:// resources", async () => {
316
+ const response = await client!.request<{
317
+ result?: { resources?: Array<{ uri: string }> };
318
+ }>("resources/list");
319
+ const uris = (response.result?.resources ?? []).map((r) => r.uri).sort();
320
+ assert.deepEqual(uris, ["sonar://metrics/current", "sonar://reports/latest.sarif"]);
321
+ });
322
+
323
+ it("resources/read returns a SARIF 2.1.0 document for latest.sarif", async () => {
324
+ const response = await client!.request<{
325
+ result?: { contents?: Array<{ text: string }> };
326
+ }>("resources/read", { uri: "sonar://reports/latest.sarif" });
327
+ const text = response.result?.contents?.[0]?.text ?? "";
328
+ const doc = JSON.parse(text) as { version: string; runs: unknown[] };
329
+ assert.equal(doc.version, "2.1.0");
330
+ assert.ok(Array.isArray(doc.runs));
331
+ });
332
+
333
+ it("resources/read sonar://metrics/current returns a JSON snapshot", async () => {
334
+ const response = await client!.request<{
335
+ result?: { contents?: Array<{ text: string }> };
336
+ }>("resources/read", { uri: "sonar://metrics/current" });
337
+ const text = response.result?.contents?.[0]?.text ?? "";
338
+ const doc = JSON.parse(text) as Record<string, unknown>;
339
+ assert.ok(typeof doc.generatedAt === "string");
340
+ assert.ok(doc.sarif && typeof doc.sarif === "object");
341
+ assert.ok(doc.tdrApprox && typeof doc.tdrApprox === "object");
342
+ });
343
+
344
+ it("rejects unknown tools with a proper JSON-RPC error", async () => {
345
+ const response = await client!.request<{ error?: { message?: string } }>(
346
+ "tools/call",
347
+ { name: "no_such_tool", arguments: {} },
348
+ );
349
+ assert.ok(response.error, "expected an error response for unknown tool");
350
+ assert.match(response.error?.message ?? "", /Unknown tool/);
351
+ });
352
+ });