fix(node): every language and folder of an overloaded name shows in codegraph_node (#3030)

codegraph_node's multi-definition answer rendered a name's definitions in
index order (getNodesByName: file_path, start_line) until its caps ran
out: 16 bodies within 12,000 characters, then 20 list lines, then a bare
"+N more". Forty definitions under an early-sorting folder hid the only
one in another language or a late folder: on the triage fixture (40
TypeScript `num` in aaa_client/, one Python `num` in zzz_server/),
zzz_server/format.py appeared nowhere. Without includeCode, a long list
was cut by the output cap at "... (output truncated)".

The bodies still fill in index order, so every overload that rendered
before still renders. On top of them, each language with no body gets its
first hand-written definition rendered, while the whole answer stays
under the output cap. The list of the rest fills its 20 slots in a new
definitionCapOrder (the first definition of each language, then of each
top-level folder within a language, then the rest, each tier in index
order) and is shown in index order; the overflow line names what it
leaves out by language and folder, like "+365 more: 365 typescript in
aaa_client/". The list-only answer is unchanged unless it is longer than
the output cap; then it fills in the same order and names the rest the
same way. Generated stubs stay behind hand-written definitions.

Promoting each folder's first definition into the bodies, as first
tried, cost Alamofire's `validate` three Validation.swift overloads to a
Tests/ helper, and `adapt`, `urlSession`, `encode` and `cancel` one or
two Source/ bodies each, so folders get early list slots only.

Validation: new node-overload-spread tests (6): 5 fail on main's source
(the fits-every-cap guard passes on both) and all pass with the fix. A/B
of every multi-definition name, with and without includeCode, main vs
fix: gin 171 names, 170 identical (`_` only names its overflow);
Alamofire 706 names, 702 identical. No body that rendered before is lost:
`cancel` adds the jQuery `cancel` from docs/ as a 13th body, and
`request`, `urlSession` and `encode` swap one Source/ list line for the
first Tests/ one and name their overflow. Alamofire's `validate`, `task`
and `didCompleteTask` and gin's `reset`, `Bind` and `Render` are
byte-identical.

Reimplements PR #1657 by @ferrine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 05:30:27 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 60b0d7a92d
commit 9f379af79b
3 changed files with 304 additions and 28 deletions
+1
View File
@@ -318,6 +318,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named.
- `codegraph_explore`'s "Dynamic-dispatch links among your symbols" list now shows every link on its own line. Before, the links from an interface method to its implementations all read alike, like `TrySet → TrySet`, so only one was listed, chosen by the order of the index rather than by your query: asking about gin's `tryToSetValue setter.TrySet multipartRequest.TrySet setByForm` showed `headerSource`'s implementation and hid `formSource`'s. Both ends of such a link are now named with their type, like `setter.TrySet → formSource.TrySet`. When there are more links than the list shows, the implementations your query names come first and those in tests or examples last, and a hop the Flow already shows is not repeated.
- `codegraph_explore`, `codegraph_node`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact` and the `codegraph callers`, `callees` and `impact` commands now find a symbol written with its package, the way Go code names it: `refresh.Discovery.refresh`, `storage.Queryable.Querier`, or `gin.Context.Next` in gin's root package. Before, such a name found nothing, so a question written that way lost the symbol and explore's Flow had nothing to connect. A name the search ranked below other results, like gin's `binding.Default`, is found as well, and a Python or Rust module followed by a class and method works the same way.
- When a name has more definitions than one `codegraph_node` answer holds, every language and top-level folder it is defined in now shows up, in `codegraph_node` and `codegraph node`. Before, definitions filled the answer in file order, so forty in one folder could hide the only one in another language or a later folder behind a bare "+5 more". Now a language with no code in the answer gets one of its definitions in full, on top of the ones that fit, the list of the others starts with one from each folder, and the rest are counted by language and folder. Thanks @ferrine.
- When `codegraph_explore` finds more dynamic-dispatch links than its list shows, the symbols you named now take turns, so each one's links make the list: asking about prometheus's `refresh.Discovery.refresh hetzner.refresher.refresh hcloudDiscovery.refresh` now lists hetzner's `robotDiscovery` beside the links into other discovery packages that used to fill the list.
- `codegraph_explore` now shows a function you name even when it is too long for its share of the answer: its definition and the start of its body, with a note where the body is cut that says which lines were left out and which `codegraph_explore` query returns them. Before, a file made of one long function and a line that calls it could come back as just its last few lines, without the function, and asking for the file by path did the same. A trimmed answer also no longer suggests `codegraph_node`, which isn't offered by default. Thanks @Huy3ko for the report and @danusha2345 for the first fix. (#2486)
- When a `codegraph_explore` query names symbols the index has never seen, the answer now says so first: it lists those names and points to `codegraph status`, and to how a folder skipped by default, like a root `build/` folder, is brought into the index. Before, the answer showed only what the query's other words matched, such as an unrelated `Main` and a C++ `Parser`, with nothing to say the names asked about were missing. Thanks @tippmar-nr for the report. (#2462)
+157
View File
@@ -0,0 +1,157 @@
/**
* codegraph_node on a name with more definitions than its caps hold (#1657).
*
* The index lists a name's definitions by path, and the multi-definition
* answer rendered and listed them in that order until its caps ran out: 16
* bodies, 20 list lines, then a bare "+N more". So hundreds of definitions
* under an early-sorting folder hid the only one in another language or a
* late folder, and nothing in the answer said another existed. Now a language
* with no body gets one on top of the overloads that fit, each language and
* top-level folder gets an early list line, and the overflow names what it
* leaves out. The overloads that rendered before still render.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { ToolHandler, definitionCapOrder, describeOmittedDefinitions } from '../src/mcp/tools';
import type { Node } from '../src/types';
const MAX_OUTPUT_LENGTH = 15000;
function write(root: string, rel: string, body: string): void {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), body);
}
async function indexed(root: string): Promise<{ cg: CodeGraph; handler: ToolHandler }> {
const cg = CodeGraph.initSync(root);
await cg.indexAll();
return { cg, handler: new ToolHandler(cg) };
}
async function nodeText(handler: ToolHandler, args: Record<string, unknown>): Promise<string> {
const res = await handler.execute('codegraph_node', args);
expect(res.isError).toBeFalsy();
return res.content?.[0]?.text ?? '';
}
describe('codegraph_node: definitions past the caps (#1657)', () => {
let root: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeAll(async () => {
// 400 TypeScript `num` methods under an early-sorting folder, one more in
// another top-level folder, and the only Python `num` in a late one.
root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-node-spread-'));
for (let f = 0; f < 40; f++) {
const classes = Array.from({ length: 10 }, (_, c) =>
`export class C${f}_${c} {\n num(v: number): string {\n return v.toFixed(${c});\n }\n}\n`).join('\n');
write(root, `aaa_client/m${String(f).padStart(2, '0')}.ts`, classes);
}
write(root, 'bbb_lib/helper.ts', 'export function num(v: number): number {\n return Math.round(v);\n}\n');
write(root, 'zzz_server/format.py', 'def num(value):\n return round(value, 2)\n');
({ cg, handler } = await indexed(root));
}, 120_000);
afterAll(() => {
handler?.closeAll();
cg?.destroy();
fs.rmSync(root, { recursive: true, force: true });
});
it('renders the only Python definition beside the TypeScript overloads that fit', async () => {
const text = await nodeText(handler, { symbol: 'num', includeCode: true });
expect(text).toContain('**402 definitions named "num"**');
expect(text).toContain('Returning 17 in full; 385 more listed below');
// main: the Python body, and the file, appeared nowhere.
expect(text).toContain('**Location:** zzz_server/format.py:1');
expect(text).toContain('def num(value):');
// The same sixteen TypeScript bodies as before still render, in index order.
const tsBodies = [...text.matchAll(/^\*\*Location:\*\* (aaa_client\/\S+)/gm)].map((m) => m[1]);
expect(tsBodies).toEqual([
...Array.from({ length: 10 }, (_, c) => `aaa_client/m00.ts:${2 + 6 * c}`),
...Array.from({ length: 6 }, (_, c) => `aaa_client/m01.ts:${2 + 6 * c}`),
]);
expect(text.length).toBeLessThanOrEqual(MAX_OUTPUT_LENGTH);
});
it('gives another top-level folder an early list line and names the overflow', async () => {
const text = await nodeText(handler, { symbol: 'num', includeCode: true });
const listed = [...text.slice(text.indexOf('**Other definitions**')).matchAll(/^- `num` \(\w+\) — (\S+)$/gm)].map((m) => m[1]);
expect(listed).toHaveLength(20);
// main: bbb_lib/helper.ts was behind a bare "- … +365 more".
expect(listed).toContain('bbb_lib/helper.ts:1');
expect(text).toContain('- … +365 more: 365 typescript in aaa_client/');
expect(text).not.toMatch(/^- … \+\d+ more$/m);
});
it('keeps every language and folder in a list too long for the output cap', async () => {
const text = await nodeText(handler, { symbol: 'num' });
// main: the output cap cut the list at "... (output truncated)", past
// which both late folders fell.
expect(text).not.toContain('(output truncated)');
expect(text.length).toBeLessThanOrEqual(MAX_OUTPUT_LENGTH);
expect(text).toContain('- `num` (function) — zzz_server/format.py:1');
expect(text).toContain('- `num` (function) — bbb_lib/helper.ts:1');
expect(text).toMatch(/^- … \+\d+ more: \d+ typescript in aaa_client\/$/m);
});
});
describe('codegraph_node: a set that fits every cap reads as before (#1657)', () => {
let root: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-node-fits-'));
write(root, 'a/x.ts', 'export function num(v: number): string {\n return String(v);\n}\n');
write(root, 'b/y.py', 'def num(value):\n return str(value)\n');
write(root, 'c/z.ts', 'export function num(v: number): number {\n return v;\n}\n');
({ cg, handler } = await indexed(root));
}, 60_000);
afterAll(() => {
handler?.closeAll();
cg?.destroy();
fs.rmSync(root, { recursive: true, force: true });
});
it('renders every definition in index order with nothing listed', async () => {
const text = await nodeText(handler, { symbol: 'num', includeCode: true });
expect(text).toContain('Returning 3 in full — pick the one you need');
expect([...text.matchAll(/^\*\*Location:\*\* (\S+)/gm)].map((m) => m[1])).toEqual(['a/x.ts:1', 'b/y.py:1', 'c/z.ts:1']);
expect(text).not.toContain('**Other definitions**');
});
});
describe('definitionCapOrder / describeOmittedDefinitions (#1657)', () => {
const def = (filePath: string, language: string, startLine = 1): Node =>
({ id: `${filePath}:${startLine}`, name: 'num', kind: 'function', filePath, language, startLine } as unknown as Node);
it('puts each language first, then each top-level folder, then the rest in order', () => {
const nodes = [
def('a/1.ts', 'typescript'), def('a/2.ts', 'typescript'), def('b/1.ts', 'typescript'),
def('b/2.ts', 'typescript'), def('root.py', 'python'), def('z/1.py', 'python'),
];
expect(definitionCapOrder(nodes).map((n) => n.filePath)).toEqual([
'a/1.ts', 'root.py', // the first in each language
'b/1.ts', 'z/1.py', // the first in each other folder within a language
'a/2.ts', 'b/2.ts', // the rest
]);
const oneFolder = [def('a/1.ts', 'typescript'), def('a/2.ts', 'typescript', 9), def('a/3.ts', 'typescript')];
expect(definitionCapOrder(oneFolder)).toEqual(oneFolder);
});
it('names omitted definitions by language and folder, largest first', () => {
expect(describeOmittedDefinitions([
def('z/1.py', 'python'), def('a/1.ts', 'typescript'), def('a/2.ts', 'typescript'), def('main.go', 'go'),
])).toBe('2 typescript in a/, 1 python in z/, 1 go at the project root');
const many = Array.from({ length: 8 }, (_, i) => def(`d${i}/x.ts`, 'typescript'));
expect(describeOmittedDefinitions(many)).toBe(
'1 typescript in d0/, 1 typescript in d1/, 1 typescript in d2/, 1 typescript in d3/, 1 typescript in d4/, 1 typescript in d5/, 2 elsewhere',
);
});
});
+146 -28
View File
@@ -1912,6 +1912,66 @@ export const tools: ToolDefinition[] = [
* explore's `['query']` becomes `['query', 'projectPath']`, and a tool with no
* `required` list (status/files) gains `['projectPath']`.
*/
/** A path's first folder, or '' for a file at the project root. */
function topLevelFolder(filePath: string): string {
const slash = filePath.indexOf('/');
return slash > 0 ? filePath.slice(0, slash) : '';
}
/**
* The order in which codegraph_node's multi-definition answer fills its
* capped list (#1657): the first definition in each language, then the first
* in each top-level folder within a language, then the rest, each tier in the
* order given. The index lists a name's definitions by path, so forty under an
* early-sorting folder used to take every slot, and a late folder's or another
* language's definition showed up nowhere. A set within one language and
* folder keeps its order.
*/
export function definitionCapOrder(nodes: readonly Node[]): Node[] {
const languages = new Set<string>();
const folders = new Set<string>();
const languageFirsts: Node[] = [];
const folderFirsts: Node[] = [];
const rest: Node[] = [];
for (const n of nodes) {
const folder = `${n.language}\0${topLevelFolder(n.filePath)}`;
const newFolder = !folders.has(folder);
folders.add(folder);
if (!languages.has(n.language)) {
languages.add(n.language);
languageFirsts.push(n);
} else if (newFolder) {
folderFirsts.push(n);
} else {
rest.push(n);
}
}
return [...languageFirsts, ...folderFirsts, ...rest];
}
/**
* What a capped definition list left out, by language and top-level folder,
* largest first: "35 typescript in client/, 2 python at the project root".
*/
export function describeOmittedDefinitions(nodes: readonly Node[]): string {
const groups = new Map<string, { language: string; folder: string; count: number }>();
for (const n of nodes) {
const folder = topLevelFolder(n.filePath);
const key = `${n.language}\0${folder}`;
const group = groups.get(key);
if (group) group.count++;
else groups.set(key, { language: n.language, folder, count: 1 });
}
const MAX_NAMED = 6;
const ranked = [...groups.values()].sort((a, b) => b.count - a.count);
const parts = ranked
.slice(0, MAX_NAMED)
.map((g) => `${g.count} ${g.language} ${g.folder ? `in ${g.folder}/` : 'at the project root'}`);
const elsewhere = ranked.slice(MAX_NAMED).reduce((sum, g) => sum + g.count, 0);
if (elsewhere > 0) parts.push(`${elsewhere} elsewhere`);
return parts.join(', ');
}
function withRequiredProjectPath(defs: ToolDefinition[]): ToolDefinition[] {
return defs.map((tool) => {
if (!tool.inputSchema.properties.projectPath) return tool;
@@ -7941,12 +8001,49 @@ export class ToolHandler {
// FULL bodies as fit a char budget (the agent gets the one it needs in this
// one call, no follow-up parameter to learn), and list any remainder by
// file:line so a large overload set can't overflow the per-tool cap.
//
// The index lists a name's definitions by path, so forty under an
// early-sorting folder used to take every body and list slot, and the only
// one in a late folder or another language showed up nowhere, past a bare
// "+N more" (#1657). Now a language with no body gets one on top of the
// overloads that fit, and the list's slots go first to each language and
// top-level folder (`definitionCapOrder`); an overflow names the languages
// and folders it leaves out. Overloads that fit before still render, and
// what is shown keeps index order, so a set that fits every cap reads
// exactly as before.
const header = `**${matches.length} definitions named "${symbol}"**`;
const indexOrder = new Map(matches.map((n, i) => [n, i] as const));
const inIndexOrder = <T>(items: T[], nodeOf: (item: T) => Node): T[] =>
[...items].sort((a, b) => indexOrder.get(nodeOf(a))! - indexOrder.get(nodeOf(b))!);
const isGenerated = cg.generatedFilePredicate(matches.map((n) => n.filePath));
// Generated stubs stay behind every hand-written definition (findSymbolMatches).
const capOrderOf = (nodes: Node[]): Node[] => [
...definitionCapOrder(nodes.filter((n) => !isGenerated(n.filePath))),
...definitionCapOrder(nodes.filter((n) => isGenerated(n.filePath))),
];
const listLine = (n: Node) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`;
const omittedLine = (omitted: Node[]) => `- … +${omitted.length} more: ${describeOmittedDefinitions(omitted)}`;
if (!includeCode) {
const list = matches.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`);
return this.textResult(this.truncateOutput(
[header, '', 'Re-query with `includeCode: true` to get every body in one call — no need to pick one first.', '', ...list].join('\n'),
));
const prefix = [header, '', 'Re-query with `includeCode: true` to get every body in one call — no need to pick one first.', ''];
const prefixChars = prefix.join('\n').length + 1;
let list = matches.map(listLine);
if (prefixChars + list.join('\n').length > MAX_OUTPUT_LENGTH) {
// Too long for the output cap, which would cut the tail off unnamed:
// fill what fits in cap order, keeping room to name the rest.
const OMITTED_LINE_RESERVE = 1000;
const budget = MAX_OUTPUT_LENGTH - prefixChars - OMITTED_LINE_RESERVE;
const capOrder = capOrderOf(matches);
const shown: Node[] = [];
let listChars = 0;
for (const n of capOrder) {
const lineChars = listLine(n).length + 1;
if (listChars + lineChars > budget) break;
shown.push(n);
listChars += lineChars;
}
list = [...inIndexOrder(shown, (n) => n).map(listLine), omittedLine(capOrder.slice(shown.length))];
}
return this.textResult(this.truncateOutput([...prefix, ...list].join('\n')));
}
const BODY_BUDGET = 12000; // leaves room under MAX_OUTPUT_LENGTH for the header + list
@@ -7955,42 +8052,63 @@ export class ToolHandler {
// render in full rather than relegating the one the agent wanted to a
// bodiless list. Only a set of many LARGE bodies hits the char budget first.
const HARD_CAP = 16;
const rendered: string[] = [];
const listed: Node[] = [];
type Body = { node: Node; section: string };
let rendered: Body[] = [];
let notRendered: Node[] = [];
let used = 0;
for (const n of matches) {
if (rendered.length >= HARD_CAP) { listed.push(n); continue; }
if (rendered.length >= HARD_CAP) { notRendered.push(n); continue; }
const section = await this.renderNodeSection(cg, n, true);
// Always emit the first; emit the rest only while within the char budget.
if (rendered.length === 0 || used + section.length <= BODY_BUDGET) {
rendered.push(section);
rendered.push({ node: n, section });
used += section.length;
} else {
listed.push(n);
notRendered.push(n);
}
}
const out: string[] = [
header,
`Returning ${rendered.length} in full${listed.length ? `; ${listed.length} more listed below` : ''} — pick the one you need (no Read required).`,
'',
rendered.join('\n\n---\n\n'),
];
if (listed.length) {
const LIST_CAP = 20;
const shownList = listed.slice(0, LIST_CAP);
out.push(
const LIST_CAP = 20;
const compose = (bodies: Body[], rest: Node[]): string => {
const restInCapOrder = capOrderOf(rest);
const listed = inIndexOrder(restInCapOrder.slice(0, LIST_CAP), (n) => n);
const omitted = restInCapOrder.slice(LIST_CAP);
const out: string[] = [
header,
`Returning ${bodies.length} in full${rest.length ? `; ${rest.length} more listed below` : ''} — pick the one you need (no Read required).`,
'',
'**Other definitions**',
...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`),
);
if (listed.length > LIST_CAP) out.push(`- … +${listed.length - LIST_CAP} more`);
out.push(
'',
`> Need one of these in full? Call codegraph_node again with \`file\` (e.g. \`"${listed[0]!.filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`,
);
inIndexOrder(bodies, (b) => b.node).map((b) => b.section).join('\n\n---\n\n'),
];
if (listed.length) {
out.push(
'',
'**Other definitions**',
...listed.map(listLine),
);
if (omitted.length) out.push(omittedLine(omitted));
out.push(
'',
`> Need one of these in full? Call codegraph_node again with \`file\` (e.g. \`"${listed[0]!.filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`,
);
}
return out.join('\n');
};
// Each language still without a body gets its first hand-written
// definition rendered on top, while the whole answer stays under the
// output cap; one that doesn't fit keeps its early line in the list.
const languagesWithBody = new Set(rendered.map((b) => b.node.language));
for (const n of [...notRendered]) {
if (languagesWithBody.has(n.language) || isGenerated(n.filePath)) continue;
languagesWithBody.add(n.language);
const bodies = [...rendered, { node: n, section: await this.renderNodeSection(cg, n, true) }];
const rest = notRendered.filter((m) => m !== n);
if (compose(bodies, rest).length <= MAX_OUTPUT_LENGTH) {
rendered = bodies;
notRendered = rest;
}
}
return this.textResult(this.truncateOutput(out.join('\n')));
return this.textResult(this.truncateOutput(compose(rendered, notRendered)));
}
/**