- resolution/tier-synthesizer.ts: http-client (literal fetch/axios/ky/got/$fetch paths, axios.create baseURL instances, template holes as :params, base-URL holes by a two-segment tail; unique match only), queue-job (BullMQ/Bull add ↔ @Process/@Processor, WorkerHost process, new Worker, queue.process), event-bus (EventEmitter2 emit ↔ @OnEvent with globs; socket emit ↔ @SubscribeMessage / socket.on both ways with tier); channel, tier, callee, registeredAt on every edge; generic transport events never pair; test and generated files never sources; registered before the emitter pass
- steps.ts: crossing() reads tier/channel before languages; an endpoint reached across a tier is a bridge box and a boundary like a screen (through=1 enters it); a channel's call is not also an effect; sites read as written; a Next 'use server' action is a crossing by its directive (when.ts directive); a function-valued constant handler (asyncHandler(...)) is a route root and borrows the file-scope calls and refs within its lines
- express.ts: app.use('/prefix', router) mounts composed onto route names in postExtract (nested, by import or require); chained router.route('/x').get(h).put(h2) extracted, across lines
- frameworks/package-deps.ts: dependencies read from workspace package.json files too (Express, React, Expo Router, NestJS detect)
- routing manifest names constant handlers; e2e/ is a test directory; explore's Flow section labels the new channels
- tests: ui-steps-cross-tier (monorepo fixture: Next client + Express/Nest API), servers test updated for the queue landing
- docs: CHANGELOG, spec §3.13 cross-tier paragraph, CLAUDE.md, callback-edge-synthesis.md, plan P3 built
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
14 KiB
Design + status: general callback / observer edge synthesis
Status: SHIPPED (the synthesizer in callback-synthesizer.ts is merged and on
main). This doc records the original design.
Motivation: close the dynamic-dispatch hole that static extraction leaves for
observer / event-emitter / signal patterns, where a dispatcher invokes callbacks
registered elsewhere through a shared store — so flows like "how does an update
reach the screen" actually exist in the graph.
Update (2026-06-01): the
codegraph_traceandcodegraph_contextMCP tools were since removed —codegraph_exploreis the single surfacing tool now. Its "Flow" section (buildFlowFromNamedSymbols) and thecodegraph_nodetrail surface these synthesized edges; thetrace(a, b)notation below means "the a→b flow," which you now verify withcodegraph_explore/probe-explore.mjs(theprobe-trace.mjs/probe-context.mjsdev probes went away with the tools).
TL;DR for a new session
We synthesize dispatcher → callback edges that static parsing misses. It works:
- Field observer (excalidraw
Scene.onUpdate/triggerUpdate): synthesizestriggerUpdate → triggerRender.trace(mutateElement, triggerRender)now = 3 hops. - EventEmitter (express
on('mount', …)/emit('mount')): synthesizesuse → onmount. - Precision is high: excalidraw got 1 synthesized edge out of 27k (the correct one); node count moved +3 after Phase 3 (no explosion).
Files touched (all uncommitted on main):
src/resolution/callback-synthesizer.ts— the whole-graph synthesis pass (Phase 1 + 2).src/resolution/index.ts— callssynthesizeCallbackEdges()at the end ofresolveAndPersistBatched()(after base edges are persisted) + the import.src/extraction/tree-sitter.ts—visitFunctionBodynow extracts named nested functions (Phase 3), so inline named handlers become linkable nodes.
How to reproduce / test:
npm run build
rm -rf /tmp/codegraph-corpus/excalidraw/.codegraph
( cd /tmp/codegraph-corpus/excalidraw && codegraph init -i )
# synthesized edges (provenance='heuristic', metadata.synthesizedBy in {callback,event-emitter,…,http-client,queue-job,event-bus}):
sqlite3 /tmp/codegraph-corpus/excalidraw/.codegraph/codegraph.db \
"select s.name||' → '||t.name||' '||coalesce(e.metadata,'') from edges e \
join nodes s on e.source=s.id join nodes t on e.target=t.id where e.provenance='heuristic';"
# end-to-end flow (the synthesized edge shows up in explore's Flow section + node trail):
node scripts/agent-eval/probe-explore.mjs /tmp/codegraph-corpus/excalidraw "triggerUpdate triggerRender"
Probe scripts (dev-only, in scripts/agent-eval/): probe-node.mjs (symbol + trail),
probe-explore.mjs (relevant source + the flow among named symbols). EventEmitter
fixture lives at /tmp/cb-fixture/bus.js (ephemeral — recreate or move into __tests__/).
Cross-tier channels (src/resolution/tier-synthesizer.ts, 2026-08-28)
The web's RN bridge: one pass, registered before the in-process emitter pass (the more specific edge wins a duplicate
source>target pair in the merge), gated on JS-family files, never sourced from a test suite or a generated file.
Three channels, each keyed on a literal on both sides, each edge kind:'calls', provenance:'heuristic', with
synthesizedBy, channel (http | queue | event | socket), tier (client→server / server→client) when the
direction is known, the event / queue / method / href it paired on, line + column of the call, and
registeredAt = the other side (route registration, decorator, .on):
http-client—fetch/$fetch/ofetch/axios/ky/got/useFetch/useSWR,<client>.get|post|…(where the receiver is a known client name or a binding made byaxios.create(…)/ky.extend(…)(same file or the file it is imported from —resolveImportPath, since import mappings carry no resolved path), with a literal / template first argument (new URL('/x', base)and{ url, method }configs read too) → the ONE routeMETHOD pathin the index it denotes. A hole fills a:param/{id}/[id]/ catch-all segment and never a literal one; a hole in front of the path (${API_URL}/users) matches by the route's tail; a line a framework resolver made a route node on is a registration, not a client call; a tie between routes is nothing. No fan-out cap — the match is exact.queue-job—<queue>.add('job', …)where the queue is named (new Queue('email'),@InjectQueue('email') x, in the file or its import) or queue-shaped → the@Process('job')method of the@Processor('email')class (a WorkerHost'sprocesswhen there is none),new Worker('email', handler)(an inline handler → the enclosing function, else the enclosing constant), Bull'squeue.process('job', handler). Most specific pairing wins (queue+job > job > the queue's default); an unnamed queue pairs only on a unique job name. Fan-out cap 6.event-bus—.emit|emitAsync('x')on a bus-shaped receiver (eventEmitter,bus,pubsub, …) →@OnEventhandlers,*/**globs honoured; on a socket-shaped receiver (socket,io,server,client,.to(room), …) from a file without a socket server →@SubscribeMessage('x')and server-sidesocket.on('x')(client→server); from a file with one (@WebSocketGateway,io.on('connection'),new Server) → client-sidesocket.on('x', …), named or inline (→ the enclosing component) (server→client). Plain.on↔.emitstays the emitter pass's. Fan-out cap 6.
The Steps view (ui-server/api/steps.ts) reads tier / channel before the languages in crossing(), so a hop between
two TS files draws as a bridge (⇢ POST /api/users, a boundary like another screen) or an event (⇠ welcome); explore's
Flow section labels them (context/index.ts, mcp/tools.ts). A Next 'use server' action needs no edge: steps.ts marks
the call at request time from the directive. Validated on bradtraversy/proshop_mern (30 routes, 23 client→route edges,
all correct on inspection, after Express mounts + chained router.route() landed) and nestjs/nest (sample/26-queues,
sample/30-event-emitter); test __tests__/ui-steps-cross-tier.test.ts.
The hole
class Scene {
private callbacks = new Set<Callback>();
onUpdate(cb: Callback) { this.callbacks.add(cb); } // REGISTRAR
triggerUpdate() { for (const cb of this.callbacks) cb(); } // DISPATCHER
}
this.scene.onUpdate(this.triggerRender); // REGISTRATION SITE
The runtime edge triggerUpdate → triggerRender does not exist statically:
triggerUpdate's only literal call is cb() (anonymous). Measured: triggerUpdate's
only callee was randomInteger; trace(triggerUpdate, triggerRender) returned no path.
Why it's a whole-graph pass, not a FrameworkResolver.resolve()
resolve(ref) answers "what does this named ref point to," one ref at a time. The
callback edge has no ref to resolve (cb() is anonymous) and needs cross-file,
multi-site correlation (registrar, registration, dispatcher). So it's a whole-graph
pass after base resolution, language-level (any OO observer), living in
src/resolution/callback-synthesizer.ts — not under frameworks/.
Sibling mechanism for the other dynamic-dispatch class — named attribute/ descriptor dispatch (e.g. django
self._iterable_class(...)) — is theclaimsReferencehook (resolution/types.ts+resolution/index.tspre-filter)
- a
FrameworkResolver.resolve()(django ORM resolver inframeworks/python.ts). That one does fitresolve()because the ref is named. Both are part of the same coverage effort; see the "Related work" section.
As-built algorithm (and where it diverged from the original design)
Field-observer channels (fieldChannelEdges, Phase 1)
- Candidates by method/function name — registrar
^(on[A-Z]\w*|subscribe| addListener|addEventListener|register|watch|listen|addCallback)$; dispatcher contains(emit|trigger|notify|dispatch|fire|publish|flush). - Confirm by body (read via
ctx.readFile+ slice node lines): registrar hasthis.<F>.add|push|set(; dispatcher hasfor (… of [Array.from(]this.<F>)+ a call, orthis.<F>.forEach(. - Pairing — DIVERGENCE: the design said pair by class; the build pairs by
same file + same field
F(file as a class proxy — getting the containing class reliably was harder). Works for the common 1-class-per-file case; revisit for multi-class files. - Registrations:
queries.getIncomingEdges(registrar.id, ['calls'])→ for each, read the caller's source at the edge line and regex-recover the arg (<registrarName>\s*\(\s*(?:this\.)?(\w+)). DIVERGENCE: design preferred tree-sitter re-parse; build uses regex (named refs only — arrows/inline args are missed here). - Synthesize
dispatcher → fn(getNodesByName(arg)→ method|function). Capped atMAX_CALLBACKS_PER_CHANNEL = 40.
EventEmitter channels (eventEmitterEdges, Phase 2)
- File-oriented scan (
ctx.getAllFiles()+readFile, substring pre-filter on.emit(/.on(/etc).ON_RE=\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s* (?:function\s+(\w+)|(?:this\.)?(\w+));EMIT_RE=\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]. - Dispatcher = enclosing function of the
emit('e')call (enclosingFnfinds the tightest function/method/component node containing the line). Handler =getNodesByNameof the on-handler name. - Correlate by event-name literal; synthesize dispatcher → handler.
- Precision — DIVERGENCE: design proposed receiver-type matching; build uses an
event fan-out cap (
EVENT_FANOUT_CAP = 6) — skip events with >6 handlers or dispatchers (generic names likeerror/changewould over-link without type info).
Provenance — DIVERGENCE
Edge.provenance is a fixed enum ('tree-sitter'|'scip'|'heuristic'), so synthesized
edges use provenance: 'heuristic' + metadata: { synthesizedBy: 'callback'| 'event-emitter', via/event/field }. The design's 'callback-synthesis' provenance and
high/medium/low confidence tiers were NOT implemented — the fan-out cap +
registrar-name uniqueness + named-only handlers are the precision guards instead.
Phase 3 — inline callback extraction (tree-sitter.ts)
The real blocker for EventEmitter on real repos: inline handlers
(on('mount', function onmount(){})) weren't nodes, so nothing could link to them.
Root cause: visitFunctionBody walked through nested functions without extracting them.
Fix: in visitForCallsAndStructure, when a body node is a functionType and
extractName returns a real name, call extractFunction (which extracts it and walks
its own body) and return. Named only — anonymous arrows fall through to the existing
recursion (so their inner calls stay attributed to the enclosing fn). This bounded it:
excalidraw +3 nodes, no explosion, no regression.
Validation results (actual)
| Repo | Result |
|---|---|
| excalidraw | 1 synthesized edge triggerUpdate → triggerRender (of 27,214); trace(mutateElement, triggerRender) = 3 hops; nodes 9,286 → 9,289 |
| express | after Phase 3: use → onmount {event-emitter, event:"mount"} (onmount now extracted at application.js:109) |
/tmp/cb-fixture/bus.js |
tick → handleRefresh, persist → handleSave (named-method EventEmitter handlers) |
| excalidraw / express | no Phase-1 regression; node counts stable |
Remaining work (prioritized for the next session)
- Anonymous-arrow handlers —
on('e', () => foo())still produce no edge (no node, intentionally not extracted in Phase 3). The fix is synthesizer link-through-body: parse the arrow's body and linkdispatcher → (calls inside the arrow). Highest remaining recall win; handles the most common modern callback shape. - Wire into
resolveAndPersist(incremental sync) — synthesis currently runs only inresolveAndPersistBatched(full index). Incremental re-index won't refresh synthesized edges. - Receiver-type matching for EventEmitter precision (replace/augment the fan-out
cap) — use
type_ofedges sox.emit('change')only links toy.on('change', fn)whenx,yare the same type. Lets the fan-out cap relax. - Tree-sitter arg recovery (replace the regex in field-channel Stage 4) — robust for arrows, multi-arg, line-wrapped calls.
- Single-callback fields (
this.onChange = cb; … this.onChange()) — scalar-store variant of the field observer; not built. - Broad precision/recall audit — run across the full corpus; tally synthesized edges per repo, spot-check, confirm no explosion on EventEmitter-heavy repos.
- Tests + CHANGELOG — the fixture is a ready vitest case for the synthesizer; add extractor tests for Phase 3 (named-nested-fn extraction; confirm other languages unaffected — the change is in the shared walker), resolver tests for the django side.
Edge cases / model
- Over-approximation across instances is accepted (reachability, not instance
precision).
unregister/offignored. - Synthesized edges are additive — never replace static edges; tooling can filter on
provenance='heuristic'+metadata.synthesizedBy.
Related work (same coverage effort)
This is one half of closing dynamic-dispatch coverage. The other artifacts on main:
- Named attribute/descriptor resolver:
claimsReference(resolution/types.ts, pre-filter inresolution/index.ts) + django ORM resolver (frameworks/python.ts,_iterable_class→ModelIterable.__iter__). - Retrieval/UX changes (separate from coverage):
explorewhole-small-file + glue fixes, theexploreFlow section (buildFlowFromNamedSymbols), andnode-with-trail — all insrc/mcp/tools.ts. (codegraph_trace/codegraph_contextwere later removed; explore is the one surfacing tool.) - Full investigation context + findings: auto-memory
project_codegraph_read_displacement(why coverage — not prompting/hooks/new-tools — is the lever for getting agents to use codegraph over Read).