Files
codegraph/docs/design/dynamic-dispatch-coverage-playbook.md
T
025ebc88d6 Release 0.9.4: framework-aware routing + dynamic-dispatch coverage + retrieval improvements (#365)
* feat(resolution): close dynamic-dispatch coverage holes (callback synthesis + django ORM)

Static tree-sitter extraction misses calls whose target is computed or indirect,
so flows through callbacks, observers, and descriptors were absent from the graph.

- callback-synthesizer.ts: whole-graph pass after base resolution. Detects
  registrar/dispatcher channels (field-backed observers + string-keyed
  EventEmitters), correlates registration sites, and synthesizes
  dispatcher->callback `calls` edges (provenance:'heuristic'). Records the
  registration site (registeredAt) in edge metadata. Precision guards: named
  handlers only, registrar-name match, event fan-out cap.
- frameworks/python.ts + resolution/{index,types}.ts: claimsReference hook +
  django ORM resolver (_iterable_class -> ModelIterable.__iter__).
- extraction/tree-sitter.ts: extract named nested functions so inline named
  handlers become linkable nodes.

trace(mutateElement, triggerRender) and trace(_fetch_all, execute_sql) now
connect; node count stable (no explosion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp): self-sufficient flow output + fix explore budget regression

- Surface synthesized-edge evidence in trace, the node trail, and context call
  paths: a dynamic-dispatch hop now shows "callback via onUpdate @App.tsx:3148"
  with the registration site inline (and trace inlines each hop's call-site
  source line) -- the exact glue agents previously Read/Grep'd to reconstruct.
- Fix non-monotonic explore output budget: the 500-5000 file tier capped
  maxCharsPerFile at 2500, BELOW the <500 tier's 3800, so on god-file projects
  (excalidraw's 415 KB App.tsx) one explore returned <1% of the file and forced
  a Read. Raised to 6500/file, 28000 total.
- Stop explore from inviting Read: truncation/trim notes said "use Read for
  more"; they now steer to another codegraph_explore and treat returned source
  as already Read.

Measured on excalidraw: best-case flow answer went from 5 reads / 131s to
0 reads / 73s with ~3-4 codegraph calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(agent-eval): coverage probes, block-read hook, and design docs

Dev-only validation harness for the dynamic-dispatch coverage work:
- probe-{trace,node,context,explore}.mjs: drive MCP tools against a built index
  without a full agent run.
- block-read-hook.sh + hook-settings.json: PreToolUse experiment that denies
  source Reads to measure codegraph sufficiency (forced Read-0).
- docs/design/: callback-edge-synthesis + dynamic-dispatch-coverage playbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): bridge React boundaries — re-render + JSX child synthesis

Closes the two dynamic-dispatch hops that broke "state mutation -> on-screen
render" flows in React apps. Both are call-invisible (React-internal) but the
code between them is fully call-connected, so one synthesized edge each makes the
whole flow trace end-to-end.

- reactRenderEdges: setState(...) re-runs the component's render(). For each
  class with a render method, link sibling methods calling this.setState ->
  render. The setState gate keeps it to React class components.
- reactJsxChildEdges: a component that returns <Child .../> mounts Child. Link
  parent -> each capitalized JSX child, resolved to a component/function/class
  node (the resolution gate drops TS generics like Array<Foo>). File-oriented,
  capped per parent.
- Surface both in synthEdgeNote (trace + node trail) and context call-paths.

Validated on excalidraw: trace(mutateElement, renderStaticScene) now connects in
6 hops across callback -> react-render -> jsx-child; 1 + 46 + 280 synthesized
edges, node count stable (no explosion). Partial coverage is worse than none:
react-render alone raised agent reads (revealed a hop it then drilled); adding
the jsx hop closed the flow and dropped reads to 0-1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(claude): retrieval performance contract + coverage validation methodology

Add a "Retrieval performance & dynamic-dispatch coverage" section so future
changes/PRs don't silently regress agent retrieval:
- the explore call+output budget table by repo size, with the monotonic-per-file
  invariant (the bug that started this: <5000 tier's 2500 < <500 tier's 3800).
- the "partial coverage is worse than none" principle.
- the required validation methodology (small/medium/large x >=3 prompts per
  language x framework; deterministic probes + agent A/B; pass bar).
- the Excalidraw worked example (before/after numbers) as the template to
  replicate for every language/framework.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(claude): use full n=4 measured range in Excalidraw worked example

Best run 0 Read/3 cg/76s; typical ~1 Read/~4 cg; occasional over-drill outlier.
Report the range, not a single run — run-to-run variance is large.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp): steer flow questions to codegraph_trace first (tightens variance)

codegraph_trace was absent from every steering intent map — all three guidance
files routed "how does X reach Y" to context+explore, never to the trace tool.
So agents used trace only by chance; when one didn't, it floundered
reconstructing the path with search+callers (an 18-call run vs ~6 for trace-users).

Add codegraph_trace to the intent map + a "flow" common chain (trace from->to
FIRST = the whole path in one call, then ONE explore for bodies) across all three
synced files (server-instructions, instructions-template, .cursor rule).

Validated on excalidraw (hard "to the screen" Q, n=4 before/after):
- call count 3-10 -> 3-4 (over-drill outlier gone)
- duration 64-112s -> 51-74s
- trace adoption 3/4 -> 4/4; search+callers path-reconstruction -> 0
- fully-clean runs (0 Read, 0 Grep) 0/4 -> 2/4; best 3 cg / 0 / 0 / 51s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Vue SFC template coverage (events + kebab components)

The .vue extractor only parses <script>, so template usage is invisible —
handlers and kebab child components used only in <template> have no edge. Add a
vueTemplateEdges channel (scoped to the <template> block of .vue files):
- event bindings: @click="onClick" / v-on:submit="save" -> handler method/function
  (skips inline arrows and $emit; resolves same-file first to avoid cross-app
  mis-match in monorepos).
- kebab child components: <el-button> -> ElButton (PascalCase children like
  <VPNav/> are already caught by the JSX channel via the SFC component node).

Surface vue-handler in synthEdgeNote (trace/node trail) + context call-paths.

Validated on vue repos (reindex, no node explosion):
- vue-handler edges: vitepress 15, vben 404, element-plus 603 — all precise
  (code-login @submit -> handleLogin, register @submit -> handleSubmit, ...).
- callers(handleLogin) now includes the login component (was 0); each monorepo
  app's login resolves to its own same-file handler.
- composition: PascalCase + kebab work; element-plus's el-/filename naming
  (el-button -> button.vue) is a known library-prefix limitation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Vue validation in coverage matrix + limits

Vue / Nuxt row →  template events + composition (vitepress S / vben M /
element-plus L); 🔬 reactive→render (vue-core Proxy runtime, deferred).

§7: Vue results + the two real limits — composable-destructure handlers
(@click="closeSidebar" from useSidebarControl, a data-flow frontier) and
prefix-convention kebab (el-button→button.vue). Agent reads dropped in every
size; strongest where handlers are local functions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): resolve Vue composable-destructure template handlers

@click="closeSidebar" where `const { close: closeSidebar } = useSidebarControl()`
previously didn't resolve — the handler is a destructured composable return, not a
local fn node. Now: parse the SFC's `use*()` destructures into alias→{composable,
key}, and for an unresolved template handler follow alias → composable → the
returned member (`close`) defined in the composable's file. Precise-only: no
fallback to the composable itself (the component already has a static useX() call
edge), so we add an edge only when the specific returned fn is found.

Validated: vitepress Layout @click→close / @open-menu→open (in composables/
sidebar.ts); sidebar-flow agent run dropped 6→0 reads (best case). element-plus's
fallback-only matches correctly drop to 0; node counts stable; direct handlers
(vben handleLogin) unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): composable-destructure handlers now resolved (Vue)

@click="closeSidebar" → composable returned fn; vitepress sidebar 6→0 reads.
Remaining Vue limits: prefix-convention kebab + reactive→render frontier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(extraction): extract function-valued properties of exported-const objects

`export const actions = { default: async () => {...} }` (SvelteKit form actions,
and general JS handler/route/reducer maps) left the arrow functions unextracted —
the walker skips object-literal functions (deliberately, to avoid inline-object
noise like `ctx.set({...})`). So an action's body (and its calls) was invisible.

Now: for an EXPORTED const whose initializer is an object literal, extract each
function-valued property (arrow / function expression) as a function named by its
key and walk its body. extractFunction gains a nameOverride so ONLY this explicit
path names pair-arrows — inline-object arrows reached by the general walker still
fall through to the <anonymous> skip, so no noise returns. JS/TS-gated.

Validated: fixtures extract the actions + walk bodies (default→helper, default→
api.post resolve); SvelteKit detection doesn't break it. Blast radius tiny:
excalidraw +1 node, Python (django) +0, Vue repos +0, realworld +11 (the actions).

Known residual: a `$lib`-alias namespace-member call (`api.post`) from an extracted
action node doesn't resolve even though the same alias resolves for `load` — a
deeper resolver interaction, separate from this extraction change. Local/relative
calls from actions connect fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Svelte validation (already well-covered) + actions fix

Svelte/SvelteKit row → already strong (template calls/composition/namespace/load);
+ exported-const object-of-functions extraction. Lesson: measure before assuming
a hole — modern Svelte barely uses on:click={fn}; Svelte needed far less than Vue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): connect Express inline arrow route handlers to their services

The Express resolver created route nodes but linked handlers via a single regex
whose `[^)]+` broke on inline arrows — so `router.post('/x', async (req,res) =>
{...})` (the dominant modern pattern) connected to NOTHING, and the anonymous
handler's body (the actual request→service flow) was lost. The whole inline-handler
API was unreachable: e.g. realworld's `POST /users/login` route → 0 edges.

Now: match the route head, span the full call with a string-aware balanced-paren
scan, and for an inline arrow handler extract its body's calls (string-aware brace
scan) and attribute them to the route node as `calls` edges. A RESERVED denylist
drops res/req/builtin methods (json, next, status, ...) to keep only business calls.
Named-handler routes keep the existing reference behavior.

Validated: realworld POST /users/login → login (auth.service); 19 precise
route→service edges (was 0) — POST /articles→createArticle, .../favorite→
favoriteArticle, etc., no json/next noise. ghost +65 inline-handler edges. No node
explosion (ghost 40767, parse 3394 unchanged). Framework-scoped: zero blast radius
off Express.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Express validation (inline-handler fix)

Express/Koa row → resolver already handled named handlers; the real hole was
inline arrow route handlers (router.post('/x', async (req,res)=>{...})) — fixed:
route→service body calls (realworld 19 / ghost 65 edges, no explosion). Agent A/B
muddied by repo size (realworld tiny) / complexity (ghost layered API). Lesson
inverse of Svelte: Express's dominant pattern WAS the uncovered one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record NestJS validation (already well-covered)

