Files
codegraph/docs/design/dispatch-synthesizer-backlog.md
T
Colby McHenryandClaude Opus 4.8 7f970296cf feat(resolution): synthesize object-literal registry dispatch edges
Adds `objectRegistryEdges` — a dynamic-dispatch synthesizer for the command/handler
registry pattern: an object literal maps string keys → handler classes/functions, then
dispatches by a RUNTIME key static parsing can't follow:

    this.commands = { [Cmd.ADD]: AddObjectCommand, ... }    // registration
    new this.commands[command](args).execute()              // dynamic dispatch

It links each dispatching function → each registered handler's callable entry (a class's
execute/run/handle method — preferring the method chained at the dispatch site — or the
function value), like the gin-middleware-chain fan-out. Same-file registry+dispatch only.

Validated precise on 3 real repos (the discipline that caught redux-thunk's n=1 overfit):
EtherealEngine's CommandManager (64 edges, class registry → .execute), Prebid.js (7:
builder/consent/message dispatch, function registry), warp-drive (1). Zero false positives
after several precision gates found during validation:
- skip minified/generated bundles (avg line length > 200) — draco/three.min were a
  false-positive minefield of `h[x](...)` calls + `{a:b}` literals;
- DEPTH-AWARE entry parsing (top-level `key: Identifier` only) so method-shorthand bodies
  and nested objects don't leak their inner `k: v` pairs as bogus handlers;
- callable-only targets (drop data `constant`s — a `{x: URL}` entry resolving to the global);
- dynamic-dispatch gate (a statically-accessed look-alike object yields nothing).
Handles constructor and field-initializer registry forms (this. normalized). Surfaces in
codegraph_explore via the existing Dynamic-dispatch-links section.

Deferred (recall, documented in dispatch-synthesizer-backlog.md): assign-then-call dispatch,
augmentation registration (reg[k]=H), and the cross-file barrel-namespace variant
(trezor getMethod) — the hard tier.

Full suite green (1606); new __tests__/object-registry-synthesizer.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:17:31 -05:00

