feat(extraction): capture function-as-value — callback registration sites in callers/impact (#756) (#807)
A function name used as a VALUE — passed as an argument
(signal(SIGINT, handler), qsort(..., compare)), assigned to a function
pointer or field (ops->recv_cb = my_cb, OnClick := Handler), or placed in
a struct initializer / handler table ({ .recv_cb = my_cb },
{ "get", getCommand }) — produced no edge in ANY of the 19 tree-sitter
languages, so registered callbacks looked dead and their registration
sites were invisible to callers/impact.
This adds table-driven function-as-value capture across all 19 languages
(plus the wrapper forms: &fn, &Cls::method, Java Class::m, Kotlin ::f,
Swift #selector, ObjC @selector, Ruby method(:sym), Scala eta, Pascal
@Handler), gated at extraction (same-file definitions + imported
bindings; C-family file-scope initializers are constant-expression
contexts and skip the gate, which is how redis-style cross-file command
tables resolve), and resolved by a dedicated strategy: function/method
targets only, same-file first, unique-or-drop cross-file, no fuzzy
fallback ever. Edges persist as kind 'references' with metadata.fnRef,
so getCallers/getImpactRadius surface them with zero graph-layer
changes; MCP callers/callees label them "via callback registration".
Precision rules bought by real-repo false positives (full A/B record in
docs/design/function-ref-capture.md): C++ is &-explicit outside
file-scope tables (fmt's begin/out/size collisions; out-of-line member
defs are function-kind); TS/JS/Python bare ids resolve to functions only
(TS class fields extract as method-kind — pre-existing quirk); Swift
refuses same-file method overload-families; param-forward shapes
(this.x = x, value: value) and destructuring are skipped; minified
bundles (*.min.js) produce no candidates.
Validated on 17 public OSS repos (redis, excalidraw, gin, bytes, okhttp,
okio, Alamofire, flask, sinatra, Newtonsoft.Json, scopt, provider,
busted, Fusion, AFNetworking, PascalCoin, fmt): node counts identical,
zero calls edges lost or gained, references strictly additive
(+3,200 registration edges total), precision spot-checked by reading
sampled source lines (redis 30/30, flask 8/8). Deliberately NOT covered:
indirect-dispatch resolution (o->cb(x) → impl) — that needs data-flow
through struct fields, and a wrong edge is worse than none.
EXTRACTION_VERSION 18 → 19 (re-index to benefit).
Closes #756
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0df9246752
commit
8a114ba53c
@@ -47,7 +47,7 @@ typically one to a few calls; a grep/read exploration is dozens.
|
||||
- **Almost any question — "how does X work", architecture, a bug, "what/where is X", or surveying an area** → \`codegraph_explore\` (PRIMARY — call FIRST; ONE capped call returns the verbatim source of the relevant symbols grouped by file; most often the ONLY call you need)
|
||||
- **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, including dynamic-dispatch hops (callbacks, React re-render, JSX children) grep can't follow
|
||||
- **"What is the symbol named X?" (just its location)** → \`codegraph_search\`
|
||||
- **"What calls this?" / "What does this call?" / "What would changing this break?"** → \`codegraph_callers\` / \`codegraph_callees\` / \`codegraph_impact\`
|
||||
- **"What calls this?" / "What does this call?" / "What would changing this break?"** → \`codegraph_callers\` / \`codegraph_callees\` / \`codegraph_impact\`. Callers includes where a function is **registered as a callback** (passed as an argument, assigned to a function pointer/field, listed in a handler table) — labeled "via callback registration" — so a function with no direct calls is NOT dead if it's wired up somewhere
|
||||
- **Reading a source FILE (any time you'd use the \`Read\` tool)** → \`codegraph_node\` with a \`file\` path and no \`symbol\`. It returns the file's **current source with line numbers — the same \`<n>\\t<line>\` shape \`Read\` gives you, safe to \`Edit\` from** — narrowable with \`offset\`/\`limit\` exactly like \`Read\`, PLUS a one-line note of which files depend on it. Same bytes as \`Read\`, faster (served from the index), with the blast radius attached. Use it **instead of \`Read\`** for indexed source files; fall back to \`Read\` only for what codegraph doesn't index (configs, docs). Pass \`symbolsOnly: true\` for just the file's structure.
|
||||
- **About to read or edit a symbol you can name** → \`codegraph_node\` with that \`symbol\` (SECONDARY — the after-explore depth tool): the verbatim source (\`includeCode: true\`) PLUS its caller/callee trail, so before changing it you see what calls it and what your edit would break. For an OVERLOADED name it returns EVERY matching definition's body in one call, so you never Read a file to find the right overload
|
||||
- **"What's in directory X?"** → \`codegraph_files\`
|
||||
|
||||
+30
-5
@@ -1113,11 +1113,14 @@ export class ToolHandler {
|
||||
// Aggregate callers across all matching symbols
|
||||
const seen = new Set<string>();
|
||||
const allCallers: Node[] = [];
|
||||
const labels = new Map<string, string>();
|
||||
for (const node of allMatches.nodes) {
|
||||
for (const c of cg.getCallers(node.id)) {
|
||||
if (!seen.has(c.node.id)) {
|
||||
seen.add(c.node.id);
|
||||
allCallers.push(c.node);
|
||||
const label = this.edgeLabel(c.edge);
|
||||
if (label) labels.set(c.node.id, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1126,7 +1129,7 @@ export class ToolHandler {
|
||||
return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
|
||||
}
|
||||
|
||||
const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
|
||||
const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`, labels) + allMatches.note;
|
||||
return this.textResult(this.truncateOutput(formatted));
|
||||
}
|
||||
|
||||
@@ -1148,11 +1151,14 @@ export class ToolHandler {
|
||||
// Aggregate callees across all matching symbols
|
||||
const seen = new Set<string>();
|
||||
const allCallees: Node[] = [];
|
||||
const labels = new Map<string, string>();
|
||||
for (const node of allMatches.nodes) {
|
||||
for (const c of cg.getCallees(node.id)) {
|
||||
if (!seen.has(c.node.id)) {
|
||||
seen.add(c.node.id);
|
||||
allCallees.push(c.node);
|
||||
const label = this.edgeLabel(c.edge);
|
||||
if (label) labels.set(c.node.id, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1161,7 +1167,7 @@ export class ToolHandler {
|
||||
return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
|
||||
}
|
||||
|
||||
const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
|
||||
const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`, labels) + allMatches.note;
|
||||
return this.textResult(this.truncateOutput(formatted));
|
||||
}
|
||||
|
||||
@@ -3337,18 +3343,37 @@ export class ToolHandler {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private formatNodeList(nodes: Node[], title: string): string {
|
||||
private formatNodeList(nodes: Node[], title: string, labels?: Map<string, 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
|
||||
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
|
||||
// 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}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship label for a non-`calls` edge in callers/callees lists. A
|
||||
* function-as-value edge (#756) is the high-signal one: `callers(cb)`
|
||||
* showing "via callback registration" tells the agent this is where the
|
||||
* callback is WIRED, not where it's invoked.
|
||||
*/
|
||||
private edgeLabel(edge: Edge): string | null {
|
||||
if (edge.kind === 'calls') return null;
|
||||
if (edge.metadata?.fnRef === true) return 'callback registration';
|
||||
if (edge.kind === 'instantiates') return 'instantiation';
|
||||
if (edge.kind === 'imports') return 'import';
|
||||
if (edge.kind === 'references') return 'reference';
|
||||
return edge.kind;
|
||||
}
|
||||
|
||||
private formatImpact(symbol: string, impact: Subgraph): string {
|
||||
const nodeCount = impact.nodes.size;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user