feat(mcp): codegraph_explore as the sole primary tool + store coverage + overload disambiguation (#647)
## Summary
Completes the explore-overhaul arc: `codegraph_explore` becomes the single primary tool an agent reaches for, and its coverage + output shape are tuned so flow/architecture questions resolve with near-zero Read/Grep.
### What changed
- **explore is the sole primary tool** — removed `codegraph_context` (the fuzzy-input Read-trigger) and `codegraph_trace` (under-picked by agents); explore already surfaces the call flow among the symbols you name. A plain natural-language question now works as the query.
- **Store/handler coverage** — functions defined inside object literals (Zustand `create((set, get) => ({ … }))`, Redux/Pinia/MobX, exported handler/route maps) are indexed as real symbols, including calls through `useStore.getState().fn()` and destructured `const { fn } = useStore.getState()`. A general AST rule, not a per-lib hack.
- **Overload disambiguation** — explore leads with the *right* definition when a method name is overloaded across types (a PascalCase type token in the query biases to that type's own def); `codegraph_node` returns *every* overload's body in one call, with an optional `file`/`line` selector to pin one.
- **Method-atomic render** — explore never returns half a method; at the size budget it drops whole methods/files (and lists what it dropped) instead of truncating a body mid-method.
- **Native-read-shaped output** — per-call output is capped to ~24K with a 25K hard ceiling and concentrated into ~150–250-line flow windows, mirroring how the agent natively reads; repo size scales the *call* budget, not the per-call size (a larger response just gets externalized to a file the host Reads back).
- **Blast radius** folded into explore (dependents + covering tests, locations only).
### Benchmark (refreshed on this build)
Re-validated the 7-repo A/B on 2026-06-02 (Opus 4.8, effort=high, median of 4). WITH arm re-measured on this build, WITHOUT reused:
**~16% cheaper · 47% fewer tokens · 22% faster · 58% fewer tool calls** — 0 file reads on 6 of 7 repos (Gin ~1).
The arc trades larger, cache-heavy explore responses for guaranteed near-zero reads, so cost/token margins soften vs the prior build (Excalidraw and Tokio land at cost break-even) while time and tool-calls stay clear wins everywhere — consistent with the project's stated optimization target (latency + tool-calls, not token cost).
### Validation
- Full suite green: **1112 passed, 2 skipped**.
- 28/28 plain WITH runs across the 7 README repos completed clean; reads median 0 on 6/7.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -75,7 +75,8 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — module-qualified lookups (#173)'
|
||||
let projectRoot: string;
|
||||
let cg: any;
|
||||
let handler: any;
|
||||
let findSymbol: (cg: any, s: string) => { node: any; note: string } | null;
|
||||
// findSymbolMatches returns ALL ranked matches; [0] is the resolved/picked one.
|
||||
let findSymbolMatches: (cg: any, s: string) => any[];
|
||||
let findAllSymbols: (cg: any, s: string) => { nodes: any[]; note: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -87,7 +88,7 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — module-qualified lookups (#173)'
|
||||
});
|
||||
await cg.indexAll();
|
||||
handler = new ToolHandler(cg);
|
||||
findSymbol = (handler as any).findSymbol.bind(handler);
|
||||
findSymbolMatches = (handler as any).findSymbolMatches.bind(handler);
|
||||
findAllSymbols = (handler as any).findAllSymbols.bind(handler);
|
||||
});
|
||||
|
||||
@@ -98,10 +99,11 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — module-qualified lookups (#173)'
|
||||
});
|
||||
|
||||
it('resolves `stage_apply::run` to the run in stage_apply.rs (not stage_detect.rs)', () => {
|
||||
const match = findSymbol(cg, 'stage_apply::run');
|
||||
expect(match).not.toBeNull();
|
||||
expect(match!.node.name).toBe('run');
|
||||
expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/);
|
||||
const matches = findSymbolMatches(cg, 'stage_apply::run');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
expect(matches[0]!.name).toBe('run');
|
||||
// Every match must be in stage_apply.rs — never stage_detect.rs.
|
||||
for (const n of matches) expect(n.filePath).toMatch(/configurator\/stage_apply\.rs$/);
|
||||
});
|
||||
|
||||
it('rejects `stage_apply::run` for the same-named function in a different module', () => {
|
||||
@@ -114,29 +116,29 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — module-qualified lookups (#173)'
|
||||
});
|
||||
|
||||
it('resolves `configurator::stage_apply::run` (multi-level qualifier)', () => {
|
||||
const match = findSymbol(cg, 'configurator::stage_apply::run');
|
||||
expect(match).not.toBeNull();
|
||||
expect(match!.node.name).toBe('run');
|
||||
expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/);
|
||||
const matches = findSymbolMatches(cg, 'configurator::stage_apply::run');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
expect(matches[0]!.name).toBe('run');
|
||||
expect(matches[0]!.filePath).toMatch(/configurator\/stage_apply\.rs$/);
|
||||
});
|
||||
|
||||
it('resolves `crate::configurator::stage_apply::run` (Rust path prefix stripped)', () => {
|
||||
const match = findSymbol(cg, 'crate::configurator::stage_apply::run');
|
||||
expect(match).not.toBeNull();
|
||||
expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/);
|
||||
const matches = findSymbolMatches(cg, 'crate::configurator::stage_apply::run');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
expect(matches[0]!.filePath).toMatch(/configurator\/stage_apply\.rs$/);
|
||||
});
|
||||
|
||||
it('resolves `configurator/stage_apply` (slash qualifier)', () => {
|
||||
const match = findSymbol(cg, 'configurator/stage_apply/run');
|
||||
expect(match).not.toBeNull();
|
||||
expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/);
|
||||
const matches = findSymbolMatches(cg, 'configurator/stage_apply/run');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
expect(matches[0]!.filePath).toMatch(/configurator\/stage_apply\.rs$/);
|
||||
});
|
||||
|
||||
it('does not silently collide bare `run` with `run_due_tasks`', () => {
|
||||
const match = findSymbol(cg, 'run');
|
||||
expect(match).not.toBeNull();
|
||||
// Whatever it picks, it must be an exact-name match, not a partial.
|
||||
expect(match!.node.name).toBe('run');
|
||||
const matches = findSymbolMatches(cg, 'run');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
// Whatever it picks, every match must be an exact-name match, not a partial.
|
||||
for (const n of matches) expect(n.name).toBe('run');
|
||||
});
|
||||
|
||||
it('aggregates all bare-name `run` matches across modules', () => {
|
||||
@@ -148,9 +150,22 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — module-qualified lookups (#173)'
|
||||
expect(all.note).toMatch(/Aggregated|symbols named "run"/);
|
||||
});
|
||||
|
||||
it('still returns null for genuinely unknown qualified lookups', () => {
|
||||
const match = findSymbol(cg, 'stage_apply::nonexistent_fn');
|
||||
expect(match).toBeNull();
|
||||
it('still returns nothing for genuinely unknown qualified lookups', () => {
|
||||
const matches = findSymbolMatches(cg, 'stage_apply::nonexistent_fn');
|
||||
expect(matches.length).toBe(0);
|
||||
});
|
||||
|
||||
it('codegraph_node with a `file` hint pins an overloaded name to that file', async () => {
|
||||
// `run` is defined in BOTH stage_apply.rs and stage_detect.rs. A bare lookup
|
||||
// returns both; the `file` hint narrows to the one the caller saw in a trail.
|
||||
const res = await handler.execute('codegraph_node', {
|
||||
symbol: 'run',
|
||||
includeCode: true,
|
||||
file: 'stage_detect.rs',
|
||||
});
|
||||
const text = res.content?.[0]?.text ?? '';
|
||||
expect(text).toMatch(/stage_detect\.rs/);
|
||||
expect(text).not.toMatch(/stage_apply\.rs/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,7 +173,7 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — dotted lookups (regression for #
|
||||
let projectRoot: string;
|
||||
let cg: any;
|
||||
let handler: any;
|
||||
let findSymbol: (cg: any, s: string) => { node: any; note: string } | null;
|
||||
let findSymbolMatches: (cg: any, s: string) => any[];
|
||||
|
||||
beforeEach(async () => {
|
||||
projectRoot = tmpRoot();
|
||||
@@ -166,7 +181,7 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — dotted lookups (regression for #
|
||||
fs.mkdirSync(src, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(src, 'session.ts'),
|
||||
`export class Session {\n request(): void {}\n}\nexport function request(): void {}\n`
|
||||
`export class Session {\n request(): void { fetch('x'); }\n}\nexport function request(): void {}\n`
|
||||
);
|
||||
|
||||
const CodeGraph = (await import('../src/index')).default;
|
||||
@@ -176,7 +191,7 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — dotted lookups (regression for #
|
||||
});
|
||||
await cg.indexAll();
|
||||
handler = new ToolHandler(cg);
|
||||
findSymbol = (handler as any).findSymbol.bind(handler);
|
||||
findSymbolMatches = (handler as any).findSymbolMatches.bind(handler);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -186,9 +201,22 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — dotted lookups (regression for #
|
||||
});
|
||||
|
||||
it('`Session.request` resolves to the method, not the bare function', () => {
|
||||
const match = findSymbol(cg, 'Session.request');
|
||||
expect(match).not.toBeNull();
|
||||
expect(match!.node.kind).toBe('method');
|
||||
expect(match!.node.qualifiedName).toContain('Session::request');
|
||||
const matches = findSymbolMatches(cg, 'Session.request');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
expect(matches[0]!.kind).toBe('method');
|
||||
expect(matches[0]!.qualifiedName).toContain('Session::request');
|
||||
});
|
||||
|
||||
it('codegraph_node on an ambiguous bare name returns ALL overloads with bodies (no guess)', async () => {
|
||||
// `request` is BOTH a method (Session.request) and a free function. The old
|
||||
// behavior returned one + a dead-end "Others:" note, forcing a Read to get
|
||||
// the other overload; now both bodies come back in one call.
|
||||
const res = await handler.execute('codegraph_node', { symbol: 'request', includeCode: true });
|
||||
const text = res.content?.[0]?.text ?? '';
|
||||
expect(text).toContain('2 definitions named "request"');
|
||||
// Both definitions are rendered (method + function), each with a Location.
|
||||
expect(text).toMatch(/\(method\)/);
|
||||
expect(text).toMatch(/\(function\)/);
|
||||
expect((text.match(/\*\*Location:\*\*/g) || []).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user