146 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Dispatch-Synthesizer Backlog — the "dispatch-through-indirection" family
**Audience:** a Claude agent continuing the coverage mission.
**Relationship to the playbook:** this is a *cross-cutting* companion to
[`dynamic-dispatch-coverage-playbook.md`](./dynamic-dispatch-coverage-playbook.md).
The playbook's §6 matrix is organized by **language × framework**. This doc is
organized by **dispatch *shape*** — because a single framework can contain several
distinct indirection shapes (Redux alone is ≥2: hand-written thunks vs RTK Query),
and several shapes recur identically across many frameworks/languages (a name→class
registry is the same problem in trezor `connect`, n8n nodes, and a VS Code command
palette). Redux-thunk (`synthesizedBy:'redux-thunk'`) was the first member shipped;
this is the queue behind it.
Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but under-validated
· 🔬 hole identified · ⬜ not started · ⛔ deliberately not built (silent beats wrong).
---
## The discipline (lessons already paid for — read before building any of these)
1. **Build against ≥2 real repos that *contain the pattern*, from the start.**
redux-thunk was tuned on **trezor-suite alone (n=1)**. The obvious second repo,
**shapeshift/web**, fires **0** redux-thunk edges — and that 0 is *correct*:
shapeshift has **zero** `createAsyncThunk`/`createThunk` (it's an **RTK Query**
codebase, 14 `createApi` files). So shapeshift could neither confirm nor refute
generalization — it doesn't contain the shape. **A synthesizer validated on one
repo is unvalidated.** Pick the validation repos *by grepping for the pattern
first*, not by reputation.
2. **"One framework" ≠ "one shape."** The trezor→shapeshift split is the proof:
- `createAsyncThunk` + thunk→thunk `dispatch(Y())` chains → **redux-thunk** ✅ (trezor)
- `createApi` + `builder.query/mutation` endpoints → hooks/components → **RTK Query** 🔬 (shapeshift) — a *different, unbuilt* synthesizer
- plain `dispatch(action)` → matching `reducer`/slice `case`**slice-dispatch**
Don't let "we did Redux" hide two-thirds of Redux.
3. **Precision is free recall's price.** redux-thunk's 0-on-shapeshift is the *good*
kind of zero (no false edges on a non-thunk repo — same bar as the playbook's
"0 on every non-pattern control"). Every synthesizer below must show **0 on a
control that lacks the shape** *and* **non-zero + precise on ≥2 that have it**.
4. **Two-part master lever still governs.** An edge only helps if a *realistic
symbol-named explore seeds a path it lies on*. A synthesizer whose far endpoint
no normal query names buys nothing (the trezor "11 explores" tail). Prefer shapes
where both endpoints are names an agent would actually type.
5. **Partial coverage is worse than none** (playbook §7). Close each flow
*end-to-end* and re-measure; never ship a half-bridged flow.
---
## The backlog (prioritized by frequency × static-resolvability × query-seedability)
### Tier A — high traffic, cleanly static, build next
| Shape | Ecosystem | The static anchor that bridges it | Mechanism | Status |
|---|---|---|---|---|
| **Name→class registry / command bus** | any (TS/JS first) | object-literal registry `{key: Handler}` + computed-key dispatch `(new) reg[var](…)` | S (fan-out, `object-registry`) | ✅ **SHIPPED v1 (2026-06-20)**`objectRegistryEdges`. Links each dispatcher fn → each registered handler's callable entry (a class's `execute`/run/handle method — preferring the method chained at the dispatch — or the function value). Precise on **xrengine** (CommandManager, 64 edges, class registry → `.execute`), **Prebid.js** (7: builder/consent/message dispatch, fn registry), **warp-drive** (1). **0 false positives** after: minified-file skip (avg line >200), **depth-aware** entry parse (top-level `key: Ident` only — method-shorthand/nested-object bodies don't leak), callable-only targets (no data `constant`), dynamic-dispatch gate. Handles constructor + field-initializer (`this.` normalized) forms. **Deferred (recall, documented):** assign-then-call (`const h=reg[k]; h()` — warp-drive's main `COMMANDS`), augmentation (`reg[k]=H` — Prebid single-entry), method-shorthand entry recall, and the **cross-file barrel-namespace** variant (trezor `getMethod`: `import * as M; M[method]→new` + computed dynamic import + camel↔Pascal — the hard tier, still 🔬). |
| **RTK Query** | TS / Redux Toolkit | `createApi({ endpoints: b => ({ getX: b.query(...) }) })` → generated `useGetXQuery` hook → component; endpoint name ↔ hook name (`getX``useGetXQuery`) is convention | X (extract endpoints) + S (endpoint→hook) | 🔬 **found on shapeshift** (14 `createApi` files, currently invisible). The modern RTK default — likely higher traffic than hand thunks now. |
| **Vuex / Pinia** | Vue | `store.dispatch('ns/action')` / `commit('mutation')` → action/mutation by string key (namespaced) | S (string-keyed, like `event-emitter`) | ⬜ |
| **NgRx effects** | Angular | `createEffect(() => actions.pipe(ofType(LoginAction), …))` → effect handler; `Store.dispatch(new LoginAction())` → effect by action type/class | S (type/class-keyed) | ⬜ |
### Tier B — backend command/event/message buses (each needs its own canonical flow + ≥2 repos)
| Shape | Ecosystem | Anchor | Mechanism | Status |
|---|---|---|---|---|
| **MediatR / CQRS** | .NET | `IRequest<T>``IRequestHandler<TReq,T>` by the generic request type; `_mediator.Send(new GetFooQuery())` → handler | S (generic-type-keyed) | 🔬 named a frontier in CLAUDE.md, but it's statically keyable via the generic — worth a real attempt |
| **Celery / Sidekiq** | Python / Ruby | `@task`/`@shared_task` + `.delay()`/`.apply_async()`; `Worker.perform_async``perform` | R/X (decorator + name) | ⬜ |
| **Laravel / Spring events** | PHP / Java | `event(OrderShipped::class)``EventServiceProvider` listener map; `@EventListener onX(EventT)` → publisher by event type | R (mapped) | ⬜ |
### Tier C — frontier, ⛔ do **not** build (no static anchor; would add noise)
| Shape | Why not |
|---|---|
| **RxJS subscribe** | observable→observer is predominantly *anonymous* closures; no name to seed (playbook ⬜, deferred) |
| **MobX / Vue-reactivity / Solid signals** | Proxy reactive runtime — the edge doesn't exist statically at all; silent beats wrong (matches vue-core deferral) |
| **Redux-Saga** | generator `yield put()` / `takeEvery(ACTION, saga*)` — generator-body dispatch, materially harder; revisit only if a real repo demands it |
### Already shipped (for context)
| Shape | `synthesizedBy` | Validated on |
|---|---|---|
| Redux thunk | `redux-thunk` | ✅ **generalizes (2026-06-20)** — precise on uwave-web (small, 5 edges), session-desktop (medium, 2), trezor (large, 211); control shapeshift (RTK Query, no thunks) = 0. Receiver-agnostic (`api.dispatch`/`thunkApi.dispatch`/`window.…dispatch` all matched). **⚠️ 2 follow-ups below.** |
| (see playbook §6 / `callback-synthesizer.ts` for the other ~20 channels) | | |
### redux-thunk follow-ups (found by the n>1 validation — this is exactly what it's for)
1. **Precision: name-collision target resolution — ✅ FIXED (2026-06-20).** `reduxThunkEdges`
resolved the dispatched name via `getNodesByName(name).find(kind ∈ {constant,function,
method})` — first match wins, no preference for the thunk. On **octo-call**, `leaveCall`
collides (a `createAsyncThunk` const at `state/call.ts:201` *and* a service `function`
at `services/firestore-signaling.ts:253`); **both** edges mis-resolved to the *service
function*. trezor's long unique thunk names hid this. **Fix:** resolution now prefers a
thunk-signature const > other const > same-file callable > first match (single-candidate
unaffected). Verified: octo-call's 2 edges now target the thunk (`call.ts:201`); uwave's 5
unchanged; regression test in `__tests__/redux-thunk-synthesizer.test.ts`.
2. **Surfacing: synth edges between non-callable nodes were invisible — ✅ ROOT-CAUSED + FIXED
(2026-06-20).** redux-thunk connects `constant` nodes (thunks are `const X=createAsyncThunk`),
but explore's flow machinery assumed callables, so the hop fell through both surfacing
paths: **(a)** `buildFlowFromNamedSymbols` filtered its named set to
`CALLABLE={method,function,component,constructor}` (tools.ts:1554) → constants never entered
the Flow scan / #687 Dynamic-dispatch-links loop, at any tier; **(b)** the kind-agnostic
`### Relationships` section (which *does* render constant→constant) is
`includeRelationships:false` below 500 files. Net: redux-thunk edges surfaced ONLY via
Relationships, ONLY on repos ≥500 files (uwave/octo-call showed nothing). **Fix (surgical,
tier-independent):** a `dynNamed` set of named CONSTANT/VARIABLE/FIELD nodes that participate
in a heuristic edge feeds the `## Dynamic-dispatch links` scan (main call-chain stays
callable-only); plus a generic `synthEdgeNote` fallback so any synth hop reads
`dynamic: <kind> @wiring-site`, not a bare `[calls]`. Verified: uwave `shufflePlaylist→
loadPlaylist` and `register→login→initState` now surface; trezor unchanged; full suite +
new `__tests__/explore-synth-constant-endpoints.test.ts` pass. **No-op for callable flows**
(dynNamed stays empty) — so it generalizes: any future constant/variable/field-connecting
synth (RTK Query, Vuex) surfaces for free.
---
## Per-synthesizer validation protocol (condensed from the playbook)
For each shape, before marking ✅:
1. **Grep ≥3 real repos for the pattern**; keep the **2+ that contain it** (small/medium)
+ **1 control that lacks it**. (Graph-level precision/recall validation does **not**
need not-trained-on repos — that constraint is only for *agent A/B baselines*.)
2. **Measure the hole**: `select count(*) from edges where synthesizedBy='X'`
non-zero + node count stable (no explosion) on the pattern repos; **0 on the control**.
3. **Precision spot-check**: sample ~12 edges; source & target must both be real and the
indirection must actually exist in the source body.
4. **Seed a flow**: `scripts/agent-eval/probe-explore.mjs` with the shape's endpoint
symbol names → the Flow section shows the path through the synthesized hop.
5. **Agent A/B** (only for the headline repo, not every control): `--model sonnet
--effort high`, n≥2/arm, record Read/Grep/duration.
---
## Immediate next actions
- [ ] **Validate redux-thunk for real (workstream 1):** clone a small + medium
`createAsyncThunk`-using app (grep-confirmed), re-index, repeat the protocol.
Promote `redux-thunk` 🟡→✅ or fix the overfit. *(None of the 4 already-cloned
eval repos contain `createAsyncThunk`.)*
- [ ] **Decide trezor end (workstream 3):** the **name→class registry** synthesizer is
the valuable, generalizable Tier-A item (closes trezor's `getMethod` end *and*
n8n/VS-Code-class registries). The **facade** (`connect-common/factory.ts`) is
**low-value** — it collapses every method to a single `call` fan-in with no
per-method disambiguation; bridging it buys ~nothing. Build the registry, skip the facade.
- [ ] **RTK Query (workstream 2 spillover):** shapeshift handed us a free, real,
already-indexed target. Strong candidate right after redux-thunk is de-risked.