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:
Colby Mchenry
2026-06-11 14:20:27 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0df9246752
commit 8a114ba53c
13 changed files with 1706 additions and 16 deletions
+30 -5
View File
@@ -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;