@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.
- package/README.md +48 -43
- package/dist/scripts/run-benchmarks.js +147 -0
- package/dist/src/agent/config.js +149 -40
- package/dist/src/agent/editable-config.js +314 -0
- package/dist/src/analysis/structural-analysis.js +379 -0
- package/dist/src/benchmark/evaluator.js +79 -0
- package/dist/src/benchmark/index.js +4 -0
- package/dist/src/benchmark/reporter.js +177 -0
- package/dist/src/benchmark/runner.js +100 -0
- package/dist/src/benchmark/task-loader.js +78 -0
- package/dist/src/benchmark/types.js +5 -0
- package/dist/src/cli/args.js +10 -0
- package/dist/src/cli/config-slash-command.js +135 -0
- package/dist/src/cli/plugin-install.js +69 -0
- package/dist/src/index.js +76 -6
- package/dist/src/indexer/cache.js +6 -4
- package/dist/src/indexer/code-map.js +41 -13
- package/dist/src/indexer/plugins/typescript.js +70 -23
- package/dist/src/indexer/project-index.js +175 -36
- package/dist/src/indexer/symbol-names.js +92 -0
- package/dist/src/model-utils.js +18 -0
- package/dist/src/serve/agent-bridge.js +203 -24
- package/dist/src/serve/mcp-server.js +405 -0
- package/dist/src/serve/server.js +165 -10
- package/dist/src/serve/websocket.js +8 -0
- package/dist/src/shared/graph-styles.js +119 -0
- package/dist/src/tools/find-path.js +75 -0
- package/dist/src/tools/find-references.js +7 -2
- package/dist/src/tools/get-dependencies.js +3 -2
- package/dist/src/tools/read-symbol.js +12 -5
- package/dist/src/tools/registry.js +3 -1
- package/dist/src/tools/search-code-map.js +4 -2
- package/dist/src/ui/app.js +1 -1
- package/dist/src/ui/cli-ink.js +79 -4
- package/dist/src/ui/components/header-bar.js +6 -2
- package/dist/src/ui/state/ui-store.js +5 -0
- package/dist/src/web/app.js +1124 -176
- package/dist/src/web/index.html +113 -3
- package/dist/src/web/style.css +973 -55
- package/dist/tests/agent.test.js +31 -0
- package/dist/tests/analysis-helpers.test.js +89 -0
- package/dist/tests/analysis-ui.test.js +29 -0
- package/dist/tests/benchmark-harness.test.js +527 -0
- package/dist/tests/config-api.test.js +143 -0
- package/dist/tests/config-integration.test.js +751 -0
- package/dist/tests/config-slash-command.test.js +106 -0
- package/dist/tests/config.test.js +42 -1
- package/dist/tests/context-indicator.test.js +220 -0
- package/dist/tests/editable-config.test.js +109 -0
- package/dist/tests/find-path.test.js +183 -0
- package/dist/tests/focus-tracker.test.js +62 -0
- package/dist/tests/graph-onboarding.test.js +55 -0
- package/dist/tests/graph-styles.test.js +65 -0
- package/dist/tests/indexer.test.js +137 -0
- package/dist/tests/mcp-and-plugin.test.js +186 -0
- package/dist/tests/model-client-openai.test.js +29 -0
- package/dist/tests/model-selection.test.js +136 -0
- package/dist/tests/model-utils.test.js +22 -0
- package/dist/tests/reasoning-effort.test.js +264 -0
- package/dist/tests/run-benchmarks.test.js +161 -0
- package/dist/tests/search-code-map.test.js +18 -0
- package/dist/tests/serve.integration.test.js +218 -2
- package/dist/tests/session-ui.test.js +21 -0
- package/dist/tests/session.test.js +50 -0
- package/dist/tests/settings-ui.test.js +30 -0
- package/dist/tests/structural-analysis.test.js +218 -0
- package/node_modules/@minicode/agent-sdk/README.md +80 -51
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts +16 -5
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +51 -33
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +14 -0
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +3 -2
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.js +2 -0
- package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts +35 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts.map +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js +64 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js.map +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +7 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +5 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +83 -11
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js +8 -1
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.js +4 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js +3 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js +8 -2
- package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -5
- package/plugin/.claude-plugin/plugin.json +12 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/CLAUDE.md +26 -0
- package/plugin/skills/analyze/SKILL.md +12 -0
- package/plugin/skills/focus/SKILL.md +20 -0
- package/plugin/skills/graph/SKILL.md +13 -0
- 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
|
+
});
|