Summary
The field-channel callback synthesizer (fieldChannelEdges in src/resolution/callback-synthesizer.ts, the synthesizedBy: 'callback' pass) resolves a registered handler by name globally, ignoring the file the registration actually happened in. When two files declare a same-named handler method (render, update, dispose, triggerRender, …), the synthesized dispatcher → handler calls edge can attach to the wrong file's method — a silently wrong dynamic-dispatch edge (corrupted blast-radius), not merely a miss. Verified end-to-end against a real indexed project. Every sibling synthesizer in this same file disambiguates by file; this one doesn't.
Root cause
// src/resolution/callback-synthesizer.ts:205-228 (fieldChannelEdges)
for (const e of queries.getIncomingEdges(reg.node.id, ['calls'])) {
...
const caller = queries.getNodeById(e.source);
if (!caller) continue;
const line = ctx.readFile(caller.filePath)?.split('\n')[e.line - 1]; // caller.filePath is in scope
const am = line?.match(argRe);
if (!am) continue;
const fn = ctx.getNodesByName(am[1]!).find((n) => n.kind === 'method' || n.kind === 'function'); // <- no filePath filter
if (!fn) continue;
...
metadata: { synthesizedBy: 'callback', ..., registeredAt: `${caller.filePath}:${e.line}` }, // uses caller.filePath
}
am[1] is the handler name pulled from a registrar(this.<handler>) call site, so the handler lives in the caller's own file — caller.filePath is already in scope (and is used two lines later for registeredAt), but is never applied to the lookup. getNodesByName is SELECT * FROM nodes WHERE name = ? with no ORDER BY (src/db/queries.ts:1089-1095), so .find(...) returns whichever same-named method was inserted first (filesystem scan order), which need not be the caller's.
Compare the sibling disambiguation already used in this file: :200 (d.node.filePath === reg.node.filePath), :1315 (matches.find((n) => n.filePath === file) ?? matches[0]), :1350 (n.filePath === composable.filePath).
Repro (executed end-to-end against a real indexed project)
Three files — a store with a registrar/dispatcher field channel, the real registrant, and a decoy that declares a same-named triggerRender but registers nothing. The decoy is named to sort/scan first:
// a_decoy.ts
export class Decoy { triggerRender() { return 'DECOY — never registered anything'; } }
// store.ts
export class Store {
private handlers = new Set<Function>();
subscribe(cb: Function) { this.handlers.add(cb); } // registrar (name matches, this.handlers.add)
emit() { this.handlers.forEach(h => h()); } // dispatcher (name matches, this.handlers.forEach)
}
// z_real.ts
import { Store } from './store';
export class Real {
store = new Store();
init() { this.store.subscribe(this.triggerRender); } // registers Real's own triggerRender
triggerRender() { return 'REAL — the one actually registered'; }
}
After CodeGraph.init + indexAll + resolveReferencesBatched, the single synthesized callback edge is:
emit -> triggerRender [target_file = a_decoy.ts] registeredAt = z_real.ts:4
The edge's own registeredAt says the wiring is at z_real.ts:4 (Real.init), but its target is a_decoy.ts's triggerRender — a class with no relationship to the store. So Store.emit is shown dispatching to Decoy.triggerRender, and Real.triggerRender (the actual handler) is missing that incoming edge. The edge internally contradicts itself.
(When the registrant's file happens to scan first, the edge lands correctly — so the behavior is order-dependent, which is itself the defect: correctness should not depend on scan order.)
Suggested direction
Filter by the caller's file first, mirroring :1315:
ctx.getNodesByName(am[1]!).filter(n => n.kind === 'method' || n.kind === 'function').find(n => n.filePath === caller.filePath) ?? <same-kind fallback>.
Environment
main @ 2d72891, package 1.4.1, Node 22.23.1 (Linux). Reproduces on a clean npm ci && npm run build.
Summary
The field-channel callback synthesizer (
fieldChannelEdgesinsrc/resolution/callback-synthesizer.ts, thesynthesizedBy: 'callback'pass) resolves a registered handler by name globally, ignoring the file the registration actually happened in. When two files declare a same-named handler method (render,update,dispose,triggerRender, …), the synthesized dispatcher → handlercallsedge can attach to the wrong file's method — a silently wrong dynamic-dispatch edge (corrupted blast-radius), not merely a miss. Verified end-to-end against a real indexed project. Every sibling synthesizer in this same file disambiguates by file; this one doesn't.Root cause
am[1]is the handler name pulled from aregistrar(this.<handler>)call site, so the handler lives in the caller's own file —caller.filePathis already in scope (and is used two lines later forregisteredAt), but is never applied to the lookup.getNodesByNameisSELECT * FROM nodes WHERE name = ?with noORDER BY(src/db/queries.ts:1089-1095), so.find(...)returns whichever same-named method was inserted first (filesystem scan order), which need not be the caller's.Compare the sibling disambiguation already used in this file:
:200(d.node.filePath === reg.node.filePath),:1315(matches.find((n) => n.filePath === file) ?? matches[0]),:1350(n.filePath === composable.filePath).Repro (executed end-to-end against a real indexed project)
Three files — a store with a registrar/dispatcher field channel, the real registrant, and a decoy that declares a same-named
triggerRenderbut registers nothing. The decoy is named to sort/scan first:After
CodeGraph.init+indexAll+resolveReferencesBatched, the single synthesizedcallbackedge is:The edge's own
registeredAtsays the wiring is atz_real.ts:4(Real.init), but its target isa_decoy.ts'striggerRender— a class with no relationship to the store. SoStore.emitis shown dispatching toDecoy.triggerRender, andReal.triggerRender(the actual handler) is missing that incoming edge. The edge internally contradicts itself.(When the registrant's file happens to scan first, the edge lands correctly — so the behavior is order-dependent, which is itself the defect: correctness should not depend on scan order.)
Suggested direction
Filter by the caller's file first, mirroring
:1315:ctx.getNodesByName(am[1]!).filter(n => n.kind === 'method' || n.kind === 'function').find(n => n.filePath === caller.filePath) ?? <same-kind fallback>.Environment
main@2d72891, package1.4.1, Node 22.23.1 (Linux). Reproduces on a cleannpm ci && npm run build.