claude-crap 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +308 -0
- package/LICENSE +21 -0
- package/README.md +550 -0
- package/bin/claude-crap.mjs +141 -0
- package/dist/adapters/bandit.d.ts +48 -0
- package/dist/adapters/bandit.d.ts.map +1 -0
- package/dist/adapters/bandit.js +145 -0
- package/dist/adapters/bandit.js.map +1 -0
- package/dist/adapters/common.d.ts +73 -0
- package/dist/adapters/common.d.ts.map +1 -0
- package/dist/adapters/common.js +78 -0
- package/dist/adapters/common.js.map +1 -0
- package/dist/adapters/eslint.d.ts +52 -0
- package/dist/adapters/eslint.d.ts.map +1 -0
- package/dist/adapters/eslint.js +142 -0
- package/dist/adapters/eslint.js.map +1 -0
- package/dist/adapters/index.d.ts +47 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +64 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/semgrep.d.ts +30 -0
- package/dist/adapters/semgrep.d.ts.map +1 -0
- package/dist/adapters/semgrep.js +130 -0
- package/dist/adapters/semgrep.js.map +1 -0
- package/dist/adapters/stryker.d.ts +55 -0
- package/dist/adapters/stryker.d.ts.map +1 -0
- package/dist/adapters/stryker.js +165 -0
- package/dist/adapters/stryker.js.map +1 -0
- package/dist/ast/cyclomatic.d.ts +48 -0
- package/dist/ast/cyclomatic.d.ts.map +1 -0
- package/dist/ast/cyclomatic.js +106 -0
- package/dist/ast/cyclomatic.js.map +1 -0
- package/dist/ast/index.d.ts +26 -0
- package/dist/ast/index.d.ts.map +1 -0
- package/dist/ast/index.js +23 -0
- package/dist/ast/index.js.map +1 -0
- package/dist/ast/language-config.d.ts +70 -0
- package/dist/ast/language-config.d.ts.map +1 -0
- package/dist/ast/language-config.js +192 -0
- package/dist/ast/language-config.js.map +1 -0
- package/dist/ast/tree-sitter-engine.d.ts +133 -0
- package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
- package/dist/ast/tree-sitter-engine.js +270 -0
- package/dist/ast/tree-sitter-engine.js.map +1 -0
- package/dist/config.d.ts +57 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +78 -0
- package/dist/config.js.map +1 -0
- package/dist/crap-config.d.ts +97 -0
- package/dist/crap-config.d.ts.map +1 -0
- package/dist/crap-config.js +144 -0
- package/dist/crap-config.js.map +1 -0
- package/dist/dashboard/server.d.ts +65 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +147 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +574 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/crap.d.ts +71 -0
- package/dist/metrics/crap.d.ts.map +1 -0
- package/dist/metrics/crap.js +67 -0
- package/dist/metrics/crap.js.map +1 -0
- package/dist/metrics/index.d.ts +31 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +27 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/score.d.ts +143 -0
- package/dist/metrics/score.d.ts.map +1 -0
- package/dist/metrics/score.js +224 -0
- package/dist/metrics/score.js.map +1 -0
- package/dist/metrics/tdr.d.ts +106 -0
- package/dist/metrics/tdr.d.ts.map +1 -0
- package/dist/metrics/tdr.js +117 -0
- package/dist/metrics/tdr.js.map +1 -0
- package/dist/metrics/workspace-walker.d.ts +43 -0
- package/dist/metrics/workspace-walker.d.ts.map +1 -0
- package/dist/metrics/workspace-walker.js +137 -0
- package/dist/metrics/workspace-walker.js.map +1 -0
- package/dist/sarif/index.d.ts +21 -0
- package/dist/sarif/index.d.ts.map +1 -0
- package/dist/sarif/index.js +19 -0
- package/dist/sarif/index.js.map +1 -0
- package/dist/sarif/sarif-builder.d.ts +128 -0
- package/dist/sarif/sarif-builder.d.ts.map +1 -0
- package/dist/sarif/sarif-builder.js +79 -0
- package/dist/sarif/sarif-builder.js.map +1 -0
- package/dist/sarif/sarif-store.d.ts +205 -0
- package/dist/sarif/sarif-store.d.ts.map +1 -0
- package/dist/sarif/sarif-store.js +246 -0
- package/dist/sarif/sarif-store.js.map +1 -0
- package/dist/sarif/sarif-validator.d.ts +45 -0
- package/dist/sarif/sarif-validator.d.ts.map +1 -0
- package/dist/sarif/sarif-validator.js +138 -0
- package/dist/sarif/sarif-validator.js.map +1 -0
- package/dist/schemas/tool-schemas.d.ts +216 -0
- package/dist/schemas/tool-schemas.d.ts.map +1 -0
- package/dist/schemas/tool-schemas.js +208 -0
- package/dist/schemas/tool-schemas.js.map +1 -0
- package/dist/sdk.d.ts +45 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +44 -0
- package/dist/sdk.js.map +1 -0
- package/dist/tools/index.d.ts +24 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/test-harness.d.ts +75 -0
- package/dist/tools/test-harness.d.ts.map +1 -0
- package/dist/tools/test-harness.js +137 -0
- package/dist/tools/test-harness.js.map +1 -0
- package/dist/workspace-guard.d.ts +53 -0
- package/dist/workspace-guard.d.ts.map +1 -0
- package/dist/workspace-guard.js +61 -0
- package/dist/workspace-guard.js.map +1 -0
- package/package.json +133 -0
- package/plugin/.claude-plugin/plugin.json +29 -0
- package/plugin/.mcp.json +18 -0
- package/plugin/CLAUDE.md +143 -0
- package/plugin/bundle/dashboard/public/index.html +368 -0
- package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
- package/plugin/bundle/mcp-server.mjs +8718 -0
- package/plugin/bundle/mcp-server.mjs.map +7 -0
- package/plugin/bundle/tdr-engine.mjs +50 -0
- package/plugin/bundle/tdr-engine.mjs.map +7 -0
- package/plugin/hooks/hooks.json +62 -0
- package/plugin/hooks/lib/crap-config.mjs +152 -0
- package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
- package/plugin/hooks/lib/hook-io.mjs +151 -0
- package/plugin/hooks/lib/quality-gate.mjs +329 -0
- package/plugin/hooks/lib/test-harness.mjs +152 -0
- package/plugin/hooks/post-tool-use.mjs +245 -0
- package/plugin/hooks/pre-tool-use.mjs +290 -0
- package/plugin/hooks/session-start.mjs +109 -0
- package/plugin/hooks/stop-quality-gate.mjs +226 -0
- package/plugin/package.json +18 -0
- package/plugin/skills/adopt/SKILL.md +74 -0
- package/plugin/skills/analyze/SKILL.md +77 -0
- package/plugin/skills/check-test/SKILL.md +50 -0
- package/plugin/skills/score/SKILL.md +31 -0
- package/scripts/bug-report.mjs +328 -0
- package/scripts/build-fast.mjs +130 -0
- package/scripts/bundle-plugin.mjs +74 -0
- package/scripts/doctor.mjs +320 -0
- package/scripts/install.mjs +192 -0
- package/scripts/lib/cli-ui.mjs +122 -0
- package/scripts/postinstall.mjs +127 -0
- package/scripts/run-tests.mjs +95 -0
- package/scripts/status.mjs +110 -0
- package/scripts/uninstall.mjs +72 -0
- package/src/adapters/bandit.ts +191 -0
- package/src/adapters/common.ts +133 -0
- package/src/adapters/eslint.ts +187 -0
- package/src/adapters/index.ts +78 -0
- package/src/adapters/semgrep.ts +150 -0
- package/src/adapters/stryker.ts +218 -0
- package/src/ast/cyclomatic.ts +131 -0
- package/src/ast/index.ts +33 -0
- package/src/ast/language-config.ts +231 -0
- package/src/ast/tree-sitter-engine.ts +385 -0
- package/src/config.ts +109 -0
- package/src/crap-config.ts +196 -0
- package/src/dashboard/public/index.html +368 -0
- package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
- package/src/dashboard/server.ts +205 -0
- package/src/index.ts +696 -0
- package/src/metrics/crap.ts +101 -0
- package/src/metrics/index.ts +51 -0
- package/src/metrics/score.ts +329 -0
- package/src/metrics/tdr.ts +155 -0
- package/src/metrics/workspace-walker.ts +146 -0
- package/src/sarif/index.ts +31 -0
- package/src/sarif/sarif-builder.ts +139 -0
- package/src/sarif/sarif-store.ts +347 -0
- package/src/sarif/sarif-validator.ts +145 -0
- package/src/schemas/tool-schemas.ts +225 -0
- package/src/sdk.ts +110 -0
- package/src/tests/adapters/bandit.test.ts +111 -0
- package/src/tests/adapters/dispatch.test.ts +100 -0
- package/src/tests/adapters/eslint.test.ts +138 -0
- package/src/tests/adapters/semgrep.test.ts +125 -0
- package/src/tests/adapters/stryker.test.ts +103 -0
- package/src/tests/crap-config.test.ts +228 -0
- package/src/tests/crap.test.ts +59 -0
- package/src/tests/cyclomatic.test.ts +87 -0
- package/src/tests/dashboard-http.test.ts +108 -0
- package/src/tests/dashboard-integrity.test.ts +128 -0
- package/src/tests/integration/mcp-server.integration.test.ts +352 -0
- package/src/tests/pre-tool-use-hook.test.ts +178 -0
- package/src/tests/sarif-store.test.ts +241 -0
- package/src/tests/sarif-validator.test.ts +164 -0
- package/src/tests/score.test.ts +260 -0
- package/src/tests/skills-frontmatter.test.ts +172 -0
- package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
- package/src/tests/tdr.test.ts +86 -0
- package/src/tests/test-harness.test.ts +153 -0
- package/src/tests/workspace-guard.test.ts +111 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/test-harness.ts +158 -0
- package/src/workspace-guard.ts +64 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,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
|
+
});
|