Three callback-registration shapes deferred from #756/#808, one arc: 1. INHERITED this.X (TS/JS + every this.-routed language): a `this.<member>` registration whose member isn't on the enclosing class defers to a second pass (resolveDeferredThisMemberRefs — in-memory like deferredChainRefs, runs after implements/extends edges persist, same lifecycle as the #750 conformance pass) and resolves up the supertype chain, depth-capped BFS, validated targets only. `bus.on("submit", this.handleSubmit)` in a subclass links to FormBase::handleSubmit; same-named methods on unrelated classes never match. this.-prefixed candidates skip the extraction name gate (an inherited member can't be in definedHere). 2. JAVA/KOTLIN qualified method refs: `Handlers::onMessage` / `OtherClass::handle` emit QUALIFIED names resolved by the scoped suffix-matcher — cross-file capable, gated on the scope name being a same-file type or an imported name (dotted JVM imports now contribute their last segment). `this::m` and `super::m` route through the class-scoped resolver (super rides the supertype pass). References through a VARIABLE (`subscriber::onNext`) deliberately produce nothing — receiver type is unknowable; RxJava's baseline bare capture was resolving these to same-named same-file methods (a test method "registering" an anonymous class's onNext) — the rework drops 18 such wrong edges and keeps the 7 genuine Type::method refs RxJava's main tree actually has. 3. SWIFT enclosing-type scoping (implicit self): bare callback names match methods only of the from-symbol's own type (extension/nested scopes reconciled by suffix), and top-level code never matches methods. Alamofire: −44 wrong edges (parameters like `request`/`data`/`retrier` resolving to same-named methods on unrelated protocols), all verified; the same-class param collision (`task`) remains and is documented. New ResolutionContext.getNodeById lets matchers derive the from-symbol's class scope. Controls: redis/fmt fnref edges byte-identical; excalidraw stable; typeorm +4 genuine inherited-getter dependencies; zero calls edges changed on any of 7 A/B repos; nodes identical everywhere. Kotlin companion-object members extract unqualified (pre-existing) so `Type::companionFn` stays silent rather than guessing — documented. Full suite 1389 passed. EXTRACTION_VERSION 20 → 21 (re-index to benefit). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
Function-as-value capture (#756) — registration-linking for callbacks
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.
Mechanism
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.
Per-language value positions (probe-verified)
| 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 |
this.method (member_expression, class-scoped — see rule 3) |
| 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 |
Precision rules (each one bought by a real-repo false positive)
- The gate (extraction-time): a candidate survives only if its name matches
a same-file function/method or an imported binding (
referenceKind === 'imports'only — scraping type-annotationreferencesnames let locals that shared a type-member's name through; excalidraw). - C-family ungated file scope: C has no symbol imports and registers
callbacks cross-file at repo scale (redis
server.c's command table names handlers fromt_*.c). File-scope initializer positions (value/listmodes) 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 whenrhs/varinitwere ungated. - TS/JS/Python: bare ids resolve to
functionkind only. A bare identifier can never be a method value in these languages (methods need a receiver —this.m/self.m), so allowing method targets soaked up locals passed as arguments (new Set(selectedPointsIndices); docopt.py'sname/matchparams — excalidraw/fmt A/B findings). TS/JSthis.Xvalues are captured asthis.-PREFIXED candidates and resolved CLASS-SCOPED (resolveThisMemberFnRefinsrc/resolution/index.ts): the target must be a function/method whose qualified name shares the from-symbol's class prefix, same file, no fallback of any kind —addEventListener(…, this.onResize)hits the enclosing class's method;this.fonts(a property, post-#808 field classification) and inherited/unknown members yield no edge. Python'sself.mform 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). - C++ is
&-explicit (addressOfOnly): bare identifiers qualify only in FILE-scope initializer tables; everywhere else (args, assignments, local braced-init lists{begin, size}) only&fn/&Cls::methodcount. 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).&xvs*xshare C'spointer_expression; only the&operator qualifies.&Cls::methodresolves SCOPED to that class. - Swift overload-family refusal: several same-named METHODS in one file
(
Session.request(...)× N) + a bare identifier = almost always a same-named parameter, not a method value (Alamofire) — refuse rather than guess. A unique method (SwiftUIaction: handleTap) still resolves. - Param-forward skips:
this.status = status/o->cb = cb(assignment whose member name equals the RHS identifier) and Swift/Kotlin labeled argsvalue: value— a forwarded local/parameter whose function value is unknowable; a same-named function elsewhere would be the WRONG target. - Destructuring skip:
const { center } = ellipseextracts data, never a function alias. - Generated/minified files (
*.min.jsand the codegen patterns ingenerated-detection.ts) produce no fn-ref candidates — minified single-letter symbols resolve everywhere (Alamofire's vendored jquery). - Resolution: function/method kinds only, same language family, never the
ref's own node (no self-loops), same-file match first, cross-file only when
the name is UNIQUE — ambiguity yields no edge. No fuzzy fallback,
ever (
matchReferenceshort-circuitsfunction_refrefs tomatchFunctionRef). - Runaway invariant (#760):
matchFunctionRefalways returnsoriginal: ref— the stored row — sodeleteSpecificResolvedReferencesdrains the batch.
Validation (2026-06-11, EXTRACTION_VERSION 19)
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.
Known limits (documented, deliberate)
- Dispatch resolution (
o->cb(x)→ implementations): uncovered, see above. - C cross-file in gated positions: an extern callback registered via assignment in a different file than its definition only resolves when the name is repo-unique (initializer tables don't have this limit — they're ungated at file scope).
- C++ bare-name registration (
register_handler(my_cb)without&): dropped byaddressOfOnly— 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. - Local/param shadowing an imported or same-file function
(
mutateElement(newElement, …)where the file also importsnewElement; JS plugins'indexOf(val)with a same-fileval()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. - Swift same-class param collisions (
eventMonitor?.request(self, didFailTask: task…)where the enclosing type ALSO has ataskmethod): enclosing-type scoping (implicit self — methods match only the from-symbol's own type, top-level bare ids never match methods) eliminated the CROSS-class collision class on Alamofire (−44 wrong edges), but a parameter named after a method of the SAME type is statically indistinguishable from an implicit-self method value. Residual, documented. - Pascal paren-less calls (
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. - Java/Kotlin method refs through a VARIABLE (
subscriber::onNext,m::run0): receiver type unknown statically — deliberately no edge (the obj.method class). RxJava's baseline bare capture was resolving these to same-named same-file methods (a test method "registering" an anonymous class'sonNext); the qualified rework drops them.Type::methodresolves cross-file (scope gated on same-file types ∪ imported names, incl. the last segment of dotted JVM imports);this::m/super::mride the class-scoped + supertype path. - Kotlin companion-object members extract UNQUALIFIED (node
handle, notKtHandlers::Companion::handle— pre-existing extraction shape), soKtHandlers::handlerefs to companion members stay silent rather than guess. Fix belongs in kotlin companion extraction. - Swift cross-file bare references: Swift sees module-wide symbols without
imports, so cross-file bare callbacks only resolve when repo-unique
(functions; methods are enclosing-type-only). Cross-TYPE
#selectortargets (rare — target-action is normally self) are scoped away too. - PHP string callables, Ruby bare symbols outside
method(:sym),obj.methodmember values whereobjisn'tthis/self: deferred. this.Xinherited members resolve through the supertype pass (resolveDeferredThisMemberRefs, depth-capped BFS over implements/extends, runs after edges persist — same lifecycle as the #750 conformance pass). Reading a getter into a local (const s = this.snapshot) still produces a references edge to the getter — a true dependency with an imperfect "registration" flavor.