Problem. A function used as a value — passed as an argument, assigned to a
function pointer or field, placed in a struct initializer or handler table —
produced no edge in any of the 19 tree-sitter languages (probed 2026-06-11;
0/19). callers(my_recv_cb) on a C callback showed nothing but direct calls, so
every registered callback looked dead, and the registration sites — the agent's
actual next question ("where is this wired up?") — were invisible.
Non-goal, deliberate. Resolving the dispatch (o->cb(x) → the concrete
registered function) needs data-flow through struct fields; even an LSP needs
fallbacks there (see the #756 thread). Partial coverage is worse than none and
a wrong edge is worse than silence — dispatch resolution stays uncovered. What
ships is the registration side, which is deterministic: the function's name
is literally in the source at the registration site.
capture (tree-sitter.ts walkers, table-driven per language: src/extraction/function-ref.ts)
→ gate (flushFnRefCandidates: same-file fn/method name ∪ imported binding names;
C-family file-scope initializers skip the gate — see below)
→ unresolved ref, referenceKind 'function_ref' (internal-only kind)
→ resolution (resolveOne branch: resolveViaImport first, then matchFunctionRef —
exact name, function/method kinds only, same-family, same-file first,
cross-file only when UNIQUE, never fuzzy)
→ edge kind 'references', metadata { fnRef: true, resolvedBy, confidence }
getCallers/getCallees/getImpactRadius already traverse references, so
registration sites surface with no graph-layer changes. The MCP callers/callees
lists label them "via callback registration".
Capture fires from three walkers (a node is only ever visited by one):
visitNode (file/class scope), visitForCallsAndStructure (function bodies),
visitPascalBlock (Pascal bodies). Subtrees the walkers consume without
descending (top-level variable initializers, class field/property initializers,
custom visitNode hooks like Scala's val/var handler) get a candidates-only
scanFnRefSubtree that halts at nested function boundaries.
| Language | arg | assign RHS | keyed init | list/table | wrapper forms |
|---|---|---|---|---|---|
| C / ObjC | argument_list |
assignment_expression.right |
initializer_pair.value |
initializer_list, init_declarator.value |
&fn (pointer_expression), @selector(...) (ObjC) |
| C++ | & forms only in args/rhs/varinit |
(same — explicit & only) |
bare ids at FILE scope only | bare ids at FILE scope only | &fn, &Cls::method (resolved scoped to the class) |
| TS / JS (tsx/jsx) | arguments |
assignment_expression.right |
pair.value |
array, variable_declarator.value |
— (see TS notes) |
| Python | argument_list, keyword_argument.value |
assignment.right |
pair.value |
list |
self.method (attribute) |
| Go | argument_list |
assignment_statement / short_var_declaration (expression_list) |
keyed_element |
literal_value, var_spec.value |
— |
| Rust | arguments |
assignment_expression.right |
field_initializer.value |
array_expression, static_item / let_declaration.value |
— |
| Java | argument_list |
assignment_expression.right |
— | variable_declarator.value |
method_reference (Cls::m, this::m) — the only form |
| Kotlin | value_arguments |
assignment (last child) |
— | — | callable_reference (::f), navigation_expression this::m |
| C# | argument_list (argument) |
assignment_expression.right (incl. +=) |
— | initializer_expression, variable_declarator |
this.M (member_access_expression; vendored grammar keeps this anonymous — handled) |
| Ruby | argument_list |
— | pair.value |
— | only method(:sym) / &method(:sym) — bare ids are calls/locals in Ruby |
| Swift | value_arguments (value_argument.value) |
assignment.result |
(labeled ctor args = args) | array_literal, property_declaration.value |
#selector(...) |
| Scala | arguments |
assignment_expression.right |
— | val_definition.value (via hook scan) |
eta fn _ (postfix_expression) |
| Dart | arguments (argument) |
assignment_expression.right |
pair.value |
list_literal, static_final_declaration |
— |
| Lua / Luau | arguments |
assignment_statement (expression_list.value) |
field.value (keyed + positional) |
(same) | — |
| Pascal | exprArgs (via visitPascalBlock) |
assignment.rhs (OnFire := Handler) |
— | — | @Handler (exprUnary.operand) |
| PHP | skipped | — | — | — | first-class callable fn(...) already extracts as a calls edge; string callables are a precision risk, deferred |
referenceKind ===
'imports' only — scraping type-annotation references names let locals that
shared a type-member's name through; excalidraw).server.c's command table names
handlers from t_*.c). File-scope initializer positions (value/list
modes) skip the gate — safe because a C file-scope initializer is a
constant-expression context: a bare identifier there can only be a
function address (enum/macro names get dropped by the kind filter). Local
initializers and assignments stay gated: prev = next, *str = field,
arena_ind_prev = arena_ind (redis/jemalloc) each matched a unique
same-named function somewhere and produced wrong edges when rhs/varinit
were ungated.function kind only. A bare
identifier can never be a method value in these languages (methods need a
receiver — this.m / self.m), and TS class FIELDS are extracted as
method-kind nodes (pre-existing extractor quirk), so allowing method
targets soaked up locals passed as arguments
(new Set(selectedPointsIndices) → a same-named "method" field;
docopt.py's name/match params). For the same reason this.X capture
is disabled for TS/JS — in real code this.X value positions are mostly
data reads (setCursor(this.canvas)). Python's self.m form keeps method
targets through its own capture shape. C#/Swift/Dart/Java/Kotlin keep
method targets (method groups, implicit-self, method references are real
method values).&-explicit (addressOfOnly): bare identifiers qualify only in
FILE-scope initializer tables; everywhere else (args, assignments, local
braced-init lists {begin, size}) only &fn / &Cls::method count.
C++ codebases are dense with generic free-function names (begin, end,
out, size, data) colliding with locals, and OUT-OF-LINE member
definitions extract as function-kind nodes, defeating the kind filter —
bare-id matching on fmt was mostly wrong edges (72 generic-name + 105
member/macro mismatches → after the rule: 22 edges, ~20 genuine gtest
member-pointer wirings). &x vs *x share C's pointer_expression; only
the & operator qualifies. &Cls::method resolves SCOPED to that class.Session.request(...) × N) + a bare identifier = almost always a
same-named parameter, not a method value (Alamofire) — refuse rather than
guess. A unique method (SwiftUI action: handleTap) still resolves.this.status = status / o->cb = cb (assignment
whose member name equals the RHS identifier) and Swift/Kotlin labeled args
value: value — a forwarded local/parameter whose function value is
unknowable; a same-named function elsewhere would be the WRONG target.const { center } = ellipse extracts data, never a
function alias.*.min.js and the codegen patterns in
generated-detection.ts) produce no fn-ref candidates — minified
single-letter symbols resolve everywhere (Alamofire's vendored jquery).matchReference short-circuits function_ref refs to
matchFunctionRef).matchFunctionRef always returns
original: ref — the stored row — so deleteSpecificResolvedReferences
drains the batch.Stash-free A/B (baseline = worktree at main), fresh shallow clones, public
OSS only. Per repo: node count must be identical, calls edges identical,
references strictly additive, precision spot-checked by reading the source
line of sampled fnRef edges.
Final build, all 17 repos (nodes identical and calls edges untouched on every
row; unresolved_refs fully drained — no batched-resolver runaway):
| Lang | Repo | Nodes (base=fix) | calls Δ | refs gained | Notes |
|---|---|---|---|---|---|
| C | redis | 18931 | 0/0 | +1918 | 30/30 sample genuine — ops tables, qsort comparators, module registration, lua lib tables |
| TS/React | excalidraw | 10299 | 0/0 | +121 | 18/20 — residual = param shadowing an imported function (file-level dep real) |
| Go | gin | 2599 | 0/0 | +14 | |
| Rust | bytes | 947 | 0/0 | +76 | map(fn), struct init |
| Java | okhttp | 16008 | 0/0 | +2 | method-ref forms only, by design |
| Kotlin | okio | 7801 | 0/0 | +1 | ::fn forms only, by design |
| Swift | alamofire | 3477 | 0/0 | +116 | adversarial case (params mirror API names); overload-family + label==name rules applied |
| Python | flask | 2705 | 0/0 | +111 | 8/8 sample genuine — incl. ensure_sync(self.dispatch_request) |
| Ruby | sinatra | 1751 | 0/0 | +8 | method(:sym) only |
| C# | newtonsoft | 20208 | 0/0 | +38 | method groups, += |
| Scala | scopt | 694 | 0/0 | +10 | eta-expansion |
| Dart | provider | 1154 | 0/0 | +73 | implicit-this getter reads — true same-class dependencies |
| Lua | busted | 1257 | 0/0 | +14 | |
| Luau | fusion | 2126 | 0/0 | +18 | :Connect(fn) |
| ObjC | afnetworking | 1487 | 0/0 | +52 | @selector, target-action |
| Pascal | pascalcoin | 48788 | 0/0 | +577 | OnClick := event wiring + paren-less-call refs (see limits) |
| C++ | fmt | 7345 | 0/0 | +22 | ~20/22 genuine gtest member-pointer plumbing after addressOfOnly |
Index cost on redis: +6% time, +5% db size.
o->cb(x) → implementations): uncovered, see above.register_handler(my_cb) without &):
dropped by addressOfOnly — the generic-name collision rate made bare ids
net-negative on real C++ (fmt). &my_cb / file-scope tables cover the
idioms; C files keep bare args.mutateElement(newElement, …) where the file also imports newElement;
JS plugins' indexOf(val) with a same-file val() helper): irreducible
without local-scope tracking — the data-flow frontier deliberately left
uncovered. ~1-2 per 20 sampled edges on callback-heavy repos; the file-level
dependency is real in every observed case.request(self, didFailTask:
task…) where one task method exists): the overload-family rule only
refuses when ≥2 same-named methods share the file. Alamofire-style
API-mirrored param naming keeps a residual; needs same-type scoping (v2).Result := DoInitialize): captured as
references (Pascal can't distinguish a procedure VALUE from a paren-less
CALL without types). The dependency direction is correct and these calls
were previously invisible entirely (#791) — strictly more truth, imperfect
label.OtherClass::method without the
defining class imported as a simple name): gated away; same-file and
this::m forms work.method(:sym),
obj.method member values where obj isn't this/self: deferred.this.X: disabled until TS class-field kind classification is fixed
(fields currently extract as method-kind nodes).