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.
Files changed (100) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +74 -7
  3. package/dist/adapters/common.d.ts +1 -1
  4. package/dist/adapters/common.d.ts.map +1 -1
  5. package/dist/adapters/common.js +1 -1
  6. package/dist/adapters/common.js.map +1 -1
  7. package/dist/adapters/dart-analyzer.d.ts +41 -0
  8. package/dist/adapters/dart-analyzer.d.ts.map +1 -0
  9. package/dist/adapters/dart-analyzer.js +120 -0
  10. package/dist/adapters/dart-analyzer.js.map +1 -0
  11. package/dist/adapters/dotnet-format.d.ts +35 -0
  12. package/dist/adapters/dotnet-format.d.ts.map +1 -0
  13. package/dist/adapters/dotnet-format.js +96 -0
  14. package/dist/adapters/dotnet-format.js.map +1 -0
  15. package/dist/adapters/index.d.ts +2 -0
  16. package/dist/adapters/index.d.ts.map +1 -1
  17. package/dist/adapters/index.js +8 -0
  18. package/dist/adapters/index.js.map +1 -1
  19. package/dist/crap-config.d.ts +4 -0
  20. package/dist/crap-config.d.ts.map +1 -1
  21. package/dist/crap-config.js +51 -28
  22. package/dist/crap-config.js.map +1 -1
  23. package/dist/dashboard/file-detail.d.ts.map +1 -1
  24. package/dist/dashboard/file-detail.js.map +1 -1
  25. package/dist/dashboard/server.d.ts +2 -0
  26. package/dist/dashboard/server.d.ts.map +1 -1
  27. package/dist/dashboard/server.js +7 -12
  28. package/dist/dashboard/server.js.map +1 -1
  29. package/dist/index.js +89 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/workspace-walker.d.ts +4 -1
  32. package/dist/metrics/workspace-walker.d.ts.map +1 -1
  33. package/dist/metrics/workspace-walker.js +12 -28
  34. package/dist/metrics/workspace-walker.js.map +1 -1
  35. package/dist/monorepo/project-map.d.ts +112 -0
  36. package/dist/monorepo/project-map.d.ts.map +1 -0
  37. package/dist/monorepo/project-map.js +384 -0
  38. package/dist/monorepo/project-map.js.map +1 -0
  39. package/dist/scanner/auto-scan.d.ts +1 -0
  40. package/dist/scanner/auto-scan.d.ts.map +1 -1
  41. package/dist/scanner/auto-scan.js +14 -5
  42. package/dist/scanner/auto-scan.js.map +1 -1
  43. package/dist/scanner/bootstrap.d.ts +1 -1
  44. package/dist/scanner/bootstrap.d.ts.map +1 -1
  45. package/dist/scanner/bootstrap.js +15 -1
  46. package/dist/scanner/bootstrap.js.map +1 -1
  47. package/dist/scanner/complexity-scanner.d.ts +2 -0
  48. package/dist/scanner/complexity-scanner.d.ts.map +1 -1
  49. package/dist/scanner/complexity-scanner.js +11 -26
  50. package/dist/scanner/complexity-scanner.js.map +1 -1
  51. package/dist/scanner/detector.d.ts +24 -4
  52. package/dist/scanner/detector.d.ts.map +1 -1
  53. package/dist/scanner/detector.js +110 -10
  54. package/dist/scanner/detector.js.map +1 -1
  55. package/dist/scanner/runner.d.ts +4 -1
  56. package/dist/scanner/runner.d.ts.map +1 -1
  57. package/dist/scanner/runner.js +25 -3
  58. package/dist/scanner/runner.js.map +1 -1
  59. package/dist/schemas/tool-schemas.d.ts +16 -1
  60. package/dist/schemas/tool-schemas.d.ts.map +1 -1
  61. package/dist/schemas/tool-schemas.js +16 -1
  62. package/dist/schemas/tool-schemas.js.map +1 -1
  63. package/dist/shared/exclusions.d.ts +53 -0
  64. package/dist/shared/exclusions.d.ts.map +1 -0
  65. package/dist/shared/exclusions.js +126 -0
  66. package/dist/shared/exclusions.js.map +1 -0
  67. package/package.json +3 -1
  68. package/plugin/.claude-plugin/plugin.json +1 -1
  69. package/plugin/CLAUDE.md +37 -0
  70. package/plugin/bundle/mcp-server.mjs +762 -144
  71. package/plugin/bundle/mcp-server.mjs.map +4 -4
  72. package/plugin/package-lock.json +15 -2
  73. package/plugin/package.json +2 -1
  74. package/scripts/bundle-plugin.mjs +2 -1
  75. package/src/adapters/common.ts +1 -1
  76. package/src/adapters/dart-analyzer.ts +161 -0
  77. package/src/adapters/dotnet-format.ts +125 -0
  78. package/src/adapters/index.ts +8 -0
  79. package/src/crap-config.ts +78 -18
  80. package/src/dashboard/file-detail.ts +0 -2
  81. package/src/dashboard/server.ts +9 -10
  82. package/src/index.ts +103 -5
  83. package/src/metrics/workspace-walker.ts +15 -27
  84. package/src/monorepo/project-map.ts +476 -0
  85. package/src/scanner/auto-scan.ts +17 -6
  86. package/src/scanner/bootstrap.ts +18 -1
  87. package/src/scanner/complexity-scanner.ts +15 -26
  88. package/src/scanner/detector.ts +119 -10
  89. package/src/scanner/runner.ts +25 -2
  90. package/src/schemas/tool-schemas.ts +17 -1
  91. package/src/shared/exclusions.ts +156 -0
  92. package/src/tests/adapters/dispatch.test.ts +2 -2
  93. package/src/tests/auto-scan.test.ts +2 -2
  94. package/src/tests/boot-monorepo.test.ts +804 -0
  95. package/src/tests/boot-scanner-detection.test.ts +692 -0
  96. package/src/tests/boot-single-project.test.ts +780 -0
  97. package/src/tests/exclusions.test.ts +117 -0
  98. package/src/tests/integration/mcp-server.integration.test.ts +2 -1
  99. package/src/tests/project-map.test.ts +302 -0
  100. 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
+ });