NestJS row → resolver handles @decorator routes; DI controller→service
(this.svc.method) resolves correctly at scale (immich: addUsersToAlbum→addUsers,
etc.). Agent A/B: codegraph eliminated Grep (0 vs 3). No dynamic-dispatch hole.
Surfaced a general hygiene gap (not NestJS): committed dist/ build output gets
indexed (no default build-dir ignore) — narrow (real apps gitignore dist/),
deferred as a core-indexer follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Rails RESTful resources routing → controller#action

The rails resolver only saw explicit `get '/x' => 'c#a'` routes, so apps using
the dominant `resources :articles` / `resource :user` RESTful routing had ZERO
route nodes (realworld + spree: 0 routes despite full routes.rb files). The whole
request→controller flow was disconnected.

Fix (frameworks/ruby.ts):
- extract: expand `resources`/`resource` into their REST actions (only/except
  filters; pluralize the singular `resource :user` → users_controller), emit a
  precise `controller#action` ref per action. Explicit routes now also reference
  `controller#action` instead of a bare ambiguous `action`.
- resolve: new `controller#action` pattern → the action method in
  <ctrl>_controller.rb (file convention + controller-class fallback).
- claimsReference: claim `controller#action` refs so resolveOne's pre-filter
  doesn't drop them before resolve() runs (same hook the django ORM work needed —
  these refs name no declared symbol).

Validated: realworld 0→16, forem 0→635 precise route→action edges (GET /articles→
index, resource :user→users#show, etc.), pluralization correct, no node explosion
(route nodes proportional to resources). Agent A/B (forem, large): with codegraph
1-4 reads / 0 grep / 47-53s vs without 4-5 reads / 2-3 grep / 66-85s. Framework-
scoped (zero blast radius off Rails). Residuals: Rails Engine routing (spree
mounts an engine), ActiveRecord dynamic finders (metaprogramming frontier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Spring bare + class-prefixed route mappings → controller method

The Spring resolver required a string path in the mapping regex, so BARE method
mappings (`@PostMapping` with the path on the class-level `@RequestMapping`) were
missed — the dominant multi-method-controller pattern. realworld's two-action
ArticleFavoriteApi only linked one method; halo had 28 routes for 2444 files.

Fix (frameworks/java.ts):
- Treat class-level `@RequestMapping` as a PREFIX (not a bogus route) and join it
  onto each method's path.
- Match verb-specific mappings (@GetMapping/@PostMapping/...) BARE or with a path.
- Also handle method-level `@RequestMapping(value=..., method=RequestMethod.X)`
  (older style) — restored after an initial cut dropped it (mall regressed 292→1;
  caught by the regression check).

Validated: realworld 13→19, mall 246 (all precise, class prefix joined:
GET /subject/listAll→listAll, POST /articles/{slug}/favorite→favoriteArticle +
DELETE→unfavoriteArticle), no node explosion. DI controller→service resolves
(article→findBySlug, updateArticle→canWriteArticle). Agent A/B (mall cart flow):
with codegraph 0 reads/0 grep vs without 2/2. Residuals: halo's complex custom
patterns (9/29 resolve); Spring Data JPA derived queries (metaprogramming frontier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Spring validation (bare-mapping routing fix)

Spring row → bare @GetMapping/@PostMapping + class @RequestMapping prefix join →
route→method (realworld 13→19, mall →246); DI controller→service resolves. A
first cut regressed mall 292→1 (dropped @RequestMapping-on-method), caught by the
route-count regression check. Residuals: halo custom patterns, JPA derived queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Django DRF router.register → ViewSet

Django's ORM (_iterable_class, prior work) and URL routing (path/url/as_view→view)
were already covered. The remaining hole: DRF `router.register(r'articles',
ArticleViewSet)` — the core CRUD endpoints — wasn't extracted (only path()/url()),
so a DRF API's main resources connected to nothing (realworld's ArticleViewSet:
0 callers).

Fix (frameworks/python.ts): match `.register(r'prefix', XViewSet)` → route→ViewSet
class. The STRING first arg distinguishes DRF router.register from
`admin.site.register(Model, Admin)` (model class first arg); View/ViewSet suffix
keeps it to viewsets. The ViewSet class resolves via the existing View/ViewSet
pattern.

Validated: realworld VIEWSET /articles → ArticleViewSet (was 0). Narrow in corpus
(realworld 1 router; wagtail=path, saleor=GraphQL) but real for DRF-router APIs.
Agent A/B (wagtail Page flow, medium): with codegraph 4-7 reads / 1-4 grep / 58-81s
vs without 7-9 reads / 6 grep / 82-86s. No regression (wagtail/saleor route counts
unchanged — purely additive). Residuals: signals, DRF inherited viewset actions,
GraphQL resolvers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Laravel route → precise Controller@method (not bare action)

extractLaravelHandler discarded the controller: `Route::get([UserController::class,
'index'])` and `'UserController@index'` both emitted a BARE `index` ref. With the
route in routes/api.php (not the controller file), name-matching mis-resolved every
common action to the WRONG controller — realworld's GET user → ArticleController.index
(should be UserController), GET articles/feed → ArticleController (should be
FeedController), etc. The routes existed but pointed at the wrong handler.

Fix (frameworks/laravel.ts): emit precise `Controller@method` (array + string
syntax, namespace-stripped) and `claimsReference` it so resolveOne's pre-filter
doesn't drop it before Pattern-4 resolveControllerMethod runs (the recurring hook,
also needed by django ORM + Rails routing).

Validated: realworld all routes now resolve to the correct controller; bookstack
267/332 precise (GET pages → PageApiController.list, array syntax). No node
explosion. Agent A/B (bookstack page-view, large): with codegraph 2-3 reads / 1-2
grep / 51-60s vs without 4-6 / 3-5 / 60-74s. Residuals: firefly's fluent
->uses()/['uses'=>...] handler format (3/568 resolve), Eloquent dynamic finders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Gin/chi routes on group vars (any receiver, not just r/router)

The route regex matched only `(router|r|mux|app|e).METHOD(...)`, but real Gin/chi
apps route on GROUP variables — `v1.GET`, `PublicGroup.GET`, `userRouter.POST` —
so group-routed apps connected almost nothing: gin-vue-admin had 4 routes for 625
files. Broaden the receiver to ANY identifier; the verb + string-path + handler-arg
gates keep it route-specific (e.g. `http.Get(url)` has no handler arg, so it's
excluded).

Validated: gin-vue-admin 4→259 routes, 257 resolve precisely (POST createInfo→
CreateInfo, GET getInfoList→GetInfoList); realworld stable 24→25 (no regression);
no garbage (257/259 resolve, not false positives), node count proportional. gitness
(chi, custom handlers) is a residual (26/321). Inline `func(c *gin.Context){...}`
handlers still lose their body (anonymous, like Express was) — separate residual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Gin validation (group-var routing fix)

Gin/chi row → routes on ANY group var (v1.GET/PublicGroup.GET), not just r/router
(gin-vue-admin 4→259 routes). Agent A/B: 0 reads/0 grep/26-30s vs 3/3/52-53s —
cleanest backend win yet. Residuals: inline func handlers, gitness chi custom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): ASP.NET feature-folder detection + bare attribute routes

