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:
Colby Mchenry
2026-06-02 10:15:27 -05:00
committed by GitHub
parent 8629f7ab4c
commit 68eaf0dbd8
27 changed files with 1471 additions and 1194 deletions
+58 -30
View File
@@ -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);
});
});