@sean.holung/minicode 0.3.2 → 0.3.3

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 (107) hide show
  1. package/README.md +48 -43
  2. package/dist/scripts/run-benchmarks.js +147 -0
  3. package/dist/src/agent/config.js +149 -40
  4. package/dist/src/agent/editable-config.js +314 -0
  5. package/dist/src/analysis/structural-analysis.js +379 -0
  6. package/dist/src/benchmark/evaluator.js +79 -0
  7. package/dist/src/benchmark/index.js +4 -0
  8. package/dist/src/benchmark/reporter.js +177 -0
  9. package/dist/src/benchmark/runner.js +100 -0
  10. package/dist/src/benchmark/task-loader.js +78 -0
  11. package/dist/src/benchmark/types.js +5 -0
  12. package/dist/src/cli/args.js +10 -0
  13. package/dist/src/cli/config-slash-command.js +135 -0
  14. package/dist/src/cli/plugin-install.js +69 -0
  15. package/dist/src/index.js +76 -6
  16. package/dist/src/indexer/cache.js +6 -4
  17. package/dist/src/indexer/code-map.js +41 -13
  18. package/dist/src/indexer/plugins/typescript.js +70 -23
  19. package/dist/src/indexer/project-index.js +175 -36
  20. package/dist/src/indexer/symbol-names.js +92 -0
  21. package/dist/src/model-utils.js +18 -0
  22. package/dist/src/serve/agent-bridge.js +203 -24
  23. package/dist/src/serve/mcp-server.js +405 -0
  24. package/dist/src/serve/server.js +165 -10
  25. package/dist/src/serve/websocket.js +8 -0
  26. package/dist/src/shared/graph-styles.js +119 -0
  27. package/dist/src/tools/find-path.js +75 -0
  28. package/dist/src/tools/find-references.js +7 -2
  29. package/dist/src/tools/get-dependencies.js +3 -2
  30. package/dist/src/tools/read-symbol.js +12 -5
  31. package/dist/src/tools/registry.js +3 -1
  32. package/dist/src/tools/search-code-map.js +4 -2
  33. package/dist/src/ui/app.js +1 -1
  34. package/dist/src/ui/cli-ink.js +79 -4
  35. package/dist/src/ui/components/header-bar.js +6 -2
  36. package/dist/src/ui/state/ui-store.js +5 -0
  37. package/dist/src/web/app.js +1124 -176
  38. package/dist/src/web/index.html +113 -3
  39. package/dist/src/web/style.css +973 -55
  40. package/dist/tests/agent.test.js +31 -0
  41. package/dist/tests/analysis-helpers.test.js +89 -0
  42. package/dist/tests/analysis-ui.test.js +29 -0
  43. package/dist/tests/benchmark-harness.test.js +527 -0
  44. package/dist/tests/config-api.test.js +143 -0
  45. package/dist/tests/config-integration.test.js +751 -0
  46. package/dist/tests/config-slash-command.test.js +106 -0
  47. package/dist/tests/config.test.js +42 -1
  48. package/dist/tests/context-indicator.test.js +220 -0
  49. package/dist/tests/editable-config.test.js +109 -0
  50. package/dist/tests/find-path.test.js +183 -0
  51. package/dist/tests/focus-tracker.test.js +62 -0
  52. package/dist/tests/graph-onboarding.test.js +55 -0
  53. package/dist/tests/graph-styles.test.js +65 -0
  54. package/dist/tests/indexer.test.js +137 -0
  55. package/dist/tests/mcp-and-plugin.test.js +186 -0
  56. package/dist/tests/model-client-openai.test.js +29 -0
  57. package/dist/tests/model-selection.test.js +136 -0
  58. package/dist/tests/model-utils.test.js +22 -0
  59. package/dist/tests/reasoning-effort.test.js +264 -0
  60. package/dist/tests/run-benchmarks.test.js +161 -0
  61. package/dist/tests/search-code-map.test.js +18 -0
  62. package/dist/tests/serve.integration.test.js +218 -2
  63. package/dist/tests/session-ui.test.js +21 -0
  64. package/dist/tests/session.test.js +50 -0
  65. package/dist/tests/settings-ui.test.js +30 -0
  66. package/dist/tests/structural-analysis.test.js +218 -0
  67. package/node_modules/@minicode/agent-sdk/README.md +80 -51
  68. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts +16 -5
  69. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  70. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +51 -33
  71. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  72. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +14 -0
  73. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
  74. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +3 -2
  75. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
  76. package/node_modules/@minicode/agent-sdk/dist/src/index.js +2 -0
  77. package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
  78. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts +35 -0
  79. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts.map +1 -0
  80. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js +64 -0
  81. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js.map +1 -0
  82. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +7 -0
  83. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
  84. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +5 -1
  85. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  86. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +83 -11
  87. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  88. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts +1 -0
  89. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts.map +1 -1
  90. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js +8 -1
  91. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js.map +1 -1
  92. package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts.map +1 -1
  93. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js +4 -1
  94. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js.map +1 -1
  95. package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js +3 -1
  96. package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js.map +1 -1
  97. package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js +8 -2
  98. package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js.map +1 -1
  99. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +9 -5
  101. package/plugin/.claude-plugin/plugin.json +12 -0
  102. package/plugin/.mcp.json +8 -0
  103. package/plugin/CLAUDE.md +26 -0
  104. package/plugin/skills/analyze/SKILL.md +12 -0
  105. package/plugin/skills/focus/SKILL.md +20 -0
  106. package/plugin/skills/graph/SKILL.md +13 -0
  107. package/plugin/skills/symbols/SKILL.md +13 -0
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { test, afterEach } from "node:test";
3
3
  import { createServer } from "node:http";
4
4
  import { mkdirSync, writeFileSync, rmSync } from "node:fs";
5
- import { createRequestHandler } from "../src/serve/server.js";
5
+ import { createRequestHandler, shutdownServe } from "../src/serve/server.js";
6
6
  import { AgentBridge } from "../src/serve/agent-bridge.js";
7
7
  import { createTestAgentConfig } from "./test-utils.js";
8
8
  /**
@@ -11,6 +11,7 @@ import { createTestAgentConfig } from "./test-utils.js";
11
11
  */
12
12
  class MockBridge extends AgentBridge {
13
13
  _busy = false;
14
+ _currentSessionId = "sess-1";
14
15
  turnHistory = [];
15
16
  constructor() {
16
17
  super(() => { }, false);
@@ -66,6 +67,9 @@ class MockBridge extends AgentBridge {
66
67
  return null;
67
68
  return { session: {}, label };
68
69
  }
70
+ getCurrentSessionId() {
71
+ return this._currentSessionId;
72
+ }
69
73
  setBusy(busy) {
70
74
  this._busy = busy;
71
75
  }
@@ -113,6 +117,71 @@ class MockBridge extends AgentBridge {
113
117
  edges: [{ from: "foo", to: "Bar", kind: "calls" }],
114
118
  };
115
119
  }
120
+ getStructuralAnalysis() {
121
+ return {
122
+ version: 1,
123
+ findings: [
124
+ {
125
+ id: "hotspot:foo",
126
+ type: "hotspot",
127
+ severity: "warning",
128
+ title: "foo is a structural hotspot",
129
+ summary: "foo has total degree 4.",
130
+ symbols: ["foo"],
131
+ files: ["src/foo.ts"],
132
+ metrics: {
133
+ totalDegree: 4,
134
+ fanIn: 1,
135
+ fanOut: 3,
136
+ threshold: 4,
137
+ score: 4,
138
+ },
139
+ rationale: ["Total degree exceeds the deterministic hotspot threshold."],
140
+ },
141
+ ],
142
+ symbolMetrics: [
143
+ {
144
+ qualifiedName: "foo",
145
+ name: "foo",
146
+ kind: "function",
147
+ filePath: "src/foo.ts",
148
+ spanLines: 12,
149
+ fanIn: 1,
150
+ fanOut: 3,
151
+ totalDegree: 4,
152
+ inboundKinds: ["calls"],
153
+ outboundKinds: ["calls"],
154
+ },
155
+ ],
156
+ fileMetrics: [
157
+ {
158
+ filePath: "src/foo.ts",
159
+ symbolCount: 1,
160
+ incomingEdgeCount: 1,
161
+ outgoingEdgeCount: 3,
162
+ internalEdgeCount: 0,
163
+ afferentCoupling: 1,
164
+ efferentCoupling: 2,
165
+ totalCoupling: 3,
166
+ instability: 0.667,
167
+ },
168
+ ],
169
+ summary: {
170
+ symbolCount: 2,
171
+ edgeCount: 1,
172
+ fileCount: 2,
173
+ findingCount: 1,
174
+ cycleCount: 0,
175
+ hotspotCount: 1,
176
+ thresholds: {
177
+ fanIn: 3,
178
+ fanOut: 3,
179
+ hotspot: 4,
180
+ fileCoupling: 3,
181
+ },
182
+ },
183
+ };
184
+ }
116
185
  getPinnedSymbols() {
117
186
  return [...this._pinned];
118
187
  }
@@ -170,6 +239,13 @@ class MockBridge extends AgentBridge {
170
239
  onEvent({ type: "streaming_chunk", content: `Explaining ${name}...` });
171
240
  return `Explaining ${name}...`;
172
241
  }
242
+ async explainStructuralFinding(findingId, onEvent) {
243
+ if (findingId !== "hotspot:foo") {
244
+ throw new Error(`Structural finding "${findingId}" not found`);
245
+ }
246
+ onEvent({ type: "streaming_chunk", content: `Interpreting ${findingId}...` });
247
+ return `Interpreting ${findingId}...`;
248
+ }
173
249
  }
174
250
  // ── Test harness ──
175
251
  let activeServer;
@@ -220,7 +296,7 @@ test("GET /api/status returns busy when agent is busy", async () => {
220
296
  const body = (await res.json());
221
297
  assert.equal(body.status, "busy");
222
298
  });
223
- test("GET /api/config returns formatted config", async () => {
299
+ test("GET /api/config returns formatted config plus structured settings", async () => {
224
300
  const bridge = new MockBridge();
225
301
  const base = await startTestServer(bridge);
226
302
  const res = await fetch(`${base}/api/config`);
@@ -228,6 +304,10 @@ test("GET /api/config returns formatted config", async () => {
228
304
  const body = (await res.json());
229
305
  assert.ok(body.config.includes("workspaceRoot"));
230
306
  assert.ok(body.config.includes("test-model"));
307
+ assert.equal(body.restartRequired, true);
308
+ assert.equal(body.secretsUiSupported, false);
309
+ assert.ok(body.settings.configPath.endsWith("/.minicode/agent.config.json"));
310
+ assert.ok(body.settings.entries.some((entry) => entry.key === "maxSteps"));
231
311
  });
232
312
  test("GET /api/sessions returns session list", async () => {
233
313
  const bridge = new MockBridge();
@@ -236,6 +316,8 @@ test("GET /api/sessions returns session list", async () => {
236
316
  assert.equal(res.status, 200);
237
317
  const body = (await res.json());
238
318
  assert.equal(body.sessions.length, 1);
319
+ assert.equal(body.currentSessionId, "sess-1");
320
+ assert.equal(body.sessions[0].id, "sess-1");
239
321
  assert.equal(body.sessions[0].label, "test-session");
240
322
  });
241
323
  test("POST /api/sessions/save saves a session", async () => {
@@ -526,6 +608,59 @@ test("GET /api/graph returns nodes and edges", async () => {
526
608
  assert.equal(body.edges[0].to, "Bar");
527
609
  assert.equal(body.edges[0].kind, "calls");
528
610
  });
611
+ test("GET /api/analysis returns deterministic structural findings", async () => {
612
+ const bridge = new MockBridge();
613
+ const base = await startTestServer(bridge);
614
+ const res = await fetch(`${base}/api/analysis`);
615
+ assert.equal(res.status, 200);
616
+ const body = (await res.json());
617
+ assert.equal(body.version, 1);
618
+ assert.equal(body.summary.hotspotCount, 1);
619
+ assert.equal(body.summary.findingCount, 1);
620
+ assert.equal(body.findings[0]?.type, "hotspot");
621
+ assert.deepEqual(body.findings[0]?.symbols, ["foo"]);
622
+ });
623
+ test("POST /api/analysis/explain returns SSE stream", async () => {
624
+ const bridge = new MockBridge();
625
+ const base = await startTestServer(bridge);
626
+ const res = await fetch(`${base}/api/analysis/explain`, {
627
+ method: "POST",
628
+ headers: { "Content-Type": "application/json" },
629
+ body: JSON.stringify({ findingId: "hotspot:foo" }),
630
+ });
631
+ assert.equal(res.status, 200);
632
+ assert.equal(res.headers.get("content-type"), "text/event-stream");
633
+ const text = await res.text();
634
+ assert.ok(text.includes("Interpreting hotspot:foo"));
635
+ assert.ok(text.includes("[DONE]"));
636
+ });
637
+ test("POST /api/analysis/explain rejects missing finding id", async () => {
638
+ const bridge = new MockBridge();
639
+ const base = await startTestServer(bridge);
640
+ const res = await fetch(`${base}/api/analysis/explain`, {
641
+ method: "POST",
642
+ headers: { "Content-Type": "application/json" },
643
+ body: JSON.stringify({}),
644
+ });
645
+ assert.equal(res.status, 400);
646
+ assert.deepEqual(await res.json(), { error: "findingId is required" });
647
+ });
648
+ test("POST /api/analysis/explain streams error event for unknown finding", async () => {
649
+ const bridge = new MockBridge();
650
+ const base = await startTestServer(bridge);
651
+ const res = await fetch(`${base}/api/analysis/explain`, {
652
+ method: "POST",
653
+ headers: { "Content-Type": "application/json" },
654
+ body: JSON.stringify({ findingId: "missing" }),
655
+ });
656
+ assert.equal(res.status, 200);
657
+ const text = await res.text();
658
+ const errorLine = text.split("\n").find((line) => line.startsWith("data: {") && line.includes('"type":"error"'));
659
+ assert.ok(errorLine);
660
+ const payload = JSON.parse(errorLine.slice(6));
661
+ assert.equal(payload.type, "error");
662
+ assert.equal(payload.message, 'Structural finding "missing" not found');
663
+ });
529
664
  test("GET /api/focus returns pinned symbols", async () => {
530
665
  const bridge = new MockBridge();
531
666
  const base = await startTestServer(bridge);
@@ -757,3 +892,84 @@ test("GET /api/symbols/:name/explain returns error for unknown symbol", async ()
757
892
  const text = await res.text();
758
893
  assert.ok(text.includes("error"));
759
894
  });
895
+ // ── Graceful shutdown tests ──
896
+ test("shutdownServe terminates WebSocket clients and calls exit(0)", async () => {
897
+ const bridge = new MockBridge();
898
+ const handler = createRequestHandler(bridge);
899
+ const server = createServer(handler);
900
+ const { WebSocketServer, WebSocket } = await import("ws");
901
+ const wss = new WebSocketServer({ server });
902
+ const openSockets = new Set();
903
+ server.on("connection", (socket) => {
904
+ openSockets.add(socket);
905
+ socket.on("close", () => openSockets.delete(socket));
906
+ });
907
+ // Start server on random port
908
+ await new Promise((resolve) => {
909
+ server.listen(0, "127.0.0.1", () => resolve());
910
+ });
911
+ const addr = server.address();
912
+ // Connect a WebSocket client (simulates browser tab)
913
+ const ws = new WebSocket(`ws://127.0.0.1:${addr.port}`);
914
+ await new Promise((resolve) => ws.on("open", resolve));
915
+ assert.ok(wss.clients.size >= 1, "Should have at least 1 WS client connected");
916
+ assert.ok(openSockets.size >= 1, "Should have at least 1 open socket");
917
+ // Call shutdownServe with a mock exit function
918
+ let exitCode;
919
+ shutdownServe(server, wss, openSockets, (code) => {
920
+ exitCode = code;
921
+ });
922
+ // Wait a tick for async cleanup to propagate
923
+ await new Promise((resolve) => setTimeout(resolve, 100));
924
+ assert.equal(exitCode, 0, "Should have called exit with code 0");
925
+ assert.equal(openSockets.size, 0, "All sockets should be cleared");
926
+ assert.equal(wss.clients.size, 0, "All WS clients should be removed");
927
+ });
928
+ test("shutdownServe works when no clients are connected", async () => {
929
+ const bridge = new MockBridge();
930
+ const handler = createRequestHandler(bridge);
931
+ const server = createServer(handler);
932
+ const { WebSocketServer } = await import("ws");
933
+ const wss = new WebSocketServer({ server });
934
+ const openSockets = new Set();
935
+ await new Promise((resolve) => {
936
+ server.listen(0, "127.0.0.1", () => resolve());
937
+ });
938
+ let exitCode;
939
+ shutdownServe(server, wss, openSockets, (code) => {
940
+ exitCode = code;
941
+ });
942
+ await new Promise((resolve) => setTimeout(resolve, 100));
943
+ assert.equal(exitCode, 0, "Should exit cleanly with no clients");
944
+ });
945
+ test("shutdownServe terminates multiple WebSocket clients", async () => {
946
+ const bridge = new MockBridge();
947
+ const handler = createRequestHandler(bridge);
948
+ const server = createServer(handler);
949
+ const { WebSocketServer, WebSocket } = await import("ws");
950
+ const wss = new WebSocketServer({ server });
951
+ const openSockets = new Set();
952
+ server.on("connection", (socket) => {
953
+ openSockets.add(socket);
954
+ socket.on("close", () => openSockets.delete(socket));
955
+ });
956
+ await new Promise((resolve) => {
957
+ server.listen(0, "127.0.0.1", () => resolve());
958
+ });
959
+ const addr = server.address();
960
+ // Connect 3 WebSocket clients
961
+ const clients = [];
962
+ for (let i = 0; i < 3; i++) {
963
+ const ws = new WebSocket(`ws://127.0.0.1:${addr.port}`);
964
+ await new Promise((resolve) => ws.on("open", resolve));
965
+ clients.push(ws);
966
+ }
967
+ assert.equal(wss.clients.size, 3, "Should have 3 WS clients connected");
968
+ let exitCode;
969
+ shutdownServe(server, wss, openSockets, (code) => {
970
+ exitCode = code;
971
+ });
972
+ await new Promise((resolve) => setTimeout(resolve, 100));
973
+ assert.equal(exitCode, 0, "Should have called exit with code 0");
974
+ assert.equal(openSockets.size, 0, "All sockets should be cleared");
975
+ });
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { test } from "node:test";
5
+ const distWeb = join(import.meta.dirname, "..", "dist", "src", "web");
6
+ test("built HTML contains update action for the current saved session", () => {
7
+ const html = readFileSync(join(distWeb, "index.html"), "utf8");
8
+ assert.ok(html.includes('id="session-update-row"'), "HTML should contain the session update row");
9
+ assert.ok(html.includes('id="session-update-btn"'), "HTML should contain the session update button");
10
+ });
11
+ test("built CSS contains active-session styling", () => {
12
+ const css = readFileSync(join(distWeb, "style.css"), "utf8");
13
+ assert.ok(css.includes(".session-item.active"), "CSS should style the active saved session row");
14
+ assert.ok(css.includes(".session-active-badge"), "CSS should style the active session badge");
15
+ });
16
+ test("built JS contains active saved session update logic", () => {
17
+ const js = readFileSync(join(distWeb, "app.js"), "utf8");
18
+ assert.ok(js.includes("activeSavedSession"), "JS should track the active saved session");
19
+ assert.ok(js.includes("currentSessionId"), "JS should read the current session id from the sessions API");
20
+ assert.ok(js.includes("Session updated:"), "JS should emit the update confirmation message");
21
+ });
@@ -35,3 +35,53 @@ test("trim keeps recent messages while reducing token estimate", () => {
35
35
  assert.equal(messages[1]?.role, "tool");
36
36
  assert.equal(messages[2]?.role, "assistant");
37
37
  });
38
+ test("shrinkToolResults does not mutate original message objects", () => {
39
+ const session = new Session("test");
40
+ session.addMessage({ role: "user", content: "go" });
41
+ session.addMessage({
42
+ role: "assistant",
43
+ content: "calling tool",
44
+ toolCalls: [{ id: "t1", name: "read_file", input: { path: "x" } }],
45
+ });
46
+ const toolMsg = {
47
+ role: "tool",
48
+ toolCallId: "t1",
49
+ toolName: "read_file",
50
+ content: "original-tool-output-that-is-long-enough-to-be-summarized",
51
+ };
52
+ session.addMessage(toolMsg);
53
+ session.addMessage({ role: "assistant", content: "done" });
54
+ // Get a reference to the messages before trimming
55
+ const beforeTrim = session.getMessages();
56
+ const toolBefore = beforeTrim.find((m) => m.role === "tool");
57
+ assert.ok(toolBefore);
58
+ const originalContent = toolBefore.content;
59
+ // Trim to trigger shrinkToolResults
60
+ session.trim(30, 2);
61
+ // The original message object captured before trim should be unchanged
62
+ assert.equal(toolBefore.content, originalContent, "original message object should not be mutated");
63
+ });
64
+ test("shrinkToolResults replaces message in array instead of mutating", () => {
65
+ const session = new Session("test");
66
+ session.addMessage({ role: "user", content: "go" });
67
+ session.addMessage({
68
+ role: "assistant",
69
+ content: "calling tool",
70
+ toolCalls: [{ id: "t1", name: "search", input: { query: "foo" } }],
71
+ });
72
+ session.addMessage({
73
+ role: "tool",
74
+ toolCallId: "t1",
75
+ toolName: "search",
76
+ content: "a]".repeat(500),
77
+ });
78
+ session.addMessage({ role: "user", content: "thanks" });
79
+ session.addMessage({ role: "assistant", content: "ok" });
80
+ session.trim(50, 2);
81
+ const after = session.getMessages();
82
+ const toolAfter = after.find((m) => m.role === "tool");
83
+ // If the tool message survived trimming, its content should be summarized
84
+ if (toolAfter) {
85
+ assert.ok(toolAfter.content.startsWith("[summary:"), "tool result should be summarized after trim");
86
+ }
87
+ });
@@ -0,0 +1,30 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { test } from "node:test";
5
+ const distWeb = join(import.meta.dirname, "..", "dist", "src", "web");
6
+ test("built HTML contains settings entry point and modal shell", () => {
7
+ const html = readFileSync(join(distWeb, "index.html"), "utf8");
8
+ assert.ok(html.includes('id="settings-btn"'), "HTML should contain the settings button");
9
+ assert.ok(html.includes('id="settings-modal"'), "HTML should contain the settings modal");
10
+ assert.ok(!html.includes('id="settings-scope"'), "HTML should no longer contain the settings scope selector");
11
+ assert.ok(html.includes('id="settings-save"'), "HTML should contain the settings save action");
12
+ });
13
+ test("built CSS contains modal and settings layout styles", () => {
14
+ const css = readFileSync(join(distWeb, "style.css"), "utf8");
15
+ assert.ok(css.includes(".modal-panel"), "CSS should contain modal panel styles");
16
+ assert.ok(css.includes(".settings-list"), "CSS should contain settings list styles");
17
+ assert.ok(css.includes(".settings-item-meta"), "CSS should contain settings metadata grid styles");
18
+ assert.ok(css.includes(".settings-help-warning"), "CSS should contain warning styling for env overrides");
19
+ assert.ok(css.includes("body.modal-open"), "CSS should lock scroll while the settings modal is open");
20
+ });
21
+ test("built JS contains config loading and saving logic for settings", () => {
22
+ const js = readFileSync(join(distWeb, "app.js"), "utf8");
23
+ assert.ok(js.includes("/api/config"), "JS should fetch the config API");
24
+ assert.ok(js.includes("Save settings"), "JS should contain the settings save action text");
25
+ assert.ok(js.includes("settingsPayload"), "JS should track settings payload state");
26
+ assert.ok(js.includes("persistedValue"), "JS should wire persisted settings behavior");
27
+ assert.ok(js.includes("settings-help settings-help-warning"), "JS should mark env override help as warning text");
28
+ assert.ok(js.includes("home-dotenv"), "JS should distinguish home dotenv overrides");
29
+ assert.ok(js.includes("manage this setting here"), "JS should explain how to resolve env overrides");
30
+ });
@@ -0,0 +1,218 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { analyzeProjectStructure } from "../src/analysis/structural-analysis.js";
4
+ import { createProjectIndex } from "../src/indexer/project-index.js";
5
+ function makeSymbol(qualifiedName, filePath, kind = "function", startLine = 1, endLine = 5) {
6
+ const shortName = qualifiedName.includes(".")
7
+ ? qualifiedName.slice(qualifiedName.lastIndexOf(".") + 1)
8
+ : qualifiedName;
9
+ return {
10
+ name: shortName,
11
+ qualifiedName,
12
+ kind,
13
+ filePath,
14
+ startLine,
15
+ endLine,
16
+ signature: `${kind} ${qualifiedName}`,
17
+ exported: true,
18
+ dependencies: [],
19
+ };
20
+ }
21
+ function buildTestIndex(symbols, edges) {
22
+ const symbolMap = new Map();
23
+ const fileMap = new Map();
24
+ for (const symbol of symbols) {
25
+ symbolMap.set(symbol.qualifiedName, symbol);
26
+ const list = fileMap.get(symbol.filePath);
27
+ if (list) {
28
+ list.push(symbol);
29
+ }
30
+ else {
31
+ fileMap.set(symbol.filePath, [symbol]);
32
+ }
33
+ }
34
+ return createProjectIndex(symbolMap, fileMap, edges, [], new Map(), "/tmp/test-workspace");
35
+ }
36
+ test("analyzeProjectStructure reports cycles, symbol outliers, and file coupling", () => {
37
+ const symbols = [
38
+ makeSymbol("entry", "src/entry.ts"),
39
+ makeSymbol("service", "src/service.ts"),
40
+ makeSymbol("repo", "src/repo.ts"),
41
+ makeSymbol("util", "src/util.ts"),
42
+ makeSymbol("cycleA", "src/cycle.ts"),
43
+ makeSymbol("cycleB", "src/cycle.ts"),
44
+ ];
45
+ const edges = [
46
+ { from: "entry", to: "service", kind: "calls" },
47
+ { from: "entry", to: "util", kind: "calls" },
48
+ { from: "service", to: "repo", kind: "calls" },
49
+ { from: "service", to: "util", kind: "calls" },
50
+ { from: "service", to: "cycleA", kind: "calls" },
51
+ { from: "repo", to: "util", kind: "calls" },
52
+ { from: "repo", to: "cycleA", kind: "calls" },
53
+ { from: "cycleA", to: "cycleB", kind: "calls" },
54
+ { from: "cycleB", to: "cycleA", kind: "calls" },
55
+ ];
56
+ const report = analyzeProjectStructure(buildTestIndex(symbols, edges));
57
+ assert.equal(report.version, 1);
58
+ assert.equal(report.summary.symbolCount, 6);
59
+ assert.equal(report.summary.edgeCount, 9);
60
+ assert.equal(report.summary.fileCount, 5);
61
+ assert.ok(report.summary.findingCount >= 5);
62
+ assert.equal(report.summary.cycleCount, 1);
63
+ assert.equal(report.summary.thresholds.fanIn, 3);
64
+ assert.equal(report.summary.thresholds.fanOut, 3);
65
+ assert.equal(report.summary.thresholds.hotspot, 4);
66
+ assert.equal(report.summary.thresholds.fileCoupling, 4);
67
+ const cycleFinding = report.findings.find((finding) => finding.type === "cycle");
68
+ assert.ok(cycleFinding);
69
+ assert.deepEqual(cycleFinding?.symbols, ["cycleA", "cycleB"]);
70
+ assert.equal(cycleFinding?.severity, "warning");
71
+ const fanInFinding = report.findings.find((finding) => finding.type === "fanInOutlier" && finding.symbols.includes("util"));
72
+ assert.ok(fanInFinding);
73
+ assert.equal(fanInFinding?.metrics.fanIn, 3);
74
+ const hotspotFinding = report.findings.find((finding) => finding.type === "hotspot" && finding.symbols.includes("service"));
75
+ assert.ok(hotspotFinding);
76
+ assert.equal(hotspotFinding?.metrics.totalDegree, 4);
77
+ assert.ok(!report.findings.some((finding) => finding.type === "fanOutOutlier" && finding.symbols.includes("service")), "hotspot findings should absorb overlapping fan-out outliers for the same symbol");
78
+ const fileFinding = report.findings.find((finding) => finding.type === "fileCoupling" && finding.files.includes("src/service.ts"));
79
+ assert.ok(fileFinding);
80
+ assert.equal(fileFinding?.metrics.totalCoupling, 4);
81
+ assert.equal(fileFinding?.metrics.instability, 0.75);
82
+ const serviceMetric = report.symbolMetrics.find((metric) => metric.qualifiedName === "service");
83
+ assert.ok(serviceMetric);
84
+ assert.equal(serviceMetric?.totalDegree, 4);
85
+ const serviceFileMetric = report.fileMetrics.find((metric) => metric.filePath === "src/service.ts");
86
+ assert.ok(serviceFileMetric);
87
+ assert.equal(serviceFileMetric?.totalCoupling, 4);
88
+ });
89
+ test("analyzeProjectStructure stays empty for disconnected graphs", () => {
90
+ const symbols = [
91
+ makeSymbol("alpha", "src/alpha.ts"),
92
+ makeSymbol("beta", "src/beta.ts"),
93
+ ];
94
+ const report = analyzeProjectStructure(buildTestIndex(symbols, []));
95
+ assert.equal(report.findings.length, 0);
96
+ assert.equal(report.summary.cycleCount, 0);
97
+ assert.equal(report.fileMetrics.length, 2);
98
+ assert.equal(report.symbolMetrics[0]?.totalDegree, 0);
99
+ });
100
+ test("analyzeProjectStructure avoids flagging routine fan-out in skewed graphs", () => {
101
+ const symbols = [makeSymbol("sharedUtil", "src/shared.ts")];
102
+ const edges = [];
103
+ for (let i = 0; i < 40; i += 1) {
104
+ const worker = `worker${i}`;
105
+ symbols.push(makeSymbol(worker, `src/workers/${worker}.ts`));
106
+ edges.push({ from: worker, to: "sharedUtil", kind: "calls" });
107
+ }
108
+ for (let i = 0; i < 5; i += 1) {
109
+ const orchestrator = `orchestrator${i}`;
110
+ symbols.push(makeSymbol(orchestrator, `src/orchestrators/${orchestrator}.ts`, "function", 1, 80));
111
+ for (let j = 0; j < 10; j += 1) {
112
+ const leaf = `${orchestrator}.leaf${j}`;
113
+ symbols.push(makeSymbol(leaf, `src/orchestrators/${orchestrator}.ts`));
114
+ edges.push({ from: orchestrator, to: leaf, kind: "calls" });
115
+ }
116
+ }
117
+ const report = analyzeProjectStructure(buildTestIndex(symbols, edges));
118
+ assert.equal(report.summary.thresholds.fanOut, 10);
119
+ const fanOutFindings = report.findings.filter((finding) => finding.type === "fanOutOutlier");
120
+ assert.equal(fanOutFindings.length, 5);
121
+ assert.ok(fanOutFindings.every((finding) => finding.symbols[0]?.startsWith("orchestrator")));
122
+ assert.ok(report.findings.every((finding) => !finding.symbols.includes("worker0")));
123
+ });
124
+ test("analyzeProjectStructure keeps fan-out findings for non-hotspot orchestrators", () => {
125
+ const symbols = [
126
+ makeSymbol("orchestrator", "src/orchestrator.ts", "function", 1, 80),
127
+ ];
128
+ const edges = [];
129
+ for (let i = 0; i < 3; i += 1) {
130
+ const shared = `shared${i}`;
131
+ symbols.push(makeSymbol(shared, `src/shared/${shared}.ts`));
132
+ edges.push({ from: "orchestrator", to: shared, kind: "calls" });
133
+ }
134
+ const report = analyzeProjectStructure(buildTestIndex(symbols, edges));
135
+ const fanOutFinding = report.findings.find((finding) => finding.type === "fanOutOutlier" && finding.symbols.includes("orchestrator"));
136
+ assert.ok(fanOutFinding, "high fan-out should still surface when the symbol is not also a hotspot");
137
+ assert.ok(!report.findings.some((finding) => finding.type === "hotspot" && finding.symbols.includes("orchestrator")), "fan-out only orchestrators should not be forced into hotspot findings");
138
+ });
139
+ test("analyzeProjectStructure ignores self-recursive loops and suppresses shared type hubs", () => {
140
+ const symbols = [
141
+ makeSymbol("SharedConfig", "src/types.ts", "interface"),
142
+ makeSymbol("loadConfig", "src/load.ts"),
143
+ makeSymbol("saveConfig", "src/save.ts"),
144
+ makeSymbol("runConfigFlow", "src/flow.ts"),
145
+ makeSymbol("walk", "src/walk.ts"),
146
+ ];
147
+ const edges = [
148
+ { from: "loadConfig", to: "SharedConfig", kind: "references" },
149
+ { from: "saveConfig", to: "SharedConfig", kind: "references" },
150
+ { from: "runConfigFlow", to: "SharedConfig", kind: "references" },
151
+ { from: "walk", to: "walk", kind: "calls" },
152
+ ];
153
+ const report = analyzeProjectStructure(buildTestIndex(symbols, edges));
154
+ assert.ok(!report.findings.some((finding) => finding.type === "cycle"), "self-recursion should not surface as a cycle finding");
155
+ assert.ok(!report.findings.some((finding) => finding.type !== "fileCoupling" && finding.symbols.includes("SharedConfig")), "shared type/interface hubs should not produce symbol-level findings by default");
156
+ });
157
+ test("analyzeProjectStructure suppresses contract modules and composition roots at the file level", () => {
158
+ const symbols = [
159
+ makeSymbol("SharedConfig", "src/types.ts", "interface"),
160
+ makeSymbol("SharedEvent", "src/types.ts", "type"),
161
+ makeSymbol("loadConfig", "src/load.ts"),
162
+ makeSymbol("saveConfig", "src/save.ts"),
163
+ makeSymbol("runWorkflow", "src/run.ts"),
164
+ makeSymbol("auditWorkflow", "src/audit.ts"),
165
+ makeSymbol("bridgeRuntime", "src/bridge.ts"),
166
+ makeSymbol("buildShell", "src/bootstrap.ts"),
167
+ makeSymbol("helperA", "src/helpers/a.ts"),
168
+ makeSymbol("helperB", "src/helpers/b.ts"),
169
+ makeSymbol("helperC", "src/helpers/c.ts"),
170
+ makeSymbol("helperD", "src/helpers/d.ts"),
171
+ ];
172
+ const edges = [
173
+ { from: "loadConfig", to: "SharedConfig", kind: "references" },
174
+ { from: "saveConfig", to: "SharedConfig", kind: "references" },
175
+ { from: "runWorkflow", to: "SharedConfig", kind: "references" },
176
+ { from: "auditWorkflow", to: "SharedEvent", kind: "references" },
177
+ { from: "loadConfig", to: "bridgeRuntime", kind: "calls" },
178
+ { from: "saveConfig", to: "bridgeRuntime", kind: "calls" },
179
+ { from: "bridgeRuntime", to: "helperA", kind: "calls" },
180
+ { from: "bridgeRuntime", to: "helperB", kind: "calls" },
181
+ { from: "buildShell", to: "helperA", kind: "calls" },
182
+ { from: "buildShell", to: "helperB", kind: "calls" },
183
+ { from: "buildShell", to: "helperC", kind: "calls" },
184
+ { from: "buildShell", to: "helperD", kind: "calls" },
185
+ ];
186
+ const report = analyzeProjectStructure(buildTestIndex(symbols, edges));
187
+ const fileCouplingFiles = report.findings
188
+ .filter((finding) => finding.type === "fileCoupling")
189
+ .flatMap((finding) => finding.files);
190
+ assert.ok(!fileCouplingFiles.includes("src/types.ts"), "pure contract modules should not surface as file-coupling smells");
191
+ assert.ok(!fileCouplingFiles.includes("src/bootstrap.ts"), "obvious composition roots should not surface as file-coupling smells");
192
+ assert.ok(fileCouplingFiles.includes("src/bridge.ts"), "runtime bridge files with meaningful inbound and outbound coupling should still surface");
193
+ });
194
+ test("analyzeProjectStructure suppresses small composition-root hotspot symbols but keeps long orchestrators", () => {
195
+ const symbols = [
196
+ makeSymbol("composeTools", "src/registry.ts", "function", 1, 28),
197
+ makeSymbol("runRuntime", "src/runtime.ts", "function", 1, 120),
198
+ ];
199
+ const edges = [];
200
+ for (let i = 0; i < 3; i += 1) {
201
+ const caller = `caller${i}`;
202
+ symbols.push(makeSymbol(caller, `src/callers/${caller}.ts`));
203
+ edges.push({ from: caller, to: "composeTools", kind: "calls" });
204
+ }
205
+ for (let i = 0; i < 12; i += 1) {
206
+ const dep = `dep${i}`;
207
+ symbols.push(makeSymbol(dep, `src/deps/${dep}.ts`));
208
+ edges.push({ from: "composeTools", to: dep, kind: "calls" });
209
+ }
210
+ for (let i = 0; i < 16; i += 1) {
211
+ const dep = `runtimeDep${i}`;
212
+ symbols.push(makeSymbol(dep, `src/runtime/deps/${dep}.ts`));
213
+ edges.push({ from: "runRuntime", to: dep, kind: "calls" });
214
+ }
215
+ const report = analyzeProjectStructure(buildTestIndex(symbols, edges));
216
+ assert.ok(!report.findings.some((finding) => finding.symbols.includes("composeTools")), "small composition-root helpers should not surface as structural outliers by default");
217
+ assert.ok(report.findings.some((finding) => finding.symbols.includes("runRuntime")), "long orchestrators should still surface when they fan out broadly");
218
+ });