Skip to content

Callback synthesizer resolves the registered handler by name globally (no caller-file filter), attaching the dispatch edge to the wrong file's same-named method — verified end-to-end #1355

Description

@inth3shadows

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 filecaller.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions