claude-crap 0.3.7 → 0.4.0
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 +33 -0
- package/README.md +74 -7
- package/dist/adapters/common.d.ts +1 -1
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +1 -1
- package/dist/adapters/common.js.map +1 -1
- package/dist/adapters/dart-analyzer.d.ts +41 -0
- package/dist/adapters/dart-analyzer.d.ts.map +1 -0
- package/dist/adapters/dart-analyzer.js +120 -0
- package/dist/adapters/dart-analyzer.js.map +1 -0
- package/dist/adapters/dotnet-format.d.ts +35 -0
- package/dist/adapters/dotnet-format.d.ts.map +1 -0
- package/dist/adapters/dotnet-format.js +96 -0
- package/dist/adapters/dotnet-format.js.map +1 -0
- package/dist/adapters/index.d.ts +2 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/crap-config.d.ts +4 -0
- package/dist/crap-config.d.ts.map +1 -1
- package/dist/crap-config.js +51 -28
- package/dist/crap-config.js.map +1 -1
- package/dist/dashboard/file-detail.d.ts.map +1 -1
- package/dist/dashboard/file-detail.js.map +1 -1
- package/dist/dashboard/server.d.ts +2 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +7 -12
- package/dist/dashboard/server.js.map +1 -1
- package/dist/index.js +89 -5
- package/dist/index.js.map +1 -1
- package/dist/metrics/workspace-walker.d.ts +4 -1
- package/dist/metrics/workspace-walker.d.ts.map +1 -1
- package/dist/metrics/workspace-walker.js +12 -28
- package/dist/metrics/workspace-walker.js.map +1 -1
- package/dist/monorepo/project-map.d.ts +112 -0
- package/dist/monorepo/project-map.d.ts.map +1 -0
- package/dist/monorepo/project-map.js +384 -0
- package/dist/monorepo/project-map.js.map +1 -0
- package/dist/scanner/auto-scan.d.ts +1 -0
- package/dist/scanner/auto-scan.d.ts.map +1 -1
- package/dist/scanner/auto-scan.js +14 -5
- package/dist/scanner/auto-scan.js.map +1 -1
- package/dist/scanner/bootstrap.d.ts +1 -1
- package/dist/scanner/bootstrap.d.ts.map +1 -1
- package/dist/scanner/bootstrap.js +15 -1
- package/dist/scanner/bootstrap.js.map +1 -1
- package/dist/scanner/complexity-scanner.d.ts +2 -0
- package/dist/scanner/complexity-scanner.d.ts.map +1 -1
- package/dist/scanner/complexity-scanner.js +11 -26
- package/dist/scanner/complexity-scanner.js.map +1 -1
- package/dist/scanner/detector.d.ts +24 -4
- package/dist/scanner/detector.d.ts.map +1 -1
- package/dist/scanner/detector.js +110 -10
- package/dist/scanner/detector.js.map +1 -1
- package/dist/scanner/runner.d.ts +4 -1
- package/dist/scanner/runner.d.ts.map +1 -1
- package/dist/scanner/runner.js +25 -3
- package/dist/scanner/runner.js.map +1 -1
- package/dist/schemas/tool-schemas.d.ts +16 -1
- package/dist/schemas/tool-schemas.d.ts.map +1 -1
- package/dist/schemas/tool-schemas.js +16 -1
- package/dist/schemas/tool-schemas.js.map +1 -1
- package/dist/shared/exclusions.d.ts +53 -0
- package/dist/shared/exclusions.d.ts.map +1 -0
- package/dist/shared/exclusions.js +126 -0
- package/dist/shared/exclusions.js.map +1 -0
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +37 -0
- package/plugin/bundle/mcp-server.mjs +762 -144
- package/plugin/bundle/mcp-server.mjs.map +4 -4
- package/plugin/package-lock.json +15 -2
- package/plugin/package.json +2 -1
- package/scripts/bundle-plugin.mjs +2 -1
- package/src/adapters/common.ts +1 -1
- package/src/adapters/dart-analyzer.ts +161 -0
- package/src/adapters/dotnet-format.ts +125 -0
- package/src/adapters/index.ts +8 -0
- package/src/crap-config.ts +78 -18
- package/src/dashboard/file-detail.ts +0 -2
- package/src/dashboard/server.ts +9 -10
- package/src/index.ts +103 -5
- package/src/metrics/workspace-walker.ts +15 -27
- package/src/monorepo/project-map.ts +476 -0
- package/src/scanner/auto-scan.ts +17 -6
- package/src/scanner/bootstrap.ts +18 -1
- package/src/scanner/complexity-scanner.ts +15 -26
- package/src/scanner/detector.ts +119 -10
- package/src/scanner/runner.ts +25 -2
- package/src/schemas/tool-schemas.ts +17 -1
- package/src/shared/exclusions.ts +156 -0
- package/src/tests/adapters/dispatch.test.ts +2 -2
- package/src/tests/auto-scan.test.ts +2 -2
- package/src/tests/boot-monorepo.test.ts +804 -0
- package/src/tests/boot-scanner-detection.test.ts +692 -0
- package/src/tests/boot-single-project.test.ts +780 -0
- package/src/tests/exclusions.test.ts +117 -0
- package/src/tests/integration/mcp-server.integration.test.ts +2 -1
- package/src/tests/project-map.test.ts +302 -0
- package/src/tests/scanner-detector.test.ts +31 -11
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-flow integration tests — one per supported single-project language type.
|
|
3
|
+
*
|
|
4
|
+
* Each test in this suite verifies that the MCP server starts cleanly for a
|
|
5
|
+
* given project layout, correctly identifies the project type via the
|
|
6
|
+
* `list_projects` tool, and successfully scores it via `score_project` without
|
|
7
|
+
* crashing. The focus is on:
|
|
8
|
+
*
|
|
9
|
+
* - Project discovery (marker-file detection, `isMonorepo` flag)
|
|
10
|
+
* - LOC counting (workspace walker picks up source files)
|
|
11
|
+
* - Crash-free execution (server returns valid JSON-RPC for every tool call)
|
|
12
|
+
*
|
|
13
|
+
* Scanner execution is deliberately NOT tested here — the scanners (eslint,
|
|
14
|
+
* bandit, semgrep, dart, dotnet) may not be installed in CI. The tests validate
|
|
15
|
+
* the boot path and project-detection layer only.
|
|
16
|
+
*
|
|
17
|
+
* Supported project types covered:
|
|
18
|
+
* 1. TypeScript — package.json + tsconfig.json
|
|
19
|
+
* 2. JavaScript — package.json only, with src/index.js
|
|
20
|
+
* 3. Python — pyproject.toml + src/main.py
|
|
21
|
+
* 4. Java — pom.xml + src/Main.java
|
|
22
|
+
* 5. C# / .NET — MyApp.csproj + Program.cs
|
|
23
|
+
* 6. Dart/Flutter — pubspec.yaml + lib/main.dart
|
|
24
|
+
* 7. Empty — no source files, should gracefully return LOC = 0
|
|
25
|
+
*
|
|
26
|
+
* The test suite skips entirely when the bundled MCP server entry
|
|
27
|
+
* (`plugin/bundle/mcp-server.mjs`) has not been built yet — consistent with
|
|
28
|
+
* the approach used by `src/tests/integration/mcp-server.integration.test.ts`.
|
|
29
|
+
*
|
|
30
|
+
* @module tests/boot-single-project.test
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, before, after } from "node:test";
|
|
34
|
+
import assert from "node:assert/strict";
|
|
35
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
36
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, statSync } from "node:fs";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import { dirname, join, resolve } from "node:path";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Resolve the bundled server entry relative to this test file so the path
|
|
43
|
+
// survives both `tsx`-based dev runs and compiled `dist/tests/` executions.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
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
|
+
let serverBuilt = false;
|
|
52
|
+
try {
|
|
53
|
+
statSync(SERVER_ENTRY);
|
|
54
|
+
serverBuilt = true;
|
|
55
|
+
} catch {
|
|
56
|
+
// The bundle has not been compiled yet — skip the entire suite.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Shared helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Thin JSON-RPC client that writes newline-delimited frames to a server
|
|
65
|
+
* process's stdin and dispatches responses from its stdout by request id.
|
|
66
|
+
* Mirrors the `StdioClient` in `mcp-server.integration.test.ts` so both
|
|
67
|
+
* suites stay consistent without a shared module dependency.
|
|
68
|
+
*/
|
|
69
|
+
class StdioClient {
|
|
70
|
+
private readonly child: ChildProcessWithoutNullStreams;
|
|
71
|
+
private stdoutBuffer = "";
|
|
72
|
+
private readonly pending = new Map<number, (msg: unknown) => void>();
|
|
73
|
+
private nextId = 1;
|
|
74
|
+
|
|
75
|
+
constructor(child: ChildProcessWithoutNullStreams) {
|
|
76
|
+
this.child = child;
|
|
77
|
+
this.child.stdout.setEncoding("utf8");
|
|
78
|
+
this.child.stdout.on("data", (chunk: string) => this.onData(chunk));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private onData(chunk: string): void {
|
|
82
|
+
this.stdoutBuffer += chunk;
|
|
83
|
+
let newlineIdx = this.stdoutBuffer.indexOf("\n");
|
|
84
|
+
while (newlineIdx !== -1) {
|
|
85
|
+
const line = this.stdoutBuffer.slice(0, newlineIdx).trim();
|
|
86
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIdx + 1);
|
|
87
|
+
newlineIdx = this.stdoutBuffer.indexOf("\n");
|
|
88
|
+
if (!line) continue;
|
|
89
|
+
let msg: unknown;
|
|
90
|
+
try {
|
|
91
|
+
msg = JSON.parse(line);
|
|
92
|
+
} catch {
|
|
93
|
+
continue; // discard non-JSON lines (should not appear on stdout)
|
|
94
|
+
}
|
|
95
|
+
const id = (msg as { id?: number }).id;
|
|
96
|
+
if (typeof id === "number" && this.pending.has(id)) {
|
|
97
|
+
const resolver = this.pending.get(id)!;
|
|
98
|
+
this.pending.delete(id);
|
|
99
|
+
resolver(msg);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
notify(method: string, params?: Record<string, unknown>): void {
|
|
105
|
+
const frame = { jsonrpc: "2.0", method, ...(params ? { params } : {}) };
|
|
106
|
+
this.child.stdin.write(JSON.stringify(frame) + "\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
request<T = unknown>(
|
|
110
|
+
method: string,
|
|
111
|
+
params: Record<string, unknown> = {},
|
|
112
|
+
timeoutMs = 10_000,
|
|
113
|
+
): Promise<T> {
|
|
114
|
+
const id = this.nextId++;
|
|
115
|
+
return new Promise<T>((resolvePromise, rejectPromise) => {
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
this.pending.delete(id);
|
|
118
|
+
rejectPromise(new Error(`JSON-RPC timeout waiting for ${method}#${id}`));
|
|
119
|
+
}, timeoutMs);
|
|
120
|
+
this.pending.set(id, (msg) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
resolvePromise(msg as T);
|
|
123
|
+
});
|
|
124
|
+
this.child.stdin.write(
|
|
125
|
+
JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Extract the text of the first content block in a `tools/call` response and
|
|
133
|
+
* parse it as JSON. All claude-crap tools return their primary payload as a
|
|
134
|
+
* stringified JSON text block.
|
|
135
|
+
*/
|
|
136
|
+
function parseFirstContentAsJson(response: unknown): Record<string, unknown> {
|
|
137
|
+
const r = response as {
|
|
138
|
+
result?: { content?: Array<{ type: string; text: string }> };
|
|
139
|
+
};
|
|
140
|
+
const first = r.result?.content?.[0];
|
|
141
|
+
assert.ok(first, "tool call returned no content block");
|
|
142
|
+
assert.equal(first.type, "text", "first content block must be text");
|
|
143
|
+
return JSON.parse(first.text) as Record<string, unknown>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Spawn the MCP server process pointing at the given workspace directory.
|
|
148
|
+
* Returns both the raw child process and a `StdioClient` that has already
|
|
149
|
+
* completed the mandatory `initialize` / `notifications/initialized` handshake.
|
|
150
|
+
*
|
|
151
|
+
* @param workspace Absolute path that becomes `CLAUDE_CRAP_PLUGIN_ROOT`.
|
|
152
|
+
*/
|
|
153
|
+
async function spawnServer(
|
|
154
|
+
workspace: string,
|
|
155
|
+
): Promise<{ child: ChildProcessWithoutNullStreams; client: StdioClient }> {
|
|
156
|
+
// Use a random high port to avoid colliding with running plugin instances.
|
|
157
|
+
const dashboardPort = 5300 + Math.floor(Math.random() * 500);
|
|
158
|
+
|
|
159
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, "--transport", "stdio"], {
|
|
160
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
161
|
+
env: {
|
|
162
|
+
...process.env,
|
|
163
|
+
CLAUDE_CRAP_LOG_LEVEL: "error",
|
|
164
|
+
CLAUDE_CRAP_PLUGIN_ROOT: workspace,
|
|
165
|
+
CLAUDE_CRAP_DASHBOARD_PORT: String(dashboardPort),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Drain stderr to prevent the child's output buffer from filling up and
|
|
170
|
+
// blocking the process. We discard the content — only crashes matter and
|
|
171
|
+
// those surface as JSON-RPC errors or timeouts.
|
|
172
|
+
child.stderr.resume();
|
|
173
|
+
|
|
174
|
+
const client = new StdioClient(child);
|
|
175
|
+
|
|
176
|
+
await client.request("initialize", {
|
|
177
|
+
protocolVersion: "2024-11-05",
|
|
178
|
+
capabilities: {},
|
|
179
|
+
clientInfo: { name: "boot-single-project-test", version: "0.0.1" },
|
|
180
|
+
});
|
|
181
|
+
client.notify("notifications/initialized");
|
|
182
|
+
|
|
183
|
+
return { child, client };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Gracefully terminate a spawned server process, sending SIGTERM and waiting
|
|
188
|
+
* for the `exit` event (with a SIGKILL safety net after 1.5 s).
|
|
189
|
+
*/
|
|
190
|
+
async function killServer(child: ChildProcessWithoutNullStreams): Promise<void> {
|
|
191
|
+
if (!child || child.killed) return;
|
|
192
|
+
const exited = new Promise<void>((res) => {
|
|
193
|
+
child.once("exit", () => res());
|
|
194
|
+
});
|
|
195
|
+
child.kill("SIGTERM");
|
|
196
|
+
const killTimer = setTimeout(() => {
|
|
197
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
198
|
+
}, 1_500);
|
|
199
|
+
await exited;
|
|
200
|
+
clearTimeout(killTimer);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Per-test workspace factories
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/** TypeScript project: package.json + tsconfig.json + src/index.ts */
|
|
208
|
+
function makeTypeScriptWorkspace(): string {
|
|
209
|
+
const dir = mkdtempSync(join(tmpdir(), "ccrap-ts-"));
|
|
210
|
+
writeFileSync(
|
|
211
|
+
join(dir, "package.json"),
|
|
212
|
+
JSON.stringify({ name: "ts-project", version: "1.0.0" }),
|
|
213
|
+
);
|
|
214
|
+
writeFileSync(join(dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { strict: true } }));
|
|
215
|
+
mkdirSync(join(dir, "src"), { recursive: true });
|
|
216
|
+
writeFileSync(
|
|
217
|
+
join(dir, "src", "index.ts"),
|
|
218
|
+
[
|
|
219
|
+
"export interface Greeter {",
|
|
220
|
+
" greet(name: string): string;",
|
|
221
|
+
"}",
|
|
222
|
+
"",
|
|
223
|
+
"export class HelloGreeter implements Greeter {",
|
|
224
|
+
" greet(name: string): string {",
|
|
225
|
+
' return `Hello, ${name}!`;',
|
|
226
|
+
" }",
|
|
227
|
+
"}",
|
|
228
|
+
].join("\n") + "\n",
|
|
229
|
+
);
|
|
230
|
+
return dir;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* JavaScript project: package.json (no tsconfig) + src/index.js with
|
|
235
|
+
* exactly 10 non-blank, non-comment source lines.
|
|
236
|
+
*/
|
|
237
|
+
function makeJavaScriptWorkspace(): string {
|
|
238
|
+
const dir = mkdtempSync(join(tmpdir(), "ccrap-js-"));
|
|
239
|
+
writeFileSync(
|
|
240
|
+
join(dir, "package.json"),
|
|
241
|
+
JSON.stringify({ name: "js-project", version: "1.0.0" }),
|
|
242
|
+
);
|
|
243
|
+
mkdirSync(join(dir, "src"), { recursive: true });
|
|
244
|
+
writeFileSync(
|
|
245
|
+
join(dir, "src", "index.js"),
|
|
246
|
+
[
|
|
247
|
+
"const PI = Math.PI;",
|
|
248
|
+
"function circleArea(r) {",
|
|
249
|
+
" return PI * r * r;",
|
|
250
|
+
"}",
|
|
251
|
+
"function circlePerimeter(r) {",
|
|
252
|
+
" return 2 * PI * r;",
|
|
253
|
+
"}",
|
|
254
|
+
"function add(a, b) { return a + b; }",
|
|
255
|
+
"function sub(a, b) { return a - b; }",
|
|
256
|
+
"module.exports = { circleArea, circlePerimeter, add, sub };",
|
|
257
|
+
].join("\n") + "\n",
|
|
258
|
+
);
|
|
259
|
+
return dir;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Python project: pyproject.toml + src/main.py */
|
|
263
|
+
function makePythonWorkspace(): string {
|
|
264
|
+
const dir = mkdtempSync(join(tmpdir(), "ccrap-py-"));
|
|
265
|
+
writeFileSync(
|
|
266
|
+
join(dir, "pyproject.toml"),
|
|
267
|
+
[
|
|
268
|
+
"[project]",
|
|
269
|
+
'name = "my-python-project"',
|
|
270
|
+
'version = "0.1.0"',
|
|
271
|
+
].join("\n") + "\n",
|
|
272
|
+
);
|
|
273
|
+
mkdirSync(join(dir, "src"), { recursive: true });
|
|
274
|
+
writeFileSync(
|
|
275
|
+
join(dir, "src", "main.py"),
|
|
276
|
+
[
|
|
277
|
+
"def greet(name: str) -> str:",
|
|
278
|
+
' return f"Hello, {name}!"',
|
|
279
|
+
"",
|
|
280
|
+
'if __name__ == "__main__":',
|
|
281
|
+
' print(greet("World"))',
|
|
282
|
+
].join("\n") + "\n",
|
|
283
|
+
);
|
|
284
|
+
return dir;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Java project: pom.xml + src/Main.java */
|
|
288
|
+
function makeJavaWorkspace(): string {
|
|
289
|
+
const dir = mkdtempSync(join(tmpdir(), "ccrap-java-"));
|
|
290
|
+
writeFileSync(
|
|
291
|
+
join(dir, "pom.xml"),
|
|
292
|
+
[
|
|
293
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
294
|
+
'<project xmlns="http://maven.apache.org/POM/4.0.0"',
|
|
295
|
+
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
|
|
296
|
+
' xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">',
|
|
297
|
+
" <modelVersion>4.0.0</modelVersion>",
|
|
298
|
+
" <groupId>com.example</groupId>",
|
|
299
|
+
" <artifactId>demo</artifactId>",
|
|
300
|
+
" <version>1.0.0</version>",
|
|
301
|
+
"</project>",
|
|
302
|
+
].join("\n") + "\n",
|
|
303
|
+
);
|
|
304
|
+
mkdirSync(join(dir, "src"), { recursive: true });
|
|
305
|
+
writeFileSync(
|
|
306
|
+
join(dir, "src", "Main.java"),
|
|
307
|
+
[
|
|
308
|
+
"public class Main {",
|
|
309
|
+
" public static void main(String[] args) {",
|
|
310
|
+
' System.out.println("Hello, World!");',
|
|
311
|
+
" }",
|
|
312
|
+
"}",
|
|
313
|
+
].join("\n") + "\n",
|
|
314
|
+
);
|
|
315
|
+
return dir;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** C# project: MyApp.csproj + Program.cs */
|
|
319
|
+
function makeCSharpWorkspace(): string {
|
|
320
|
+
const dir = mkdtempSync(join(tmpdir(), "ccrap-cs-"));
|
|
321
|
+
writeFileSync(
|
|
322
|
+
join(dir, "MyApp.csproj"),
|
|
323
|
+
[
|
|
324
|
+
"<Project Sdk=\"Microsoft.NET.Sdk\">",
|
|
325
|
+
" <PropertyGroup>",
|
|
326
|
+
" <OutputType>Exe</OutputType>",
|
|
327
|
+
" <TargetFramework>net8.0</TargetFramework>",
|
|
328
|
+
" </PropertyGroup>",
|
|
329
|
+
"</Project>",
|
|
330
|
+
].join("\n") + "\n",
|
|
331
|
+
);
|
|
332
|
+
writeFileSync(
|
|
333
|
+
join(dir, "Program.cs"),
|
|
334
|
+
[
|
|
335
|
+
"using System;",
|
|
336
|
+
"",
|
|
337
|
+
"class Program {",
|
|
338
|
+
" static void Main(string[] args) {",
|
|
339
|
+
' Console.WriteLine("Hello, World!");',
|
|
340
|
+
" }",
|
|
341
|
+
"}",
|
|
342
|
+
].join("\n") + "\n",
|
|
343
|
+
);
|
|
344
|
+
return dir;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Dart/Flutter project: pubspec.yaml + lib/main.dart */
|
|
348
|
+
function makeDartWorkspace(): string {
|
|
349
|
+
const dir = mkdtempSync(join(tmpdir(), "ccrap-dart-"));
|
|
350
|
+
writeFileSync(
|
|
351
|
+
join(dir, "pubspec.yaml"),
|
|
352
|
+
[
|
|
353
|
+
"name: my_flutter_app",
|
|
354
|
+
"description: A sample Flutter application.",
|
|
355
|
+
"version: 1.0.0+1",
|
|
356
|
+
"environment:",
|
|
357
|
+
" sdk: '>=3.0.0 <4.0.0'",
|
|
358
|
+
].join("\n") + "\n",
|
|
359
|
+
);
|
|
360
|
+
mkdirSync(join(dir, "lib"), { recursive: true });
|
|
361
|
+
writeFileSync(
|
|
362
|
+
join(dir, "lib", "main.dart"),
|
|
363
|
+
[
|
|
364
|
+
"import 'package:flutter/material.dart';",
|
|
365
|
+
"",
|
|
366
|
+
"void main() {",
|
|
367
|
+
" runApp(const MyApp());",
|
|
368
|
+
"}",
|
|
369
|
+
"",
|
|
370
|
+
"class MyApp extends StatelessWidget {",
|
|
371
|
+
" const MyApp({super.key});",
|
|
372
|
+
" @override",
|
|
373
|
+
" Widget build(BuildContext context) {",
|
|
374
|
+
" return const MaterialApp(",
|
|
375
|
+
" home: Scaffold(",
|
|
376
|
+
" body: Center(child: Text('Hello')),",
|
|
377
|
+
" ),",
|
|
378
|
+
" );",
|
|
379
|
+
" }",
|
|
380
|
+
"}",
|
|
381
|
+
].join("\n") + "\n",
|
|
382
|
+
);
|
|
383
|
+
return dir;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Empty workspace: no marker files, no source code. */
|
|
387
|
+
function makeEmptyWorkspace(): string {
|
|
388
|
+
return mkdtempSync(join(tmpdir(), "ccrap-empty-"));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Test suite
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
describe("MCP server boot — single-project language types", { skip: !serverBuilt }, () => {
|
|
396
|
+
// -------------------------------------------------------------------------
|
|
397
|
+
// 1. TypeScript
|
|
398
|
+
// -------------------------------------------------------------------------
|
|
399
|
+
describe("TypeScript project (package.json + tsconfig.json)", () => {
|
|
400
|
+
let workspace = "";
|
|
401
|
+
let child: ChildProcessWithoutNullStreams | null = null;
|
|
402
|
+
let client: StdioClient | null = null;
|
|
403
|
+
|
|
404
|
+
before(async () => {
|
|
405
|
+
workspace = makeTypeScriptWorkspace();
|
|
406
|
+
({ child, client } = await spawnServer(workspace));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
after(async () => {
|
|
410
|
+
if (child) await killServer(child);
|
|
411
|
+
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("list_projects reports isMonorepo: false for a single-project workspace", async () => {
|
|
415
|
+
const response = await client!.request("tools/call", {
|
|
416
|
+
name: "list_projects",
|
|
417
|
+
arguments: {},
|
|
418
|
+
});
|
|
419
|
+
const payload = parseFirstContentAsJson(response);
|
|
420
|
+
assert.equal(
|
|
421
|
+
payload.isMonorepo,
|
|
422
|
+
false,
|
|
423
|
+
"single TypeScript project root should not be detected as a monorepo",
|
|
424
|
+
);
|
|
425
|
+
assert.ok(
|
|
426
|
+
Array.isArray(payload.projects),
|
|
427
|
+
"projects field must be an array",
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("score_project returns LOC > 0 and workspaceRoot matching the temp dir", async () => {
|
|
432
|
+
const response = await client!.request<{
|
|
433
|
+
result?: { content?: Array<{ type: string; text: string }>; isError?: boolean };
|
|
434
|
+
}>("tools/call", { name: "score_project", arguments: { format: "json" } });
|
|
435
|
+
|
|
436
|
+
// The server must not have set isError on a clean empty project.
|
|
437
|
+
assert.notEqual(
|
|
438
|
+
response.result?.isError,
|
|
439
|
+
true,
|
|
440
|
+
"score_project should not be an error for a clean TS project",
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const blocks = response.result?.content ?? [];
|
|
444
|
+
assert.ok(blocks.length >= 1, "score_project must return at least one content block");
|
|
445
|
+
|
|
446
|
+
const score = JSON.parse(blocks[0]!.text) as {
|
|
447
|
+
workspaceRoot: string;
|
|
448
|
+
loc: { physical: number; files: number };
|
|
449
|
+
overall: { passes: boolean };
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
assert.ok(
|
|
453
|
+
score.loc.physical > 0,
|
|
454
|
+
`LOC should be > 0 for a TS project with source files, got ${score.loc.physical}`,
|
|
455
|
+
);
|
|
456
|
+
assert.ok(
|
|
457
|
+
score.loc.files > 0,
|
|
458
|
+
`file count should be > 0, got ${score.loc.files}`,
|
|
459
|
+
);
|
|
460
|
+
assert.equal(
|
|
461
|
+
score.workspaceRoot,
|
|
462
|
+
workspace,
|
|
463
|
+
"workspaceRoot in score must match the temp directory",
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// -------------------------------------------------------------------------
|
|
469
|
+
// 2. JavaScript
|
|
470
|
+
// -------------------------------------------------------------------------
|
|
471
|
+
describe("JavaScript project (package.json, no tsconfig)", () => {
|
|
472
|
+
let workspace = "";
|
|
473
|
+
let child: ChildProcessWithoutNullStreams | null = null;
|
|
474
|
+
let client: StdioClient | null = null;
|
|
475
|
+
|
|
476
|
+
before(async () => {
|
|
477
|
+
workspace = makeJavaScriptWorkspace();
|
|
478
|
+
({ child, client } = await spawnServer(workspace));
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
after(async () => {
|
|
482
|
+
if (child) await killServer(child);
|
|
483
|
+
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("list_projects reports isMonorepo: false", async () => {
|
|
487
|
+
const response = await client!.request("tools/call", {
|
|
488
|
+
name: "list_projects",
|
|
489
|
+
arguments: {},
|
|
490
|
+
});
|
|
491
|
+
const payload = parseFirstContentAsJson(response);
|
|
492
|
+
assert.equal(payload.isMonorepo, false);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("score_project returns LOC >= 10 for a JS file with 10 source lines", async () => {
|
|
496
|
+
const response = await client!.request<{
|
|
497
|
+
result?: { content?: Array<{ type: string; text: string }> };
|
|
498
|
+
}>("tools/call", { name: "score_project", arguments: { format: "json" } });
|
|
499
|
+
|
|
500
|
+
const blocks = response.result?.content ?? [];
|
|
501
|
+
assert.ok(blocks.length >= 1, "score_project must return at least one content block");
|
|
502
|
+
|
|
503
|
+
const score = JSON.parse(blocks[0]!.text) as {
|
|
504
|
+
loc: { physical: number };
|
|
505
|
+
};
|
|
506
|
+
assert.ok(
|
|
507
|
+
score.loc.physical >= 10,
|
|
508
|
+
`expected LOC >= 10 for a JS project, got ${score.loc.physical}`,
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// -------------------------------------------------------------------------
|
|
514
|
+
// 3. Python
|
|
515
|
+
// -------------------------------------------------------------------------
|
|
516
|
+
describe("Python project (pyproject.toml + src/main.py)", () => {
|
|
517
|
+
let workspace = "";
|
|
518
|
+
let child: ChildProcessWithoutNullStreams | null = null;
|
|
519
|
+
let client: StdioClient | null = null;
|
|
520
|
+
|
|
521
|
+
before(async () => {
|
|
522
|
+
workspace = makePythonWorkspace();
|
|
523
|
+
({ child, client } = await spawnServer(workspace));
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
after(async () => {
|
|
527
|
+
if (child) await killServer(child);
|
|
528
|
+
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("list_projects returns without error (isMonorepo: false)", async () => {
|
|
532
|
+
const response = await client!.request("tools/call", {
|
|
533
|
+
name: "list_projects",
|
|
534
|
+
arguments: {},
|
|
535
|
+
});
|
|
536
|
+
// Must be a well-formed JSON-RPC result (no top-level `error` key).
|
|
537
|
+
const r = response as { error?: unknown; result?: unknown };
|
|
538
|
+
assert.equal(r.error, undefined, "list_projects must not return a JSON-RPC error");
|
|
539
|
+
const payload = parseFirstContentAsJson(response);
|
|
540
|
+
assert.equal(payload.isMonorepo, false);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("score_project completes without crash and counts .py source file", async () => {
|
|
544
|
+
const response = await client!.request<{
|
|
545
|
+
result?: { content?: Array<{ type: string; text: string }>; isError?: boolean };
|
|
546
|
+
error?: unknown;
|
|
547
|
+
}>("tools/call", { name: "score_project", arguments: { format: "json" } });
|
|
548
|
+
|
|
549
|
+
assert.equal(response.error, undefined, "score_project must not return a JSON-RPC error");
|
|
550
|
+
|
|
551
|
+
const blocks = response.result?.content ?? [];
|
|
552
|
+
assert.ok(blocks.length >= 1, "score_project must return at least one content block");
|
|
553
|
+
|
|
554
|
+
const score = JSON.parse(blocks[0]!.text) as {
|
|
555
|
+
loc: { physical: number; files: number };
|
|
556
|
+
};
|
|
557
|
+
assert.ok(
|
|
558
|
+
score.loc.physical > 0,
|
|
559
|
+
`LOC should be > 0 for a Python project, got ${score.loc.physical}`,
|
|
560
|
+
);
|
|
561
|
+
assert.ok(score.loc.files > 0, `file count should be > 0, got ${score.loc.files}`);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// -------------------------------------------------------------------------
|
|
566
|
+
// 4. Java
|
|
567
|
+
// -------------------------------------------------------------------------
|
|
568
|
+
describe("Java project (pom.xml + src/Main.java)", () => {
|
|
569
|
+
let workspace = "";
|
|
570
|
+
let child: ChildProcessWithoutNullStreams | null = null;
|
|
571
|
+
let client: StdioClient | null = null;
|
|
572
|
+
|
|
573
|
+
before(async () => {
|
|
574
|
+
workspace = makeJavaWorkspace();
|
|
575
|
+
({ child, client } = await spawnServer(workspace));
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
after(async () => {
|
|
579
|
+
if (child) await killServer(child);
|
|
580
|
+
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("list_projects returns without error", async () => {
|
|
584
|
+
const response = await client!.request("tools/call", {
|
|
585
|
+
name: "list_projects",
|
|
586
|
+
arguments: {},
|
|
587
|
+
});
|
|
588
|
+
const r = response as { error?: unknown };
|
|
589
|
+
assert.equal(r.error, undefined, "list_projects must not return a JSON-RPC error");
|
|
590
|
+
const payload = parseFirstContentAsJson(response);
|
|
591
|
+
assert.equal(payload.isMonorepo, false);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("score_project completes without crash and LOC counts the .java file", async () => {
|
|
595
|
+
const response = await client!.request<{
|
|
596
|
+
result?: { content?: Array<{ type: string; text: string }> };
|
|
597
|
+
error?: unknown;
|
|
598
|
+
}>("tools/call", { name: "score_project", arguments: { format: "json" } });
|
|
599
|
+
|
|
600
|
+
assert.equal(response.error, undefined, "score_project must not return a JSON-RPC error");
|
|
601
|
+
|
|
602
|
+
const blocks = response.result?.content ?? [];
|
|
603
|
+
assert.ok(blocks.length >= 1, "score_project must return at least one content block");
|
|
604
|
+
|
|
605
|
+
const score = JSON.parse(blocks[0]!.text) as {
|
|
606
|
+
loc: { physical: number; files: number };
|
|
607
|
+
};
|
|
608
|
+
assert.ok(
|
|
609
|
+
score.loc.physical > 0,
|
|
610
|
+
`LOC should be > 0 for a Java project with Main.java, got ${score.loc.physical}`,
|
|
611
|
+
);
|
|
612
|
+
assert.ok(score.loc.files > 0, `file count should be > 0, got ${score.loc.files}`);
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// -------------------------------------------------------------------------
|
|
617
|
+
// 5. C# / .NET
|
|
618
|
+
// -------------------------------------------------------------------------
|
|
619
|
+
describe("C# project (MyApp.csproj + Program.cs)", () => {
|
|
620
|
+
let workspace = "";
|
|
621
|
+
let child: ChildProcessWithoutNullStreams | null = null;
|
|
622
|
+
let client: StdioClient | null = null;
|
|
623
|
+
|
|
624
|
+
before(async () => {
|
|
625
|
+
workspace = makeCSharpWorkspace();
|
|
626
|
+
({ child, client } = await spawnServer(workspace));
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
after(async () => {
|
|
630
|
+
if (child) await killServer(child);
|
|
631
|
+
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("list_projects returns without error", async () => {
|
|
635
|
+
const response = await client!.request("tools/call", {
|
|
636
|
+
name: "list_projects",
|
|
637
|
+
arguments: {},
|
|
638
|
+
});
|
|
639
|
+
const r = response as { error?: unknown };
|
|
640
|
+
assert.equal(r.error, undefined, "list_projects must not return a JSON-RPC error");
|
|
641
|
+
const payload = parseFirstContentAsJson(response);
|
|
642
|
+
assert.equal(payload.isMonorepo, false);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("score_project completes without crash and LOC counts the .cs file", async () => {
|
|
646
|
+
const response = await client!.request<{
|
|
647
|
+
result?: { content?: Array<{ type: string; text: string }> };
|
|
648
|
+
error?: unknown;
|
|
649
|
+
}>("tools/call", { name: "score_project", arguments: { format: "json" } });
|
|
650
|
+
|
|
651
|
+
assert.equal(response.error, undefined, "score_project must not return a JSON-RPC error");
|
|
652
|
+
|
|
653
|
+
const blocks = response.result?.content ?? [];
|
|
654
|
+
assert.ok(blocks.length >= 1, "score_project must return at least one content block");
|
|
655
|
+
|
|
656
|
+
const score = JSON.parse(blocks[0]!.text) as {
|
|
657
|
+
loc: { physical: number; files: number };
|
|
658
|
+
};
|
|
659
|
+
assert.ok(
|
|
660
|
+
score.loc.physical > 0,
|
|
661
|
+
`LOC should be > 0 for a C# project with Program.cs, got ${score.loc.physical}`,
|
|
662
|
+
);
|
|
663
|
+
assert.ok(score.loc.files > 0, `file count should be > 0, got ${score.loc.files}`);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// -------------------------------------------------------------------------
|
|
668
|
+
// 6. Dart / Flutter
|
|
669
|
+
// -------------------------------------------------------------------------
|
|
670
|
+
describe("Dart/Flutter project (pubspec.yaml + lib/main.dart)", () => {
|
|
671
|
+
let workspace = "";
|
|
672
|
+
let child: ChildProcessWithoutNullStreams | null = null;
|
|
673
|
+
let client: StdioClient | null = null;
|
|
674
|
+
|
|
675
|
+
before(async () => {
|
|
676
|
+
workspace = makeDartWorkspace();
|
|
677
|
+
({ child, client } = await spawnServer(workspace));
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
after(async () => {
|
|
681
|
+
if (child) await killServer(child);
|
|
682
|
+
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("list_projects returns without error", async () => {
|
|
686
|
+
const response = await client!.request("tools/call", {
|
|
687
|
+
name: "list_projects",
|
|
688
|
+
arguments: {},
|
|
689
|
+
});
|
|
690
|
+
const r = response as { error?: unknown };
|
|
691
|
+
assert.equal(r.error, undefined, "list_projects must not return a JSON-RPC error");
|
|
692
|
+
const payload = parseFirstContentAsJson(response);
|
|
693
|
+
assert.equal(payload.isMonorepo, false);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("score_project completes without crash and LOC counts the .dart file", async () => {
|
|
697
|
+
const response = await client!.request<{
|
|
698
|
+
result?: { content?: Array<{ type: string; text: string }> };
|
|
699
|
+
error?: unknown;
|
|
700
|
+
}>("tools/call", { name: "score_project", arguments: { format: "json" } });
|
|
701
|
+
|
|
702
|
+
assert.equal(response.error, undefined, "score_project must not return a JSON-RPC error");
|
|
703
|
+
|
|
704
|
+
const blocks = response.result?.content ?? [];
|
|
705
|
+
assert.ok(blocks.length >= 1, "score_project must return at least one content block");
|
|
706
|
+
|
|
707
|
+
const score = JSON.parse(blocks[0]!.text) as {
|
|
708
|
+
loc: { physical: number; files: number };
|
|
709
|
+
};
|
|
710
|
+
assert.ok(
|
|
711
|
+
score.loc.physical > 0,
|
|
712
|
+
`LOC should be > 0 for a Dart project with lib/main.dart, got ${score.loc.physical}`,
|
|
713
|
+
);
|
|
714
|
+
assert.ok(score.loc.files > 0, `file count should be > 0, got ${score.loc.files}`);
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// -------------------------------------------------------------------------
|
|
719
|
+
// 7. Empty workspace
|
|
720
|
+
// -------------------------------------------------------------------------
|
|
721
|
+
describe("Empty workspace (no source files)", () => {
|
|
722
|
+
let workspace = "";
|
|
723
|
+
let child: ChildProcessWithoutNullStreams | null = null;
|
|
724
|
+
let client: StdioClient | null = null;
|
|
725
|
+
|
|
726
|
+
before(async () => {
|
|
727
|
+
workspace = makeEmptyWorkspace();
|
|
728
|
+
({ child, client } = await spawnServer(workspace));
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
after(async () => {
|
|
732
|
+
if (child) await killServer(child);
|
|
733
|
+
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("list_projects returns isMonorepo: false and an empty projects array", async () => {
|
|
737
|
+
const response = await client!.request("tools/call", {
|
|
738
|
+
name: "list_projects",
|
|
739
|
+
arguments: {},
|
|
740
|
+
});
|
|
741
|
+
const r = response as { error?: unknown };
|
|
742
|
+
assert.equal(r.error, undefined, "list_projects must not return a JSON-RPC error");
|
|
743
|
+
const payload = parseFirstContentAsJson(response);
|
|
744
|
+
assert.equal(payload.isMonorepo, false);
|
|
745
|
+
assert.deepEqual(
|
|
746
|
+
payload.projects,
|
|
747
|
+
[],
|
|
748
|
+
"empty workspace should have no discovered sub-projects",
|
|
749
|
+
);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("score_project completes without crash, LOC = 0, and overall passes quality gate", async () => {
|
|
753
|
+
const response = await client!.request<{
|
|
754
|
+
result?: { content?: Array<{ type: string; text: string }>; isError?: boolean };
|
|
755
|
+
error?: unknown;
|
|
756
|
+
}>("tools/call", { name: "score_project", arguments: { format: "json" } });
|
|
757
|
+
|
|
758
|
+
assert.equal(response.error, undefined, "score_project must not return a JSON-RPC error");
|
|
759
|
+
|
|
760
|
+
const blocks = response.result?.content ?? [];
|
|
761
|
+
assert.ok(blocks.length >= 1, "score_project must return at least one content block");
|
|
762
|
+
|
|
763
|
+
const score = JSON.parse(blocks[0]!.text) as {
|
|
764
|
+
loc: { physical: number };
|
|
765
|
+
overall: { passes: boolean; rating: string };
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
assert.equal(
|
|
769
|
+
score.loc.physical,
|
|
770
|
+
0,
|
|
771
|
+
`expected LOC = 0 for an empty workspace, got ${score.loc.physical}`,
|
|
772
|
+
);
|
|
773
|
+
assert.equal(
|
|
774
|
+
score.overall.passes,
|
|
775
|
+
true,
|
|
776
|
+
"an empty workspace with zero findings should pass the quality gate",
|
|
777
|
+
);
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
});
|