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
@@ -17,6 +17,8 @@ import {
|
||||
} from '../types';
|
||||
import { getParser, detectLanguage, isLanguageSupported, isFileLevelOnlyLanguage } from './grammars';
|
||||
import { generateNodeId, getNodeText, getChildByField, getPrecedingDocstring } from './tree-sitter-helpers';
|
||||
import { FN_REF_SPECS, captureFnRefCandidates, type FnRefSpec, type FnRefCandidate } from './function-ref';
|
||||
import { isGeneratedFile } from './generated-detection';
|
||||
import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types';
|
||||
import { EXTRACTORS } from './languages';
|
||||
import { LiquidExtractor } from './liquid-extractor';
|
||||
@@ -222,12 +224,18 @@ export class TreeSitterExtractor {
|
||||
private extractor: LanguageExtractor | null = null;
|
||||
private nodeStack: string[] = []; // Stack of parent node IDs
|
||||
private methodIndex: Map<string, string> | null = null; // lookup key → node ID for Pascal defProc lookup
|
||||
// Function-as-value capture (#756): per-language spec + candidates collected
|
||||
// during the walk, gated & flushed into unresolvedReferences at end-of-file
|
||||
// (see flushFnRefCandidates).
|
||||
private fnRefSpec: FnRefSpec | undefined;
|
||||
private fnRefCandidates: Array<FnRefCandidate & { fromNodeId: string }> = [];
|
||||
|
||||
constructor(filePath: string, source: string, language?: Language) {
|
||||
this.filePath = filePath;
|
||||
this.source = source;
|
||||
this.language = language || detectLanguage(filePath, source);
|
||||
this.extractor = EXTRACTORS[this.language] || null;
|
||||
this.fnRefSpec = FN_REF_SPECS[this.language];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,6 +322,10 @@ export class TreeSitterExtractor {
|
||||
|
||||
this.visitNode(this.tree.rootNode);
|
||||
|
||||
// Gate + flush function-as-value candidates (#756) while the file's
|
||||
// nodes and import refs are complete and the file node is still pushed.
|
||||
this.flushFnRefCandidates();
|
||||
|
||||
if (packageNodeId) this.nodeStack.pop();
|
||||
this.nodeStack.pop();
|
||||
} catch (error) {
|
||||
@@ -352,6 +364,136 @@ export class TreeSitterExtractor {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Function-as-value capture (#756): if this node is one of the language's
|
||||
* value-position containers (call arguments, assignment RHS, struct/object
|
||||
* initializer, array/table literal), collect candidate function names from
|
||||
* it. Candidates are gated & flushed at end-of-file (flushFnRefCandidates).
|
||||
*/
|
||||
private maybeCaptureFnRefs(node: SyntaxNode, nodeType: string): void {
|
||||
const spec = this.fnRefSpec;
|
||||
if (!spec) return;
|
||||
const rule = spec.dispatch.get(nodeType);
|
||||
if (!rule || this.nodeStack.length === 0) return;
|
||||
const fromNodeId = this.nodeStack[this.nodeStack.length - 1];
|
||||
if (!fromNodeId) return;
|
||||
for (const cand of captureFnRefCandidates(node, rule, spec, this.source)) {
|
||||
this.fnRefCandidates.push({ ...cand, fromNodeId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidates-only scan of a subtree the main walkers won't traverse
|
||||
* (top-level variable initializers). No extraction side effects. Halts at
|
||||
* nested function definitions: their bodies are walked — and their
|
||||
* candidates attributed — by extractFunction's own body walk.
|
||||
*/
|
||||
private scanFnRefSubtree(node: SyntaxNode, depth: number): void {
|
||||
if (!this.fnRefSpec || depth > 12) return;
|
||||
const nodeType = node.type;
|
||||
if (depth > 0 && (
|
||||
this.extractor?.functionTypes.includes(nodeType) ||
|
||||
nodeType === 'arrow_function' ||
|
||||
nodeType === 'function_expression' ||
|
||||
nodeType === 'lambda_literal' ||
|
||||
nodeType === 'lambda_expression'
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
this.maybeCaptureFnRefs(node, nodeType);
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child) this.scanFnRefSubtree(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate captured function-as-value candidates and push survivors as
|
||||
* `function_ref` unresolved references.
|
||||
*
|
||||
* The gate bounds volume and protects precision: a candidate survives only
|
||||
* if its name matches a function/method DEFINED IN THIS FILE or a name this
|
||||
* file imports/references. Everything else (locals, params, fields passed
|
||||
* as arguments) is dropped before it ever reaches the database. Resolution
|
||||
* then matches survivors against function/method nodes only
|
||||
* (matchFunctionRef) and emits `references` edges — which callers/impact
|
||||
* already traverse.
|
||||
*
|
||||
* Known v1 limit, deliberate: a C/C++ callback registered in a DIFFERENT
|
||||
* translation unit than its definition (extern, no symbol imports to match)
|
||||
* is not captured. Same-file registration — the dominant C pattern (static
|
||||
* callback + same-file ops struct) — is.
|
||||
*/
|
||||
private flushFnRefCandidates(): void {
|
||||
if (this.fnRefCandidates.length === 0) return;
|
||||
const candidates = this.fnRefCandidates;
|
||||
this.fnRefCandidates = [];
|
||||
|
||||
// Generated/minified files (vendored jquery.min.js and friends): their
|
||||
// function-as-value edges are noise — single-letter minified symbols
|
||||
// resolve everywhere. Same policy as the callback synthesizer.
|
||||
if (isGeneratedFile(this.filePath)) return;
|
||||
|
||||
const definedHere = new Set<string>();
|
||||
for (const n of this.nodes) {
|
||||
if (n.kind === 'function' || n.kind === 'method') definedHere.add(n.name);
|
||||
}
|
||||
|
||||
// Import-binding names only (all binding emitters push kind 'imports').
|
||||
// Deliberately NOT 'references': those carry type-annotation and
|
||||
// interface-member names, which let local variables that share a type
|
||||
// member's name slip through the gate (excalidraw A/B finding).
|
||||
const SIMPLE_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
const importedNames = new Set<string>();
|
||||
for (const r of this.unresolvedReferences) {
|
||||
if (r.referenceKind === 'imports' && SIMPLE_NAME.test(r.referenceName)) {
|
||||
importedNames.add(r.referenceName);
|
||||
}
|
||||
}
|
||||
|
||||
const ungated = this.fnRefSpec?.ungatedModes;
|
||||
const addressOfOnly = this.fnRefSpec?.addressOfOnly === true;
|
||||
const seen = new Set<string>();
|
||||
for (const c of candidates) {
|
||||
const atFileScope = c.fromNodeId.startsWith('file:');
|
||||
// C++ (addressOfOnly): a BARE identifier qualifies only inside a
|
||||
// file-scope initializer table. Everywhere else — args, assignments,
|
||||
// local braced-init lists like `{begin, size}` — only explicit `&`
|
||||
// forms count (fmt A/B finding: generic names `begin`/`out`/`size`
|
||||
// collide with locals and members).
|
||||
if (
|
||||
addressOfOnly &&
|
||||
!c.explicitRef &&
|
||||
!(atFileScope && (c.mode === 'value' || c.mode === 'list'))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// C-family file-scope initializers skip the gate (constant-expression
|
||||
// context — a bare identifier there is a function address, never a
|
||||
// variable; see FnRefSpec.ungatedModes). Local initializers and
|
||||
// everything else require a same-file/import match.
|
||||
const skipGate = ungated?.has(c.mode) === true && atFileScope;
|
||||
// Qualified C++ member-pointers (`Widget::on_click`) gate on the member
|
||||
// name; everything else on the full name.
|
||||
const gateName = c.name.includes('::')
|
||||
? c.name.slice(c.name.lastIndexOf('::') + 2)
|
||||
: c.name;
|
||||
if (!skipGate && !definedHere.has(gateName) && !importedNames.has(gateName)) {
|
||||
continue;
|
||||
}
|
||||
const key = `${c.fromNodeId}|${c.name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: c.fromNodeId,
|
||||
referenceName: c.name,
|
||||
referenceKind: 'function_ref',
|
||||
line: c.line,
|
||||
column: c.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit a node and extract information
|
||||
*/
|
||||
@@ -365,7 +507,14 @@ export class TreeSitterExtractor {
|
||||
if (this.extractor.visitNode) {
|
||||
const ctx = this.makeExtractorContext();
|
||||
const handled = this.extractor.visitNode(node, ctx);
|
||||
if (handled) return;
|
||||
if (handled) {
|
||||
// The hook consumed this subtree, so the walkers below never descend
|
||||
// into it — scan it for function-as-value candidates (#756). Scala's
|
||||
// hook handles val/var definitions (`val table = Seq(targetCb)`), for
|
||||
// example. The scan is capture-only and halts at nested functions.
|
||||
this.scanFnRefSubtree(node, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Pascal-specific AST handling
|
||||
@@ -374,6 +523,11 @@ export class TreeSitterExtractor {
|
||||
if (skipChildren) return;
|
||||
}
|
||||
|
||||
// Function-as-value capture (#756) — independent of the dispatch ladder
|
||||
// below (the captured container types have no other handler there), so it
|
||||
// can never shadow or be shadowed by an extraction branch.
|
||||
this.maybeCaptureFnRefs(node, nodeType);
|
||||
|
||||
// Check for function declarations
|
||||
// For Python/Ruby, function_definition inside a class should be treated as method
|
||||
if (this.extractor.functionTypes.includes(nodeType)) {
|
||||
@@ -437,17 +591,33 @@ export class TreeSitterExtractor {
|
||||
// Check for class properties (e.g. C# property_declaration)
|
||||
else if (this.extractor.propertyTypes?.includes(nodeType) && this.isInsideClassLikeNode()) {
|
||||
this.extractProperty(node);
|
||||
// Property initializers aren't walked — scan for function-as-value
|
||||
// candidates (#756): Scala `val table = Seq(targetCb)` in an object,
|
||||
// Kotlin `val cb = ::handler` class properties.
|
||||
this.scanFnRefSubtree(node, 0);
|
||||
skipChildren = true;
|
||||
}
|
||||
// Check for class fields (e.g. Java field_declaration, C# field_declaration)
|
||||
else if (this.extractor.fieldTypes?.includes(nodeType) && this.isInsideClassLikeNode()) {
|
||||
this.extractField(node);
|
||||
// Field initializers aren't walked — scan for function-as-value
|
||||
// candidates (#756): Java `List<IntConsumer> table = List.of(Main::cb)`,
|
||||
// C# `List<Action<int>> table = new() { TargetCb }`.
|
||||
this.scanFnRefSubtree(node, 0);
|
||||
skipChildren = true;
|
||||
}
|
||||
// Check for variable declarations (const, let, var, etc.)
|
||||
// Only extract top-level variables (not inside functions/methods)
|
||||
else if (this.extractor.variableTypes.includes(nodeType) && !this.isInsideClassLikeNode()) {
|
||||
this.extractVariable(node);
|
||||
// extractVariable doesn't walk every initializer shape (object literals
|
||||
// are deliberately skipped; Python/Ruby don't walk at all), so scan the
|
||||
// declaration subtree for function-as-value candidates — `const routes =
|
||||
// { home: renderHome }`, `handlers = {"recv": target_cb}`. The scan halts
|
||||
// at nested function definitions (their bodies are walked — and
|
||||
// attributed — separately) and flush-time dedup absorbs any overlap with
|
||||
// initializers extractVariable DOES walk.
|
||||
this.scanFnRefSubtree(node, 0);
|
||||
skipChildren = true; // extractVariable handles children
|
||||
}
|
||||
// Swift stored properties inside a type. Swift instance properties aren't
|
||||
@@ -3086,6 +3256,10 @@ export class TreeSitterExtractor {
|
||||
const visitForCallsAndStructure = (node: SyntaxNode): void => {
|
||||
const nodeType = node.type;
|
||||
|
||||
// Function-as-value capture (#756) — function bodies are walked here,
|
||||
// not in visitNode, so the capture hook must fire in both walkers.
|
||||
this.maybeCaptureFnRefs(node, nodeType);
|
||||
|
||||
// Rocket route-registration macros (`routes![…]` / `catchers![…]`): the
|
||||
// handler paths live in a raw token tree the call walker can't see.
|
||||
if (nodeType === 'macro_invocation') this.extractRustRouteMacro(node);
|
||||
@@ -4461,8 +4635,16 @@ export class TreeSitterExtractor {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
// Function-as-value capture (#756): Pascal bodies are walked here, not
|
||||
// in visitNode/visitForCallsAndStructure, so the capture hook fires here
|
||||
// — assignment RHS is the Delphi event-wiring idiom (`OnFire := Handler`).
|
||||
this.maybeCaptureFnRefs(child, child.type);
|
||||
if (child.type === 'exprCall') {
|
||||
this.extractPascalCall(child);
|
||||
// The walker doesn't descend into a call's arguments — dispatch the
|
||||
// argument container directly (`RegisterHandler(TargetCb)` / `(@Cb)`).
|
||||
const args = child.namedChildren.find((c: SyntaxNode) => c.type === 'exprArgs');
|
||||
if (args) this.maybeCaptureFnRefs(args, 'exprArgs');
|
||||
} else if (child.type === 'exprDot') {
|
||||
// A STATEMENT-level bare exprDot is a paren-less call (`Obj.Free;`,
|
||||
// `TFoo.GetInstance.DoIt;`). Anywhere else (assignment side, condition,
|
||||
|
||||
Reference in New Issue
Block a user