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
-3
@@ -16,7 +16,7 @@ import {
|
||||
FrameworkResolver,
|
||||
ImportMapping,
|
||||
} from './types';
|
||||
import { matchReference, matchDottedCallChain, matchScopedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
|
||||
import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
|
||||
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef } from './import-resolver';
|
||||
import { detectFrameworks } from './frameworks';
|
||||
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
||||
@@ -669,6 +669,22 @@ export class ReferenceResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function-as-value refs (#756) get a dedicated, strictly-gated path:
|
||||
// import-based resolution first (an imported callback resolves through its
|
||||
// import, the most precise cross-file signal), then matchFunctionRef
|
||||
// (same-file first, unique-only cross-file, function/method targets only).
|
||||
// They never reach the framework or fuzzy strategies below.
|
||||
if (ref.referenceKind === 'function_ref') {
|
||||
const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
||||
if (viaImport) {
|
||||
const target = this.queries.getNodeById(viaImport.targetNodeId);
|
||||
if (target && (target.kind === 'function' || target.kind === 'method')) {
|
||||
return viaImport;
|
||||
}
|
||||
}
|
||||
return this.gateLanguage(matchFunctionRef(ref, this.context), ref);
|
||||
}
|
||||
|
||||
// JVM FQN imports skip framework/name-matcher: `import com.example.Bar`
|
||||
// resolves directly through the qualifiedName index, which is unambiguous
|
||||
// even when several `Bar` classes exist in different packages.
|
||||
@@ -750,7 +766,13 @@ export class ReferenceResolver {
|
||||
*/
|
||||
createEdges(resolved: ResolvedRef[]): Edge[] {
|
||||
return resolved.map((ref) => {
|
||||
let kind = ref.original.referenceKind;
|
||||
// `function_ref` (#756) is internal-only: it persists as a `references`
|
||||
// edge (the registration site depends on the callback), distinguishable
|
||||
// by metadata.resolvedBy === 'function-ref'. callers/impact already
|
||||
// traverse `references`, so registration sites surface with no
|
||||
// graph-layer changes.
|
||||
let kind: Edge['kind'] =
|
||||
ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind;
|
||||
|
||||
// Promote "extends" to "implements" when a class/struct targets an interface
|
||||
if (kind === 'extends') {
|
||||
@@ -784,6 +806,11 @@ export class ReferenceResolver {
|
||||
metadata: {
|
||||
confidence: ref.confidence,
|
||||
resolvedBy: ref.resolvedBy,
|
||||
// Uniform marker for function-as-value edges (#756), regardless of
|
||||
// which strategy resolved them (import vs matchFunctionRef) — lets
|
||||
// tooling label "callback registration" and lets validation diff
|
||||
// exactly the edges this feature added.
|
||||
...(ref.original.referenceKind === 'function_ref' ? { fnRef: true } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1161,7 +1188,7 @@ export class ReferenceResolver {
|
||||
if (!result) return result;
|
||||
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
|
||||
if (!tgt || !ref.language) return result;
|
||||
if (ref.referenceKind === 'references' && !sameLanguageFamily(tgt, ref.language)) return null;
|
||||
if ((ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') && !sameLanguageFamily(tgt, ref.language)) return null;
|
||||
if (ref.referenceKind === 'imports' && crossesKnownFamily(tgt, ref.language)) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ export function crossesKnownFamily(a: string, b: string): boolean {
|
||||
* both-known filter so `.vue`/`.svelte` (own tag) importing `.ts` survives.
|
||||
*/
|
||||
function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] {
|
||||
if (ref.referenceKind === 'references') {
|
||||
if (ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') {
|
||||
return candidates.filter((c) => sameLanguageFamily(c.language, ref.language));
|
||||
}
|
||||
if (ref.referenceKind === 'imports') {
|
||||
@@ -167,6 +167,113 @@ function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a function-as-value reference (#756) — a function name used as a
|
||||
* callback/function-pointer value (`register(handler)`, `o->cb = handler`,
|
||||
* `{ .cb = handler }`, `signal(SIGINT, handler)`). The ONLY strategy allowed
|
||||
* for `function_ref` refs: exact name, function/method targets only, same
|
||||
* language family, same-file first, and cross-file only when the match is
|
||||
* UNIQUE. No fuzzy fallback, no qualified-name walking — a wrong callback
|
||||
* edge is worse than none.
|
||||
*/
|
||||
export function matchFunctionRef(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
// In JS/TS/Python a bare identifier can never be a method value (methods
|
||||
// are only reachable through a receiver — `this.m` / `self.m` /
|
||||
// `Cls.m`), so bare fn-refs match FUNCTIONS only. This also sidesteps the
|
||||
// pre-existing TS quirk of class fields extracting as method-kind nodes,
|
||||
// which otherwise soaked up local names passed as arguments (excalidraw
|
||||
// A/B finding; same pattern in vendored docopt.py). Python's `self.m`
|
||||
// form keeps method targets via its own capture shape. C++ likewise: a
|
||||
// bare identifier can only be a FREE function (member values need
|
||||
// `&Cls::method`). Other languages keep method targets: C# method groups,
|
||||
// Swift/Dart implicit-self, Java/Kotlin method references.
|
||||
const bareFnOnly =
|
||||
ref.language === 'typescript' || ref.language === 'tsx' ||
|
||||
ref.language === 'javascript' || ref.language === 'jsx' ||
|
||||
ref.language === 'cpp' || ref.language === 'python';
|
||||
|
||||
// Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"):
|
||||
// resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m`
|
||||
// shape is an explicit member reference). Unique-or-drop like everything else.
|
||||
if (ref.referenceName.includes('::')) {
|
||||
const memberName = ref.referenceName.slice(ref.referenceName.lastIndexOf('::') + 2);
|
||||
const scoped = context
|
||||
.getNodesByName(memberName)
|
||||
.filter(
|
||||
(n) =>
|
||||
(n.kind === 'function' || n.kind === 'method') &&
|
||||
sameLanguageFamily(n.language, ref.language) &&
|
||||
n.id !== ref.fromNodeId &&
|
||||
(n.qualifiedName === ref.referenceName ||
|
||||
n.qualifiedName.endsWith(`::${ref.referenceName}`))
|
||||
);
|
||||
if (scoped.length === 0) return null;
|
||||
const sameFileScoped = scoped.filter((n) => n.filePath === ref.filePath);
|
||||
const pool = sameFileScoped.length > 0 ? sameFileScoped : scoped;
|
||||
if (sameFileScoped.length === 0 && scoped.length > 1) return null;
|
||||
const target = pool.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.id,
|
||||
confidence: 0.9,
|
||||
resolvedBy: 'function-ref',
|
||||
};
|
||||
}
|
||||
|
||||
const candidates = context
|
||||
.getNodesByName(ref.referenceName)
|
||||
.filter(
|
||||
(n) =>
|
||||
(n.kind === 'function' || (!bareFnOnly && n.kind === 'method')) &&
|
||||
sameLanguageFamily(n.language, ref.language) &&
|
||||
n.id !== ref.fromNodeId // a function registering itself is not a dependency edge
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Same-file definition wins — the extraction gate guarantees most survivors
|
||||
// have one, and it's the dominant C pattern (static callback registered in
|
||||
// a same-file ops struct).
|
||||
const sameFile = candidates.filter((n) => n.filePath === ref.filePath);
|
||||
if (sameFile.length > 0) {
|
||||
// Swift: several same-named METHODS in one file is an API overload family
|
||||
// (`Session.request(...)` × N), and a bare identifier hitting it is almost
|
||||
// always a same-named parameter, not a method value (Alamofire A/B
|
||||
// finding) — refuse rather than guess. A single method (SwiftUI's
|
||||
// `action: handleTap`) still resolves.
|
||||
if (
|
||||
ref.language === 'swift' &&
|
||||
sameFile.length > 1 &&
|
||||
sameFile.every((n) => n.kind === 'method')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Same-name overloads in one file are the same conceptual symbol; pick
|
||||
// the first by position for determinism.
|
||||
const target = sameFile.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.id,
|
||||
confidence: sameFile.length === 1 ? 0.95 : 0.9,
|
||||
resolvedBy: 'function-ref',
|
||||
};
|
||||
}
|
||||
|
||||
// Cross-file (imported names the import resolver didn't already claim):
|
||||
// only an unambiguous match resolves.
|
||||
if (candidates.length === 1) {
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: candidates[0]!.id,
|
||||
confidence: 0.8,
|
||||
resolvedBy: 'function-ref',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve a reference by exact name match
|
||||
*/
|
||||
@@ -1124,6 +1231,13 @@ export function matchReference(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
// Function-as-value refs (#756) resolve ONLY through the dedicated matcher —
|
||||
// never the fuzzy/qualified fallthrough below (a wrong callback edge is
|
||||
// worse than none).
|
||||
if (ref.referenceKind === 'function_ref') {
|
||||
return matchFunctionRef(ref, context);
|
||||
}
|
||||
|
||||
// Try strategies in order of confidence
|
||||
let result: ResolvedRef | null;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Types for the reference resolution system.
|
||||
*/
|
||||
|
||||
import { EdgeKind, Language, Node } from '../types';
|
||||
import { Language, Node, ReferenceKind } from '../types';
|
||||
|
||||
/**
|
||||
* An unresolved reference from extraction
|
||||
@@ -15,7 +15,7 @@ export interface UnresolvedRef {
|
||||
/** The name being referenced */
|
||||
referenceName: string;
|
||||
/** Type of reference */
|
||||
referenceKind: EdgeKind;
|
||||
referenceKind: ReferenceKind;
|
||||
/** Line where reference occurs */
|
||||
line: number;
|
||||
/** Column where reference occurs */
|
||||
@@ -39,7 +39,7 @@ export interface ResolvedRef {
|
||||
/** Confidence score (0-1) */
|
||||
confidence: number;
|
||||
/** How it was resolved */
|
||||
resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path';
|
||||
resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user