@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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { FocusTracker } from "@minicode/agent-sdk";
|
|
4
|
+
test("FocusTracker tracks added symbols", () => {
|
|
5
|
+
const tracker = new FocusTracker();
|
|
6
|
+
tracker.addSymbol("Foo");
|
|
7
|
+
tracker.addSymbol("Bar");
|
|
8
|
+
const focused = tracker.getFocusedSymbols();
|
|
9
|
+
assert.equal(focused.size, 2);
|
|
10
|
+
assert.ok(focused.has("Foo"));
|
|
11
|
+
assert.ok(focused.has("Bar"));
|
|
12
|
+
});
|
|
13
|
+
test("FocusTracker evicts oldest symbol when exceeding limit", () => {
|
|
14
|
+
const tracker = new FocusTracker();
|
|
15
|
+
// Add 31 symbols (limit is 30)
|
|
16
|
+
for (let i = 0; i < 31; i++) {
|
|
17
|
+
tracker.addSymbol(`sym_${i}`);
|
|
18
|
+
}
|
|
19
|
+
const focused = tracker.getFocusedSymbols();
|
|
20
|
+
assert.equal(focused.size, 30);
|
|
21
|
+
// The first symbol should have been evicted
|
|
22
|
+
assert.ok(!focused.has("sym_0"), "oldest symbol should be evicted");
|
|
23
|
+
assert.ok(focused.has("sym_30"), "newest symbol should remain");
|
|
24
|
+
});
|
|
25
|
+
test("FocusTracker refreshes existing symbol generation on re-add", () => {
|
|
26
|
+
const tracker = new FocusTracker();
|
|
27
|
+
// Add initial symbols
|
|
28
|
+
tracker.addSymbol("first");
|
|
29
|
+
for (let i = 0; i < 29; i++) {
|
|
30
|
+
tracker.addSymbol(`filler_${i}`);
|
|
31
|
+
}
|
|
32
|
+
// Re-add "first" to refresh its generation
|
|
33
|
+
tracker.addSymbol("first");
|
|
34
|
+
// Add one more to trigger eviction — "first" should survive since it was refreshed
|
|
35
|
+
tracker.addSymbol("extra");
|
|
36
|
+
const focused = tracker.getFocusedSymbols();
|
|
37
|
+
assert.ok(focused.has("first"), "re-added symbol should survive eviction");
|
|
38
|
+
assert.equal(focused.size, 30);
|
|
39
|
+
});
|
|
40
|
+
test("FocusTracker.hasFocus returns correct status", () => {
|
|
41
|
+
const tracker = new FocusTracker();
|
|
42
|
+
tracker.addSymbol("Foo");
|
|
43
|
+
assert.ok(tracker.hasFocus("Foo"));
|
|
44
|
+
assert.ok(!tracker.hasFocus("Bar"));
|
|
45
|
+
});
|
|
46
|
+
test("FocusTracker.clear resets all state", () => {
|
|
47
|
+
const tracker = new FocusTracker();
|
|
48
|
+
tracker.addSymbol("Foo");
|
|
49
|
+
tracker.addSymbol("Bar");
|
|
50
|
+
tracker.clear();
|
|
51
|
+
assert.equal(tracker.getFocusedSymbols().size, 0);
|
|
52
|
+
assert.ok(!tracker.hasFocus("Foo"));
|
|
53
|
+
});
|
|
54
|
+
test("FocusTracker.addSymbols adds multiple symbols at once", () => {
|
|
55
|
+
const tracker = new FocusTracker();
|
|
56
|
+
tracker.addSymbols(["A", "B", "C"]);
|
|
57
|
+
const focused = tracker.getFocusedSymbols();
|
|
58
|
+
assert.equal(focused.size, 3);
|
|
59
|
+
assert.ok(focused.has("A"));
|
|
60
|
+
assert.ok(focused.has("B"));
|
|
61
|
+
assert.ok(focused.has("C"));
|
|
62
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const distWeb = join(import.meta.dirname, '..', 'dist', 'src', 'web');
|
|
6
|
+
test('built CSS contains graph-onboarding styles', () => {
|
|
7
|
+
const css = readFileSync(join(distWeb, 'style.css'), 'utf-8');
|
|
8
|
+
assert.ok(css.includes('.graph-onboarding'), 'CSS should contain .graph-onboarding class');
|
|
9
|
+
assert.ok(css.includes('.graph-onboarding-icon'), 'CSS should contain .graph-onboarding-icon class');
|
|
10
|
+
assert.ok(css.includes('.graph-onboarding-title'), 'CSS should contain .graph-onboarding-title class');
|
|
11
|
+
assert.ok(css.includes('.graph-onboarding-subtitle'), 'CSS should contain .graph-onboarding-subtitle class');
|
|
12
|
+
assert.ok(css.includes('pointer-events: none'), 'onboarding overlay should not block interactions');
|
|
13
|
+
assert.ok(css.includes('width: 380px;'), 'symbol detail panel should have a wider default width');
|
|
14
|
+
});
|
|
15
|
+
test('built JS contains onboarding hint logic', () => {
|
|
16
|
+
const js = readFileSync(join(distWeb, 'app.js'), 'utf-8');
|
|
17
|
+
assert.ok(js.includes('graph-onboarding'), 'JS should create onboarding hint element');
|
|
18
|
+
assert.ok(js.includes('showOnboardingHint'), 'JS should define showOnboardingHint function');
|
|
19
|
+
assert.ok(js.includes('removeOnboardingHint'), 'JS should define removeOnboardingHint function');
|
|
20
|
+
});
|
|
21
|
+
test('built HTML contains #cy graph container', () => {
|
|
22
|
+
const html = readFileSync(join(distWeb, 'index.html'), 'utf-8');
|
|
23
|
+
assert.ok(html.includes('id="cy"'), 'HTML should contain the #cy graph container');
|
|
24
|
+
assert.ok(html.includes('id="graph-pane"'), 'HTML should contain the #graph-pane wrapper');
|
|
25
|
+
});
|
|
26
|
+
test('onboarding hint includes user-facing guidance text in built JS', () => {
|
|
27
|
+
const js = readFileSync(join(distWeb, 'app.js'), 'utf-8');
|
|
28
|
+
assert.ok(js.includes('Code dependency graph'), 'onboarding title should mention the code dependency graph');
|
|
29
|
+
assert.ok(js.includes('Search for a symbol'), 'onboarding subtitle should guide users to search');
|
|
30
|
+
});
|
|
31
|
+
test('onboarding hint is re-shown on clear in built JS', () => {
|
|
32
|
+
const js = readFileSync(join(distWeb, 'app.js'), 'utf-8');
|
|
33
|
+
// The clear handler should call showOnboardingHint after removing elements
|
|
34
|
+
assert.ok(js.includes('graph-clear'), 'JS should reference the clear button');
|
|
35
|
+
// Verify that showOnboardingHint appears more than once (init + clear handler)
|
|
36
|
+
const matches = js.match(/showOnboardingHint/g);
|
|
37
|
+
assert.ok(matches && matches.length >= 2, 'showOnboardingHint should be called at init and on clear');
|
|
38
|
+
});
|
|
39
|
+
test('onboarding hint is removed when nodes are added in built JS', () => {
|
|
40
|
+
const js = readFileSync(join(distWeb, 'app.js'), 'utf-8');
|
|
41
|
+
// removeOnboardingHint should be called inside addNodeToGraph
|
|
42
|
+
assert.ok(js.includes('removeOnboardingHint'), 'JS should call removeOnboardingHint when adding nodes');
|
|
43
|
+
});
|
|
44
|
+
test('built JS keeps agent activity reveals narrower than manual expansion', () => {
|
|
45
|
+
const js = readFileSync(join(distWeb, 'app.js'), 'utf-8');
|
|
46
|
+
assert.ok(js.includes('renderNodeNeighborhoodAndLayout'), 'JS should define a configurable graph-rendering helper');
|
|
47
|
+
assert.ok(js.includes('maxDegrees: 0'), 'agent activity should reveal only the active symbol instead of expanding neighbors');
|
|
48
|
+
assert.ok(js.includes('maxDegrees: 1'), 'intentional graph exploration should still render first-degree neighbors');
|
|
49
|
+
});
|
|
50
|
+
test('built JS auto-opens symbol details for agent activity and graph search selection', () => {
|
|
51
|
+
const js = readFileSync(join(distWeb, 'app.js'), 'utf-8');
|
|
52
|
+
assert.ok(js.includes('focusSymbolInGraph'), 'JS should define a shared focus helper for graph-driven symbol selection');
|
|
53
|
+
assert.ok(js.includes('openDetail: true'), 'JS should request the detail panel when focusing symbols from agent activity or search');
|
|
54
|
+
assert.ok(js.includes('await showDetail(node, detailEl)'), 'JS should populate the symbol detail panel when focus requests it');
|
|
55
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { buildStylesheet, KIND_COLORS } from '../src/shared/graph-styles.js';
|
|
4
|
+
describe('buildStylesheet', () => {
|
|
5
|
+
const styles = buildStylesheet();
|
|
6
|
+
it('returns an array of style entries', () => {
|
|
7
|
+
assert.ok(Array.isArray(styles));
|
|
8
|
+
assert.ok(styles.length > 0);
|
|
9
|
+
});
|
|
10
|
+
it('includes base node style', () => {
|
|
11
|
+
const base = styles.find((s) => s.selector === 'node');
|
|
12
|
+
assert.ok(base, 'base node style should exist');
|
|
13
|
+
assert.equal(base.style['shape'], 'roundrectangle');
|
|
14
|
+
assert.equal(base.style['label'], 'data(label)');
|
|
15
|
+
});
|
|
16
|
+
it('includes base edge style', () => {
|
|
17
|
+
const edge = styles.find((s) => s.selector === 'edge');
|
|
18
|
+
assert.ok(edge, 'base edge style should exist');
|
|
19
|
+
assert.equal(edge.style['curve-style'], 'bezier');
|
|
20
|
+
});
|
|
21
|
+
it('includes a style for each kind color', () => {
|
|
22
|
+
for (const kind of Object.keys(KIND_COLORS)) {
|
|
23
|
+
const entry = styles.find((s) => s.selector === `node.${kind}`);
|
|
24
|
+
assert.ok(entry, `style for node.${kind} should exist`);
|
|
25
|
+
assert.equal(entry.style['border-color'], KIND_COLORS[kind].border);
|
|
26
|
+
assert.equal(entry.style['background-color'], KIND_COLORS[kind].bg);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
it('includes hover-target style with pointer affordance cues', () => {
|
|
30
|
+
const hover = styles.find((s) => s.selector === 'node.hover-target');
|
|
31
|
+
assert.ok(hover, 'hover-target style should exist');
|
|
32
|
+
// Increased size signals interactivity
|
|
33
|
+
assert.equal(hover.style['width'], 24, 'hover-target should enlarge node width');
|
|
34
|
+
assert.equal(hover.style['height'], 24, 'hover-target should enlarge node height');
|
|
35
|
+
// Thicker border for visual highlight
|
|
36
|
+
assert.equal(hover.style['border-width'], 3, 'hover-target should have thicker border');
|
|
37
|
+
// Overlay provides subtle glow
|
|
38
|
+
assert.ok(hover.style['overlay-opacity'] > 0, 'hover-target should have a visible overlay');
|
|
39
|
+
// Higher z-index to pop above neighbors
|
|
40
|
+
assert.ok(hover.style['z-index'] >= 20, 'hover-target should have high z-index');
|
|
41
|
+
});
|
|
42
|
+
it('includes highlighted style for neighborhood nodes', () => {
|
|
43
|
+
const highlighted = styles.find((s) => s.selector === 'node.highlighted');
|
|
44
|
+
assert.ok(highlighted, 'highlighted style should exist');
|
|
45
|
+
assert.ok(highlighted.style['border-width'] > 1.5, 'highlighted should increase border width');
|
|
46
|
+
});
|
|
47
|
+
it('includes structural analysis styles for flagged graph elements', () => {
|
|
48
|
+
const flaggedNode = styles.find((s) => s.selector === 'node.analysis-flagged');
|
|
49
|
+
assert.ok(flaggedNode, 'analysis-flagged node style should exist');
|
|
50
|
+
assert.equal(flaggedNode.style['border-style'], 'double');
|
|
51
|
+
const selectedNode = styles.find((s) => s.selector === 'node.analysis-selected');
|
|
52
|
+
assert.ok(selectedNode, 'analysis-selected node style should exist');
|
|
53
|
+
assert.ok(selectedNode.style['border-width'] >= 4, 'analysis-selected node should emphasize border width');
|
|
54
|
+
const selectedEdge = styles.find((s) => s.selector === 'edge.analysis-selected');
|
|
55
|
+
assert.ok(selectedEdge, 'analysis-selected edge style should exist');
|
|
56
|
+
assert.ok(selectedEdge.style['width'] >= 3, 'analysis-selected edge should increase edge width');
|
|
57
|
+
});
|
|
58
|
+
it('includes faded style for non-neighborhood elements', () => {
|
|
59
|
+
const fadedNode = styles.find((s) => s.selector === 'node.faded');
|
|
60
|
+
assert.ok(fadedNode, 'faded node style should exist');
|
|
61
|
+
assert.ok(fadedNode.style['opacity'] < 1, 'faded nodes should reduce opacity');
|
|
62
|
+
const fadedEdge = styles.find((s) => s.selector === 'edge.faded');
|
|
63
|
+
assert.ok(fadedEdge, 'faded edge style should exist');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -7,6 +7,7 @@ import { generateCodeMap } from "../src/indexer/code-map.js";
|
|
|
7
7
|
import { getPluginForFile, loadPlugins } from "../src/indexer/plugin-loader.js";
|
|
8
8
|
import { buildProjectIndex } from "../src/indexer/project-index.js";
|
|
9
9
|
import { typescriptPlugin } from "../src/indexer/plugins/typescript.js";
|
|
10
|
+
import { normalizeIndexedSymbols } from "../src/indexer/symbol-names.js";
|
|
10
11
|
const SAMPLE_TS = `
|
|
11
12
|
export interface Foo {
|
|
12
13
|
bar: string;
|
|
@@ -199,6 +200,39 @@ test("reindexFile updates symbols and code map after file change", async () => {
|
|
|
199
200
|
const codeMap = index.getCodeMap();
|
|
200
201
|
assert.ok(codeMap.text.includes("title?: string"), "code map should reflect new signature");
|
|
201
202
|
});
|
|
203
|
+
test("buildProjectIndex preserves colliding top-level symbols with distinct display names", async () => {
|
|
204
|
+
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-colliding-symbols-"));
|
|
205
|
+
const samplePath = path.join(workspaceRoot, "sample.ts");
|
|
206
|
+
const content = `export interface Employee {
|
|
207
|
+
id: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class Employee {
|
|
211
|
+
constructor(public id: string) {}
|
|
212
|
+
}
|
|
213
|
+
`;
|
|
214
|
+
await writeFile(samplePath, content, "utf8");
|
|
215
|
+
const index = await buildProjectIndex(workspaceRoot);
|
|
216
|
+
const fileSymbols = index.getSymbolsInFile("sample.ts");
|
|
217
|
+
assert.equal(fileSymbols.length, 3, "should preserve interface, class, and constructor");
|
|
218
|
+
const employeeSymbols = fileSymbols.filter((symbol) => symbol.name === "Employee");
|
|
219
|
+
assert.equal(employeeSymbols.length, 2, "should keep both colliding Employee symbols");
|
|
220
|
+
assert.notEqual(employeeSymbols[0].qualifiedName, employeeSymbols[1].qualifiedName, "colliding symbols should have unique internal qualified names");
|
|
221
|
+
assert.ok(employeeSymbols.some((symbol) => symbol.displayName === "Employee (interface)"));
|
|
222
|
+
assert.ok(employeeSymbols.some((symbol) => symbol.displayName === "Employee (class)"));
|
|
223
|
+
const employeeClass = index.getSymbol("Employee (class)");
|
|
224
|
+
const employeeInterface = index.getSymbol("Employee (interface)");
|
|
225
|
+
assert.ok(employeeClass, "should resolve colliding class by display name");
|
|
226
|
+
assert.ok(employeeInterface, "should resolve colliding interface by display name");
|
|
227
|
+
assert.equal(employeeClass.kind, "class");
|
|
228
|
+
assert.equal(employeeInterface.kind, "interface");
|
|
229
|
+
const codeMap = index.getCodeMap();
|
|
230
|
+
assert.ok(codeMap.text.includes("interface Employee (interface)"));
|
|
231
|
+
assert.ok(codeMap.text.includes("class Employee (class)"));
|
|
232
|
+
const resolved = index.getSymbol("Employee");
|
|
233
|
+
assert.ok(resolved, "bare lookup should still resolve one of the colliding symbols");
|
|
234
|
+
assert.ok(resolved.displayName === "Employee (class)" || resolved.displayName === "Employee (interface)", "resolved symbol should use a disambiguated display name");
|
|
235
|
+
});
|
|
202
236
|
test("TypeScript plugin indexes class expressions assigned to variables", () => {
|
|
203
237
|
const code = `const MyClass = class MyClass {
|
|
204
238
|
constructor(name) {
|
|
@@ -261,3 +295,106 @@ function setup() {
|
|
|
261
295
|
const newCallEdge = edges.find((e) => e.from === "setup" && e.to === "Logger" && e.kind === "calls");
|
|
262
296
|
assert.ok(newCallEdge, "new Logger() should produce a calls edge from setup to Logger");
|
|
263
297
|
});
|
|
298
|
+
test("AST cache avoids double-parsing: resolveDependencies reuses cached ASTs from indexFile", () => {
|
|
299
|
+
const code = `
|
|
300
|
+
export interface Task {
|
|
301
|
+
id: string;
|
|
302
|
+
name: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function createTask(id: string, name: string): Task {
|
|
306
|
+
return { id, name };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export class TaskManager {
|
|
310
|
+
run(task: Task) {
|
|
311
|
+
return createTask(task.id, task.name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
`;
|
|
315
|
+
// indexFile populates the internal AST cache
|
|
316
|
+
const symbols = typescriptPlugin.indexFile("cached.ts", code);
|
|
317
|
+
assert.ok(symbols.length > 0, "should extract symbols");
|
|
318
|
+
// resolveDependencies should reuse the cached AST (no re-parse)
|
|
319
|
+
const projectFiles = new Map([["cached.ts", code]]);
|
|
320
|
+
const edges = typescriptPlugin.resolveDependencies(symbols, projectFiles);
|
|
321
|
+
// Verify edges are still correctly produced (cache didn't break resolution)
|
|
322
|
+
const callEdge = edges.find((e) => e.from === "TaskManager.run" && e.to === "createTask" && e.kind === "calls");
|
|
323
|
+
assert.ok(callEdge, "should find calls edge from TaskManager.run to createTask");
|
|
324
|
+
const refEdge = edges.find((e) => e.from === "createTask" && e.to === "Task" && e.kind === "references");
|
|
325
|
+
assert.ok(refEdge, "should find references edge from createTask to Task");
|
|
326
|
+
});
|
|
327
|
+
test("AST cache is cleared after resolveDependencies completes", () => {
|
|
328
|
+
const code = `export function foo(): void {}`;
|
|
329
|
+
// First pass: indexFile caches the AST
|
|
330
|
+
typescriptPlugin.indexFile("clear-test.ts", code);
|
|
331
|
+
// resolveDependencies should clear the cache when done
|
|
332
|
+
typescriptPlugin.resolveDependencies(typescriptPlugin.indexFile("clear-test.ts", code), new Map([["clear-test.ts", code]]));
|
|
333
|
+
// Second pass with different content on same path — should parse fresh, not stale cache
|
|
334
|
+
const newCode = `export function bar(): string { return "bar"; }`;
|
|
335
|
+
const symbols = typescriptPlugin.indexFile("clear-test.ts", newCode);
|
|
336
|
+
const names = symbols.map((s) => s.qualifiedName);
|
|
337
|
+
assert.ok(names.includes("bar"), "should extract bar from fresh parse");
|
|
338
|
+
assert.ok(!names.includes("foo"), "should not have stale foo from cache");
|
|
339
|
+
});
|
|
340
|
+
test("resolveDependencies works without prior indexFile (cache miss)", () => {
|
|
341
|
+
const code = `
|
|
342
|
+
function alpha() { beta(); }
|
|
343
|
+
function beta() {}
|
|
344
|
+
`;
|
|
345
|
+
// Call resolveDependencies without indexFile first — should still work via fallback parse
|
|
346
|
+
const symbols = [
|
|
347
|
+
{ name: "alpha", qualifiedName: "alpha", kind: "function", filePath: "no-cache.ts", startLine: 2, endLine: 2, signature: "function alpha()", exported: false, dependencies: [] },
|
|
348
|
+
{ name: "beta", qualifiedName: "beta", kind: "function", filePath: "no-cache.ts", startLine: 3, endLine: 3, signature: "function beta()", exported: false, dependencies: [] },
|
|
349
|
+
];
|
|
350
|
+
const projectFiles = new Map([["no-cache.ts", code]]);
|
|
351
|
+
const edges = typescriptPlugin.resolveDependencies(symbols, projectFiles);
|
|
352
|
+
const callEdge = edges.find((e) => e.from === "alpha" && e.to === "beta" && e.kind === "calls");
|
|
353
|
+
assert.ok(callEdge, "should resolve dependencies even without cached AST");
|
|
354
|
+
});
|
|
355
|
+
test("resolveDependencies prefers same-file symbols over same-named helpers in other files", () => {
|
|
356
|
+
const projectIndexCode = `
|
|
357
|
+
async function collectSourceFiles(dir: string): Promise<void> {
|
|
358
|
+
await collectSourceFiles(dir + "/nested");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function buildProjectIndex(): Promise<void> {
|
|
362
|
+
await collectSourceFiles("src");
|
|
363
|
+
}
|
|
364
|
+
`;
|
|
365
|
+
const cacheCode = `
|
|
366
|
+
async function collectSourceFiles(dir: string): Promise<void> {
|
|
367
|
+
await collectSourceFiles(dir + "/cached");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function computeFileHashes(): Promise<void> {
|
|
371
|
+
await collectSourceFiles("src");
|
|
372
|
+
}
|
|
373
|
+
`;
|
|
374
|
+
const byFile = new Map([
|
|
375
|
+
["src/indexer/project-index.ts", typescriptPlugin.indexFile("src/indexer/project-index.ts", projectIndexCode)],
|
|
376
|
+
["src/indexer/cache.ts", typescriptPlugin.indexFile("src/indexer/cache.ts", cacheCode)],
|
|
377
|
+
]);
|
|
378
|
+
const symbols = [...normalizeIndexedSymbols(byFile).values()];
|
|
379
|
+
const projectFiles = new Map([
|
|
380
|
+
["src/indexer/project-index.ts", projectIndexCode],
|
|
381
|
+
["src/indexer/cache.ts", cacheCode],
|
|
382
|
+
]);
|
|
383
|
+
const edges = typescriptPlugin.resolveDependencies(symbols, projectFiles);
|
|
384
|
+
const projectHelper = symbols.find((symbol) => symbol.name === "collectSourceFiles" && symbol.filePath === "src/indexer/project-index.ts");
|
|
385
|
+
const cacheHelper = symbols.find((symbol) => symbol.name === "collectSourceFiles" && symbol.filePath === "src/indexer/cache.ts");
|
|
386
|
+
assert.ok(projectHelper, "project-index helper should be uniquely preserved after normalization");
|
|
387
|
+
assert.ok(cacheHelper, "cache helper should be uniquely preserved after normalization");
|
|
388
|
+
const projectSelfEdge = edges.find((edge) => edge.from === projectHelper?.qualifiedName
|
|
389
|
+
&& edge.to === projectHelper?.qualifiedName
|
|
390
|
+
&& edge.kind === "calls");
|
|
391
|
+
const cacheSelfEdge = edges.find((edge) => edge.from === cacheHelper?.qualifiedName
|
|
392
|
+
&& edge.to === cacheHelper?.qualifiedName
|
|
393
|
+
&& edge.kind === "calls");
|
|
394
|
+
const crossFileEdge = edges.find((edge) => edge.from === projectHelper?.qualifiedName
|
|
395
|
+
&& edge.to === cacheHelper?.qualifiedName
|
|
396
|
+
&& edge.kind === "calls");
|
|
397
|
+
assert.ok(projectSelfEdge, "project-index helper should resolve recursive calls to itself");
|
|
398
|
+
assert.ok(cacheSelfEdge, "cache helper should resolve recursive calls to itself");
|
|
399
|
+
assert.ok(!crossFileEdge, "same-named helpers in other files should not be linked by default");
|
|
400
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { parseCliArgs, validateCliArgs } from "../src/cli/args.js";
|
|
7
|
+
// ── CLI args: plugin install ──
|
|
8
|
+
test("parseCliArgs parses 'plugin install' subcommand", () => {
|
|
9
|
+
const parsed = parseCliArgs(["node", "src/index.ts", "plugin", "install"]);
|
|
10
|
+
assert.equal(parsed.pluginInstall, true);
|
|
11
|
+
assert.equal(parsed.serve, false);
|
|
12
|
+
assert.equal(parsed.oneshot, false);
|
|
13
|
+
});
|
|
14
|
+
test("parseCliArgs does not set pluginInstall for bare 'plugin'", () => {
|
|
15
|
+
const parsed = parseCliArgs(["node", "src/index.ts", "plugin"]);
|
|
16
|
+
assert.equal(parsed.pluginInstall, false);
|
|
17
|
+
assert.equal(parsed.task, "plugin");
|
|
18
|
+
});
|
|
19
|
+
test("validateCliArgs rejects plugin install with serve", () => {
|
|
20
|
+
assert.throws(() => validateCliArgs({
|
|
21
|
+
verbose: false, oneshot: false, json: false, serve: true,
|
|
22
|
+
port: 4567, task: "", pluginInstall: true,
|
|
23
|
+
}), /mutually exclusive/);
|
|
24
|
+
});
|
|
25
|
+
test("validateCliArgs rejects plugin install with oneshot", () => {
|
|
26
|
+
assert.throws(() => validateCliArgs({
|
|
27
|
+
verbose: false, oneshot: true, json: false, serve: false,
|
|
28
|
+
port: 4567, task: "test", pluginInstall: true,
|
|
29
|
+
}), /mutually exclusive/);
|
|
30
|
+
});
|
|
31
|
+
// ── MCP endpoint: malformed JSON ──
|
|
32
|
+
test("MCP POST with malformed JSON returns 400 with JSON-RPC error", async () => {
|
|
33
|
+
// Import after top-level to avoid issues with module loading
|
|
34
|
+
const { createRequestHandler } = await import("../src/serve/server.js");
|
|
35
|
+
const { AgentBridge } = await import("../src/serve/agent-bridge.js");
|
|
36
|
+
// Create a minimal mock bridge
|
|
37
|
+
class MinimalBridge extends AgentBridge {
|
|
38
|
+
constructor() { super(() => { }, false); }
|
|
39
|
+
getConfig() {
|
|
40
|
+
return {
|
|
41
|
+
modelProvider: "openai-compatible",
|
|
42
|
+
model: "test",
|
|
43
|
+
maxSteps: 10,
|
|
44
|
+
maxTokens: 1024,
|
|
45
|
+
maxContextTokens: 4000,
|
|
46
|
+
workspaceRoot: "/tmp/mcp-test",
|
|
47
|
+
commandTimeoutMs: 5000,
|
|
48
|
+
maxFileSizeBytes: 100000,
|
|
49
|
+
commandDenylist: [],
|
|
50
|
+
confirmDestructive: false,
|
|
51
|
+
keepRecentMessages: 6,
|
|
52
|
+
loopDetectionWindow: 4,
|
|
53
|
+
maxToolOutputChars: 2000,
|
|
54
|
+
openAiBaseUrl: "http://localhost:1234/v1",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const bridge = new MinimalBridge();
|
|
59
|
+
const handler = createRequestHandler(bridge, () => { });
|
|
60
|
+
const server = createServer(handler);
|
|
61
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
62
|
+
const addr = server.address();
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`http://127.0.0.1:${addr.port}/mcp`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: "this is not json{{{",
|
|
68
|
+
});
|
|
69
|
+
assert.equal(res.status, 400);
|
|
70
|
+
const body = await res.json();
|
|
71
|
+
assert.equal(body.jsonrpc, "2.0");
|
|
72
|
+
assert.equal(body.error.code, -32700);
|
|
73
|
+
assert.ok(body.error.message.includes("Parse error"));
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
server.close();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// ── MCP endpoint: missing session ID ──
|
|
80
|
+
test("MCP POST without session ID and non-init request returns 400", async () => {
|
|
81
|
+
const { createRequestHandler } = await import("../src/serve/server.js");
|
|
82
|
+
const { AgentBridge } = await import("../src/serve/agent-bridge.js");
|
|
83
|
+
class MinimalBridge extends AgentBridge {
|
|
84
|
+
constructor() { super(() => { }, false); }
|
|
85
|
+
getConfig() {
|
|
86
|
+
return {
|
|
87
|
+
modelProvider: "openai-compatible",
|
|
88
|
+
model: "test",
|
|
89
|
+
maxSteps: 10,
|
|
90
|
+
maxTokens: 1024,
|
|
91
|
+
maxContextTokens: 4000,
|
|
92
|
+
workspaceRoot: "/tmp/mcp-test",
|
|
93
|
+
commandTimeoutMs: 5000,
|
|
94
|
+
maxFileSizeBytes: 100000,
|
|
95
|
+
commandDenylist: [],
|
|
96
|
+
confirmDestructive: false,
|
|
97
|
+
keepRecentMessages: 6,
|
|
98
|
+
loopDetectionWindow: 4,
|
|
99
|
+
maxToolOutputChars: 2000,
|
|
100
|
+
openAiBaseUrl: "http://localhost:1234/v1",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const bridge = new MinimalBridge();
|
|
105
|
+
const handler = createRequestHandler(bridge, () => { });
|
|
106
|
+
const server = createServer(handler);
|
|
107
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
108
|
+
const addr = server.address();
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(`http://127.0.0.1:${addr.port}/mcp`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }),
|
|
114
|
+
});
|
|
115
|
+
assert.equal(res.status, 400);
|
|
116
|
+
const body = await res.json();
|
|
117
|
+
assert.ok(body.error.includes("session"));
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
server.close();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
// ── MCP endpoint: method not allowed ──
|
|
124
|
+
test("MCP PATCH returns 405", async () => {
|
|
125
|
+
const { createRequestHandler } = await import("../src/serve/server.js");
|
|
126
|
+
const { AgentBridge } = await import("../src/serve/agent-bridge.js");
|
|
127
|
+
class MinimalBridge extends AgentBridge {
|
|
128
|
+
constructor() { super(() => { }, false); }
|
|
129
|
+
getConfig() {
|
|
130
|
+
return {
|
|
131
|
+
modelProvider: "openai-compatible",
|
|
132
|
+
model: "test",
|
|
133
|
+
maxSteps: 10,
|
|
134
|
+
maxTokens: 1024,
|
|
135
|
+
maxContextTokens: 4000,
|
|
136
|
+
workspaceRoot: "/tmp/mcp-test",
|
|
137
|
+
commandTimeoutMs: 5000,
|
|
138
|
+
maxFileSizeBytes: 100000,
|
|
139
|
+
commandDenylist: [],
|
|
140
|
+
confirmDestructive: false,
|
|
141
|
+
keepRecentMessages: 6,
|
|
142
|
+
loopDetectionWindow: 4,
|
|
143
|
+
maxToolOutputChars: 2000,
|
|
144
|
+
openAiBaseUrl: "http://localhost:1234/v1",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const bridge = new MinimalBridge();
|
|
149
|
+
const handler = createRequestHandler(bridge, () => { });
|
|
150
|
+
const server = createServer(handler);
|
|
151
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
152
|
+
const addr = server.address();
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch(`http://127.0.0.1:${addr.port}/mcp`, {
|
|
155
|
+
method: "PATCH",
|
|
156
|
+
});
|
|
157
|
+
assert.equal(res.status, 405);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
server.close();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
// ── Plugin installer: resolves source dir ──
|
|
164
|
+
test("plugin source directory contains plugin.json", async () => {
|
|
165
|
+
// Verify the plugin directory exists in the repo
|
|
166
|
+
const pluginJson = path.resolve(process.cwd(), "plugin/.claude-plugin/plugin.json");
|
|
167
|
+
assert.ok(existsSync(pluginJson), `Plugin manifest should exist at ${pluginJson}`);
|
|
168
|
+
});
|
|
169
|
+
test("plugin manifest has required fields", async () => {
|
|
170
|
+
const pluginJson = path.resolve(process.cwd(), "plugin/.claude-plugin/plugin.json");
|
|
171
|
+
const { readFile } = await import("node:fs/promises");
|
|
172
|
+
const content = JSON.parse(await readFile(pluginJson, "utf-8"));
|
|
173
|
+
assert.ok(content.name, "Plugin must have a name");
|
|
174
|
+
assert.equal(content.name, "minicode");
|
|
175
|
+
assert.ok(content.version, "Plugin must have a version");
|
|
176
|
+
assert.ok(content.description, "Plugin must have a description");
|
|
177
|
+
});
|
|
178
|
+
test("plugin .mcp.json references minicode MCP endpoint", async () => {
|
|
179
|
+
const mcpJson = path.resolve(process.cwd(), "plugin/.mcp.json");
|
|
180
|
+
assert.ok(existsSync(mcpJson), `.mcp.json should exist`);
|
|
181
|
+
const { readFile } = await import("node:fs/promises");
|
|
182
|
+
const content = JSON.parse(await readFile(mcpJson, "utf-8"));
|
|
183
|
+
assert.ok(content.mcpServers.minicode, "Should have a minicode server entry");
|
|
184
|
+
assert.equal(content.mcpServers.minicode.type, "http");
|
|
185
|
+
assert.ok(content.mcpServers.minicode.url.includes("/mcp"), "URL should point to /mcp");
|
|
186
|
+
});
|
|
@@ -73,6 +73,35 @@ test("openai-compatible client sends tool schemas and parses tool calls", async
|
|
|
73
73
|
const tools = parsedBody.tools;
|
|
74
74
|
assert.equal(tools[0]?.type, "function");
|
|
75
75
|
});
|
|
76
|
+
test("openai-compatible client sends correct app URL in HTTP-Referer header", async () => {
|
|
77
|
+
let capturedHeaders = {};
|
|
78
|
+
const fetchImpl = async (_input, init) => {
|
|
79
|
+
const rawHeaders = init?.headers;
|
|
80
|
+
capturedHeaders = rawHeaders ?? {};
|
|
81
|
+
return new Response(JSON.stringify({
|
|
82
|
+
choices: [
|
|
83
|
+
{
|
|
84
|
+
finish_reason: "stop",
|
|
85
|
+
message: { content: "hello", tool_calls: [] },
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
usage: { prompt_tokens: 5, completion_tokens: 3 },
|
|
89
|
+
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
90
|
+
};
|
|
91
|
+
const client = new OpenAICompatibleModelClient({
|
|
92
|
+
baseUrl: "http://localhost:1234/v1",
|
|
93
|
+
fetchImpl,
|
|
94
|
+
});
|
|
95
|
+
await client.chat({
|
|
96
|
+
model: "test-model",
|
|
97
|
+
system: "sys",
|
|
98
|
+
messages: [{ role: "user", content: "hi" }],
|
|
99
|
+
tools: [],
|
|
100
|
+
maxTokens: 64,
|
|
101
|
+
});
|
|
102
|
+
assert.equal(capturedHeaders["HTTP-Referer"], "https://minicode.seanholung.com", "HTTP-Referer should point to minicode.seanholung.com");
|
|
103
|
+
assert.equal(capturedHeaders["X-Title"], "minicode");
|
|
104
|
+
});
|
|
76
105
|
test("createModelClient returns openai-compatible client", () => {
|
|
77
106
|
const config = {
|
|
78
107
|
...createTestAgentConfig("/tmp"),
|