Two holes left ASP.NET apps disconnected:
1. detect() only fired on a /Controllers/ dir, root Program.cs/Startup.cs, or a
   .csproj (which often isn't in the indexed source set). Feature-folder apps
   (realworld: Features/*/FooController.cs, subdir Program.cs) were never detected
   → 0 routes despite a full set of controllers. Broaden: scan Controller/Program/
   Startup .cs source for ASP.NET signatures ([ApiController]/[Route]/[Http*],
   ControllerBase, MapControllers, WebApplication, Microsoft.AspNetCore).
2. The attribute regex required a string path, so BARE [HttpGet] (route on the
   class [Route("[controller]")]) was missed — eShopOnWeb was 24 bare / 2 string.
   Match bare-or-with-path + join the class [Route] prefix (like the Spring fix).

No claimsReference needed: ASP.NET attribute routes are co-located IN the controller
with the action, so the bare method-name ref resolves same-file.

Validated: realworld 0→19 routes (all precise: GET /articles→Get, POST /articles→
Create, class prefix joined), eShopOnWeb 9→33. Route→action correct + co-located.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record ASP.NET validation (detection + bare-attribute fix)

ASP.NET Core row → feature-folder detection (realworld 0→19, was undetected) +
bare [HttpGet] / class [Route] prefix (eShopOnWeb 9→33, jellyfin 362→399). No
claimsReference needed (routes co-located in controller). Agent A/B (eShop): 1-2
reads/0 grep vs 6-7/1-6. Residual: EF Core LINQ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Flask/FastAPI route holes + Python builtin-name handler guard

Three fixes that connect the request→route→handler flow for Flask and
FastAPI. Validated S/L: fastapi-realworld 12→20, flask-microblog 6→27,
Netflix dispatch 290/290 (100%), redash decorator routes 6/6; canonical
flows trace end-to-end (login→get_user_by_email, create_user→from_dict).

- Flask: the route regex required `def` immediately after `@x.route(...)`,
  so an intervening decorator (@login_required, @cache.cached) or stacked
  @x.route lines (one view bound to several URLs) dropped the route.
  Switch to the findHandler scan (match the decorator, then find the next
  def) like FastAPI — skips intervening decorators.
- FastAPI: the path regex `[^'"]+` rejected the empty path `@router.get("")`
  (router/prefix-root routes, frequently multi-line). Allow empty path +
  guard the route name against a trailing space.
- Python builtin-name guard (src/resolution/index.ts): a handler named
  after a Python builtin method (index/get/update/count…) was filtered by
  isBuiltInOrExternal and lost its route→handler edge. Mirror the
  dotted-method branch's knownNames guard onto the bare branch — a bare
  name a declared symbol owns is a real target, not a builtin call.
  +2 legit edges on realworld, 0 change on the django control (precision held).

Tests: new Flask (intervening/stacked decorator) and FastAPI (empty-path,
multi-line) extractor cases + a Flask end-to-end integration test (a view
named `index` behind @login_required). Also corrects 6 pre-existing stale
Laravel/Rails route-ref assertions surfaced by the suite — they expected
the old bare action name, but the resolvers now emit precise
controller@action / controller#action (from earlier precision commits).
Full suite green (781 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Flask/FastAPI validation (decorator + builtin-name fixes)

Matrix row Python/Flask+FastAPI 🔬 and a §7 note: Flask intervening/
stacked decorators, FastAPI empty-path routes, the Python builtin-name
handler guard, S/L numbers, the login-auth A/B (0–1 read/0 grep with vs
3 read/2 grep without), and residuals (Flask-RESTful class-based
add_resource; redash JS file-route false-positives).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Drupal route-handler resolution (claimsReference, single-colon controllers, contrib detection)

The *.routing.yml extractor and _controller/_form resolver existed but two
gaps left most routes unlinked. Validated S/M/L: admin_toolbar 0→14 (14/14),
webform 144/208, drupal-core 536→731/836 (87%); canonical flow traverses
(getAnnouncements ← /admin/announcements_feed); node count unchanged.

- claimsReference: Drupal handler refs are FQCNs (\Drupal\…\Class::method),
  bare form classes (\…\SettingsForm), or single-colon controller-services
  (\…\Controller:method). Only the ::method shape survived resolveOne's
  pre-filter (its member is a known method name); the bare-FQCN forms and
  single-colon controllers were dropped before resolve() ran. Claim FQCN /
  Class:method / hook_* refs (same pattern as Rails controller#action).
- Single-colon controller match: broaden the controller regex from :: to
  :{1,2} and tighten the _form branch to !name.includes(':').
- Detection: detect() only checked composer `require` for a drupal/* dep, but
  a contrib module often has an empty require and is identified only by
  "name":"drupal/<m>" + "type":"drupal-module" (admin_toolbar → 0 routes).
  Broaden to composer name/type + a *.info.yml fallback.

Remaining unresolved is the entity-annotation handler frontier
(_entity_form: type.op) and OOP #[Hook] attributes (Drupal 11 moved ~all
procedural hooks to attribute methods — out of scope here). Tests: contrib
detection, *.info.yml fallback, claimsReference, single-colon controller.
Full suite green (787 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Drupal validation (claimsReference + contrib detection)

Add the PHP/Drupal matrix row () and a §7 note: the claimsReference
pre-filter fix for FQCN/single-colon handlers, broadened contrib detection,
S/M/L numbers (admin_toolbar 0→14, webform 144/208, core 536→731), the
route→controller A/B (0 read/1 grep with vs 1 read/2 grep+glob without), and
the frontier residuals (entity-annotation handlers, OOP #[Hook] attributes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Axum chained methods + namespaced handlers

The Axum route extractor used a flat regex that captured only the first
method(handler) of a .route() call and only a bare \w+ handler, so two
dominant Axum idioms broke:
- method chains: .route("/user", get(get_current_user).put(update_user))
  emitted no node for the .put arm — half the API was missing.
- namespaced handlers: get(listing::feed_articles) captured `listing`
  (the module), so the route resolved to nothing.

Rewrite with a balanced-paren scan of each .route(...) call, a route node
per chained method, and last-::-segment handler names. realworld-axum
12→19 routes, 19/19 resolved (every chained PUT/DELETE/POST now present,
feed_articles resolves). Rocket needed nothing (550/556, 99%, attribute
macros); crates.io confirms namespaced axum handlers resolve.

Residual frontier: actix runtime routing web::get().to(handler) (the
dominant actix style, unextracted; attribute macros 35/51). Fix is
Axum-scoped — the attribute/actix/Rocket path is untouched. Tests: chained
methods + multi-line namespaced handler. Full suite green (789 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Rust/Axum validation (chained methods + namespaced handlers)

Update the Rust matrix row 🔬 and add a §7 note: the Axum chained-method
+ namespaced-handler fix (realworld-axum 12→19, 19/19), Rocket already 99%,
crates.io (utoipa routes! macro frontier + SvelteKit frontend routes), the
update-user A/B, and the actix runtime-routing frontier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Vapor grouped/RouteCollection routing (was 0 routes on real apps)

The Vapor extractor only matched (app|router|routes).METHOD("path", use:
handler), but real Vapor apps route on a grouped builder inside
RouteCollection.boot(routes:): `let todos = routes.grouped("todos");
todos.get(use: index)` — any var receiver, no path arg (the path is the
group prefix). Every real app tested extracted 0 routes (template,
SteamPress, SwiftPackageIndex-Server, penny-bot, Feather).

Rewrite the extractor:
- any receiver (\w+), not just app/router/routes;
- optional path segments that may be non-string (User.parameter, :id, a
  path constant) — the `use:` keyword discriminates a route from
  Environment.get("X") / req.parameters.get("X");
- a group-prefix map from `let X = Y.grouped("a")` and
  `Y.group("a") { X in }` so a grouped/nested route gets its full path
  (todo.delete(use: delete) -> DELETE /todos/:todoID).

Result: vapor-template 0→3 (3/3, nested path exact), SteamPress 0→27
(27/27), SwiftPackageIndex-Server 0→14 (14/14 handler resolution).
Canonical flow traverses (createPostHandler <- GET /createPost ->
createPostView). Route names now carry a leading slash (GET /users),
consistent with the other frameworks.

Frontier: typed-route enums (SPI's SiteURL.x.pathComponents — handler
resolves, path label only) and closure handlers (app.get("x"){ } —
anonymous). Tests: grouped RouteCollection, self.handler + non-string
segments, use:-discriminator. Full suite green (792 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Vapor validation (grouped RouteCollection routing)

Update the Swift/Vapor matrix row  and add a §7 note: the extractor was
dead on real apps (0 routes everywhere); rewrote for any receiver, optional
non-string paths, .grouped/.group{} prefix tracking, and the use:
discriminator. S/M/L all 100% handler resolution (template 0→3, SteamPress
0→27, SPI 0→14), the create-post A/B (0 read/0 grep with vs 1–4 read
without), and frontiers (typed-route enums, closure handlers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): React Router <Route> JSX route extraction

react.ts extracted components/hooks and Next.js file routes but returned
references: [], so React Router <Route> declarations produced no route
nodes or route→component edges. Add <Route> JSX extraction: scan a window
after each <Route (so the nested > in element={<Comp/>} doesn't truncate
the match), pull path="…" + component={C} (v5) or element={<C/>} (v6) in
any attribute order, emit a route node + component reference (resolved by
the existing PascalCase resolveComponent). The <Routes> container is
excluded via the \b boundary.

react-realworld 0→10 routes, 10/10 resolved (/login→Login,
/editor/:slug→Editor, /@:username→Profile). No regression on excalidraw
(9,290 nodes, 46 react-render synth edges intact, 0 false routes). Tests:
v5 component=, v6 element=, <Routes>-container guard. Suite green (794).

Frontier: object data-router createBrowserRouter([{path,element}]) (modern
v6) is object-based not JSX — not covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record React Router routing (the React row's routing half)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): actix-web builder-API routing (web::resource / .to(handler))

Actix's attribute macros were covered, but the dominant actix style is the
builder API — web::resource("/path").route(web::get().to(handler)),
web::resource("/").to(handler) (all methods), and App .route("/path",
web::get().to(handler)). The handler is in .to(handler), not get(handler),
so the Axum .route scan extracted nothing — actix-examples had 80
web::resource calls all unlinked.

Add an actix block: scan each web::resource("/path") (bounding its method
chain at the next resource) for web::METHOD().to(h) pairs, fall back to a
direct .to(h) (method ANY), plus the App-level .route("/x",
web::METHOD().to(h)) form. actix-examples 51→128 routes, 35→112 resolved
(GET /user/{name}→with_param, POST /user→add_user). No regression on Axum
(realworld-axum still 19/19). Tests: resource+route, resource direct .to,
App-level route. Suite green (797).

Frontier: web::scope("/api") prefixes not prepended; anonymous .to(|req|…)
closures have no named target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record actix builder-API routing validation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(extraction): Flutter setState→build synthesis + Dart method body ranges

Two changes that connect Flutter's reactive dispatch:

- Dart method ranges (foundational): Dart models a method body as a SIBLING
  of the method_signature node, so every Dart method node had endLine ==
  startLine (signature only) — body-level analysis (callees, context slices,
  the synthesizer's body scan) saw only `void f() {`. Extend endLine to the
  resolved body in the shared createNode, guarded to only ever extend
  (child-body grammars are a no-op; controls excalidraw 9,290 / django 302
  unchanged).
- Flutter setState→build synthesizer channel (the Dart analog of react-render):
  for each Dart class with a `build` method, link sibling methods whose body
  calls setState( → build. setState re-runs build (Flutter-internal, no static
  edge), so "tap → handler → setState → rebuilt UI" dead-ended at setState.

counter initState→build, books build→BookDetail/BookForm. Widget composition
needs no synthesis — Dart widgets are explicit constructor calls, already
static (compass_app build→ErrorIndicator/HomeButton). Tests: Dart method
spans its body; Flutter handler→build synthesis end-to-end. Suite green (798).

Frontier: MVVM Command/ChangeNotifier dispatch (no setState) + Navigator.push
route-as-widget navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Dart/Flutter validation (setState→build + method ranges)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Spring Boot Kotlin routing (.kt + fun handlers)

Kotlin had zero framework coverage — no resolver listed kotlin, and the
Spring resolver was languages:['java'] with a .java-only extract gate and a
Java-syntax handler regex (public X name()). Spring Boot Kotlin apps (same
@GetMapping/@RestController annotations, .kt files) extracted 0 routes.

Extend the Spring resolver: languages ['java','kotlin'], accept .kt, and add
a Kotlin `fun name(` alternative to the handler-method regex (Kotlin has no
access modifier; the return type follows the name). Also allow Kotlin class
modifiers (open/data/sealed) in the class @RequestMapping-prefix detection,
and tag route/ref language per file.

spring-petclinic-kotlin 0→18 routes, 18/18 resolved; class @RequestMapping
prefixes join, stacked annotations skipped, DI controller→repo resolves
(showOwner ← GET /owners/{ownerId} → OwnerRepository.findById). Java Spring
unchanged (realworld 19/19 — the Kotlin fun and Java public-X alternatives
are disjoint per language). Jetpack Compose composition already works
(@Composable→child are plain function calls). Tests: Kotlin @GetMapping+fun,
class-prefix + stacked annotation. Suite green (800).

Frontier: Ktor inline-lambda routing, Compose recomposition, coroutines/Flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Kotlin validation (Spring Boot Kotlin + Compose)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Lua/Luau validation (module dispatch already covered)

Measure-first: Neovim/Roblox dispatch is module-heavy (require + cross-file
mod.fn calls), already resolved by general import+name resolution
(telescope.nvim 220 imports + 335 cross-file calls; traces end-to-end). The
matrix's assumed "callback synthesizer" hole isn't real — event-callback
registration (keymap/autocmd/:Connect) is predominantly inline anonymous
closures (corpus ~12 inline vs ~2 named), too rare to synthesize. A/B: 0
read/0 grep with codegraph vs 1 read without. No code change; validated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Play Framework conf/routes → controller routing (Scala/Java)

Play declares routes in an extensionless conf/routes file (GET /computers
controllers.Application.list(p: Int ?= 0)) the file walk never indexed
(isSourceFile requires an extension), so Play apps had 0 route nodes.

- grammars.ts: add isPlayRoutesFile (conf/routes + *.routes), opt it into
  isSourceFile, and map it to the no-grammar (yaml-style) path in
  detectLanguage so the framework resolver extracts it. Narrow match — only
  ADDS Play routes files, never affects other indexing.
- play.ts: a Play resolver — detect (build.sbt/conf), extract (parse each
  METHOD /path Controller.action(args) line, drop package + args), resolve
  (Controller.action → the action method in that controller class),
  claimsReference for the dotted Controller.action handler.

computer-database 0→8 routes, 7/8 resolved (the 1 unresolved is
controllers.Assets.versioned — Play's framework controller, external);
starter 0→4 (3/4). Flow connects request→route→controller→DAO. No-regression
(excalidraw 9,290 / suite unchanged). Tests: routes parse + `->` include
skipped, conf/routes file detection.

Frontier: SIRD programmatic routers (-> include + case GET(p"/x")) + Akka
actor message→handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Scala/Play validation (conf/routes → controller)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(extraction): C++ inheritance (base_class_clause) + virtual-override synthesis

C/C++ direct dispatch already resolves well (redis 29k / leveldb 1.4k
cross-file calls). Two changes close the C++ virtual-dispatch gap:

- extractInheritance handled base_clause (PHP) but not C++'s
  base_class_clause, so C++ `extends` edges were missing/partial. Add the
  C++ branch (emit an extends ref per base type, skipping access
  specifiers) — leveldb extends 219→298.
- cpp-override synthesizer channel (the C++ analog of react-render): for
  each extends edge, link each base method → the subclass override of the
  same name, so trace/callees from a virtual/interface method reach the
  implementation. Gated to C++, capped per class. leveldb 12 precise edges
  (Iterator::Next/Seek/Prev → MergingIterator), 0 on C (redis) and TS
  (excalidraw). Test: base virtual → subclass override bridge.

Frontier: C callback structs (cmd->proc() → 422-way fan-out, too noisy)
and C++ pure-virtual base methods (declarations aren't nodes, so those
overrides can't bridge). Suite green (804).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record C/C++ validation (inheritance fix + override synthesis)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): React Router object data-router + Next.js route precision

- Object data-router (v6.4+): createBrowserRouter([{ path, element: <Comp/> }])
  / { path, Component: Comp } — extract route + component (gated to files using
  the data-router API; requires a component so a stray `path:` field isn't a route).
- Next.js precision: filePathToRoute treated config files (next.config.mjs,
  vite.config.ts) and a `nextjs-pages/` dir (substring of "pages/") as routes.
  Require a real page extension (.tsx/.ts/.jsx/.js), exclude *.config.* and
  _app/_document, and match pages/ + app/ as path SEGMENTS. bulletproof-react
  4 bogus config "routes" → 0.

Frontier: lazy data-router routes (path: paths.x.path + lazy: () => import())
use variable paths + lazily-imported modules — no literal path/named component.
Tests: object-router literal form, config/nextjs-pages exclusion. Suite 806.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Flask-RESTful add_resource + tuple methods + broader detection

Three Flask gaps closed (redash Flask-RESTful 6→77 py routes; flask-realworld 0→19):
- Flask-RESTful: api.add_resource(ResourceClass, '/path') (+ redash's
  add_org_resource) now extracts a route per path referencing the Resource
  class, whose get/post verb methods resolve as the handlers.
- Tuple methods: @x.route('/p', methods=('POST',)) — the method regex only
  accepted a list [...]; now accepts a tuple (...) too, so POST/DELETE routes
  aren't mislabeled GET.
- Detection: detect() only checked root app.py for the literal Flask(__name__);
  broadened to requirements/pyproject/Pipfile/setup.py + any entrypoint file
  (root or subdir, e.g. conduit/app.py) that imports flask and instantiates
  Flask(...). flask-realworld (subdir app-factory) 0→19; django not falsely
  detected.

Tests: tuple methods, add_resource. Suite green (808).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record frontier pass; test(go): gorilla/mux subrouter coverage

Frontier triage after the main sweep — tractable partials closed (React object
data-router, Next.js false-positive fix, Flask-RESTful add_resource, Flask
tuple methods + detection, gorilla/mux confirmed), and the genuinely
hard/low-precision ones (C callback fan-out, metaprogramming finders, reactive
runtimes, Akka, anonymous closures, lazy data-router, C++ pure-virtual) left
documented with rationale. Adds a gorilla/mux subrouter-var HandleFunc test
(confirms the any-receiver handling already covers it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(benchmarks): A/B with/without codegraph across every language (S/M/L)

37-cell matrix (every flow-relevant language × small/medium/large indexed
repos): a headless agent answers one canonical flow question per repo, with the
codegraph MCP vs without any MCP. Fresh re-index per cell so the with-arm
reflects current resolvers.

Result: 75% fewer file reads with codegraph (40 vs 158 across cells), ~70%
fewer greps, never more reads in any cell. Biggest wins on medium/large
backends (excalidraw 0R vs 9R, spring-halo 0R vs 9R+8 Bash, jellyfin 4R vs 13R+
21 Bash + a spawned sub-agent); tie zone on tiny repos where the flow fits in
1-2 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp): self-sufficient codegraph_trace + CODEGRAPH_MCP_TOOLS allowlist

codegraph_trace now returns a complete flow dossier in one call: each hop with its full body inlined (not just the call-site line), plus the destination's own outgoing calls — the last mile agents otherwise explore/Read to get. Validated by A/B (arm I, 6 repos x 2): >= baseline on reads/turns/cost with no wall-clock regression, because one richer trace call displaces the explore+node+Read follow-ups. Sufficiency, not steering: complete context is what stops further investigation.

Also adds CODEGRAPH_MCP_TOOLS, an optional comma-separated allowlist that trims the exposed MCP tool surface (inert when unset); used to run the tool-ablation experiment cleanly, and useful for constraining an agent to a minimal surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(benchmarks): call-sequence + tool-ablation analysis; agent-eval arms harness

Records why codegraph read savings (-75%) under-convert to wall-clock (-16%): the bottleneck is round-trips + the synthesis turn, not reads. Ablation (arms A-I) shows explore is 68% of payload but load-bearing, trace is path-scoped but under-adopted, instruction/description steering cannot match an append-prompt's salience (and regresses), and the shippable win is making the trace output sufficient (arm I). Adds harness: seq-matrix, run-arms/arms-*, parse-arms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp): line-number codegraph_node + codegraph_trace source output

node's code block and trace's inlined hop/destination bodies now carry cat -n line numbers (reusing numberSourceLines, matching codegraph_explore and Read), so the agent can cite or edit exact lines without re-Reading the file just to get them. Consistency across the code-returning tools + edit-workflow sufficiency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolution): Java/Kotlin interface & abstract dispatch synthesis

A call through an injected interface (Spring @Autowired svc.list()) or an abstract base dead-ended at the interface method — no static edge to the implementation — so request->service->impl flows broke at the DI boundary. Adds interfaceOverrideEdges: for each class implementing an interface (or extending an abstract base), synthesize interface/base-method -> same-name override 'calls' edges (JVM-gated, capped per class, overload-aware), with an 'interface-impl' trace label. trace + callees now follow the flow into the implementation.

Validated on spring-mall: 310 synth edges, node count unchanged (edges only); trace(PmsProductController.getList, PmsProductServiceImpl.list) connects in 3 hops (controller -> service interface -> impl) where it previously dead-ended at the interface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(playbook): record Java/Kotlin interface-DI synthesizer (probe-validated; agent A/B adoption-gated)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp): codegraph_explore surfaces the execution flow from its named symbols

Agents call explore far more than trace and pass a bag of symbol names that spans the flow they're after. explore now resolves those names and surfaces the longest call path AMONG them — riding synthesized dynamic-dispatch edges (callback/react-render/jsx/interface-impl) — leading the output with it, so a flow question answered via explore gets the trace-quality path without switching tools.

Precision: ambiguous tokens disambiguated by CO-NAMING (keep candidates whose qualifiedName SEGMENT matches another named token, so 'list' resolves to PmsProductServiceImpl::list not OmsOrderService::list); BFS anchored at named symbols on both ends with <=1 consecutive unnamed bridge (crosses a missing intermediate, never wanders a god-function's fan-out). Validated by probe: spring-mall getList->service-interface->impl (3 hops); excalidraw mutateElement->triggerUpdate->[callback]->triggerRender->[react-render]->render->[jsx]->StaticCanvas (full re-render chain). No flow section on fuzzy queries (safe). Suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp): explore-flow resolves qualified Class.method query tokens

The agent often passes fully-qualified names to explore (PostEndpoint.publishPost, PmsProductServiceImpl.list) — its most precise input. The tokenizer's file-extension strip mangled Class.method into Class (treating .method as an extension), then the identifier filter dropped anything with a dot, throwing the method away. Now strips only REAL file extensions and keeps qualified tokens, which findAllSymbols resolves exactly; disambiguates ambiguous SIMPLE names by whether their container class is also named (segment match). Validated: 'PmsProductController.getList PmsProductServiceImpl.list' now surfaces getList->interface->impl. (spring-halo's publish flow stays absent — it's reactive/reconciler dispatch with no static edges, a coverage frontier, not an explore-flow gap.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(claude): record the 'adapt the tool to the agent' retrieval principle

The lever that decides whether a retrieval change lands: make a tool the agent already calls do more with the input it already gives; changes that need the agent to behave differently (different tool, query, examples) hit codegraph's low-salience channels and don't land. Captures the validated evidence (sufficiency + explore-flow pass; steering + new-tools + context-fuzzy-flow fail) and points coverage as the remaining lever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: correct 'cost stays flat' → neutral-to-lower (excalidraw with/without A/B)

Fresh with-vs-without A/B on excalidraw (current build, n=3): 3x faster (49s vs 145s), 15x fewer tool calls, ~0 vs 23 reads, and -40% cost ($0.41 vs $0.68). Cost is neutral-to-lower, not flat — compact codegraph answers cache across turns while the without-arm's read/grep thrash is fresh, poorly-cacheable input. Recorded in call-sequence-analysis.md; corrected the CLAUDE.md optimization-target note (still: don't optimize for cost; target wall-clock + tool-call count).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(benchmarks): current-build A/B on all 7 README repos + fix token-measurement bug

Re-ran the README benchmark on the current build (7 repos reindexed, median of 4): avg 35% cost / 57% tokens / 46% time / 71% tool calls saved — reproduces the published README (35/59/49/70), no regression. Adds bench-readme.sh + parse-bench-readme.mjs harness.

Fixes a token-measurement bug: result.usage is last-turn-only in current Claude Code; must sum per-turn assistant usage for cumulative tokens. Corrects the earlier excalidraw note (its '-34% tokens' was off this bug; real ~90%) and the cost MECHANISM (volume/fewer-turns, not cache-ability — the without-arm's huge token volume is mostly cheap cache-reads, so token savings 57% > cost savings 35%). Cost/time were always correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: finalize 0.9.4 — consolidate CHANGELOG + re-validate README benchmark

Folds the framework sweep + retrieval work into [0.9.4] (2026-05-24). README benchmark table refreshed with current-build medians (avg 35% cost / 57% tokens / 46% time / 71% tool calls) + a v0.9.4 re-validation note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(readme): add codegraph_trace to the MCP Tools table

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:41:04 -05:00

549 lines
57 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.
# 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 <repo>/.codegraph && ( cd <repo> && codegraph init -i )
node scripts/agent-eval/probe-trace.mjs <repo> <from-symbol> <to-symbol> # does the flow break? where?
node scripts/agent-eval/probe-node.mjs <repo> <break-symbol> # 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.<attr>(...)` / 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/<lang>.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 <repo>/.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 <repo> with "<flow question>"
# or the full A/B + interactive Explore-subagent path:
scripts/agent-eval/audit.sh local <name> <url> "<flow question>" 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 <repo> <from> <to>` | call-path between two symbols (the hole detector) |
| `scripts/agent-eval/probe-node.mjs <repo> <sym> [code]` | symbol + trail (callers/callees); `code` adds the body |
| `scripts/agent-eval/probe-context.mjs <repo> "<task>"` | context output incl. call-paths |
| `scripts/agent-eval/probe-explore.mjs <repo> "<query>"` | explore output |
| `scripts/agent-eval/{audit,run-agent,itrun}.sh` | agent A/B (headless + interactive); also the `/agent-eval` skill |
| `sqlite3 <repo>/.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 <repo>/.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 / React Router | state→render; dispatch→callback; route→component | S + X | ✅ rendering+dispatch (excalidraw); **React Router JSX routing** `<Route path component={C}/>` (v5) + `element={<C/>}` (v6) → component (react-realworld **0→10, 10/10**). + **object data-router** `createBrowserRouter([{path, element/Component}])` (literal form); Next.js config/`nextjs-pages` false-positives FIXED. 🔬 lazy data-router (`path: paths.x.path, lazy: () => import()` — variable paths + lazy modules) |
| 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, `<Pascal/>` 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 → handler → dependency | R + X | ✅ **Flask: handler resolved across intervening decorators (`@login_required`) + stacked `@x.route` lines** (microblog S 6→27, redash L decorator routes 6/6); **FastAPI: empty-path router-root routes `@router.get("")` incl. multi-line** (realworld S 12→20 / Netflix dispatch L **290/290 100%**) + **bare-name builtin guard** — a handler named after a Python builtin method (`index`/`get`/`update`/`count`…) was filtered as a builtin and lost its route→handler edge. + **Flask-RESTful `add_resource(Resource,'/x')` → Resource class** (redash 6→**77**) + **tuple `methods=('GET',)`** (was mislabeled GET) + **broadened detection** (requirements/Pipfile/setup + subdir app-factory entrypoints — flask-realworld 0→**19**). 🔬 FastAPI `Depends()` dependency edges (light validation) |
| Go | Gin / chi / gorilla/mux / 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. **gorilla/mux confirmed covered** by the any-receiver `HandleFunc`/`Handle` handling (subrouter-var `s.HandleFunc(...)` + namespaced handlers; `.Methods()` chain ignored). 🔬 inline `func(c){}` handlers (anonymous, body lost); subrouter/`PathPrefix` path-prefix not prepended (label only); gitness chi custom (26/321) |
| Rust | Axum / actix / Rocket | request → route → handler | R + X | ✅ **Axum chained methods + namespaced handlers** — `.route("/x", get(h1).post(h2))` emitted only the first method+handler, and `get(mod::handler)` captured the module not the fn (realworld-axum S **12→19, 19/19**); balanced-paren scan + per-method nodes + last-`::`-segment handler. **Rocket attribute macros 550/556 (99%)** (Rocket repo L) — already strong. crates.io named axum routes resolve (6/8; rest are closures/var handlers; its API is mostly the utoipa `routes!` macro = frontier). Cargo-workspace module resolution (prior work). **actix builder API** `web::resource("/x").route(web::get().to(h))` / `.to(h)` / App `.route("/x", web::get().to(h))` (actix-examples **51→128 routes, 35→112 resolved**) — was the dominant actix style and fully missed (the handler is in `.to(h)`, not `get(h)`). 🔬 actix `web::scope("/api")` prefix (not prepended to nested resource paths) + anonymous `.to` closure handlers |
| 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) + **interface→impl dispatch synthesizer** (`interfaceOverrideEdges`: a class's `implements`/`extends` → link each interface/base method → its same-name override; JVM-gated, capped, **overload-aware**; mall **310** / halo **734** synth edges, node count unchanged) so trace follows controller→service-**interface**→**impl** instead of dead-ending at the abstract method — `trace("PmsProductController.getList","PmsProductServiceImpl.list")` connects in **3 hops** (probe-validated). ⚠️ **agent A/B null** (n=2: the agent went context→explore→Read and never invoked `trace`, so the synth edges weren't exercised — adoption-gated, the recurring wall; see `docs/benchmarks/call-sequence-analysis.md`). The fix is correct + improves trace/callees/impact/context connectivity regardless; agent-visible read reduction needs trace adoption. 🔬 Spring Data JPA derived queries (`findByEmail`) — metaprogramming frontier |
| Kotlin | Spring Boot / Jetpack Compose | request → @RestController → service; @Composable → child | R + X | ✅ **Spring Boot Kotlin** — the Spring resolver was `['java']`-only with a Java-syntax method regex (`public X name()`); extended to `.kt` + Kotlin `fun name(` handler matching (petclinic-kotlin **0→18, 18/18**; class-prefix joins; DI controller→repo resolves — `showOwner ← GET /owners/{ownerId}` → `OwnerRepository.findById`). **Compose composition already static** (@Composable→child are plain function calls — Jetcaster `PodcastInformation→HtmlTextContainer`). Java Spring unchanged (realworld 19/19). 🔬 Ktor `routing { get("/x"){…} }` lambda handlers (anonymous) + Compose recomposition (implicit `mutableStateOf`, no setState gate) + coroutines/Flow |
| Swift | Vapor | request → route → controller | R + X | ✅ **was 0 routes on every real app** — the extractor required an `app/router/routes` receiver + a `"path"` literal, but real Vapor routes on grouped builders (`let todos = routes.grouped("todos"); todos.get(use: index)`) with NO path arg. Rewrote: any receiver, optional/non-string path segments, `.grouped`/`.group{}` prefix tracking, `use:` discriminator. vapor-template S **0→3 (3/3**, nested `/todos/:todoID`), SteamPress M **0→27 (27/27)**, SwiftPackageIndex-Server L **0→14 (14/14** handler resolution). 🔬 typed-route enums (SPI `SiteURL.x.pathComponents` — path label only, handler still resolves) + closure handlers `app.get("x"){ }` (anonymous) |
| 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) |
| PHP | Drupal | request → *.routing.yml → _controller/_form | R | ✅ **`claimsReference` for FQCN handlers** (`\Drupal\…\Class::method` passed the pre-filter only because the `::method` name was known; bare `_form` FQCNs `\…\FormClass` and single-colon `Class:method` controller-services were dropped before resolve()) + **single-colon controller match** + **detect via composer `type:drupal-*` / `name:drupal/*` + `*.info.yml` fallback** (a contrib module with empty `require` was undetected → 0 routes). admin_toolbar S **0→14 (14/14)** / webform M 208 (**144**) / core L 836 (536→**731, 87%**). Remainder is the **entity-annotation handler frontier** (`_entity_form: type.op` resolves via the entity's PHP `#[ContentEntityType]` handlers, not a direct class). 🔬 **OOP `#[Hook]` attributes** — Drupal 11 moved ~all procedural hooks to attribute methods (core: 418 `#[Hook]` files vs 3 procedural), so the resolver's docblock/`module_hook` detection is obsolete for modern core (0 hook edges) |
| C/C++ | C++ vtables / inheritance | virtual call → override; general direct dispatch | S + X | ✅ **general dispatch strong** (redis C **29k** cross-file calls / leveldb C++ **1.4k**) + **C++ inheritance extraction fix** (`base_class_clause` was unhandled, so C++ extends edges were missing — leveldb **219→298**) + **cpp-override synthesizer** (base virtual method → subclass override, gated to C++, capped — leveldb 12 precise: `Iterator::Next→MergingIterator`). 🔬 C callback structs (`s->fn()` → 422-way fan-out, too noisy to synthesize) + C++ pure-virtual base methods (`virtual void f()=0;` declarations aren't extracted as nodes, so those overrides can't bridge) |
| Dart | Flutter | setState → build; build → child widgets | S + X | ✅ **setState→build synthesizer** (Dart analog of react-render: a State method whose body calls `setState(` → `build`) gated to `.dart` + **foundational Dart method-range fix** — Dart models a method body as a *sibling* of the signature, so method nodes were signature-only (`end==start`); now `endLine` spans the body (required for ALL body analysis: callees, context slices, the synthesizer's body scan). counter `initState→build`, books `build→BookDetail/BookForm`; widget composition already static (compass_app `build→ErrorIndicator/HomeButton`). Controls unchanged (excalidraw 9,290 / django 302 — the range fix only extends sibling-body grammars). 🔬 MVVM Command/ChangeNotifier dispatch (compass_app — no setState) + `Navigator.push(MaterialPageRoute(builder:))` nav routes |
| Lua / Luau | Neovim / Roblox | module dispatch (require→mod, mod.fn); event/callback | — | ✅ **already covered for the dominant flow (measure-first, no code change)** — Neovim is module-heavy (`require('x')` + `x.fn()`), and the general import + name resolution already handles it: telescope.nvim **220 imports + 335 cross-file `mod.fn` calls**, traces end-to-end (`map_entries ← init.lua → get_current_picker (state.lua)`). Luau instance-path `require(game:GetService(...))` handled by the extractor. 🔬 event-callback registration (`vim.keymap.set(…, fn)`, autocmd `callback=`, Roblox `signal:Connect(fn)`) is predominantly INLINE anonymous closures (corpus ~12 inline vs ~2 named) — the anonymous-handler frontier; named handlers too rare to justify a synthesizer |
| Scala | Play / Akka | request → conf/routes → controller action | R + X | ✅ **Play `conf/routes` → controller** — the extensionless `conf/routes` wasn't indexed; added narrow file-walk opt-in (`isPlayRoutesFile`) + a Play resolver parsing `METHOD /path Controller.action(args)` → the action method (computer-database **0→8, 7/8**; starter 0→4, 3/4 — the unresolved are Play's framework `Assets` controller, external). Scala general controller→DAO dispatch already resolves. No-regression: the file-walk change only ADDS Play routes files (excalidraw 9,290 / suite 800 unchanged). 🔬 SIRD programmatic router (`-> /v1 Router` include + `case GET(p"/x")` in code) + Akka actor `receive`/`Behaviors.receiveMessage` 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 `<template>`
is unparsed by the extractor, so template usage needs synthesis (`vueTemplateEdges`):
`@click="fn"` → handler, kebab `<el-button>` → `ElButton`. PascalCase `<Child/>` is
already covered by the JSX channel (the SFC component node spans the template). Result:
agent reads drop in every size (vben login 13 vs 411), **strongest where handlers are
local functions** (vben `handleLogin`/`handleSubmit`).
**Composable-destructure handlers RESOLVED:** `@click="closeSidebar"` where
`const { close: closeSidebar } = useSidebarControl()` now follows alias → composable →
the returned `close` fn (when it's defined in the composable's file). vitepress sidebar
flow dropped **6 → 0 reads** (best case). Precise-only — no fallback to the composable
itself (the static `useX()` call edge already covers that), so it adds nothing where the
returned fn can't be located (e.g. re-exported / external composable). Remaining limits:
**prefix-convention kebab** — element-plus `el-button` → `button.vue` (component named
`button`, not `ElButton`), so kebab stays unresolved there; and **reactive→render**
(vue-core Proxy runtime) — the deep framework-internal frontier, deferred.
- **Svelte / SvelteKit (validated 2026-05-23, realworld S / skeleton M / shadcn L) — already well-covered.**
Unlike Vue, the `.svelte` extractor already parses the template: `extractTemplateCalls` (`{fn()}`),
`extractTemplateComponents` (`<Pascal/>` composition — skeleton 956 / shadcn 1610 reference edges),
plus `import * as api` namespace + `load`→api resolution all work. Agent A/B (realworld login): with
codegraph **1 read** vs without **4** — codegraph already wins out of the box. The one extraction gap
was **object-of-functions** (`export const actions = { default: async () => {} }`; the walker
deliberately skips object-literal functions to avoid inline-object noise). Fixed for EXPORTED consts
(general — Redux/Express handler maps too); `extractFunction` `nameOverride` keeps inline-object arrows
skipped. **Residual:** a `$lib`-alias namespace call (`api.post`) from an extracted action node doesn't
resolve even though the same alias resolves for `load` — a deeper resolver interaction, deferred
(local/relative calls from actions connect). **Lesson: measure before assuming a hole** — modern Svelte
barely uses `on:click={fn}` (form actions / callback props instead), so the assumed event-handler hole
wasn't the real one; Svelte needed far less than Vue.
- **Express / Koa (validated 2026-05-23, realworld S / parse M / ghost L) — high-value inline-handler fix.**
The resolver already handled named handlers, middleware, and `XController.method`/`XService.method`.
The real hole was **inline arrow route handlers** (`router.post('/x', async (req,res) => {...})` — the
dominant modern pattern): the handler regex `[^)]+` broke on the arrow's `)`, so the route connected to
NOTHING and the anonymous handler's body (the request→service flow) was lost. The entire inline-handler
API was unreachable (realworld `POST /users/login` → 0 edges). Fixed (`frameworks/express.ts`): span the
call with a string-aware balanced scan; for inline arrows, extract the body's calls (RESERVED-filtered to
drop res/req/builtins) and attribute them to the route node → realworld **19** / ghost **65** precise
route→service edges (POST /users/login→login, POST /articles→createArticle, …), no node explosion,
framework-scoped (zero blast radius off Express). **Deterministic win is clear; the agent A/B is muddied
by repo characteristics** — realworld (39 files) is below the size where codegraph beats reading, and
Ghost's layered custom-API architecture makes both arms thrash. Residual: **custom routers** — payload's
6.4k-file codebase had 0 routes (its router abstraction isn't `app.get`-style, so undetected). Lesson
inverse of Svelte: Express's dominant pattern WAS the uncovered one, so it needed real work like Vue.
- **NestJS (validated 2026-05-23, realworld S / immich M-L / amplication L) — already well-covered.** The
`nestjs` resolver handles @decorator routes (HTTP/GraphQL/microservice/WS). DI controller→service
(`this.svc.method()`) resolves correctly **even at scale** — every immich controller→service edge hit the
right same-module service (`addUsersToAlbum→addUsers`, `getMyApiKey→getMine`, `copyAsset→copy`) via
name + co-location, no type_of edge needed. Agent A/B (immich album flow): codegraph **eliminated Grep
(0 vs 3)** tracing route→controller→service. No dynamic-dispatch hole. One GENERAL hygiene gap surfaced
(not NestJS-specific): the realworld example **commits its `dist/`** build output, which codegraph indexes
(246 dup nodes) because the file walk only respects `.gitignore` with no default build-dir ignore. Real
apps (immich/amplication) gitignore `dist/` (0 dup nodes), so it's narrow — a default ignore for
`dist/build/out/.next/coverage` is a clean follow-up, deferred (core-indexer change, the user's call).
- **Rails (validated 2026-05-23, realworld S / spree M / forem L) — high-value RESTful-routing fix.** The
`rails` resolver only saw explicit `get '/x' => 'c#a'` routes, so resource-routed apps (the dominant
pattern) had ZERO route nodes (realworld + spree). Fixed (`frameworks/ruby.ts`): expand `resources :x` /
`resource :x` into their RESTful actions (only/except filters + pluralization for the singular `resource`),
reference a precise `controller#action`, and resolve that to the action method in `<ctrl>_controller.rb`
(explicit routes fixed too — they referenced a bare ambiguous `action`). realworld **0→16**, forem
**0→635** precise route→action edges. Agent A/B (forem comment-creation, large): codegraph **14 reads /
0 grep / 4753s** vs without **45 reads / 23 grep / 6685s** — fewer reads, no grep, faster. **The
`claimsReference` pre-filter was the gotcha:** `articles#index` names no declared symbol, so `resolveOne`
dropped it before `resolve()` ran — needed the same claim hook as the django ORM work. Residuals: **Rails
Engine routing** (spree still 0 — it mounts an engine, not `config/routes.rb` resources); ActiveRecord
dynamic finders (`Article.find_by_slug` — metaprogramming frontier).
- **Spring (validated 2026-05-23, realworld S / mall M / halo L) — bare-mapping + class-prefix routing fix.**
The resolver required a string path in the mapping regex, so BARE method mappings (`@PostMapping` with the
path on the class `@RequestMapping`) — the dominant multi-method-controller pattern — were missed (halo
had 28 routes for 2444 files; realworld's 2-action favorite controller linked only one). Fix
(`frameworks/java.ts`): treat class `@RequestMapping` as a PREFIX (joined, not a bogus route); match
verb-specific mappings BARE-or-with-path; also handle method-level `@RequestMapping(method=...)` (older
style). realworld 13→19, mall →246 precise route→method (class prefix joined); DI controller→service
resolves (`article→findBySlug`). Agent A/B (mall cart flow): with codegraph 0 reads/0 grep vs without 2/2.
**A first cut regressed mall 292→1** by dropping `@RequestMapping`-on-method — *caught by the cross-repo
route-count check*; the playbook's regression guard earns its keep. Residuals: halo's custom patterns
(9/29 resolve); Spring Data JPA derived queries (metaprogramming frontier).
- **Django / DRF (validated 2026-05-23, realworld S / wagtail M / saleor L) — mostly covered + a DRF-router
fix.** The ORM (`_iterable_class`→ModelIterable, the original investigation) and URL routing
(`path`/`url`/`as_view`→view) were already done. The one hole: **DRF `router.register(r'articles',
ArticleViewSet)`** (the core CRUD endpoints) wasn't extracted — only `path()`/`url()` were. Fix
(`frameworks/python.ts`): match `router.register` (the STRING first arg separates it from
`admin.register(Model, Admin)`, whose first arg is a model class) → route→ViewSet class. Narrow in this
corpus (realworld has 1 router; wagtail uses `path()`, saleor is GraphQL) but real for DRF-router APIs.
Agent A/B (wagtail Page flow, medium): codegraph **47 reads / 14 grep / 5881s** vs without **79 reads
/ 6 grep / 8286s** — fewer reads, fewer greps, faster. No regression (wagtail/saleor route counts
unchanged — purely additive). Residuals: signals (`post_save`→receiver), DRF viewset CRUD actions
(inherited from the base class, not in the user's ViewSet), saleor's GraphQL resolvers.
- **Laravel (validated 2026-05-23, realworld S / firefly M / bookstack L) — route precision fix.** The
resolver discarded the controller from the handler: `Route::get([UserController::class,'index'])` /
`'UserController@index'` emitted a BARE `index` ref, which name-matching mis-resolved to the WRONG
controller (every `index`/`show` → whichever it found first; realworld GET user → ArticleController.index,
should be UserController). Fix (`frameworks/laravel.ts`): emit precise `Controller@method` (array + string
syntax, namespace-stripped) + `claimsReference` it past the pre-filter → existing Pattern-4
`resolveControllerMethod`. realworld all routes correct; bookstack 267/332 precise (GET pages →
PageApiController.list). Agent A/B (bookstack page-view, large): codegraph **23 reads / 12 grep /
5160s** vs without **46 / 35 / 6074s**. No node explosion. Residuals: firefly resolves only 3/568
(its fluent `->uses()` / `['uses'=>...]` handler format isn't parsed); Eloquent dynamic finders
(metaprogramming frontier).
- **Gin / chi (validated 2026-05-23, realworld S / gin-vue-admin M / gitness L) — group-var routing fix.**
The route regex matched only `(router|r|mux|app|e).METHOD(...)`, but real apps route on GROUP vars
(`v1.GET`, `PublicGroup.GET`, `userRouter.POST`), so group-routed apps connected almost nothing
(gin-vue-admin: **4 routes for 625 files**). Fix (`frameworks/go.ts`): broaden the receiver to ANY
identifier — the verb + string-path + handler-arg gates keep it route-specific (`http.Get(url)` has no
handler arg → excluded). gin-vue-admin **4→259** routes (257 resolve precisely: `POST createInfo →
CreateInfo`); realworld stable (no regression); no garbage. **Agent A/B (create-user flow): codegraph
0 reads / 0 grep / 2630s vs without 3 / 3 / 5253s — cleanest backend win yet (0/0, 2× faster).**
Residuals: inline `func(c *gin.Context){}` handlers (anonymous, body lost — like Express before its fix);
gitness's chi custom handlers (26/321).
- **ASP.NET Core (validated 2026-05-23, realworld S / eShopOnWeb M / jellyfin L) — detection + bare-attribute
fix.** Two holes: (1) `detect()` only fired on a `/Controllers/` dir or root `Program.cs`/`.csproj` (which
often isn't in the indexed source set), so feature-folder apps (realworld: `Features/*/FooController.cs`,
subdir `Program.cs`) were NEVER detected → 0 routes despite a full controller set. Broaden: scan
Controller/Program/Startup `.cs` for ASP.NET signatures. (2) the attribute regex required a string path →
bare `[HttpGet]` (route on the class `[Route("[controller]")]`) missed (eShopOnWeb was 24 bare / 2
string). Match bare-or-path + join the class `[Route]` prefix (like Spring). **No `claimsReference`
needed** — ASP.NET attribute routes are co-located IN the controller with the action, so the bare method
ref resolves same-file (unlike Rails/Laravel, whose routes live in a separate file). realworld 0→19,
eShopOnWeb 9→33, jellyfin 362→399, all precise (`GET /articles → Get`, class prefix joined), no explosion.
Agent A/B (eShop catalog listing): codegraph **12 reads / 0 grep / 6375s** vs without **67 / 16 /
7779s**. Residual: EF Core LINQ/DbSet (metaprogramming frontier).
- **Flask / FastAPI (validated 2026-05-23, fastapi-realworld S / flask-microblog S / Netflix dispatch L /
redash L) — decorator-extraction + builtin-name fixes.** Routes were extracted but the request→route→handler
flow broke at two regex assumptions and one resolver filter. (1) **Flask required `def` immediately after
`@x.route(...)`**, so any intervening decorator (`@login_required`, `@cache.cached`) or **stacked `@x.route`
lines** (one view bound to several URLs) dropped the route — microblog extracted **6 of 27** real routes.
Switched Flask to FastAPI's `findHandler` scan (match the decorator, then find the next `def`), skipping
intervening decorators: **6→27**, all resolved. (2) **FastAPI's path regex `[^'"]+` rejected the empty path**
`@router.get("")` (router/prefix-root routes, frequently multi-line) → realworld lost 8 endpoints (list/create
article, comments, login/register). `[^'"]+`→`[^'"]*` + empty-path name guard: realworld **12→20**, Netflix
dispatch **290/290 (100%)**. (3) **Bare-name builtin guard** (`src/resolution/index.ts`): a handler named
after a Python builtin *method* (`index`, `get`, `update`, `count`…) was filtered by `isBuiltInOrExternal`
and lost its route→handler edge — microblog's `index` view (its `/` + `/index` stacked routes) resolved to
nothing. The dotted-method branch already had a `knownNames` guard; mirrored it onto the bare branch (a name
a declared symbol owns is not a builtin call). +2 legit edges on realworld, **0 change on the django control**
(302/373 identical — precision held). Flows trace end-to-end (`login → get_user_by_email` 2 hops;
`create_user → from_dict`). Agent A/B (realworld login-auth flow, n=2/arm): codegraph **01 read / 0 grep /
34 codegraph / 3039s** (context→[search]→trace→node) vs without **3 read / 2 grep / 3336s** — eliminates
grep, cuts reads to 01 (small repo, so wall-clock ties; the tool-count drop is the win). Residuals: **Flask-RESTful** class-based
`api.add_resource(Resource,'/x')` (redash's actual API shape — a separate class-method-as-verb mechanism, NOT
the README's documented decorator/blueprint Flask) and a pre-existing **JS file-route false-positive** in
redash's React frontend (32 bogus `.js` "routes" from a JS resolver — unrelated to Python). **Lesson: the
builtin-name filter is a silent precision tax across Python** — any view/function named `get`/`index`/`update`
loses edges; the fix is general (helps Django/DRF handlers too), not Flask-specific.
- **Drupal (validated 2026-05-23, admin_toolbar S / webform M / drupal-core L) — pre-filter + detection fixes.**
The `*.routing.yml` extractor and the `_controller`/`_form` resolver already existed but two gaps kept most
routes unlinked. (1) **The `claimsReference` pre-filter gotcha (again):** Drupal handler refs are FQCNs
(`\Drupal\…\Class::method`), bare form classes (`\…\SettingsForm`), or single-colon controller-services
(`\…\Controller:method`). Only the `::method` shape survived `resolveOne`'s pre-filter (its `member` is a
known method name); the bare-FQCN forms and single-colon controllers named no declared symbol and were
dropped before `resolve()` ran. Added `claimsReference` (FQCN / `Class:method` / `hook_*`) + a single-colon
branch in the controller regex → core **536→731 of 836 routes (87%)**; all three previously-broken shapes now
resolve (`/admin/content/comment`→CommentAdminOverview form, `/big_pipe/no-js`→setNoJsCookie controller).
(2) **Detection missed standalone contrib modules:** `detect()` only checked composer `require` for a
`drupal/*` dep, but a contrib module often has an EMPTY `require` and is identified only by
`"name":"drupal/<m>"` + `"type":"drupal-module"` (admin_toolbar → 0 routes). Broadened to composer name/type
+ a `*.info.yml` fallback → admin_toolbar **0→14 (14/14)**. Canonical flow traverses (`getAnnouncements` ←
`/admin/announcements_feed`); node count unchanged (resolution-only). Agent A/B (dblog route→controller,
n=2/arm): codegraph **0 read / 1 grep / 2022s** vs without **1 read / 2 grep + glob / 2832s** — fewer
tools and faster on the ~10k-file core. **Residuals (frontier):**
entity-annotation handlers (`_entity_form: comment.default` → handler classes declared in the entity's
`#[ContentEntityType]` annotation, not a direct ref — ~78 of core's ~105 remaining unresolved) and **OOP
`#[Hook]` attributes** — Drupal 11 converted nearly all procedural hooks to `#[Hook('event')]` methods (core:
418 attribute files vs 3 procedural `*.module` hooks), so the resolver's procedural-hook detection (docblock
`@Implements` / `module_hook` naming) finds essentially nothing in modern core (0 hook edges). Both are real
follow-ups, not regressions.
- **Rust / Axum + Rocket + actix (validated 2026-05-23, realworld-axum S / actix-examples + Rocket M / crates.io L) — Axum chained-method + namespaced-handler fix.**
The attribute-macro path (`#[get("/x")] fn h`, actix/Rocket) and single Axum `.route("/x", get(h))` already
worked, but the Axum extractor used a flat regex that captured only the FIRST `method(handler)` of a route
and only a bare `\w+` handler. Two dominant Axum idioms broke it: (1) **method chains**
`.route("/user", get(get_current_user).put(update_user))` — the `.put` arm produced NO route node, so half
the API was missing (realworld-axum had only the GET of each chain); (2) **namespaced handlers**
`get(listing::feed_articles)` — `\w+` captured `listing` (the module), so the route resolved to nothing.
Rewrote with a balanced-paren scan of each `.route(...)` call, a per-method node, and last-`::`-segment
handler names → realworld-axum **12→19 routes, 19/19 resolved** (every chained PUT/DELETE/POST now present;
`feed_articles` resolves). **Rocket needed nothing** (550/556, 99% — attribute macros). crates.io confirms
namespaced axum handlers resolve (router.rs 6/6) but defines most of its API via the `utoipa_axum` `routes!`
macro (frontier) and has a SvelteKit frontend (42 of its 50 "routes" are `+page.svelte`, correctly
attributed to SvelteKit). Agent A/B (update-user flow,
n=2/arm): codegraph **02 read / 0 grep / 3240s** vs without **3 read / 01 grep + glob / 3341s** — modest
(realworld-axum is in the small-repo tie zone) but consistent, with one fully-clean 0-read/0-grep run. Node
count stable; the Axum fix is Axum-scoped (the attribute/actix/Rocket path is untouched).
- **Actix runtime routing (validated 2026-05-23, actix-examples) — the builder API was the dominant style and fully missed.**
Actix's attribute macros (`#[get("/x")] fn h`) were covered, but real actix apps route via the builder API:
`web::resource("/path").route(web::get().to(handler))`, `web::resource("/").to(handler)` (all methods), and
App-level `.route("/path", web::get().to(handler))`. The handler lives in `.to(handler)`, not `get(handler)`,
so the Axum `.route` scan extracted nothing for them — actix-examples had **80 `web::resource` calls** all
unlinked. Added an actix block: scan each `web::resource("/path")` (bounding its method chain at the next
resource to avoid bleed) for `web::METHOD().to(h)` pairs, fall back to a direct `.to(h)` (method `ANY`), plus
the App-level `.route("/x", web::METHOD().to(h))` form. actix-examples **51→128 routes, 35→112 resolved
(87.5%)** (`GET /user/{name}`→with_param, `POST /user`→add_user). No regression on Axum (realworld-axum still
19/19) — the actix patterns (`web::resource`/`web::method().to()`) don't appear in Axum code. **Residuals
(frontier):** `web::scope("/api")` prefixes aren't prepended to nested resource paths, and anonymous `.to(|req|
…)` closure handlers have no named target (the ~16 still-unresolved).
- **Swift / Vapor (validated 2026-05-23, vapor-template S / SteamPress M / SwiftPackageIndex-Server L) — the resolver was effectively dead on real apps.**
The Vapor extractor only matched `(app|router|routes).METHOD("path", use: handler)`, but modern Vapor routes
on a grouped builder inside `RouteCollection.boot(routes:)`: `let todos = routes.grouped("todos");
todos.get(use: index)` — any var receiver, NO path arg (the path is the group prefix). Every real app tested
extracted **0 routes** (template, penny-bot, Feather, SteamPress, SPI). Rewrote the extractor: (1) any
receiver `\w+` (not just app/router/routes); (2) optional path segments that may be non-string
(`User.parameter`, `:id`, a path constant) — the `use:` keyword is the discriminator separating a route from
`Environment.get("X")` / `req.parameters.get("X")`; (3) a group-prefix map from `let X = Y.grouped("a")` and
`Y.group("a") { X in }` so a route on a grouped/nested var gets the full path (`todo.delete(use: delete)` →
`DELETE /todos/:todoID`). Result: vapor-template **0→3 (3/3**, nested path exact), SteamPress **0→27
(27/27**, incl. `BlogPost.parameter` routes), SPI **0→14 (14/14** handler resolution). Canonical flow
traverses (`createPostHandler` ← `GET /createPost`, → `createPostView`). **Residuals (frontier):**
typed-route enums (SPI registers via `app.get(SiteURL.x.pathComponents, use:)` — handler resolves but the
path label is `/`, no string literal) and closure handlers (`app.get("hello") { req in }` — anonymous, no
named target). penny-bot (Discord bot) and Feather (custom module router) have no standard Vapor routing at
all — the Vapor ecosystem's routing styles vary widely. Agent A/B (create-post flow, n=2/arm): codegraph
**0 read / 0 grep / 4 codegraph / 2630s** (both runs fully clean) vs without **14 read / 02 grep +
glob/bash, one run spawned a sub-agent / 3448s**. Node count stable; fix is Vapor-scoped (SwiftUI/UIKit
untouched).
- **React Router routing (validated 2026-05-23, react-realworld S) — the routing half of the React row.**
React rendering (state→render, jsx-child) was already covered; route→component was NOT — `react.ts` extracted
components/hooks and Next.js file routes but returned `references: []`, so `<Route>` declarations produced
nothing. Added `<Route>` JSX extraction: scan a window after each `<Route\b` (so the nested `>` in
`element={<Comp/>}` doesn't truncate it), pull `path="…"` + `component={C}` (v5) or `element={<C/>}` (v6) in
any attribute order, emit a route node + component reference (resolves via the existing PascalCase
`resolveComponent`). react-realworld **0→10, 10/10** (`/login`→Login, `/editor/:slug`→Editor,
`/@:username`→Profile); `<Routes>` container excluded via the `\b` boundary. No regression on excalidraw
(9,290 nodes, 46 react-render synth edges intact, 0 false routes). 🔬 the object **data-router** API
`createBrowserRouter([{ path, element }])` (modern v6, used by bulletproof-react) is object-based not JSX — a
separate frontier; plus a pre-existing Next.js false-positive (`*.config.mjs` in a `pages/` app dir treated
as a route).
- **Dart / Flutter (validated 2026-05-23, flutter/samples: counter S / books S / compass_app M) — synthesizer + a foundational extractor fix.**
Flutter's reactive hop is `setState(() {…})` re-running `build(context)` — framework-internal, no static edge,
so "tap → handler → setState → rebuilt UI" dead-ends at setState (the Dart analog of React's setState→render).
Added a `flutter-build` synthesizer channel (Phase 4b): for each Dart class with a `build` method, link every
sibling method whose body calls `setState(` → `build` (gated to `.dart`). **But it was blocked by a
foundational gap:** Dart models a method body as a *sibling* of the `method_signature` node, so every Dart
method node had `endLine == startLine` (signature only) — `sliceLines(start,end)` saw only `void f() {`, never
the body. Fixed in the shared `createNode`: when a function/method's resolved body sits beyond the node,
extend `endLine` to it (guarded — child-body grammars are a no-op; controls excalidraw 9,290 / django 302
unchanged). This fix is foundational, not Flutter-specific — every Dart callee/context/body scan was
previously truncated. Result: counter `initState→build`, books `initState→build` + `build→BookDetail/BookForm`.
**Widget composition needs no synthesis** — unlike JSX, Dart widgets are explicit constructor calls
(`BookDetail(...)`), already static (compass_app `build→ErrorIndicator/HomeButton/_Card`). **Residuals
(frontier):** MVVM state management (compass_app uses Command/ChangeNotifier + ListenableBuilder, 0 setState —
a different dispatch shape) and `Navigator.push(MaterialPageRoute(builder: (_) => DetailPage()))` navigation
(route-as-widget, uncovered).
- **Kotlin / Spring Boot + Jetpack Compose (validated 2026-05-23, spring-petclinic-kotlin S / compose-samples) — extend Spring to Kotlin; Compose is free.**
Kotlin had ZERO framework coverage — no resolver listed `kotlin`, and the Spring resolver was `languages:
['java']` with a `.java`-only extract gate and a Java-syntax handler regex (`public X name()`). So Spring Boot
Kotlin apps (identical `@GetMapping`/`@RestController` annotations, `.kt` files) extracted 0 routes. Extended
the Spring resolver: `['java','kotlin']`, accept `.kt`, and add a Kotlin `fun name(` alternative to the
handler-method regex (Kotlin has no access modifier and the return type follows the name). petclinic-kotlin
**0→18, 18/18**; class `@RequestMapping` prefixes join, stacked annotations (`@ResponseBody`) are skipped, DI
controller→repo resolves (`showOwner ← GET /owners/{ownerId}` → `OwnerRepository.findById` /
`VisitRepository.findByPetId`). Java Spring unchanged (realworld 19/19 — the Kotlin `fun` and Java `public X`
alternatives are disjoint per language). **Jetpack Compose composition needs no work** — `@Composable`
functions calling child `@Composable`s are plain Kotlin function calls, already static (Jetcaster
`PodcastInformation→HtmlTextContainer`, `FollowedPodcastCarouselItem→PodcastImage`), like Dart widget
constructors. Agent A/B (view-owner flow, n=2/arm): codegraph **01 read / 0 grep / 1 codegraph / 1118s** (a
single `context` call answers it) vs without **2 read / 01 grep + glob / 2028s**. **Residuals (frontier):**
Ktor `routing { get("/x") { … } }` inline-lambda handlers (anonymous,
no named target), Compose recomposition (implicit — reading `mutableStateOf` triggers recompose, no
`setState`-style gate to anchor a synthesizer), and coroutines/Flow dispatch.
- **Lua / Luau (validated 2026-05-23, telescope.nvim / lualine.nvim / Knit — measure-first, already covered).**
The matrix guessed "event/callback dispatch (synthesizer)", but measurement says otherwise: real Neovim
plugins are MODULE-dispatch-heavy (`local m = require('telescope.actions'); m.fn()`), and codegraph's general
`require`-import + cross-file name resolution already handles it — telescope.nvim has **220 resolved imports
and 335 cross-file `module.fn` call edges**, and a flow traces end-to-end (`map_entries ← init.lua →
get_current_picker` in actions/state.lua). The Luau extractor already handles Roblox instance-path requires
(`require(game:GetService("ReplicatedStorage").Packages.Knit)`). **The assumed hole isn't real** — like
Svelte/NestJS. The genuine frontier is event-callback registration (`vim.keymap.set(mode, lhs, fn)`, autocmd
`{callback=fn}`, Roblox `signal:Connect(fn)`), but it's predominantly INLINE anonymous closures (corpus: ~12
inline `:Connect(function…)` vs ~2 named), and telescope's keymaps are inline functions or vim-command
STRINGS, not named refs. A named-only callback synthesizer would cover a tiny fraction, so per "measure before
building / partial coverage is worse than none", none was built — no code change; recorded as validated.
Agent A/B (actions.utils map flow, n=2/arm): codegraph **0 read / 0 grep / 1824s** vs without **1 read
(+glob) / 2425s** — small flow so modest, but the 0-read confirms the module dispatch is navigable.
- **Scala / Play (validated 2026-05-23, play-samples: computer-database / starter / rest-api) — Play conf/routes → controller.**
Scala's general dispatch (controller→DAO) already resolves, but Play declares routes in an EXTENSIONLESS
`conf/routes` file (`GET /computers controllers.Application.list(p: Int ?= 0)`) the file walk never indexed
(`isSourceFile` requires an extension). Added a narrow opt-in (`isPlayRoutesFile`: `conf/routes` / `*.routes`)
routed through the no-grammar (yaml-style) path, plus a Play resolver that parses each
`METHOD /path Controller.action(args)` line (dropping package prefix + args) and resolves `Controller.action`
to the action method in that controller class. computer-database **0→8 routes, 7/8** (the 1 unresolved is
`controllers.Assets.versioned` — Play's framework Assets controller, external), starter 0→4 (3/4). The flow
connects request→route→controller→DAO. A/B (list-computers, n=2/arm): codegraph **0 read / 0 grep / 3
codegraph / 1722s** vs without **23 read / 12 grep + glob / 1617s**. **No-regression:** the file-walk
change only ADDS Play routes files (narrow match) — excalidraw 9,290 and the full suite (800) unchanged.
**Residuals (frontier):** Play SIRD programmatic routers (`-> /v1 v1.PostRouter` include + `case GET(p"/x")`
in a Router class — rest-api-example) and Akka actor message→handler (`receive { case Msg => … }` /
`Behaviors.receiveMessage` — untyped, a synthesizer shape).
- **C / C++ (validated 2026-05-23, redis C / leveldb C++) — general dispatch works; a C++ inheritance fix + override bridge.**
Measure-first: C/C++ DIRECT dispatch is excellent out of the box (redis **29,464 cross-file call edges**,
leveldb **1,462**) — the bulk of the value. The dynamic-dispatch frontier is two shapes: (1) C callback
structs (`struct {.proc=fn}` + `cmd->proc()`) — but in redis the `proc` field fans out to **422** command
functions, far too noisy to synthesize precisely, so deliberately skipped (per "partial coverage worse than
none"). (2) C++ vtables (`iter->Next()` → the subclass override). The override link was blocked upstream:
`extractInheritance` handled `base_clause` (PHP) but not C++'s `base_class_clause`, so C++ `extends` edges
were missing/partial (leveldb 219→**298** after the fix). Added a `cpp-override` synthesizer channel (the C++
analog of react-render): for each `extends` edge, link each base method → the subclass method of the same
name, so trace/callees from the interface method reach the implementation. leveldb **12 precise edges**
(`Iterator::Next/Seek/Prev → MergingIterator`), 0 on C (redis) and TS (excalidraw — gated to C++); the C++
override integration test passes. **Residual (frontier):** pure-virtual base methods (`virtual void Next() =
0;`) are declarations the extractor doesn't emit as nodes, so overrides of a purely-abstract interface can't
be bridged (only bases with a real method node — an inline default or non-pure virtual); plus the C
callback-struct fan-out. Relied on deterministic validation (no A/B): the cross-file-call counts + precise
override spot-check are conclusive.
- **Frontier pass (2026-05-23) — tractable partials closed, noise/hard ones deliberately left.** After the main
sweep, swept the documented frontiers and triaged by precision/value. **DONE:** React Router object
data-router (literal `createBrowserRouter([{path, element}])`); Next.js route false-positives (config files +
`nextjs-pages/` substring → require a real page ext + path-segment match; bulletproof 4→0); Flask-RESTful
`add_resource`→Resource class (redash 6→**77**); Flask tuple `methods=(…)`; Flask detection broadened to
subdir/app-factory entrypoints (flask-realworld 0→**19**); gorilla/mux confirmed already covered (any-receiver
HandleFunc) + a test. **LEFT (with rationale, not punts):** C callback-struct dispatch (`cmd->proc()` →
422-way field fan-out = noise); metaprogramming finders (ActiveRecord/Eloquent/Spring-Data-JPA/EF — dynamic
naming, no static target); reactive runtimes (Vue Proxy / Compose recomposition — deep internals, no
setState-style gate); Akka actor message dispatch (untyped); pure anonymous inline closures (the def-use
frontier — no named target); React lazy data-router (variable paths + lazy imports); C++ pure-virtual base
methods (extracting bodyless decls risks duplicate decl/def nodes for modest gain). Forcing these would add
noise, violating "partial coverage worse than none."
- **Difficulty gradient is real:** named-ref dispatch (resolver) is cheap; anonymous
callback dispatch (synthesizer) is medium; **anonymous-arrow handlers are the hard
remaining gap** (no identity → need synthesizer link-through-body, not yet built).
- **Extraction changes are high blast radius.** The Phase-3 named-inline-callback
extraction is in the *shared* `tree-sitter.ts` walker — re-check **node counts across
several languages** after any extraction change (it held at +3 on excalidraw because
anonymous arrows are skipped).
- **Synthesizer precision guards:** registrar-name uniqueness, named-only handlers, and
an event **fan-out cap** (skip generic events like `error`/`change`). Receiver-type
matching (via `type_of` edges) is the planned precision upgrade — deferred.
- **As-built shortcuts** (callback synthesizer): pairs registrar/dispatcher by *file*+field
(class proxy), regex arg-recovery (named refs only), `provenance:'heuristic'` +
`metadata.synthesizedBy` (the enum has no `'callback-synthesis'`). See the design doc.
- **Synthesizer runs only in `resolveAndPersistBatched`** (full index) — wire into
`resolveAndPersist` for incremental sync before shipping.
- **Symbol ambiguity in `trace`:** common names (`render`, `execute_sql`) match many
nodes; trace picks among them and may start from the wrong one. Trace from the specific
method, not a class name.
---
## 8. Definition of done (the whole mission)
For each language × framework: the canonical flow `trace`s end-to-end, an agent can
answer the flow question with Read 0 in at least some runs with the glue present, no node
explosion, no regression — recorded in the matrix (§6) with the validating repo + numbers.
Then ship-prep: tests per mechanism, CHANGELOG, wire incremental, commit.