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
+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