fix(callers): list every call site of a caller, and a function that calls itself (#3077)

getCallers keeps one row per caller (issue #1086) with the caller's first
edge only, and seeds its reported set with the symbol itself, so
codegraph callers showed a caller that calls the symbol on two lines
once, at the line its own declaration starts on, and left a recursive
call out, though the index stores every one of those edges and each is
a line to edit when the symbol changes. The CLI also merged edges on
source, target and kind, which dropped a second call site from --json.

A new GraphTraverser.getCallSites (CodeGraph.getCallSites) reads the
direct callers or callees with every edge: one row per neighbour in the
order getCallers / getCallees list them, every site kept, and the
symbol's own row, flagged recursive, when it calls itself. getCallers
and getCallees are unchanged, so ranking, explore's Flow and blast
radius, impact and the viewer read exactly as before; a test pins that
every non-recursive row of getCallSites equals getCallers' row and first
edge. src/graph/call-sites.ts holds the one wording of a site list
(9, 10, +N more) the surfaces share.

- codegraph callers prints a caller at its call-site lines (up to 10,
  then +N more), a recursive row as "(calls itself)", and --json gives
  each row lines and recursive, with every site in edges. Callees keep
  the callee's declaration line, where a call leads.
- codegraph_callers / codegraph_callees read the same rows (10 sites per
  caller), and codegraph_node's trail shows callers at their sites, 3 at
  most, and the symbol once among its callers when it calls itself.

Validation: new callers-call-sites.test.ts (7 tests) fails 7 of 7 with
main's sources and passes with the fix; 11 neighbouring callers, trail
and lookup test files (217 tests) pass. Before/after on one index per
repo (expressjs/express 0dee2c52, excalidraw d3a9c2a1, gin-gonic/gin
0f09c3a9): trails changed for 108/253, 3630/4180 and 557/1395 symbols,
nearly all callers moving from the declaration line to the call sites;
recursive rows newly shown 2/20/7. Budget: trail median 115->117,
354->360, 185->187 chars, largest growth +131/+212/+91 chars, and the
busiest caller rows (67, 300 and 100 sites) stay one capped entry.
codegraph_callers on each repo's 15 busiest callees: largest answer
1025->1358, 1882->2127, 1790->1805 chars.

Not in this change: the issue's own recursion, child.walk() on a
for...of element over children: TreeNode[], is not linked at all on
main (the receiver's element type isn't inferred), so it needs a
resolution fix of its own; this.walk() and a plain recursive call are.

Reimplements PR #2994 by @johnhldiver-beep.

Fixes #2909

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 15:30:39 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 59eb0d2820
commit fa03fa6bb4
8 changed files with 440 additions and 74 deletions
+39 -16
View File
@@ -72,6 +72,7 @@ import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
import { BROWSER_ENV, DEFAULT_UI_PORT } from '../ui-server/constants';
import type { UiServerHandle } from '../ui-server';
import { lookupSymbolNodes, describeSymbolNode, groupDefinitions } from '../graph/symbol-lookup';
import { callSiteLines, formatCallSiteLines } from '../graph/call-sites';
import { goPackageReaderFor } from '../graph/go-package';
import type { Node, Edge } from '../types';
import { isTestPath } from '../search/query-utils';
@@ -416,6 +417,9 @@ function cliNode(node: Node) {
return { name: node.name, kind: node.kind, filePath: node.filePath, startLine: node.startLine };
}
/** Call-site lines `codegraph callers` prints for one caller; `--json` lists them all. */
const CLI_CALL_SITES_SHOWN = 10;
/** Attribute a group's edges to every overload of this definition. */
function cliDefinition(group: Node[]) {
const head = group[0]!;
@@ -2419,29 +2423,47 @@ for (const direction of ['callers', 'callees'] as const) {
const note = filteredOut
? `no definition of "${symbol}" matches file "${options.file}" — showing all definitions instead.`
: undefined;
// Every site of each neighbour (#2909): a caller that calls the symbol on
// two lines has both, and a symbol that calls itself is a row of its own.
const collected = groups.map((group) => {
const nodes = new Map<string, Node>();
const edges = new Map<string, Edge>();
const recursive = new Set<string>();
for (const target of group) {
const connections = direction === 'callers' ? cg.getCallers(target.id) : cg.getCallees(target.id);
for (const { node, edge } of connections) {
nodes.set(node.id, node);
edges.set(`${edge.source}->${edge.target}:${edge.kind}`, edge);
for (const row of cg.getCallSites(target.id, direction)) {
nodes.set(row.node.id, row.node);
if (row.recursive) recursive.add(row.node.id);
for (const edge of row.edges) {
edges.set(`${edge.source}|${edge.target}|${edge.kind}|${edge.line ?? -1}|${edge.column ?? -1}`, edge);
}
}
}
return { group, nodes: [...nodes.values()], edges: [...edges.values()] };
return { group, nodes: [...nodes.values()], edges: [...edges.values()], recursive };
});
const relationships = (node: Node, edges: Edge[]) => [...new Set(edges
.filter((edge) => (direction === 'callers' ? edge.source : edge.target) === node.id)
.map((edge) => edge.kind))];
const ownEdges = (node: Node, edges: Edge[]) =>
edges.filter((edge) => (direction === 'callers' ? edge.source : edge.target) === node.id);
const relationships = (node: Node, edges: Edge[]) => [...new Set(ownEdges(node, edges).map((edge) => edge.kind))];
// A caller is listed at the lines its calls sit on, the lines to edit; a
// callee at its own declaration, where a call leads.
const location = (node: Node, edges: Edge[]) => {
const lines = direction === 'callers' ? callSiteLines(ownEdges(node, edges)) : [];
if (lines.length > 0) return `:${formatCallSiteLines(lines, CLI_CALL_SITES_SHOWN)}`;
return node.startLine ? `:${node.startLine}` : '';
};
const row = (node: Node, edges: Edge[], recursive: boolean) => ({
...cliNode(node),
relationships: relationships(node, edges),
lines: callSiteLines(ownEdges(node, edges)),
recursive,
});
if (options.json) {
const definitions = collected.map(({ group, nodes, edges }) => {
const definitions = collected.map(({ group, nodes, edges, recursive }) => {
const limited = nodes.slice(0, limit);
const shown = new Set(limited.map((node) => node.id));
return {
...cliDefinition(group),
[direction]: limited.map((node) => ({ id: node.id, ...cliNode(node), relationships: relationships(node, edges) })),
[direction]: limited.map((node) => ({ id: node.id, ...row(node, edges, recursive.has(node.id)) })),
edges: edges.filter((edge) => shown.has(direction === 'callers' ? edge.source : edge.target)),
total: nodes.length,
limit,
@@ -2453,6 +2475,7 @@ for (const direction of ['callers', 'callees'] as const) {
for (const node of nodes) union.set(node.id, node);
}
const total = union.size;
const allEdges = collected.flatMap((entry) => entry.edges);
console.log(JSON.stringify({
symbol,
targets: groups.flat().map((node) => cliDefinition([node]).definition),
@@ -2462,8 +2485,8 @@ for (const direction of ['callers', 'callees'] as const) {
filteredOut,
note,
definitions,
[direction]: [...union.values()].slice(0, limit).map((node) => ({ ...cliNode(node),
relationships: relationships(node, collected.flatMap((entry) => entry.edges)) })),
[direction]: [...union.values()].slice(0, limit).map((node) =>
row(node, allEdges, collected.some((entry) => entry.recursive.has(node.id)))),
total,
limit,
truncated: total > limit,
@@ -2473,7 +2496,7 @@ for (const direction of ['callers', 'callees'] as const) {
if (ambiguous) {
console.log(chalk.bold(`\n${title} of "${symbol}" — ${groups.length} distinct definitions (narrow with --file):`));
}
for (const { group, nodes, edges } of collected) {
for (const { group, nodes, edges, recursive } of collected) {
const limited = nodes.slice(0, limit);
const total = nodes.length;
const truncated = total > limit;
@@ -2489,11 +2512,11 @@ for (const direction of ['callers', 'callees'] as const) {
else info(`No ${direction} found for "${symbol}"`);
}
for (const node of limited) {
const loc = node.startLine ? `:${node.startLine}` : '';
const kinds = relationships(node, edges).filter((kind) => kind !== 'calls');
const relation = kinds.length ? ` [${kinds.join(', ')}]` : '';
console.log(chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name) + chalk.dim(relation));
console.log(chalk.dim(` ${node.filePath}${loc}`));
const itself = recursive.has(node.id) ? ' (calls itself)' : '';
console.log(chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name) + chalk.dim(itself + relation));
console.log(chalk.dim(` ${node.filePath}${location(node, edges)}`));
console.log();
}
if (truncated) console.log(chalk.dim(`Showing ${limited.length} of ${total}; pass --limit to widen.`));
+22
View File
@@ -0,0 +1,22 @@
/**
* How a caller's call sites read, wherever callers are listed: `codegraph
* callers`, codegraph_callers and the trail of codegraph_node (#2909). Each
* site is a line to edit when the symbol changes, so the list says them all,
* up to a cap that keeps one busy caller from crowding out the rest.
*/
import type { Edge } from '../types';
/** The lines `edges` sit on, ascending, once each. An edge with no line adds none. */
export function callSiteLines(edges: readonly Edge[]): number[] {
const lines = new Set<number>();
for (const edge of edges) {
if (typeof edge.line === 'number' && edge.line > 0) lines.add(edge.line);
}
return [...lines].sort((a, b) => a - b);
}
/** `9, 10, 14`, or past `max` lines `9, 10, 14, +7 more`. */
export function formatCallSiteLines(lines: readonly number[], max: number): string {
const shown = lines.slice(0, max).join(', ');
return lines.length > max ? `${shown}, +${lines.length - max} more` : shown;
}
+2
View File
@@ -5,6 +5,8 @@
*/
export { GraphTraverser } from './traversal';
export type { CallSiteRow } from './traversal';
export { callSiteLines, formatCallSiteLines } from './call-sites';
export { GraphQueryManager } from './queries';
export {
buildTypeHierarchy,
+67 -5
View File
@@ -28,6 +28,34 @@ interface TraversalStep {
depth: number;
}
/**
* The edges that make one symbol another's caller. `instantiates` counts:
* constructing a class (`Foo(...)` / `new Foo()`) is calling its constructor,
* so the instantiation site is a caller of the class (#774).
*/
const CALL_EDGE_KINDS: EdgeKind[] = ['calls', 'references', 'imports', 'instantiates', 'navigates'];
/** One edge's site: two edges are the same only on source, target, kind and position. */
function edgeSite(e: Edge): string {
return `${e.source}|${e.target}|${e.kind}|${e.line ?? -1}|${e.column ?? -1}`;
}
/**
* A direct caller (or callee) of a symbol with every edge between them, from
* {@link GraphTraverser.getCallSites}.
*/
export interface CallSiteRow {
/** The caller, or for callees the callee. */
node: Node;
/**
* Every edge between the two, once per site, in index order. The first is
* the edge {@link GraphTraverser.getCallers} reports for this neighbour.
*/
edges: Edge[];
/** The symbol calls itself: this row's node is the symbol asked about. */
recursive: boolean;
}
/**
* Graph traverser for BFS and DFS traversal
*/
@@ -67,8 +95,6 @@ export class GraphTraverser {
// Edge-identity dedup so a `direction:'both'` scan — which encounters A→B
// from both endpoints — records each edge once.
const seenEdges = new Set<string>();
const edgeKey = (e: Edge) =>
`${e.source}|${e.target}|${e.kind}|${e.line ?? -1}|${e.column ?? -1}`;
const queue: TraversalStep[] = [{ node: startNode, edge: null, depth: 0 }];
if (opts.includeStart) {
@@ -129,7 +155,7 @@ export class GraphTraverser {
// adjacency scan (rather than once per dequeue) is what preserves
// parallel edges to the same target (#1090); `nextNode` is guaranteed
// to be in `nodes` at this point (just added, or already in-set).
const ek = edgeKey(adjEdge);
const ek = edgeSite(adjEdge);
if (!seenEdges.has(ek)) {
seenEdges.add(ek);
edges.push(adjEdge);
@@ -308,7 +334,7 @@ export class GraphTraverser {
// caller of the class. Without it, `callers <Class>` surfaced only the
// importing file (via `imports`) and missed every construction site —
// the opposite of "what breaks if I change this class?" (#774).
const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates', 'navigates']);
const incomingEdges = this.queries.getIncomingEdges(nodeId, CALL_EDGE_KINDS);
if (incomingEdges.length === 0) return;
// Batch-fetch all caller nodes in one round-trip instead of one
@@ -363,7 +389,7 @@ export class GraphTraverser {
// (`Foo(...)` / `new Foo()`) has that class as a callee, so callers and
// callees stay inverses of each other and `trace` can cross the
// instantiation boundary (function → class → its methods) (#774).
const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates', 'navigates']);
const outgoingEdges = this.queries.getOutgoingEdges(nodeId, CALL_EDGE_KINDS);
if (outgoingEdges.length === 0) return;
// Batch-fetch callee nodes (was N+1 — see getCallersRecursive note).
@@ -381,6 +407,42 @@ export class GraphTraverser {
}
}
/**
* The direct callers (or callees) of a symbol, each with every edge that
* joins them: one row per neighbour, in the order {@link getCallers} and
* {@link getCallees} list them. Those keep a neighbour's first edge only and
* leave out the symbol itself; here a caller that calls it on two lines has
* both sites, and a symbol that calls itself is a row of its own, flagged
* `recursive`. Each of those sites is a line to edit when the symbol
* changes, so `codegraph callers`, codegraph_callers and codegraph_node's
* trail list them (#2909). The walks that rank and trace keep their own
* one-row-per-neighbour reading.
*/
getCallSites(nodeId: string, direction: 'callers' | 'callees'): CallSiteRow[] {
const edges = direction === 'callers'
? this.queries.getIncomingEdges(nodeId, CALL_EDGE_KINDS)
: this.queries.getOutgoingEdges(nodeId, CALL_EDGE_KINDS);
if (edges.length === 0) return [];
const neighbour = (e: Edge) => (direction === 'callers' ? e.source : e.target);
const nodes = this.queries.getNodesByIds(edges.map(neighbour));
const rows = new Map<string, CallSiteRow>();
const sites = new Set<string>();
for (const edge of edges) {
const node = nodes.get(neighbour(edge));
if (!node) continue;
const site = edgeSite(edge);
if (sites.has(site)) continue;
sites.add(site);
let row = rows.get(node.id);
if (!row) {
row = { node, edges: [], recursive: node.id === nodeId };
rows.set(node.id, row);
}
row.edges.push(edge);
}
return [...rows.values()];
}
/**
* Get the call graph for a function (both callers and callees)
*
+13
View File
@@ -56,6 +56,7 @@ import {
import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer';
import { createImportReader, reopenNameMatchedImports, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughChangedDefaults, reopenReferencesThroughDeclaredPackages, reopenReferencesThroughRetargetedModules } from './resolution/module-importers';
import { GraphTraverser, GraphQueryManager } from './graph';
import type { CallSiteRow } from './graph';
import { findNamedCopybooks, type NamedCopybook } from './graph/cobol-copybooks';
import { ContextBuilder, createContextBuilder } from './context';
import { Mutex, FileLock, escapesRoot } from './utils';
@@ -2863,6 +2864,18 @@ export class CodeGraph {
return this.traverser.getCallees(nodeId, maxDepth);
}
/**
* Direct callers or callees of a function/method, each with every site that
* joins them, the symbol itself included when it calls itself
*
* @param nodeId - ID of the function/method node
* @param direction - `'callers'` or `'callees'`
* @returns One row per neighbour, in the order getCallers / getCallees list them
*/
getCallSites(nodeId: string, direction: 'callers' | 'callees'): CallSiteRow[] {
return this.traverser.getCallSites(nodeId, direction);
}
/**
* Calculate the impact radius of a node
*
+93 -53
View File
@@ -57,6 +57,8 @@ import { guardLabel, guardsForFileSync, siteKey, supportsBranchGuards, warmBranc
import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report';
import { countImplementers } from '../graph/type-hierarchy';
import { describeSynthesizedHop } from '../graph/synthesized-hop';
import { callSiteLines, formatCallSiteLines } from '../graph/call-sites';
import type { CallSiteRow } from '../graph/traversal';
import { isCopybookInclude, MAX_COPYBOOK_INCLUDES, type NamedCopybook } from '../graph/cobol-copybooks';
import {
findAllSymbols,
@@ -2015,6 +2017,9 @@ export function getStaticTools(): ToolDefinition[] {
*/
const DEFAULT_MCP_TOOLS = new Set(['explore']);
/** Call-site lines codegraph_callers shows per caller (#2909), so one busy caller stays one line. */
const MCP_CALL_SITES_SHOWN = 10;
/** realpath when the path exists, the path itself otherwise — never throws. */
function canonicalPath(p: string): string {
try {
@@ -3347,27 +3352,35 @@ export class ToolHandler {
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
: '';
// Every site of each caller (#2909): one row per caller, at the lines its
// calls sit on, and the symbol itself when it calls itself.
const collect = (defNodes: Node[]) => {
const seen = new Set<string>();
const callers: Node[] = [];
const labels = new Map<string, string>();
const sites = new Map<string, Edge[]>();
const recursive = new Set<string>();
for (const node of defNodes) {
for (const c of cg.getCallers(node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
callers.push(c.node);
answerPaths.add(c.node.filePath);
const label = this.edgeLabel(c.edge);
if (label) labels.set(c.node.id, label);
for (const c of cg.getCallSites(node.id, 'callers')) {
const known = sites.get(c.node.id);
if (known) {
known.push(...c.edges);
continue;
}
sites.set(c.node.id, [...c.edges]);
if (c.recursive) recursive.add(c.node.id);
callers.push(c.node);
answerPaths.add(c.node.filePath);
const label = this.edgeLabel(c.edges[0]!);
if (label) labels.set(c.node.id, label);
}
}
return { callers, labels };
const locations = new Map(callers.map((n) => [n.id, this.callSiteLocation(n, sites.get(n.id) ?? [])]));
return { callers, labels, locations, recursive };
};
// Single definition (or same-file overloads): the familiar flat list.
if (groups.length === 1) {
const { callers, labels } = collect(groups[0]!);
const { callers, labels, locations, recursive } = collect(groups[0]!);
if (callers.length === 0) {
return this.answerResult(cg, `No callers found for "${symbol}"${allMatches.note}${filterNote}`, answerPaths);
}
@@ -3379,7 +3392,7 @@ export class ToolHandler {
const cut = callers.length > limit
? `\n\n> Showing ${limit} of ${callers.length} callers; pass \`limit\` (up to 100) to widen.`
: '';
const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + cut + note + filterNote;
const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels, locations, recursive) + cut + note + filterNote;
return this.answerResult(cg, this.truncateOutput(formatted), answerPaths);
}
@@ -3390,16 +3403,14 @@ export class ToolHandler {
`**Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
];
for (const group of groups) {
const { callers, labels } = collect(group);
const { callers, labels, locations, recursive } = collect(group);
lines.push('', this.definitionHeading(group));
if (callers.length === 0) {
lines.push('- (no callers)');
continue;
}
for (const node of callers.slice(0, limit)) {
const location = node.startLine ? `:${node.startLine}` : '';
const label = labels.get(node.id);
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
lines.push(this.formatNodeListEntry(node, labels, locations, recursive));
}
if (callers.length > limit) {
lines.push(`- … +${callers.length - limit} more (pass \`limit\` to widen)`);
@@ -3430,26 +3441,30 @@ export class ToolHandler {
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
: '';
// Callees are listed at their own declarations, where a call leads; a
// symbol that calls itself is one of them (#2909).
const collect = (defNodes: Node[]) => {
const seen = new Set<string>();
const callees: Node[] = [];
const labels = new Map<string, string>();
const recursive = new Set<string>();
for (const node of defNodes) {
for (const c of cg.getCallees(node.id)) {
for (const c of cg.getCallSites(node.id, 'callees')) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
if (c.recursive) recursive.add(c.node.id);
callees.push(c.node);
answerPaths.add(c.node.filePath);
const label = this.edgeLabel(c.edge);
const label = this.edgeLabel(c.edges[0]!);
if (label) labels.set(c.node.id, label);
}
}
}
return { callees, labels };
return { callees, labels, recursive };
};
if (groups.length === 1) {
const { callees, labels } = collect(groups[0]!);
const { callees, labels, recursive } = collect(groups[0]!);
if (callees.length === 0) {
return this.answerResult(cg, `No callees found for "${symbol}"${allMatches.note}${filterNote}`, answerPaths);
}
@@ -3461,7 +3476,7 @@ export class ToolHandler {
const cut = callees.length > limit
? `\n\n> Showing ${limit} of ${callees.length} callees; pass \`limit\` (up to 100) to widen.`
: '';
const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + cut + note + filterNote;
const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels, undefined, recursive) + cut + note + filterNote;
return this.answerResult(cg, this.truncateOutput(formatted), answerPaths);
}
@@ -3470,16 +3485,14 @@ export class ToolHandler {
`**Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
];
for (const group of groups) {
const { callees, labels } = collect(group);
const { callees, labels, recursive } = collect(group);
lines.push('', this.definitionHeading(group));
if (callees.length === 0) {
lines.push('- (no callees)');
continue;
}
for (const node of callees.slice(0, limit)) {
const location = node.startLine ? `:${node.startLine}` : '';
const label = labels.get(node.id);
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
lines.push(this.formatNodeListEntry(node, labels, undefined, recursive));
}
if (callees.length > limit) {
lines.push(`- … +${callees.length - limit} more (pass \`limit\` to widen)`);
@@ -8404,30 +8417,32 @@ export class ToolHandler {
*/
private formatTrail(cg: CodeGraph, node: Node): string {
const TRAIL_CAP = 12;
const fmt = (e: { node: Node; edge: Edge }) => {
const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
const synth = this.synthEdgeNote(e.edge);
/** Call-site lines shown per caller, so one busy caller can't crowd the trail. */
const TRAIL_CALL_SITES = 3;
const fmt = (row: CallSiteRow, where: string) => {
const base = `${row.node.name} (${where})`;
const synth = this.synthEdgeNote(row.edges[0]!);
return synth ? `${base} [${synth}]` : base;
};
const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
const seen = new Set<string>([node.id]);
const out: Array<{ node: Node; edge: Edge }> = [];
for (const e of edges) {
if (seen.has(e.node.id)) continue;
seen.add(e.node.id);
out.push(e);
}
return out;
};
const callees = collect(cg.getCallees(node.id));
const callers = collect(cg.getCallers(node.id));
// A callee reads at its declaration, where following it leads. A caller
// reads at the lines its calls sit on, every one a line to edit when this
// symbol changes (#2909), and a symbol that calls itself is among its own
// callers, once.
const callees = cg.getCallSites(node.id, 'callees').filter((row) => !row.recursive);
const callers = cg.getCallSites(node.id, 'callers');
if (callees.length === 0 && callers.length === 0) return '';
const callee = (row: CallSiteRow) => fmt(row, `${row.node.filePath}:${row.node.startLine}`);
const caller = (row: CallSiteRow) => {
const sites = callSiteLines(row.edges);
const at = sites.length > 0 ? formatCallSiteLines(sites, TRAIL_CALL_SITES) : String(row.node.startLine);
return fmt(row, `${row.node.filePath}:${at}${row.recursive ? ', calls itself' : ''}`);
};
const lines: string[] = ['', '**Trail — codegraph_node any of these to follow it (no Read needed)**'];
if (callees.length > 0) {
lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(callee).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
}
if (callers.length > 0) {
lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(caller).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
}
return lines.join('\n');
}
@@ -8861,22 +8876,47 @@ export class ToolHandler {
return lines.join('\n');
}
private formatNodeList(nodes: Node[], title: string, labels?: Map<string, string>): string {
private formatNodeList(
nodes: Node[],
title: string,
labels?: Map<string, string>,
locations?: Map<string, string>,
recursive?: Set<string>,
): string {
const lines: string[] = [`**${title} (${nodes.length} found)**`, ''];
for (const node of nodes) {
const location = node.startLine ? `:${node.startLine}` : '';
// Compact: just name, kind, location — plus the relationship when it
// isn't a plain call (callback registration, instantiation, …).
const label = labels?.get(node.id);
lines.push(
`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`
);
}
for (const node of nodes) lines.push(this.formatNodeListEntry(node, labels, locations, recursive));
return lines.join('\n');
}
/**
* One callers/callees entry. Compact: name, kind, location (the caller's
* call sites when `locations` has them, else the declaration's line), the
* relationship when it isn't a plain call (callback registration,
* instantiation, …), and whether the symbol calls itself.
*/
private formatNodeListEntry(
node: Node,
labels?: Map<string, string>,
locations?: Map<string, string>,
recursive?: Set<string>,
): string {
const location = locations?.get(node.id) ?? (node.startLine ? `:${node.startLine}` : '');
const label = labels?.get(node.id);
const itself = recursive?.has(node.id) ? ' — calls itself' : '';
return `- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}${itself}`;
}
/**
* `:9, 10` — where a caller's calls sit, the lines to edit when the symbol
* changes (#2909), capped at {@link MCP_CALL_SITES_SHOWN}; the caller's own
* line when its edges carry none.
*/
private callSiteLocation(node: Node, edges: readonly Edge[]): string {
const lines = callSiteLines(edges);
if (lines.length > 0) return `:${formatCallSiteLines(lines, MCP_CALL_SITES_SHOWN)}`;
return node.startLine ? `:${node.startLine}` : '';
}
/**
* Relationship label for a non-`calls` edge in callers/callees lists. A
* function-as-value edge (#756) is the high-signal one: `callers(cb)`