# Dynamic-Dispatch Coverage Playbook **Audience:** a Claude agent continuing this work. **Mission:** systematically close static-extraction coverage holes for **dynamic dispatch** across **every language and framework codegraph supports**, and validate each one the same way, so cross-symbol *flows* exist in the graph everywhere. > This is the top-level playbook. The deep design for one mechanism (the callback > synthesizer) is in [`callback-edge-synthesis.md`](./callback-edge-synthesis.md). > Full investigation context + findings: auto-memory `project_codegraph_read_displacement`. --- ## 1. The goal (why this matters) codegraph's value is being **the map** — answering structural/flow questions (`trace`, `impact`, callers, "how does X reach Y") that grep/Read cannot. Agents will use codegraph instead of Read **only when it is sufficient**. We proved empirically (see memory) that the lever for sufficiency is **coverage**, not prompting/hooks/new-tools: when a flow is missing from the graph, the agent reads the files to reconstruct it; when the flow *is* in the graph, the agent can answer completely without reading. **Validated end-to-end on excalidraw:** after closing the update-flow hole, 2/3 headless agent runs answered the "how does an update reach the screen" question with **Read 0 and a complete answer** — impossible before, because the key edge wasn't in the graph. (Caveat: coverage *enables* the no-read path; agent confirm-by-reading variance means it doesn't *force* it. Completeness improves unconditionally.) The mission is to make that true for **all** languages/frameworks. --- ## 2. The problem class: dynamic dispatch Static tree-sitter extraction captures explicit calls (`foo()`, `this.bar()`). It **misses** any call whose target is computed/indirect. Four recurring shapes, with a **difficulty gradient** (do the cheap ones first): | # | Shape | Example | Fix mechanism | Cost | |---|---|---|---|---| | 1 | **Named attribute / descriptor** | django `self._iterable_class(self)` | framework resolver (`claimsReference` + `resolve()`) | **cheap** | | 2 | **Field-backed observer** | `onUpdate(cb)` + `for(cb of cbs)cb()` | callback synthesizer (whole-graph pass) | medium | | 3 | **String-keyed EventEmitter** | `on('e',fn)` / `emit('e')` | callback synthesizer (event-keyed) | medium | | 4 | **Inline callback handler** | `on('e', function h(){})` / `() => {}` | extraction (named) + synthesizer link-through-body (anon) | named: cheap · anon: hard | Key distinction driving the mechanism choice: - **A named ref exists** to resolve (`_iterable_class` is an attribute name) → **resolver**. - **No ref exists** (`cb()` is anonymous; needs registrar↔dispatcher correlation) → **synthesizer**. --- ## 3. Worked examples (the two mechanisms, end to end) ### 3a. Django ORM descriptor — the **resolver** pattern (Python) - **Hole:** `QuerySet._fetch_all` calls `self._iterable_class(self)` (a runtime-chosen iterable, default `ModelIterable`), whose `__iter__` runs the SQL compiler. Static parsing can't resolve the attribute-as-callable → `_fetch_all`'s only callee was `_prefetch_related_objects`; `trace(_fetch_all, execute_sql)` returned no path. - **Fix:** `djangoResolver` claims the unresolved `_iterable_class` ref through the name-exists pre-filter, then resolves it to `ModelIterable.__iter__`. - **Files:** `src/resolution/types.ts` (`claimsReference?` on `FrameworkResolver`), `src/resolution/index.ts` (pre-filter in `resolveOne` consults `claimsReference`), `src/resolution/frameworks/python.ts` (`djangoResolver.resolve` + `claimsReference` + `resolveModelIterableIter`). - **Result:** `trace(_fetch_all, execute_sql)` → `_fetch_all → __iter__ → execute_sql` (3 hops). ### 3b. Excalidraw observer + EventEmitter — the **synthesizer** (TS) - **Hole:** `Scene.triggerUpdate` does `for (cb of this.callbacks) cb()`; `triggerRender` is registered via `scene.onUpdate(this.triggerRender)`. The `triggerUpdate → triggerRender` edge is dynamic → `trace` returned no path; the whole update flow broke. - **Fix:** a whole-graph pass that detects registrar/dispatcher channels, correlates registration sites, and synthesizes `dispatcher → callback` edges. Plus extraction of **named** inline callbacks so handlers like express's `function onmount(){}` are nodes. - **Files:** `src/resolution/callback-synthesizer.ts` (the pass — field observers + EventEmitter), `src/resolution/index.ts` (calls `synthesizeCallbackEdges()` at the end of `resolveAndPersistBatched`), `src/extraction/tree-sitter.ts` (`visitFunctionBody` extracts named nested functions). - **Result:** `trace(mutateElement, triggerRender)` → 3 hops; express `use → onmount`. --- ## 4. The repeatable methodology (run this per language/framework) ### Step 1 — Pick the framework's canonical *flow* question Every framework has a signature data/control flow. Pick the "how does X reach/become Y" question and a real repo (add to `.claude/skills/agent-eval/corpus.json`). Examples: - React state→DOM, Vue reactive→render, Svelte store→update - Rails request→controller→view, Spring request→`@Controller`→service - Express/Koa request→middleware→handler, FastAPI request→route→dependency - Redux action→reducer→store, RxJS subscribe→operator→observer - Any ORM: query builder → SQL execution (django pattern) ### Step 2 — Measure the hole (deterministic, no agent) ```bash rm -rf /.codegraph && ( cd && codegraph init -i ) node scripts/agent-eval/probe-trace.mjs # does the flow break? where? node scripts/agent-eval/probe-node.mjs # trail: is the next hop missing? ``` A "No direct call path … breaks at dynamic dispatch" + a sparse trail at the break point **locates the hole** (this is exactly how `_iterable_class` and `triggerUpdate` were found). Confirm it's dynamic by reading the break symbol's body. ### Step 3 — Classify → choose the mechanism (use the §2 table) - `self.(...)` / descriptor / metaclass → **resolver** (§3a). - `for(cb of store)cb()` / `store.forEach(cb=>cb())` → **field-observer synthesizer** (§3b). - `on('e',fn)` + `emit('e')` → **EventEmitter synthesizer** (§3b). - Inline handler not a node → **named:** extraction (already done generically in `tree-sitter.ts`); **anonymous:** synthesizer link-through-body (not yet built). ### Step 4 — Implement - **Resolver:** add to `src/resolution/frameworks/.ts` — a `resolve()` branch + `claimsReference(name)` if the ref name isn't a declared symbol. Copy `djangoResolver`. - **Synthesizer channel:** extend `src/resolution/callback-synthesizer.ts` — add the framework's registrar/dispatcher **name patterns** and **body patterns** (e.g. signals use `.connect()`/`.emit()`; Rx uses `.subscribe()`/`.next()`). - Reindex (Step 2 command) and re-run `probe-trace` — the flow should now connect. ### Step 5 — Validate (the same way every time) 1. **Deterministic:** `probe-trace(from,to)` finds the path; `probe-node` shows the bridged hop. The previously-broken hop is closed. 2. **Precision:** count + spot-check synthesized/resolved edges — no explosion, correct targets: ```bash sqlite3 /.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';" ``` (Resolver edges aren't `heuristic`; verify via the trace + callees instead.) 3. **Regression:** node count stable (`select count(*) from nodes;` before/after — a big jump means an extraction change over-fired); existing traces on a control repo intact. 4. **End-to-end agent eval:** run the flow question with codegraph and measure **reads / answer-completeness / cost** vs a pre-fix baseline: ```bash # headless (exact cost + clean tool sequence) bash scripts/agent-eval/run-agent.sh with "" # or the full A/B + interactive Explore-subagent path: scripts/agent-eval/audit.sh local "" all ``` Then parse: `Read` count, codegraph-tool count, cost, and whether the answer now contains the glue symbols (the ones that previously required a read). ### Success criteria (per language/framework) - `trace` finds the canonical flow end-to-end (no dynamic-dispatch break). - Agent can answer the flow question with **Read 0** (achievable in ≥ some runs) and the glue symbols appear in the answer. - **No node explosion** and no regression on a control repo. - Synthesized edges are precise on a spot-check (no generic-name over-linking). --- ## 5. Validation toolkit (reference) | Tool | Purpose | |---|---| | `scripts/agent-eval/probe-trace.mjs ` | call-path between two symbols (the hole detector) | | `scripts/agent-eval/probe-node.mjs [code]` | symbol + trail (callers/callees); `code` adds the body | | `scripts/agent-eval/probe-context.mjs ""` | context output incl. call-paths | | `scripts/agent-eval/probe-explore.mjs ""` | explore output | | `scripts/agent-eval/{audit,run-agent,itrun}.sh` | agent A/B (headless + interactive); also the `/agent-eval` skill | | `sqlite3 /.codegraph/codegraph.db` | direct edge/node inspection (provenance, metadata, counts) | Probe scripts use the built `dist/` — run `npm run build` first. Reindex after any extraction or resolution change (`rm -rf /.codegraph && codegraph init -i`) — the synthesizer/resolvers run at index time. Test fixtures: keep a tiny per-pattern fixture (see `/tmp/cb-fixture/bus.js`; **move into `__tests__/`** when shipping). --- ## 6. Coverage matrix (fill in as you go) Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started. `Mechanism`: R = resolver, S = synthesizer channel, X = extraction. | Language | Framework(s) | Canonical flow to test | Mechanism | Status | |---|---|---|---|---| | TypeScript/JS | React / observer / EventEmitter | state→render; dispatch→callback | S + X | ✅ (excalidraw) | | TypeScript/JS | Vue / Nuxt | template events (@click→handler); component composition; reactive→render | S + X | ✅ events + composition (vitepress S / vben M / element-plus L); 🔬 reactive→render (vue-core Proxy runtime — frontier, deferred) | | TypeScript/JS | Svelte / SvelteKit | template calls/composition; SvelteKit action→api; store→DOM | X | ✅ already strong (realworld S / skeleton M / shadcn L): template `{fn()}` calls, `` composition, `import * as api` namespace, `load`→api all work out of the box. + exported-const object-of-functions extraction (SvelteKit `actions`). 🔬 `$lib`-namespace-from-action + store/reactive frontier | | TypeScript/JS | Express / Koa | request → route → handler → service | R + X | ✅ named handlers + middleware + controller/service (resolver) + **inline arrow handlers → service body calls** (realworld S 19 / parse M / ghost L 65 edges). 🔬 custom routers (payload had 0 routes — not `app.get`-style) | | TypeScript/JS | NestJS | request → @Controller → DI service → repo | R | ✅ already well-covered (realworld S / immich M-L / amplication L): @decorator routes (HTTP/GraphQL/microservice/WS) via resolver + DI `this.svc.method()` controller→service resolves correctly at scale (name + co-location). No dynamic-dispatch hole. 🔬 committed `dist/` build output gets indexed (realworld) — general build-dir-ignore follow-up | | TypeScript/JS | RxJS / signals | subscribe → operator → observer | S | ⬜ | | Python | Django ORM | QuerySet → SQL compiler | R | ✅ | | Python | Django / DRF (views) | url → view → model | R + X | ✅ url→view (`path`/`url`/`as_view`) + **DRF `router.register`→ViewSet** (realworld S / wagtail M / saleor L); ORM QuerySet→SQL (prior work). 🔬 signals (`post_save`→receiver), DRF viewset CRUD actions (inherited), saleor GraphQL resolvers | | Python | Flask / FastAPI | request → route → dependency | R | 🔬 (routes done) | | Go | Gin / chi / net-http | request → route → handler → service | X | ✅ **routes on ANY group var** (`v1.GET`, `PublicGroup.GET`) not just `r/router` (gin-vue-admin S→M 4→259 / realworld S / gitness L) — was missing all group-routed apps; named handlers resolve precisely. 🔬 inline `func(c){}` handlers (anonymous, body lost), gitness chi custom (26/321) | | Rust | Axum / Cargo workspace | request → handler; trait dispatch | R | 🔬 (workspaces done) | | Java | Spring | request → @RestController → @Autowired service → repo | R + X | ✅ **bare `@GetMapping`/`@PostMapping` + class `@RequestMapping` prefix join → route→method** (realworld S / mall M / halo L) — was missing all path-less method mappings; DI controller→service resolves (name + dir). 🔬 Spring Data JPA derived queries (`findByEmail`) — metaprogramming frontier | | Kotlin | (coroutines / DI) | flow/callback dispatch | ? | ⬜ | | Swift | Vapor | request → route → controller | ? | ⬜ | | C# | ASP.NET Core | request → [Http*] action → DI service → EF | X | ✅ **feature-folder detection** (realworld 0→19 — was undetected) + **bare `[HttpGet]` + class `[Route]` prefix** (eShopOnWeb 9→33 / jellyfin L) — co-located so no claimsReference needed. 🔬 EF Core LINQ/DbSet (metaprogramming frontier) | | Ruby | Rails / Sinatra | request → routes.rb → Controller#action → model | R | ✅ **RESTful `resources`/`resource` routing → controller#action** (realworld S 16 / spree M / forem L), pluralization + only/except + claimsReference; explicit routes fixed to precise `controller#action` too. 🔬 ActiveRecord dynamic finders (`Article.find_by_slug`) — metaprogramming frontier | | PHP | Laravel | request → route → controller → Eloquent | R | ✅ **precise `Route::get([Ctrl::class,'m'])` / `'Ctrl@m'` → Ctrl@method** (realworld S / firefly M / bookstack L) — was resolving the bare method name to the WRONG controller (every `index`→ArticleController); Route::resource→controller. 🔬 Eloquent dynamic finders/relationships (metaprogramming frontier) | | C/C++ | (callback structs / vtables) | function-pointer dispatch | ? | ⬜ | | Dart | Flutter | setState → build | S | ⬜ | | Lua / Luau | (Neovim / Roblox) | event/callback dispatch | S | ⬜ | | Scala | (Akka / Play) | actor message → handler | ? | ⬜ | (Verify the exact supported set against `src/extraction/languages/` and `src/resolution/frameworks/` before starting — this table is a starting point.) --- ## 7. Known limits & gotchas (from the excalidraw/django work) - **Coverage enables, doesn't force, the no-read path.** Agents still read to *confirm source* sometimes; cost stays ~flat (codegraph calls trade for reads). The reliable win is **completeness** + making Read-0 *possible*. Don't expect a guaranteed cost drop. - **Vue (validated 2026-05-23, vitepress S / vben M / element-plus L).** SFC `