Quellcode durchsuchen

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>
Colby Mchenry vor 1 Monat
Ursprung
Commit
025ebc88d6
50 geänderte Dateien mit 4909 neuen und 169 gelöschten Zeilen
  1. 70 0
      .claude/handoffs/explore-flow-tool-adoption.md
  2. 70 0
      .claude/handoffs/framework-coverage-sweep-2026-05-23.md
  3. 2 1
      .cursor/rules/codegraph.mdc
  4. 58 8
      CHANGELOG.md
  5. 65 0
      CLAUDE.md
  6. 18 17
      README.md
  7. 91 0
      __tests__/drupal.test.ts
  8. 5 0
      __tests__/extraction.test.ts
  9. 140 0
      __tests__/frameworks-integration.test.ts
  10. 280 15
      __tests__/frameworks.test.ts
  11. 58 0
      __tests__/mcp-tool-allowlist.test.ts
  12. 426 0
      docs/benchmarks/call-sequence-analysis.md
  13. 111 0
      docs/benchmarks/codegraph-ab-matrix.md
  14. 179 0
      docs/design/callback-edge-synthesis.md
  15. 548 0
      docs/design/dynamic-dispatch-coverage-playbook.md
  16. 21 0
      scripts/agent-eval/arms-F.sh
  17. 37 0
      scripts/agent-eval/arms-matrix.sh
  18. 28 0
      scripts/agent-eval/bench-readme.sh
  19. 19 0
      scripts/agent-eval/block-read-hook.sh
  20. 15 0
      scripts/agent-eval/hook-settings.json
  21. 116 0
      scripts/agent-eval/parse-arms.mjs
  22. 67 0
      scripts/agent-eval/parse-bench-readme.mjs
  23. 21 0
      scripts/agent-eval/probe-context.mjs
  24. 40 0
      scripts/agent-eval/probe-explore.mjs
  25. 20 0
      scripts/agent-eval/probe-node.mjs
  26. 20 0
      scripts/agent-eval/probe-trace.mjs
  27. 56 0
      scripts/agent-eval/run-arms.sh
  28. 137 0
      scripts/agent-eval/seq-matrix.mjs
  29. 111 1
      src/context/index.ts
  30. 17 0
      src/extraction/grammars.ts
  31. 77 3
      src/extraction/tree-sitter.ts
  32. 2 1
      src/installer/instructions-template.ts
  33. 2 0
      src/mcp/server-instructions.ts
  34. 534 20
      src/mcp/tools.ts
  35. 548 0
      src/resolution/callback-synthesizer.ts
  36. 38 9
      src/resolution/frameworks/csharp.ts
  37. 52 11
      src/resolution/frameworks/drupal.ts
  38. 98 23
      src/resolution/frameworks/express.ts
  39. 6 3
      src/resolution/frameworks/go.ts
  40. 3 0
      src/resolution/frameworks/index.ts
  41. 71 14
      src/resolution/frameworks/java.ts
  42. 18 8
      src/resolution/frameworks/laravel.ts
  43. 112 0
      src/resolution/frameworks/play.ts
  44. 136 14
      src/resolution/frameworks/python.ts
  45. 97 3
      src/resolution/frameworks/react.ts
  46. 103 2
      src/resolution/frameworks/ruby.ts
  47. 104 8
      src/resolution/frameworks/rust.ts
  48. 31 6
      src/resolution/frameworks/swift.ts
  49. 23 2
      src/resolution/index.ts
  50. 8 0
      src/resolution/types.ts

+ 70 - 0
.claude/handoffs/explore-flow-tool-adoption.md

@@ -0,0 +1,70 @@
+---
+name: explore-flow-tool-adoption
+date: 2026-05-24 00:55
+project: codegraph
+branch: architectural-improvements
+summary: Investigated why codegraph's read savings don't convert to wall-clock; root cause is agent tool-CHOICE (under-uses trace). Shipped a chain of fixes; the breakthrough is "explore-surfaces-flow" — the first mechanism to show up in real agent runs by adapting the tool the agent already uses.
+---
+
+# Handoff: codegraph retrieval — tool adoption & explore-surfaces-flow
+
+## Resume here — read this first
+**Current state:** A long investigation into making agents answer flow questions faster with codegraph. 6 commits on `architectural-improvements` (all probe-validated, suite green 815). The breakthrough: **`codegraph_explore` now surfaces the execution flow** from the symbol-bag the agent already passes it (`PmsProductController getList PmsProductService list PmsProductServiceImpl` → leads output with `getList → service-interface → impl`, riding synth edges). It's the FIRST mechanism this whole arc to actually appear in real agent runs (spring-mall A/B: flow surfaced both runs, reads 2.0→1.5) — because it adapts the tool the agent USES instead of trying to make it use `trace`.
+
+**Immediate next step:** The user is weighing how to push tool-USE quality next (their open question). Decide between: (a) **extend explore-flow to surface more reliably** (spring-halo's query didn't name a connected co-named chain → no flow), (b) accept we're at the model-behavior ceiling and **wrap up**, or (c) the user's ideas — better tool-description *examples* (≈ steering, low-leverage per the evidence) or a *query-builder tool* (adds a call + new-tool adoption problem). My read: keep ADAPTING THE USED TOOL (the only thing that's worked); examples/new-tools are the "change the agent" direction that failed all session.
+
+> Suggested next message: "explore-flow only surfaced on 2 of 3 repos — dig into why spring-halo's explore query didn't produce a flow and make it surface more reliably" — OR — "we're at the model-behavior ceiling; let's stop and write the CHANGELOG/PR for this branch"
+
+## Goal
+Make an AI agent answer **flow questions** ("how does X reach Y", request→handler→service, state→render) fast: ~0 Read/Grep, few codegraph calls, lower wall-clock. `codegraph_trace` is the fastest tool (1 call = the path), but the agent under-uses it. Ultimate target = trace's speed, however the agent gets there.
+
+## Key findings (the through-line)
+- **The wall is agent tool-CHOICE, not the graph.** Matrix-wide, codegraph cuts reads −75% but wall-clock only −16% (`docs/benchmarks/codegraph-ab-matrix.md`). The floor is round-trips + the synthesis turn. The agent reliably calls `context`/`explore`, rarely `trace` (3/37 flow cells). Full analysis: `docs/benchmarks/call-sequence-analysis.md`.
+- **Steering does NOT move it** (arms B/F/G, 3 wording variants): an MCP `initialize` instruction / tool description can't match a CLI `--append-system-prompt`'s salience, and forcing trace where it doesn't connect regresses. Reverted.
+- **Sufficiency works** (committed): a self-sufficient `trace` (hop bodies + destination callees inlined) lets the unsteered agent stop — but only when it calls trace.
+- **THE breakthrough — adapt the tool the agent uses.** `explore`'s query is a precise symbol-bag spanning the flow, so `explore` finds the call path AMONG its named symbols and leads with it. First mechanism to surface in real runs + drop reads.
+- **What FAILED:** option 1 (context-surfaces-flow) — fuzzy DESCRIPTION can't disambiguate endpoints → confident WRONG-feature flow; reverted. trace multi-source-BFS over ambiguous names — same wrong-feature; reverted.
+
+## Gotchas
+- **Co-naming disambiguation must match qualifiedName SEGMENTS, not substrings** (`buildFlowFromNamedSymbols` in `src/mcp/tools.ts`): `list` is a substring of `getList` → kept every getList. Split `qualifiedName` on `::`/`.` and match segments.
+- **BFS must cap consecutive UNNAMED hops at 1** — full-graph BFS wanders a god-function's fan-out (excalidraw `render()` → pointer handlers → mutateElement). ≤1 bridge crosses a missing intermediate without wandering.
+- **`getCallees` returns non-`calls` edges too** (references) — filter `c.edge.kind === 'calls'`.
+- **Resolver/synthesizer changes need a CLEAN reindex**: `rm -rf .codegraph && codegraph init -i` (the init edge count is contains-only — query the DB for the real count). The explore-flow change is query-time (no reindex).
+- **n=2 A/B is noisy** — report ranges/patterns, never conclude from one run. Foreground `sleep` is blocked → run A/B batches with `run_in_background`.
+- Java/Kotlin `qualifiedName` is `Class::method` (so `matchesSymbol` resolves `Class.method` qualified trace endpoints — the agent already passes these).
+
+## How to test & validate
+- Probe flow surfacing (no agent): `node scripts/agent-eval/probe-explore.mjs <repo> "<SymbolA SymbolB SymbolC>"` → look for the `## Flow` section. `probe-trace.mjs <repo> <from> <to>` for trace.
+- Synthesizer: `sqlite3 <repo>/.codegraph/codegraph.db "select count(*) from edges where json_extract(metadata,'$.synthesizedBy')='interface-impl'"`; node count stable before/after reindex (synth adds edges only).
+- Agent A/B (the real test): `bash scripts/agent-eval/run-arms.sh <repo> "<Q>" I <run>` (arm I = body-trace build, no steering). Parse via the `cmp2.mjs`-style scripts in `/tmp`. Pass = flow surfaces (`flowShown=Y`) + reads ≤ baseline.
+- `npm test` (vitest, 815 pass); `__tests__/mcp-tool-allowlist.test.ts` covers the allowlist.
+
+## Repo state
+- branch `architectural-improvements`, last commit `bafae81 feat(mcp): codegraph_explore surfaces the execution flow from its named symbols`.
+- uncommitted: clean (only untracked `.claude/handoffs/`).
+- 6 session commits: `eab5cf3` self-sufficient trace + `CODEGRAPH_MCP_TOOLS` allowlist · `a6183d7` research log + arms harness · `bde8c19` node/trace line numbers · `98baf41` Java/Kotlin interface→impl synthesizer · `6f3c468` playbook · `bafae81` explore-surfaces-flow.
+- NOT pushed/merged. No version bump. CHANGELOG `[Unreleased]` has all of it.
+
+## Open threads / TODO
+- [ ] **User's open question** (answer in the next turn): better tool-description *examples* vs a *query-builder tool* vs keep adapting the used tool. Evidence favors the last.
+- [x] explore-flow reliability: now resolves QUALIFIED tokens (`Class.method`) — the agent's most precise input was being dropped by the file-ext strip (`2765c3c`). spring-halo's publish flow stays absent on purpose — it's **reactive/reconciler dispatch** (`publishPost` calls `ReactiveExtensionClient.get`/`awaitPostPublished`, not `PostService.publish`), so there's no static call chain. That's the next COVERAGE frontier (reactive runtimes — like MediatR, Vue Proxy), not an explore-flow bug.
+- [ ] Ship-prep for the whole branch (this arc + the earlier framework sweep): CHANGELOG version block + `package.json` bump + PR to main. Releases go through `.github/workflows/release.yml` only — do NOT `npm publish`.
+- [ ] Frontiers: MediatR (`_mediator.Send`→Handle) and Vue/Compose reactive runtimes are still unbridged dynamic dispatch.
+
+## Recent transcript (oldest → newest)
+### Turn — "improve the A/B matrix; trace works, reads near 0 — what else?"
+- Diagnosed: reads at floor, wall-clock floor = round-trips + synthesis. Built `seq-matrix.mjs`; found trace adoption 3/37.
+### Turn — "do explore/context/trace compete? one tool?"
+- Ablation arms A–E (`run-arms.sh`/`arms-F.sh` + `CODEGRAPH_MCP_TOOLS` allowlist). explore = 68% of payload, load-bearing; trace path-scoped but under-adopted; trace alone insufficient.
+### Turn — "prototype body-inlining trace + A/B"
+- Arm F: self-sufficient trace wins WITH append-prompt steering. But steering isn't a shippable channel.
+### Turn — "port the steering + re-run"
+- Arms G (3 variants) all regressed vs baseline; arm H (body-trace, no steer) ≈ baseline. Steering reverted; body-trace + line-numbers + allowlist committed.
+### Turn — "tee up connectivity (Spring interface-DI)"
+- Built `interfaceOverrideEdges` (Java/Kotlin interface→impl, overload-aware). Probe: 3-hop trace connects. But A/B null — agent never called trace. Committed (probe-validated, adoption-gated).
+### Turn — "make context surface the flow (option 1)"
+- Failed: fuzzy query → wrong-feature flows. Reverted.
+### Turn — "change explore to do trace in the backend"
+- WIN: explore's query is a precise symbol-bag. `buildFlowFromNamedSymbols` (co-naming segment match + ≤1 bridge). Probe perfect (Spring + excalidraw full chains); A/B: flow surfaces + modest read drop. Committed `bafae81`.
+### Turn — "update memory + handoff; what about better examples / a query-builder tool?"
+- This handoff + memory update. Strategic answer pending (adapt-the-tool > change-the-agent).

+ 70 - 0
.claude/handoffs/framework-coverage-sweep-2026-05-23.md

@@ -0,0 +1,70 @@
+---
+name: framework-coverage-sweep-2026-05-23
+date: 2026-05-23 23:59
+project: codegraph
+branch: architectural-improvements
+summary: Dynamic-dispatch coverage sweep COMPLETE — all 14 README frameworks + every flow-relevant language validated (measure→fix→validate→test→playbook→commit). ~37 commits pushed, suite green. Ship-prep (CHANGELOG + PR to main) is the only thing left.
+---
+
+# Handoff: Dynamic-dispatch framework/language coverage sweep (complete)
+
+## Resume here — read this first
+**Current state:** The coverage sweep is **done**, AND a **frontier pass** closed the tractable partials. Every framework in the README's 14-row table is ✅, every flow-relevant language is validated (TS/JS, Python, Go, Java, C#, PHP, Ruby, Rust, Swift, Dart, Kotlin, Lua/Luau, Scala, C/C++), and the frontier pass added: React object data-router (literal), Next.js false-positive fix, Flask-RESTful `add_resource` (redash 6→77), Flask tuple methods + broader detection (flask-realworld 0→19), gorilla/mux confirmed. All committed/pushed to `architectural-improvements` (tree clean except untracked `.claude/handoffs/`). Full suite green (**809 passed**, 2 skipped; flaky `watcher.test.ts > debounced sync` passes on re-run). **No CHANGELOG entry exists, and the branch is not yet merged to main.**
+**Immediate next step:** Ship-prep — write a CHANGELOG entry grouping the whole sweep (route resolution for Flask/FastAPI/Drupal/Rust-Axum+actix/Vapor/Spring-Kotlin/Play + React Router routing; the Python builtin-name guard, Dart method-range, and C++ inheritance foundational fixes; the flutter-build and cpp-override synthesizer channels), bump `package.json`, then open a PR to main.
+
+> Suggested next message: "do ship-prep: write the CHANGELOG entry covering the whole framework/language coverage sweep on this branch, bump the version, and open a PR to main"
+
+## Goal
+Close static-extraction holes for **dynamic dispatch** across every language/framework codegraph supports, so cross-symbol flows (request→route→handler→service, state→render, virtual→override) exist in the graph and an agent answers flow questions with few codegraph calls and ~0 Read/Grep. Per framework/language: canonical flow `trace`s end-to-end, agent A/B shows fewer reads, no node explosion, recorded in `docs/design/dynamic-dispatch-coverage-playbook.md` (the matrix §6 + per-item notes §7). **This goal is now met; what remains is ship-prep + documented frontiers.**
+
+## Key findings (this session's work, all committed)
+- **Routing convention is the hole in every backend** — same pattern each time: the resolver/extractor assumed one syntax. Flask (intervening `@login_required`/stacked routes), FastAPI (empty `""` path), Drupal (`claimsReference` for FQCN `_form`/single-colon controllers + contrib `detect` via composer name/type/`.info.yml`), Rust/Axum (chained `get(h).post(h2)` + namespaced `mod::handler`), actix (builder API `web::resource().route(web::get().to(h))`), Vapor (grouped `routes.grouped("x"); x.get(use:h)` — was 0 on every real app), Spring **Kotlin** (`fun` handler syntax + `.kt`), Play (extensionless `conf/routes` → controller), React Router (`<Route>` JSX).
+- **Three FOUNDATIONAL fixes (broad benefit, not framework-specific):** (1) Python **bare-name builtin guard** in `src/resolution/index.ts` — a handler named `index`/`get`/`update` was filtered as a builtin method; mirror the dotted-branch `knownNames` guard. (2) **Dart method-range** in `src/extraction/tree-sitter.ts` `createNode` — Dart bodies are SIBLINGS of the signature, so methods were `end==start` (signature-only); extend `endLine` to the resolved body (guarded, child-body grammars no-op). (3) **C++ inheritance** — `extractInheritance` handled `base_clause` (PHP) but not C++ `base_class_clause`; added it (leveldb extends 219→298).
+- **Two new synthesizer channels** in `src/resolution/callback-synthesizer.ts` (Dart analog + C++ analog of react-render): `flutter-build` (a State method calling `setState(` → `build`) and `cpp-override` (base virtual method → subclass override of same name, gated to C++).
+- **measure-first repeatedly split "needs work" from "already covered":** Svelte, NestJS (prior), and this session **Lua/Luau** (module dispatch already resolves) + **Compose** (composition is plain function calls, already static) needed NO code. The assumed hole wasn't real.
+- **`claimsReference` pre-filter is the recurring gotcha** (`src/resolution/index.ts:497-503`): a route ref naming no declared symbol (FQCN, `Controller@method`, `controller#action`, `Class.method`) is dropped before `framework.resolve()` runs. Added for Drupal + Play this session.
+
+## Gotchas
+- **`claimsReference`:** if a new framework's route refs don't resolve despite a correct `resolve()`, it's the pre-filter — add `claimsReference`.
+- **Reindex picks up resolver changes only on a CLEAN index:** `codegraph index` is incremental (skips unchanged files); after `npm run build`, do `rm -rf .codegraph && codegraph init -i` to re-extract. The init message's edge count is contains-only (~misleading); query the DB for the real count.
+- **Extraction changes are high blast radius** (shared `createNode`/`extractInheritance`): re-check node counts on control repos (excalidraw 9,290 / django 302) — the Dart/C++ fixes are guarded to only-extend / C++-only, controls unchanged.
+- **Play `conf/routes` is extensionless** → needed `isPlayRoutesFile` opt-in in `grammars.ts` (isSourceFile + detectLanguage→'yaml' no-grammar path). Narrow match, only ADDS Play files.
+- **Flaky:** `watcher.test.ts > debounced sync > should trigger sync after file change` — timing-based, passes on re-run; unrelated to any of this work.
+- **Foreground `sleep` is blocked** in Bash → background A/B batches (`run_in_background: true`), read the task output file. zsh quirks: quote globs (`'*.vue'`); SQL `count(*)` in `$(...)` needs care with quotes.
+- Global `codegraph` is npm-linked to this repo's `dist/`; `npm run build` then reindex. A/B harness: `scripts/agent-eval/run-all.sh <repo> "<Q>" headless` (with vs empty MCP), parse via `node scripts/agent-eval/parse-run.mjs`.
+
+## How to test & validate (the per-framework loop)
+- Corpus in `/tmp/codegraph-corpus/<name>` (clone S/M/L, `git clone --depth 1`). Index: `rm -rf .codegraph && codegraph init -i`.
+- Measure holes: `sqlite3 .codegraph/codegraph.db "select count(*) from nodes where kind='route'"` + route→handler edges (`join edges on source where kind='references'`). Node-count before/after (no explosion).
+- Flow: `node scripts/agent-eval/probe-node.mjs <repo> <symbol>` (shows Called-by/Calls trail) / `probe-trace.mjs <repo> <from> <to>`.
+- Agent A/B (≥2 runs/arm, variance is real): `run-all.sh` headless, record Read/Grep/duration/codegraph. Pass = fewer reads with codegraph.
+- Tests: `npm test` (vitest). Resolver extract tests in `__tests__/frameworks.test.ts`; end-to-end in `__tests__/frameworks-integration.test.ts` (real CodeGraph + indexAll); Dart range in `__tests__/extraction.test.ts`; Drupal in `__tests__/drupal.test.ts`.
+
+## Repo state
+- branch `architectural-improvements`, last commit `42a0178 docs(playbook): record frontier pass; test(go): gorilla/mux`.
+- uncommitted: clean (only untracked `.claude/handoffs/`).
+- ~37 commits total on the branch (handoff's original 11 frameworks + this session's: Flask/FastAPI, Drupal, Rust/Axum, Vapor, React Router, actix, Dart, Kotlin, Lua, Scala/Play, C/C++ — each a feat + a docs(playbook) commit; Lua was docs-only).
+
+## Open threads / TODO
+- [ ] **SHIP-PREP (the only blocker to merge):** CHANGELOG entry for the whole sweep, `package.json` bump, PR to main. Releases go through `.github/workflows/release.yml` only — do NOT `npm publish` (see CLAUDE.md).
+- [x] **Frontier pass DONE (commits 0456915, 03e49ab, 42a0178):** React object data-router (literal), Next.js false-positive fix, Flask-RESTful `add_resource`, Flask tuple methods + detection, gorilla/mux confirmed.
+- [ ] **Frontiers LEFT (deliberately, with rationale in playbook §7 "Frontier pass"):** anonymous/inline closures (def-use frontier), metaprogramming finders (AR/Eloquent/JPA/EF), reactive runtimes (Vue Proxy / Compose recomposition), Akka actors, C callback-struct 422-way fan-out, C++ pure-virtual base methods, React lazy data-router (variable paths + lazy imports), Play SIRD, Nuxt-specific. Forcing these adds noise.
+- [ ] Pre-existing, unrelated: Next.js `*.config.mjs` in a `pages/` dir treated as a route (false-positive found in bulletproof-react).
+
+## Recent transcript (oldest → newest, this session)
+### Turn — "what's left / what's next on coverage" → did Flask/FastAPI
+- 3 holes: Flask intervening/stacked decorators, FastAPI empty path, **Python bare-name builtin guard** (handlers named `index`/`get` filtered). microblog 6→27, realworld 12→20, dispatch 290/290. Fixed 6 stale Laravel/Rails tests too. Committed + pushed.
+### Turn — "Drupal next"
+- `claimsReference` for FQCN/_form/single-colon controllers + contrib `detect` (composer type/name + `.info.yml`). core 536→731 (87%), admin_toolbar 0→14. OOP `#[Hook]` = frontier. Committed.
+### Turn — "Rust: Axum/actix/Rocket"
+- Axum chained methods + namespaced handlers (realworld 12→19, 19/19); Rocket already 99%; **actix builder API** `web::resource().route(web::get().to())` (examples 51→128). Committed (2 commits: axum, then actix).
+### Turn — "Vapor (Swift)"
+- Resolver was 0-routes on every real app; rewrote for any receiver + optional non-string paths + `.grouped` prefix tracking + `use:` discriminator. template 0→3, SteamPress 0→27, SPI 0→14. Committed.
+### Turn — "2, 3, 4" (React Router, actix [done above], Dart/Flutter)
+- React Router `<Route>` JSX (react-realworld 0→10). Dart/Flutter: **method-range fix** (foundational) + `flutter-build` setState→build synthesizer. Committed.
+### Turn — "Kotlin next"
+- Spring resolver `['java']`→`['java','kotlin']` + `fun` handler regex (petclinic-kotlin 0→18, 18/18; Java unchanged 19/19). Compose composition already static. Committed.
+### Turn — "Lua/Luau, Scala, C/C++ (Lua first, but do all three)"
+- **Lua:** measure-first → module dispatch already covered (telescope 335 cross-file calls); no code change, validated. **Scala/Play:** `conf/routes` file-walk opt-in + Play resolver (computer-database 0→8). **C/C++:** general dispatch strong (redis 29k); fixed C++ `base_class_clause` inheritance + `cpp-override` synthesizer (leveldb 12 precise). All committed + pushed.
+### Turn — "wrap up + refresh handoff"
+- This handoff. Sweep complete; ship-prep (CHANGELOG + PR) is the remaining work.

+ 2 - 1
.cursor/rules/codegraph.mdc

@@ -16,6 +16,7 @@ Use codegraph for **structural** questions — what calls what, what would break
 | "Where is X defined?" / "Find symbol named X" | `codegraph_search` |
 | "What calls function Y?" | `codegraph_callers` |
 | "What does Y call?" | `codegraph_callees` |
+| "How does X reach/become Y? / trace the flow from X to Y" | `codegraph_trace` (one call = the whole path, incl. callback/React/JSX dynamic hops) |
 | "What would break if I changed Z?" | `codegraph_impact` |
 | "Show me Y's signature / source / docstring" | `codegraph_node` |
 | "Give me focused context for a task/area" | `codegraph_context` |
@@ -25,7 +26,7 @@ Use codegraph for **structural** questions — what calls what, what would break
 
 ### Rules of thumb
 
-- **Answer directly — don't delegate exploration.** For "how does X work" / architecture / trace questions, answer with 2-3 codegraph calls: `codegraph_context` first, then ONE `codegraph_explore` for the source of the symbols it surfaces. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer.
+- **Answer directly — don't delegate exploration.** For "how does X work" / architecture questions, answer with 2-3 codegraph calls: `codegraph_context` first, then ONE `codegraph_explore` for the source of the symbols it surfaces. For a specific **flow** ("how does X reach Y") start with `codegraph_trace` from→to — one call returns the whole path with dynamic hops bridged — then ONE `codegraph_explore` for the bodies; don't rebuild the path with `codegraph_search` + `codegraph_callers`. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer.
 - **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context.
 - **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call.
 - **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call.

+ 58 - 8
CHANGELOG.md

@@ -7,9 +7,66 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
 This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
 and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
-## [0.9.4] - 2026-05-22
+## [0.9.4] - 2026-05-24
+
+### Added
+- **Framework-aware route resolution — `request → route → handler → service`
+  flows now resolve end-to-end across the supported stacks.** Added or fixed
+  routing for Express (inline arrow handlers → services), Rails, Spring (Java +
+  Kotlin; bare and class-prefixed mappings), Django/DRF (`router.register` →
+  ViewSet), Laravel (`Controller@method`), Flask/FastAPI (decorator stacks,
+  empty-path routers, Flask-RESTful `add_resource`), Gin/chi (group-var routing),
+  ASP.NET (feature-folder + bare attribute routes), Drupal, Rust (Axum chained
+  methods, actix builder API), Vapor (Swift grouped routes), Play (`conf/routes`),
+  Vue/Nuxt SFC templates, Svelte/SvelteKit, and React Router (`<Route>` JSX +
+  object data-router).
+- **Dynamic-dispatch flow synthesis — `codegraph_trace`, `codegraph_callees`, and
+  `codegraph_explore` now follow flows that have no static call edge.** Bridged
+  channels: callback/observer registration, EventEmitter (`on`/`emit`), React
+  re-render (`setState` → `render`) and JSX children, Flutter `setState` → `build`,
+  C++ virtual overrides, and Java/Kotlin interface → implementation dispatch
+  (e.g. Spring `@Autowired svc.list()` → the impl). Each synthesized hop is
+  labeled inline in `trace` with where it was wired up.
+- **`CODEGRAPH_MCP_TOOLS` — trim the exposed MCP tool surface.** Set it to a
+  comma-separated list of tool names (e.g. `trace,search,node,context`) to expose
+  only those codegraph tools over MCP; unset exposes all of them. Names match on
+  the short form, so `trace` and `codegraph_trace` are equivalent. Lets you
+  constrain an agent to a minimal surface (or A/B-test tool selection) without
+  editing the client's MCP config. Inert by default.
+- **Release archives now ship with a `SHA256SUMS` file**, and the npm launcher
+  verifies the bundle it downloads against it — a mismatch aborts before anything
+  runs. Releases published before this change have no checksum file, so the
+  verification is skipped (not failed) when none is available.
+
+### Changed
+- **`codegraph_trace` now returns a self-contained flow dossier.** Each hop on
+  the path is shown with its full body inline (previously just the call-site
+  line), and the destination's own outgoing calls are appended — so one trace
+  call usually answers a "how does X reach Y" flow question without a follow-up
+  `codegraph_explore`/`codegraph_node`/Read. Measured across real repos: fewer
+  tool calls and lower cost than the prior path-only output, with no wall-clock
+  regression.
+- **`codegraph_node` and `codegraph_trace` now emit line-numbered source**
+  (`cat -n` style, matching `codegraph_explore` and Read), so an agent can cite
+  or edit exact lines without re-reading the file just to recover line numbers.
+- **`codegraph_explore` now leads with the execution flow** when its query names
+  the symbols of a flow. Agents call `explore` far more than `trace`, passing a
+  bag of symbol names that usually spans the flow they're investigating
+  (`PmsProductController getList PmsProductService list PmsProductServiceImpl`);
+  `explore` now finds the call path *among those named symbols* — riding
+  synthesized dynamic-dispatch edges (callback / React re-render / JSX child /
+  interface→impl) — and shows it first. So a flow question answered through
+  `explore` gets the trace-quality path without the agent having to switch tools.
+  Scoped to the named symbols (no wrong-feature wandering) and bridge-capped (no
+  god-function fan-out); absent when the query is fuzzy or has no connected chain.
 
 ### Fixed
+- **Static-extraction & resolution correctness fixes** underpinning the framework
+  work above: C++ inheritance (`base_class_clause` was unhandled, so C++ `extends`
+  edges were missing), Dart method body ranges (methods were extracted
+  signature-only), a Python builtin-name handler guard (handlers named
+  `index`/`get`/`update` were silently dropped), and an explore output-budget
+  regression that under-returned source on god-file repos.
 - **Orphaned `codegraph serve --mcp` processes after a parent SIGKILL.** When
   the MCP host (Claude Code, opencode, …) was force-killed — OOM killer, a
   `kill -9`, a container teardown — the child kept running indefinitely on
@@ -21,13 +78,6 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   `5000`, `0` disables). Resolves
   [#277](https://github.com/colbymchenry/codegraph/issues/277).
 
-### Added
-- **Release archives now ship with a `SHA256SUMS` file**, and the npm launcher
-  verifies the bundle it downloads against it — a mismatch aborts before
-  anything runs. Releases published before this change have no checksum file, so
-  the verification is skipped (not failed) when none is available.
-
-### Fixed
 - **`codegraph: no prebuilt bundle for <platform>` after installing through a
   registry mirror.** Installing `@colbymchenry/codegraph` from a registry that
   hadn't mirrored the matching per-platform package — most often the

+ 65 - 0
CLAUDE.md

@@ -90,6 +90,71 @@ Cursor launches MCP subprocesses with the wrong cwd and doesn't pass `rootUri` i
 
 `src/mcp/server-instructions.ts` is sent back to the agent in the MCP `initialize` response. This is the *first* thing every agent sees about how to use the tools — treat it as the authoritative tool guidance and keep it in sync with `instructions-template.ts` and `.cursor/rules/codegraph.mdc`.
 
+## Retrieval performance & dynamic-dispatch coverage (do not regress)
+
+CodeGraph's core value is letting an agent answer **structural/flow** questions ("how does X reach Y", trace, impact, callers) with a few **fast** codegraph calls and **zero Read/Grep**. The optimization target is **wall-clock latency + tool-call count** — *don't optimize for token cost*. (Cost is **lower**, not "flat" as earlier framing claimed: a current-build with-vs-without A/B across the 7 README repos, median of 4, saved on average **35% cost · 57% tokens · 46% time · 71% tool calls** — reproducing the published README. The mechanism is **far fewer turns over a much smaller accumulated context** — NOT cache-ability: the without-arm's huge token volume is *mostly* cheap cache-reads, which is why token-count savings (57%) look bigger than cost savings (35%). Measure tokens by **summing per-turn assistant usage**, not `result.usage` (last-turn only in current Claude Code). See `docs/benchmarks/call-sequence-analysis.md`.) The mechanism that drives everything here: **an agent falls back to Read/Grep the instant a codegraph answer is insufficient.** So every change is judged by one question — is codegraph's answer sufficient enough to *stop* the agent from reading?
+
+**Target behavior:** a flow question resolves in **1 codegraph call on small repos, scaling to 3–5 on large**, with **Read/Grep = 0**. When reviewing a PR or trying something new, do not regress this.
+
+### Adapt the tool to the agent — don't try to change the agent
+
+The lever that decides whether a retrieval change lands. **Test before building anything here: does this make a tool the agent _already calls_ do more with the input it _already gives_? If it instead needs the agent to behave differently — pick a different tool, query differently, learn from examples — it hits the low-salience wall and won't land.**
+
+CodeGraph's only channels to influence the agent are low-salience: the MCP `initialize` instructions (`server-instructions.ts`) and the tool descriptions. Changing them does **not** reliably move the agent's tool _choice_ or query style — validated: trace-first steering ported into the server-instructions + tool descriptions (3 wording variants) never reproduced what a CLI `--append-system-prompt` achieved, and **regressed** wall-clock vs baseline. New tools fare worse (rarely chosen — the agent under-picks even `trace`); "better examples" is the same steering. The agent's tool-choice does improve on its own as host models get better at tool use — but that is not ours to force.
+
+What works is meeting the agent where it already is:
+- **Sufficiency** — `codegraph_trace` inlines each hop's body + the destination's own callees, so one trace call ends the flow investigation (no follow-up explore/node/Read).
+- **explore-flow** — `codegraph_explore`'s query is a precise bag of symbol names (incl. qualified `Class.method`) spanning the flow the agent is after; explore finds the call path _among those named symbols_ (riding synthesized edges) and leads its output with it — delivering trace-quality flow through the call the agent reliably makes. (`buildFlowFromNamedSymbols`: segment/co-naming disambiguation; ≤1 unnamed bridge so it never wanders a god-function's fan-out.)
+
+What fails is the inverse — folding a precise answer into a **fuzzy-input** tool. `codegraph_context` gets a description, not symbols, so it can't disambiguate a flow's endpoints and surfaces the _wrong feature_. Precise output needs precise input.
+
+The remaining lever under this axis is **coverage**: every flow made to connect statically (a new dynamic-dispatch synthesizer) is then surfaced automatically by explore-flow/`trace`, no agent change needed. Reactive/reconciler runtimes (Halo's `ReactiveExtensionClient`, MediatR, Vue Proxy) are the frontier — flows there have no static edges, so nothing surfaces (correctly — silent beats wrong). Full investigation + A/B record: `docs/benchmarks/call-sequence-analysis.md`.
+
+### Explore budget — keep BOTH budgets monotonic with repo size
+
+Two functions in `src/mcp/tools.ts` scale explore with indexed file count. This is the expected resolution (a regression here silently forces agents back to Read):
+
+| Repo | files | explore calls | chars/call | per-file |
+|---|---|---|---|---|
+| express (small) | 147 | 1 | 18K | 3800 |
+| excalidraw/django (medium) | 643–3043 | 2 | 28K | 6500 |
+| vscode (large) | 10446 | 3 | 35K | 7000 |
+| ~20k / ~40k | — | 4 / 5 | 38K | 7000 |
+
+- `getExploreBudget(fileCount)` → **call** budget: `<500→1, <5000→2, <15000→3, <25000→4, ≥25000→5` (max 5).
+- `getExploreOutputBudget(fileCount)` → **per-call** output (chars / files / per-file). **Invariant: a larger tier must never get a smaller `maxCharsPerFile` than a smaller tier.** (Regression that motivated this doc: the `<5000` tier's 2500 was *below* the `<500` tier's 3800, so on a god-file repo — excalidraw's 415 KB `App.tsx` — one explore returned <1% of the file and forced a Read.)
+- Explore output must **never tell the agent to "use Read"** — steer to another `codegraph_explore` and "treat returned source as already Read."
+
+### Dynamic-dispatch coverage — the flow must EXIST in the graph end-to-end
+
+Static tree-sitter extraction misses computed/indirect calls, so flows break at dynamic dispatch and the agent reads to reconstruct them. Synthesizers/resolvers bridge these so `trace`/`explore` connect end-to-end (`src/resolution/callback-synthesizer.ts`, `src/resolution/frameworks/`). Channels today: callback/observer, EventEmitter, **React re-render** (`setState`→`render`), **JSX child** (`render`→child component), django ORM descriptor. All synthesized edges are `provenance:'heuristic'` with `metadata.synthesizedBy` + `registeredAt` (the wiring site), surfaced inline in `trace`, the `node` trail, and `context` call-paths.
+
+**Principle: partial coverage is WORSE than none.** Bridging one boundary but not the next reveals a hop the agent then drills + reads to finish. Measured on excalidraw: react-render alone *raised* reads to 5–7; only completing the flow (adding the jsx-child hop) dropped it to 0–1. **Always close the flow end-to-end and re-measure** — never ship a half-bridged flow.
+
+### Validation methodology (REQUIRED for every new language/framework)
+
+For each **language × framework**, validate on **small, medium, and large** real repos with **≥3 different flow prompts** each:
+
+1. **Pick the canonical flow** for the framework ("how does X reach Y": state→render, request→handler→view, query→SQL, action→reducer→store…).
+2. **Deterministic probes** (`scripts/agent-eval/probe-{trace,node,context,explore}.mjs` against the built `dist/`): `trace(from,to)` connects end-to-end with no break; **no node explosion** (`select count(*) from nodes` stable before/after re-index); synthesized-edge **precision** spot-check (`select … where provenance='heuristic'`).
+3. **Agent A/B** (`scripts/agent-eval/run-all.sh <repo> "<Q>"`): with vs without codegraph, **≥2 runs/arm** (run-to-run variance is large — never conclude from n=1). Record **duration, total tool calls, Read, Grep**. Optional forced-Read-0 sufficiency proof via the block-read hook (`scripts/agent-eval/hook-settings.json`).
+4. **Pass bar:** a normal flow question reaches **~0 Read/Grep within the repo's explore-call budget**, runs **faster** than without-codegraph, and shows **no regression on a control repo**. Record the numbers in `docs/design/dynamic-dispatch-coverage-playbook.md` (the coverage matrix).
+
+Full playbook + per-mechanism design: `docs/design/dynamic-dispatch-coverage-playbook.md` and `docs/design/callback-edge-synthesis.md`.
+
+### Worked example — Excalidraw (TS/React, medium, 643 files)
+
+The template to replicate per language/framework. Question: *"how does updating an element re-render the canvas on screen?"* (the full flow crosses three React boundaries: observer callback, `setState`→`render`, and JSX child).
+
+| Stage | duration | Read | Grep | codegraph |
+|---|---|---|---|---|
+| Without codegraph | 115–139s | 9–10 | 10–11 | 0 |
+| Broken (explore-budget regression) | 131–139s | 5–10 | 3–5 | 6–14 |
+| Fixed (budget + msgs + synthesis) | 64–112s | 0–2 | 2–4 | 3–**10** |
+| + trace-first steering | **51–74s** | **0–2** | 0–4 | **3–4** |
+
+n=4 unhooked runs/stage, same prompt. After steering flow questions to `codegraph_trace` first: **best run 0 Read / 0 Grep / 3 codegraph / 51s**; **2 of 4 fully clean** (0 Read, 0 Grep). Steering eliminated the over-drill variance — call count tightened from 3–10 to 3–4, trace adoption went 3/4 → 4/4, and the `search`+`callers` path-reconstruction floundering dropped to 0. Run-to-run variance is still real; report the range, never a single run. **Residual reads/greps are all the nonce data-flow** (`canvasNonce` — a local prop with no graph edges); that's the def-use/data-flow frontier, left deliberately uncovered (tracking every local would explode the graph). Validated: `trace(mutateElement, renderStaticScene)` connects in **6 hops** across all three boundaries (`mutateElement → triggerUpdate → [callback] triggerRender → [react-render] render → [jsx] StaticCanvas → renderStaticScene`), each hop showing inline source + the wiring site; node count stable at 9,289; 1 callback + 46 react-render + 280 jsx-render synthesized edges (no explosion, precision-checked).
+
 ## Tests
 
 Tests live in `__tests__/` and mirror the module they cover. Notable ones beyond the obvious:

+ 18 - 17
README.md

@@ -76,26 +76,26 @@ When Claude Code explores a codebase, it spawns **Explore agents** that scan fil
 
 ### Benchmark Results
 
-Tested across **7 real-world open-source codebases** spanning 7 languages, comparing an agent (Claude Code, headless) answering one architecture question **with** and **without** CodeGraph. Each cell is the savings at the **median of 4 runs per arm**.
+Tested across **7 real-world open-source codebases** spanning 7 languages, comparing an agent (Claude Code, headless) answering one architecture question **with** and **without** CodeGraph. Each cell is the savings at the **median of 4 runs per arm**. _Re-validated on **v0.9.4** (2026-05-24)._
 
-> **Average: 35% cheaper · 59% fewer tokens · 49% faster · 70% fewer tool calls**
+> **Average: 35% cheaper · 57% fewer tokens · 46% faster · 71% fewer tool calls**
 
 | Codebase | Language | Cost | Tokens | Time | Tool calls |
 |----------|----------|------|--------|------|------------|
-| **VS Code** | TypeScript · ~10k files | 35% cheaper | 73% fewer | 41% faster | 72% fewer |
-| **Excalidraw** | TypeScript · ~600 | 47% cheaper | 73% fewer | 60% faster | 86% fewer |
-| **Django** | Python · ~2.7k | 34% cheaper | 64% fewer | 59% faster | 81% fewer |
-| **Tokio** | Rust · ~700 | 52% cheaper | 81% fewer | 63% faster | 89% fewer |
-| **OkHttp** | Java · ~640 | 17% cheaper | 41% fewer | 36% faster | 64% fewer |
-| **Gin** | Go · ~150 | 22% cheaper | 23% fewer | 34% faster | 19% fewer |
-| **Alamofire** | Swift · ~100 | 38% cheaper | 59% fewer | 51% faster | 77% fewer |
+| **VS Code** | TypeScript · ~10k files | 26% cheaper | 78% fewer | 52% faster | 85% fewer |
+| **Excalidraw** | TypeScript · ~640 | 52% cheaper | 90% fewer | 73% faster | 96% fewer |
+| **Django** | Python · ~3k | 12% cheaper | 36% fewer | 19% faster | 53% fewer |
+| **Tokio** | Rust · ~790 | 82% cheaper | 86% fewer | 71% faster | 92% fewer |
+| **OkHttp** | Java · ~645 | 2% cheaper | 13% fewer | 31% faster | 45% fewer |
+| **Gin** | Go · ~110 | 21% cheaper | 34% fewer | 27% faster | 40% fewer |
+| **Alamofire** | Swift · ~110 | 47% cheaper | 64% fewer | 48% faster | 83% fewer |
 
 The gains scale with codebase size: on large repos the agent answers from the index in a handful of calls with **zero file reads**, while the no-CodeGraph agent fans out across grep/find/Read (and the sub-agents it spawns). On a small repo like Gin (~150 files) native search is already cheap, so the margin narrows.
 
 <details>
 <summary><strong>Full benchmark details</strong></summary>
 
-**Methodology.** Each arm is `claude -p` (Claude Opus 4.7, Claude Code v2.1.145) run headlessly against the repo with `--strict-mcp-config`: **WITH** = CodeGraph's MCP server enabled, **WITHOUT** = an empty MCP config. Built-in Read/Grep/Bash stay available to both. Same question per repo, **4 runs per arm, median reported**. Cost = the run's `total_cost_usd`; Tokens = total tokens processed (input incl. cached + output); Time = wall-clock; Tool calls = every tool invocation, including those inside any sub-agents the model spawns. Repos cloned at `--depth 1` and indexed by the same CodeGraph build that served them.
+**Methodology.** Each arm is `claude -p` (Claude Opus 4.7) run headlessly against the repo with `--strict-mcp-config`: **WITH** = CodeGraph's MCP server enabled, **WITHOUT** = an empty MCP config. Built-in Read/Grep/Bash stay available to both. Same question per repo, **4 runs per arm, median reported**. Cost = the run's `total_cost_usd`; Tokens = total tokens processed (input incl. cached + output); Time = wall-clock; Tool calls = every tool invocation, including those inside any sub-agents the model spawns. Repos cloned at `--depth 1` and indexed by the same CodeGraph build that served them. Re-validated on codegraph **v0.9.4** (2026-05-24); per-repo numbers move run-to-run with how hard the without-arm thrashes (the median-of-4 smooths it, but tails remain — e.g. Tokio's without-arm hit $2.41/3m one batch).
 
 **Queries:**
 | Codebase | Query |
@@ -111,13 +111,13 @@ The gains scale with codebase size: on large repos the agent answers from the in
 **Raw medians — WITH → WITHOUT:**
 | Codebase | Cost | Tokens | Time | Tool calls |
 |----------|------|--------|------|------------|
-| VS Code | $0.42 → $0.64 | 393k → 1.4M | 1m 0s → 1m 43s | 7 → 23 |
-| Excalidraw | $0.54 → $1.02 | 851k → 3.2M | 1m 17s → 3m 14s | 12 → 83 |
-| Django | $0.41 → $0.62 | 499k → 1.4M | 1m 0s → 2m 25s | 9 → 48 |
-| Tokio | $0.50 → $1.04 | 657k → 3.4M | 1m 5s → 2m 56s | 9 → 75 |
-| OkHttp | $0.36 → $0.44 | 352k → 596k | 45s → 1m 11s | 5 → 14 |
-| Gin | $0.36 → $0.46 | 431k → 562k | 47s → 1m 11s | 7 → 8 |
-| Alamofire | $0.61 → $0.99 | 1.1M → 2.6M | 1m 19s → 2m 41s | 15 → 64 |
+| VS Code | $0.60 → $0.80 | 601k → 2.8M | 1m 10s → 2m 26s | 8 → 55 |
+| Excalidraw | $0.43 → $0.90 | 344k → 3.5M | 48s → 2m 58s | 3 → 79 |
+| Django | $0.59 → $0.67 | 739k → 1.2M | 1m 19s → 1m 38s | 9 → 19 |
+| Tokio | $0.42 → $2.41 | 379k → 2.6M | 53s → 3m 2s | 4 → 53 |
+| OkHttp | $0.47 → $0.47 | 636k → 730k | 42s → 1m 1s | 6 → 11 |
+| Gin | $0.37 → $0.47 | 444k → 675k | 44s → 1m 0s | 6 → 10 |
+| Alamofire | $0.61 → $1.14 | 1.0M → 2.8M | 1m 17s → 2m 27s | 12 → 69 |
 
 **Why CodeGraph wins:** with the index available, the agent answers directly — `codegraph_context` to map the area, then one `codegraph_explore` for the relevant source — and stops, usually with zero file reads. Without it, the agent (and the Explore sub-agents it spawns) spends most of its budget on discovery (find/ls/grep) before reading the right code. CodeGraph only helps when queried *directly*, so its instructions steer agents to answer directly rather than delegate exploration to file-reading sub-agents — otherwise a sub-agent reads files regardless and CodeGraph becomes overhead.
 
@@ -397,6 +397,7 @@ When running as an MCP server, CodeGraph exposes these tools to Claude Code:
 |------|---------|
 | `codegraph_search` | Find symbols by name across the codebase |
 | `codegraph_context` | Build relevant code context for a task |
+| `codegraph_trace` | Trace the call path between two symbols ("how does X reach Y") in one call — each hop with its body inline, following dynamic-dispatch hops (callbacks, React re-render, interface→impl) that grep can't |
 | `codegraph_callers` | Find what calls a function |
 | `codegraph_callees` | Find what a function calls |
 | `codegraph_impact` | Analyze what code is affected by changing a symbol |

+ 91 - 0
__tests__/drupal.test.ts

@@ -87,6 +87,52 @@ describe('drupalResolver.detect', () => {
     const ctx = makeContext({ readFile: () => '{ bad json' });
     expect(drupalResolver.detect(ctx)).toBe(false);
   });
+
+  it('returns true for a contrib module with empty require (composer name/type)', () => {
+    const ctx = makeContext({
+      readFile: (f) =>
+        f === 'composer.json'
+          ? JSON.stringify({
+              name: 'drupal/admin_toolbar',
+              type: 'drupal-module',
+              require: {},
+            })
+          : null,
+    });
+    expect(drupalResolver.detect(ctx)).toBe(true);
+  });
+
+  it('returns true via the *.info.yml fallback when composer.json is absent', () => {
+    const ctx = makeContext({
+      readFile: () => null,
+      getAllFiles: () => [
+        'mymodule/mymodule.info.yml',
+        'mymodule/mymodule.routing.yml',
+      ],
+    });
+    expect(drupalResolver.detect(ctx)).toBe(true);
+  });
+
+  it('returns false for a stray *.info.yml with no Drupal PHP/route file', () => {
+    const ctx = makeContext({
+      readFile: () => null,
+      getAllFiles: () => ['some/unrelated.info.yml'],
+    });
+    expect(drupalResolver.detect(ctx)).toBe(false);
+  });
+});
+
+describe('drupalResolver.claimsReference', () => {
+  it('claims FQCN handler refs and hook names the pre-filter would drop', () => {
+    expect(drupalResolver.claimsReference!('\\Drupal\\m\\Form\\SettingsForm')).toBe(true);
+    expect(drupalResolver.claimsReference!('\\Drupal\\m\\Controller\\C:setNoJsCookie')).toBe(true);
+    expect(drupalResolver.claimsReference!('hook_form_alter')).toBe(true);
+  });
+
+  it('does not claim ordinary identifiers or entity-handler dotted refs', () => {
+    expect(drupalResolver.claimsReference!('someHelperFunction')).toBe(false);
+    expect(drupalResolver.claimsReference!('comment.default')).toBe(false);
+  });
 });
 
 // ---------------------------------------------------------------------------
@@ -435,6 +481,51 @@ describe('drupalResolver.resolve', () => {
     };
     expect(drupalResolver.resolve(ref, ctx)).toBeNull();
   });
+
+  it('resolves a single-colon controller-service ref (Class:method)', () => {
+    const methodNode = {
+      id: 'method:nojs1',
+      kind: 'method' as const,
+      name: 'setNoJsCookie',
+      qualifiedName: 'BigPipeController::setNoJsCookie',
+      filePath: 'core/modules/big_pipe/src/Controller/BigPipeController.php',
+      language: 'php' as const,
+      startLine: 10,
+      endLine: 20,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: 0,
+    };
+    const classNode = {
+      id: 'class:nojs2',
+      kind: 'class' as const,
+      name: 'BigPipeController',
+      qualifiedName: 'BigPipeController',
+      filePath: 'core/modules/big_pipe/src/Controller/BigPipeController.php',
+      language: 'php' as const,
+      startLine: 5,
+      endLine: 30,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: 0,
+    };
+    const ctx = makeContext({
+      getNodesByName: (name) => (name === 'BigPipeController' ? [classNode] : []),
+      getNodesInFile: () => [classNode, methodNode],
+    });
+    const ref = {
+      fromNodeId: 'route:x',
+      referenceName: '\\Drupal\\big_pipe\\Controller\\BigPipeController:setNoJsCookie',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 0,
+      filePath: 'big_pipe.routing.yml',
+      language: 'yaml' as const,
+    };
+    const resolved = drupalResolver.resolve(ref, ctx);
+    expect(resolved).not.toBeNull();
+    expect(resolved!.targetNodeId).toBe('method:nojs1');
+  });
 });
 
 // ---------------------------------------------------------------------------

+ 5 - 0
__tests__/extraction.test.ts

@@ -1151,6 +1151,11 @@ class UserService {
     const privateMethod = methodNodes.find((m) => m.name === '_privateMethod');
     expect(privateMethod).toBeDefined();
     expect(privateMethod?.visibility).toBe('private');
+
+    // Dart models a method body as a SIBLING of the signature, so the method
+    // node must be extended to span its body (not just the signature line) —
+    // required for body-level analysis (callees, the callback synthesizer).
+    expect(findById!.endLine).toBeGreaterThan(findById!.startLine);
   });
 
   it('should extract top-level function declarations', () => {

+ 140 - 0
__tests__/frameworks-integration.test.ts

@@ -57,3 +57,143 @@ describe('Django end-to-end framework extraction', () => {
     cg.close();
   });
 });
+
+describe('Flask end-to-end framework extraction', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('resolves stacked routes across @login_required to a view named after a builtin (index)', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-flask-'));
+    fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'flask==3.0\n');
+    fs.writeFileSync(
+      path.join(tmpDir, 'app.py'),
+      'from flask import Blueprint, render_template\n' +
+        'from flask_login import login_required\n' +
+        'bp = Blueprint("main", __name__)\n' +
+        '\n' +
+        '@bp.route("/", methods=["GET", "POST"])\n' +
+        '@bp.route("/index", methods=["GET", "POST"])\n' +
+        '@login_required\n' +
+        'def index():\n' +
+        '    return render_template("index.html")\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    // Both stacked @bp.route decorators are extracted (the second was previously
+    // dropped because @login_required broke the "def must follow" assumption).
+    const routes = cg.getNodesByKind('route');
+    expect(routes.map((r) => r.name).sort()).toEqual(['GET /', 'GET /index']);
+
+    // The view function exists even though its name is a Python builtin method.
+    const fn = cg.getNodesByKind('function').find((n) => n.name === 'index');
+    expect(fn).toBeDefined();
+
+    // Both routes resolve to it — exercises the bare-name builtin guard, which
+    // previously filtered the `index` reference as a builtin method.
+    for (const route of routes) {
+      const edges = cg.getOutgoingEdges(route.id);
+      const toView = edges.find((e) => e.target === fn!.id && e.kind === 'references');
+      expect(toView, `route ${route.name} should resolve to index()`).toBeDefined();
+    }
+
+    cg.close();
+  });
+});
+
+describe('Flutter end-to-end — setState→build synthesis', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('synthesizes a handler→build edge when a State method calls setState', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-flutter-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'main.dart'),
+      'import "package:flutter/material.dart";\n' +
+        'class CounterPage extends StatefulWidget {\n' +
+        '  @override\n' +
+        '  State<CounterPage> createState() => _CounterPageState();\n' +
+        '}\n' +
+        'class _CounterPageState extends State<CounterPage> {\n' +
+        '  int _count = 0;\n' +
+        '  void _increment() {\n' +
+        '    setState(() {\n' +
+        '      _count++;\n' +
+        '    });\n' +
+        '  }\n' +
+        '  @override\n' +
+        '  Widget build(BuildContext context) {\n' +
+        '    return Text("$_count");\n' +
+        '  }\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const methods = cg.getNodesByKind('method');
+    const increment = methods.find((n) => n.name === '_increment');
+    const build = methods.find((n) => n.name === 'build');
+    expect(increment).toBeDefined();
+    expect(build).toBeDefined();
+
+    // setState re-runs build (Flutter-internal, no static edge). The synthesizer
+    // bridges the handler → build so the "tap → setState → rebuilt UI" flow connects.
+    const edges = cg.getOutgoingEdges(increment!.id);
+    const toBuild = edges.find((e) => e.target === build!.id && e.kind === 'calls');
+    expect(toBuild, '_increment should reach build via setState synthesis').toBeDefined();
+
+    cg.close();
+  });
+});
+
+describe('C++ end-to-end — virtual override synthesis', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('bridges a base virtual method to the subclass override', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'iter.cpp'),
+      'class Iterator {\n' +
+        ' public:\n' +
+        '  virtual void Next() { }\n' +
+        '};\n' +
+        'class DBIter : public Iterator {\n' +
+        ' public:\n' +
+        '  void Next() override { advance(); }\n' +
+        '  void advance() { }\n' +
+        '};\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    // Two methods named Next: the base virtual (lower line) and the override.
+    const nexts = cg
+      .getNodesByKind('method')
+      .filter((n) => n.name === 'Next')
+      .sort((a, b) => a.startLine - b.startLine);
+    expect(nexts.length).toBe(2);
+    const [baseNext, overrideNext] = nexts;
+
+    // A vtable call to Iterator::Next dispatches to DBIter::Next — bridge it so
+    // trace/callees from the interface method reaches the implementation.
+    const edge = cg
+      .getOutgoingEdges(baseNext!.id)
+      .find((e) => e.target === overrideNext!.id && e.kind === 'calls');
+    expect(edge, 'Iterator::Next should reach DBIter::Next via override synthesis').toBeDefined();
+
+    cg.close();
+  });
+});

+ 280 - 15
__tests__/frameworks.test.ts

@@ -123,6 +123,52 @@ def create_user(id):
     expect(nodes[0].name).toBe('POST /<id>');
     expect(references[0].referenceName).toBe('create_user');
   });
+
+  it('resolves the handler across an intervening decorator (@login_required)', () => {
+    const src = `
+@bp.route('/profile')
+@login_required
+def profile():
+    return render_template('profile.html')
+`;
+    const { nodes, references } = flaskResolver.extract!('routes.py', src);
+    expect(nodes[0].name).toBe('GET /profile');
+    expect(references[0].referenceName).toBe('profile');
+  });
+
+  it('extracts stacked @x.route decorators bound to one view', () => {
+    const src = `
+@bp.route('/', methods=['GET', 'POST'])
+@bp.route('/index', methods=['GET', 'POST'])
+@login_required
+def index():
+    return render_template('index.html')
+`;
+    const { nodes, references } = flaskResolver.extract!('routes.py', src);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /', 'GET /index']);
+    expect(references.map((r) => r.referenceName)).toEqual(['index', 'index']);
+  });
+
+  it('extracts the method from a tuple methods=(...) (not just a list)', () => {
+    const src = `
+@blueprint.route('/api/articles', methods=('POST',))
+def make_article():
+    pass
+`;
+    const { nodes, references } = flaskResolver.extract!('views.py', src);
+    expect(nodes[0].name).toBe('POST /api/articles');
+    expect(references[0].referenceName).toBe('make_article');
+  });
+
+  it('extracts Flask-RESTful api.add_resource(Resource, paths) → the Resource class', () => {
+    const src = `
+api.add_resource(TodoResource, '/todos/<id>')
+api.add_org_resource(AlertResource, '/api/alerts/<id>', endpoint='alert')
+`;
+    const { nodes, references } = flaskResolver.extract!('api.py', src);
+    expect(nodes.map((n) => n.name)).toEqual(['ANY /todos/<id>', 'ANY /api/alerts/<id>']);
+    expect(references.map((r) => r.referenceName)).toEqual(['TodoResource', 'AlertResource']);
+  });
 });
 
 describe('fastapiResolver.extract', () => {
@@ -147,6 +193,32 @@ def create_item(item: Item):
     expect(nodes[0].name).toBe('POST /items');
     expect(references[0].referenceName).toBe('create_item');
   });
+
+  it('extracts a route mounted at the router/prefix root (empty path)', () => {
+    const src = `
+@router.get("", response_model=ListOfArticles, name="articles:list")
+async def list_articles():
+    return []
+`;
+    const { nodes, references } = fastapiResolver.extract!('articles.py', src);
+    expect(nodes[0].name).toBe('GET /');
+    expect(references[0].referenceName).toBe('list_articles');
+  });
+
+  it('extracts a multi-line decorator with an empty path', () => {
+    const src = `
+@router.post(
+    "",
+    status_code=201,
+    response_model=ArticleInResponse,
+)
+async def create_article():
+    pass
+`;
+    const { nodes, references } = fastapiResolver.extract!('articles.py', src);
+    expect(nodes[0].name).toBe('POST /');
+    expect(references[0].referenceName).toBe('create_article');
+  });
 });
 
 import { expressResolver } from '../src/resolution/frameworks/express';
@@ -463,13 +535,13 @@ describe('laravelResolver.extract', () => {
     const src = `Route::get('/users', [UserController::class, 'index']);\n`;
     const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
     expect(nodes[0].name).toBe('GET /users');
-    expect(references[0].referenceName).toBe('index');
+    expect(references[0].referenceName).toBe('UserController@index');
   });
 
   it('extracts route with Controller@action syntax', () => {
     const src = `Route::post('/users', 'UserController@store');\n`;
     const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
-    expect(references[0].referenceName).toBe('store');
+    expect(references[0].referenceName).toBe('UserController@store');
   });
 
   it('extracts resource route', () => {
@@ -487,13 +559,13 @@ describe('railsResolver.extract', () => {
     const src = `get '/users', to: 'users#index'\n`;
     const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
     expect(nodes[0].name).toBe('GET /users');
-    expect(references[0].referenceName).toBe('index');
+    expect(references[0].referenceName).toBe('users#index');
   });
 
   it('extracts route without to: keyword', () => {
     const src = `post '/items' => 'items#create'\n`;
     const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
-    expect(references[0].referenceName).toBe('create');
+    expect(references[0].referenceName).toBe('items#create');
   });
 });
 
@@ -511,6 +583,75 @@ public List<User> listUsers() {
     expect(nodes[0].name).toBe('GET /users');
     expect(references[0].referenceName).toBe('listUsers');
   });
+
+  it('extracts a Kotlin @GetMapping with a fun handler', () => {
+    const src = `
+@GetMapping("/vets")
+fun showVetList(model: MutableMap<String, Any>): String {
+  return "vets"
+}
+`;
+    const { nodes, references } = springResolver.extract!('VetController.kt', src);
+    expect(nodes[0].name).toBe('GET /vets');
+    expect(references[0].referenceName).toBe('showVetList');
+    expect(nodes[0].language).toBe('kotlin');
+  });
+
+  it('joins a Kotlin class @RequestMapping prefix and skips a stacked annotation', () => {
+    const src = `
+@RestController
+@RequestMapping("/owners")
+class OwnerController {
+  @GetMapping("/{ownerId}")
+  @ResponseBody
+  fun showOwner(@PathVariable ownerId: Int): String {
+    return "owner"
+  }
+}
+`;
+    const { nodes, references } = springResolver.extract!('OwnerController.kt', src);
+    expect(nodes[0].name).toBe('GET /owners/{ownerId}');
+    expect(references[0].referenceName).toBe('showOwner');
+  });
+});
+
+import { playResolver } from '../src/resolution/frameworks/play';
+import { isSourceFile, isPlayRoutesFile } from '../src/extraction/grammars';
+
+describe('playResolver.extract (conf/routes)', () => {
+  it('extracts METHOD /path Controller.action routes, dropping the package + args', () => {
+    const src = `# Routes
+GET     /                    controllers.Application.index
+GET     /computers           controllers.Application.list(p: Int ?= 0, s: Int ?= 2)
+POST    /computers           controllers.Application.save
+-> /v1/posts                 v1.post.PostRouter
+`;
+    const { nodes, references } = playResolver.extract!('conf/routes', src);
+    expect(nodes.map((n) => n.name)).toEqual([
+      'GET /',
+      'GET /computers',
+      'POST /computers',
+    ]); // the `->` include is skipped
+    expect(references.map((r) => r.referenceName)).toEqual([
+      'Application.index',
+      'Application.list',
+      'Application.save',
+    ]);
+  });
+
+  it('only runs on Play routes files', () => {
+    expect(playResolver.extract!('app/Foo.scala', 'GET / controllers.X.y').nodes).toHaveLength(0);
+  });
+});
+
+describe('Play routes file detection', () => {
+  it('recognizes conf/routes (extensionless) and *.routes as source files', () => {
+    expect(isPlayRoutesFile('conf/routes')).toBe(true);
+    expect(isPlayRoutesFile('myapp/conf/routes')).toBe(true);
+    expect(isPlayRoutesFile('conf/admin.routes')).toBe(true);
+    expect(isSourceFile('conf/routes')).toBe(true);
+    expect(isPlayRoutesFile('src/routes.ts')).toBe(false);
+  });
 });
 
 import { goResolver } from '../src/resolution/frameworks/go';
@@ -528,6 +669,14 @@ describe('goResolver.extract', () => {
     const { nodes, references } = goResolver.extract!('main.go', src);
     expect(references[0].referenceName).toBe('createItem');
   });
+
+  it('extracts gorilla/mux HandleFunc on a subrouter var, ignoring chained .Methods()', () => {
+    // `s` is a PathPrefix().Subrouter() var — any receiver is matched; the
+    // trailing .Methods("GET") doesn't break the handler capture.
+    const src = `s.HandleFunc("/users/{id}", listUsers).Methods("GET")\n`;
+    const { references } = goResolver.extract!('routes.go', src);
+    expect(references[0].referenceName).toBe('listUsers');
+  });
 });
 
 import { rustResolver } from '../src/resolution/frameworks/rust';
@@ -539,6 +688,50 @@ describe('rustResolver.extract', () => {
     expect(nodes[0].name).toBe('GET /users');
     expect(references[0].referenceName).toBe('list_users');
   });
+
+  it('extracts every method from a chained axum .route (get().put())', () => {
+    const src = `let app = Router::new().route("/user", get(get_current_user).put(update_user));\n`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /user', 'PUT /user']);
+    expect(references.map((r) => r.referenceName)).toEqual([
+      'get_current_user',
+      'update_user',
+    ]);
+  });
+
+  it('extracts a multi-line axum .route with a namespaced handler', () => {
+    const src = `
+let app = Router::new()
+    .route(
+        "/articles/feed",
+        get(listing::feed_articles),
+    );
+`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes[0].name).toBe('GET /articles/feed');
+    expect(references[0].referenceName).toBe('feed_articles');
+  });
+
+  it('extracts actix web::resource().route(web::METHOD().to(handler))', () => {
+    const src = `App::new().service(web::resource("/user/{id}").route(web::get().to(get_user)))\n`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes[0].name).toBe('GET /user/{id}');
+    expect(references[0].referenceName).toBe('get_user');
+  });
+
+  it('extracts actix web::resource("/").to(handler) (all methods)', () => {
+    const src = `App::new().service(web::resource("/").to(index))\n`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes[0].name).toBe('ANY /');
+    expect(references[0].referenceName).toBe('index');
+  });
+
+  it('extracts actix App-level .route("/path", web::METHOD().to(handler))', () => {
+    const src = `App::new().route("/health", web::get().to(health_check))\n`;
+    const { nodes, references } = rustResolver.extract!('main.rs', src);
+    expect(nodes[0].name).toBe('GET /health');
+    expect(references[0].referenceName).toBe('health_check');
+  });
 });
 
 describe('rustResolver.resolve cargo workspace crates', () => {
@@ -871,22 +1064,94 @@ describe('vaporResolver.extract', () => {
   it('extracts route from app.get with use:', () => {
     const src = `app.get("users", use: listUsers)\n`;
     const { nodes, references } = vaporResolver.extract!('routes.swift', src);
-    expect(nodes[0].name).toBe('GET users');
+    expect(nodes[0].name).toBe('GET /users');
     expect(references[0].referenceName).toBe('listUsers');
   });
+
+  it('extracts grouped RouteCollection routes with the group prefix and no path arg', () => {
+    const src = `
+func boot(routes: RoutesBuilder) throws {
+    let todos = routes.grouped("todos")
+    todos.get(use: index)
+    todos.post(use: create)
+    todos.group(":todoID") { todo in
+        todo.delete(use: delete)
+    }
+}
+`;
+    const { nodes, references } = vaporResolver.extract!('TodoController.swift', src);
+    expect(nodes.map((n) => n.name).sort()).toEqual([
+      'DELETE /todos/:todoID',
+      'GET /todos',
+      'POST /todos',
+    ]);
+    expect(references.map((r) => r.referenceName).sort()).toEqual([
+      'create',
+      'delete',
+      'index',
+    ]);
+  });
+
+  it('handles use: self.handler and non-string path segments', () => {
+    const src = `router.get("users", User.parameter, "edit", use: self.editUserHandler)\n`;
+    const { nodes, references } = vaporResolver.extract!('UserController.swift', src);
+    expect(nodes[0].name).toBe('GET /users/edit');
+    expect(references[0].referenceName).toBe('editUserHandler');
+  });
+
+  it('ignores non-route .get calls that lack use: (e.g. Environment.get)', () => {
+    const src = `let host = Environment.get("DATABASE_HOST") ?? "localhost"\n`;
+    const { nodes } = vaporResolver.extract!('configure.swift', src);
+    expect(nodes).toHaveLength(0);
+  });
 });
 
 import { reactResolver } from '../src/resolution/frameworks/react';
 import { svelteResolver } from '../src/resolution/frameworks/svelte';
 
-describe('reactResolver.extract (smoke)', () => {
-  it('returns { nodes, references } shape', () => {
+describe('reactResolver.extract — React Router', () => {
+  it('extracts a v6 <Route path element={<Comp/>}>', () => {
     const src = `<Route path="/users" element={<UsersPage/>}/>`;
-    const result = reactResolver.extract!('App.tsx', src);
-    expect(result).toHaveProperty('nodes');
-    expect(result).toHaveProperty('references');
-    expect(Array.isArray(result.nodes)).toBe(true);
-    expect(Array.isArray(result.references)).toBe(true);
+    const { nodes, references } = reactResolver.extract!('App.tsx', src);
+    const route = nodes.find((n) => n.kind === 'route');
+    expect(route?.name).toBe('/users');
+    expect(references[0]?.referenceName).toBe('UsersPage');
+  });
+
+  it('extracts a v5 <Route path component={Comp}> with attributes in any order', () => {
+    const src = `<Route exact path="/login" component={Login} />`;
+    const { nodes, references } = reactResolver.extract!('App.jsx', src);
+    const route = nodes.find((n) => n.kind === 'route');
+    expect(route?.name).toBe('/login');
+    expect(references[0]?.referenceName).toBe('Login');
+  });
+
+  it('does not treat the <Routes> container as a route', () => {
+    const src = `<Routes><Route path="/x" element={<X/>}/></Routes>`;
+    const routes = reactResolver.extract!('App.tsx', src).nodes.filter((n) => n.kind === 'route');
+    expect(routes).toHaveLength(1);
+    expect(routes[0]?.name).toBe('/x');
+  });
+
+  it('extracts createBrowserRouter object routes ({ path, element/Component })', () => {
+    const src = `const router = createBrowserRouter([
+      { path: "/dashboard", element: <Dashboard /> },
+      { path: "/login", Component: Login },
+    ]);`;
+    const { nodes, references } = reactResolver.extract!('router.tsx', src);
+    const routes = nodes.filter((n) => n.kind === 'route');
+    expect(routes.map((n) => n.name).sort()).toEqual(['/dashboard', '/login']);
+    expect(references.map((r) => r.referenceName).sort()).toEqual(['Dashboard', 'Login']);
+  });
+
+  it('does not treat config files or a nextjs-pages dir as Next.js routes', () => {
+    const cfg = reactResolver.extract!('apps/nextjs-pages/next.config.mjs', 'export default {}');
+    expect(cfg.nodes.filter((n) => n.kind === 'route')).toHaveLength(0);
+    const vite = reactResolver.extract!('src/pages/vite.config.ts', 'export default {}');
+    expect(vite.nodes.filter((n) => n.kind === 'route')).toHaveLength(0);
+    // a real page still works
+    const page = reactResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}');
+    expect(page.nodes.filter((n) => n.kind === 'route').map((n) => n.name)).toEqual(['/about']);
   });
 });
 
@@ -969,7 +1234,7 @@ Route::get('/real', [RealController::class, 'index']);
 `;
     const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
     expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
-    expect(references.map((r) => r.referenceName)).toEqual(['index']);
+    expect(references.map((r) => r.referenceName)).toEqual(['RealController@index']);
   });
 
   it('rails: skips =begin/=end and # commented routes', () => {
@@ -982,7 +1247,7 @@ get '/real', to: 'real#index'
 `;
     const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
     expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
-    expect(references.map((r) => r.referenceName)).toEqual(['index']);
+    expect(references.map((r) => r.referenceName)).toEqual(['real#index']);
   });
 
   it('spring: skips // and /* */ commented @GetMapping', () => {
@@ -1046,7 +1311,7 @@ public IActionResult ListUsers() { return Ok(); }
 app.get("real", use: listUsers)
 `;
     const { nodes, references } = vaporResolver.extract!('routes.swift', src);
-    expect(nodes.map((n) => n.name)).toEqual(['GET real']);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
     expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
   });
 

+ 58 - 0
__tests__/mcp-tool-allowlist.test.ts

@@ -0,0 +1,58 @@
+/**
+ * CODEGRAPH_MCP_TOOLS allowlist — lets an operator (or an A/B harness) trim the
+ * exposed MCP tool surface without touching the client config. Inert when unset.
+ * Filtering happens in ListTools (getTools) and is enforced again on execute().
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import { ToolHandler } from '../src/mcp/tools';
+
+const ENV = 'CODEGRAPH_MCP_TOOLS';
+
+describe('CODEGRAPH_MCP_TOOLS allowlist', () => {
+  const original = process.env[ENV];
+  afterEach(() => {
+    if (original === undefined) delete process.env[ENV];
+    else process.env[ENV] = original;
+  });
+
+  const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort();
+
+  it('exposes the full tool surface when unset', () => {
+    delete process.env[ENV];
+    const all = listed();
+    expect(all).toContain('codegraph_explore');
+    expect(all).toContain('codegraph_context');
+    expect(all).toContain('codegraph_trace');
+    expect(all.length).toBeGreaterThanOrEqual(10);
+  });
+
+  it('filters ListTools to the allowlisted short names', () => {
+    process.env[ENV] = 'trace,search,node';
+    expect(listed()).toEqual(['codegraph_node', 'codegraph_search', 'codegraph_trace']);
+  });
+
+  it('accepts fully-qualified codegraph_ names and ignores whitespace', () => {
+    process.env[ENV] = ' codegraph_trace , search ';
+    expect(listed()).toEqual(['codegraph_search', 'codegraph_trace']);
+  });
+
+  it('treats an empty/whitespace value as unset (full surface)', () => {
+    process.env[ENV] = '   ';
+    expect(listed().length).toBeGreaterThanOrEqual(10);
+  });
+
+  it('rejects a disabled tool on execute (defense in depth)', async () => {
+    process.env[ENV] = 'trace';
+    const res = await new ToolHandler(null).execute('codegraph_explore', {});
+    expect(res.isError).toBe(true);
+    expect(res.content[0].text).toMatch(/disabled via CODEGRAPH_MCP_TOOLS/);
+  });
+
+  it('lets an allowlisted tool past the guard', async () => {
+    process.env[ENV] = 'search';
+    // No CodeGraph attached, so it fails *after* the allowlist guard — the
+    // "disabled" message must NOT appear, proving the guard passed it through.
+    const res = await new ToolHandler(null).execute('codegraph_search', { query: 'x' });
+    expect(res.content[0].text).not.toMatch(/disabled via CODEGRAPH_MCP_TOOLS/);
+  });
+});

+ 426 - 0
docs/benchmarks/call-sequence-analysis.md

@@ -0,0 +1,426 @@
+# Call-sequence analysis — why read savings don't convert to wall-clock
+
+**Date:** 2026-05-23 · **Branch:** `architectural-improvements` · **Source data:** the surviving
+stream-json logs from the A/B matrix (`/tmp/ab-matrix/<Cell>/run-headless-{with,without}.jsonl`,
+37 cells × 2 arms). Re-mined — **no re-runs** — with `scripts/agent-eval/seq-matrix.mjs`.
+
+## Why this exists
+
+The [A/B matrix](codegraph-ab-matrix.md) showed codegraph cuts **reads 75%** but **wall-clock only
+~16%**, and 63% of the wall-clock win comes from just 3 large-repo cells. Reads are at the floor
+(~0), so the remaining wall-clock is **round-trips + the synthesis turn** — neither of which read
+count can explain. The matrix records tool *counts*, not the call **sequence** or per-call
+**payload size**. This analysis recovers both, to find where the wall-clock actually goes.
+
+## TL;DR — the bottleneck is trace ADOPTION, not trace completeness
+
+1. **Trace is called in 3 of 37 cells** — even though every question is a canonical flow question
+   ("trace the controller → service → repository", "how does X reach Y"). The agent overwhelmingly
+   reaches for **`context → search → search → explore`** instead — the exact path-reconstruction
+   anti-pattern the instructions tell it to avoid.
+2. **`explore` averages 17.9K chars/call; `trace` averages 0.8K** — a **22× payload difference**.
+   The path-scoped tool that solves the small-repo-bloat problem exists and is tiny. It's just not
+   being invoked.
+3. **Small repos still get bloated payloads** because of the explore-default: a **6-file** repo
+   (`flutter_module_books`) pulls **17.4K**; a 10-file repo pulls 18.0K. This is precisely the
+   "too much context on small codebases" failure mode — happening right now, via explore.
+4. **Round-trips are 25% fewer with codegraph (283 vs 375 turns)** but wall-clock is only 16%
+   faster — because the with-arm's turns each carry a ~18K explore payload, inflating TTFT and
+   eroding the turn savings.
+5. **Root cause:** `src/mcp/server-instructions.ts` leads with *"answer directly … `codegraph_context`
+   first, then ONE `codegraph_explore`"* as the headline pattern. The trace-first guidance is buried
+   in a table + a chain list below it. Agents anchor on the prominent headline → context→explore.
+
+**Decision:** the next experiment is **trace-first steering / adoption**, not enriching trace. We
+can't evaluate trace's completeness when it's used 3/37 times. Get adoption up first, then measure
+whether the residual `node`/`explore` follow-ups need a richer trace.
+
+## Finding 1 — trace adoption: 3/37
+
+| metric | value |
+|---|---|
+| flow-question cells | 37 (all of them) |
+| cells that called `codegraph_trace` | **3** (`cpp-leveldb`, `excalidraw`, `c-redis`) |
+| dominant pattern instead | `context` → `search`×N → `explore` |
+
+The 3 trace cells, and what followed the trace call:
+
+| repo | files | cg sequence | turns (with/without) |
+|---|--:|---|---|
+| cpp-leveldb | 134 | `trace, node, node` | 5 / 8 |
+| excalidraw | 643 | `context, trace, trace, explore` | 6 / **19** |
+| c-redis | 884 | `context, trace, explore, node` | 10 / 15 |
+
+Even when trace *is* used, the agent follows it with `node`/`explore` to fetch bodies — so a
+secondary lever (after adoption) is making one trace call self-sufficient enough to kill those
+follow-ups. But that's step 2.
+
+## Finding 2 — payload size: path-scoped trace (0.8K) vs breadth-scoped explore (17.9K)
+
+Across all cells, per codegraph tool — call count and **average payload per call**:
+
+| tool | calls | avg/call | total |
+|---|--:|--:|--:|
+| `explore` | 32 | **17.9K** | 573K |
+| `context` | 36 | 4.3K | 156K |
+| `search` | 39 | 1.3K | 50K |
+| `files` | 5 | 3.4K | 17K |
+| `node` | 19 | 2.0K | 38K |
+| `trace` | 4 | **0.8K** | 3.4K |
+
+`context` (used in 36/37 cells) is the default opener; `explore` is the default closer. Together
+they are the ~22K breadth dump. `trace` — the tool that would replace that with the actual path —
+is 22× smaller and barely used. This is the user's premise confirmed in numbers: explore is
+breadth-scoped (returns the neighborhood), trace is path-scoped (returns the line).
+
+## Finding 3 — payload grows with repo size, and over-returns on small repos
+
+With-arm **total** codegraph payload by repo-size tier:
+
+| tier | cells | avg total payload | range |
+|---|--:|--:|--:|
+| S (<200 files) | 19 | 12.7K | 3.0–31.2K |
+| M (<2000) | 9 | 32.4K | 5.4–58.2K |
+| L (≥2000) | 9 | 34.0K | 20.2–43.1K |
+
+The small-repo waste is concrete — these all have a 2–3 file flow but pull a full neighborhood:
+
+| repo | files | with-arm payload | sequence |
+|---|--:|--:|---|
+| flutter_module_books | 6 | 17.4K | `context, explore` |
+| computer-database | 10 | 18.0K | `context, search, status, explore` |
+| aspnet-realworld | 78 | 22.2K | `context, explore` |
+| django-realworld | 44 | 14.8K | `context, explore` |
+
+`explore`'s per-call budget is already adaptive (#185), but it doesn't help here because the agent
+isn't choosing the path-scoped tool — it's choosing breadth.
+
+## Finding 4 — round-trips, and the ToolSearch tax
+
+| metric | with | without |
+|---|--:|--:|
+| total turns (37 cells) | 283 | 375 |
+| avg turns / cell | 7.6 | 10.1 |
+
+25% fewer turns, but only ~16% faster wall-clock — the gap is the per-turn cost of the big explore
+payloads. Also: **every with-arm run opens with a `ToolSearch` round-trip** (MCP tools are deferred
+in this harness), a fixed 1-turn tax before any codegraph call. Worth confirming whether the
+production install defers codegraph tools the same way.
+
+## Conclusion → the experiment to run next
+
+Measure-first changed the plan. The hypothesis was "enrich trace so one call is self-sufficient."
+The data says trace is **used 3/37 times**, so completeness is moot until adoption is fixed.
+
+**Experiment: trace-first steering A/B.**
+- **Change:** rewrite the `server-instructions.ts` headline so a *flow* question (how does X reach Y
+  / trace / from→to) routes to `codegraph_trace` **first**, demoting the context→explore pattern to
+  non-flow/onboarding questions. Mirror into `instructions-template.ts` + `.cursor/rules/codegraph.mdc`.
+- **Metric:** trace-adoption rate (target ≫ 3/37), with-arm total payload (expect ↓ sharply,
+  especially small repos), turns (expect ↓), wall-clock (expect the 16% gap to widen toward the
+  25% turn gap as 18K explore payloads are replaced by <1K traces).
+- **Control:** a non-flow "what's the deal with module X" question must still go context→explore —
+  don't over-steer everything to trace.
+- **Then, step 2:** with adoption up, measure the `node`/`explore` follow-ups after trace
+  (cpp-leveldb/excalidraw/c-redis all had them). If they're frequent, enrich trace (per-hop body
+  snippet, capped per hop) so one trace call ends the flow investigation.
+
+## Reproduce
+
+```bash
+node scripts/agent-eval/seq-matrix.mjs            # regenerates every table above from /tmp/ab-matrix
+```
+
+---
+
+# Ablation experiment — do `context`, `explore`, and `trace` compete? Is `trace` enough?
+
+**Date:** 2026-05-23 · 52 runs, ~$20. Tool surface trimmed **server-side** via the new
+`CODEGRAPH_MCP_TOOLS` allowlist (so an ablated tool is genuinely absent from ListTools, not
+denied-on-call); trace-first steering injected with `--append-system-prompt`. 6 repos (2 S / 2 M /
+2 L) × 2 runs; arm E is a **non-flow** survey question on 2 repos. Driver `arms-matrix.sh`,
+analysis `parse-arms.mjs`.
+
+| arm | tools | steering | adoption | reads | cgOut | turns | dur |
+|---|---|---|--:|--:|--:|--:|--:|
+| **A** control | all | none | 2/12 | 1.25 | 28.8K | 7.6 | 38s |
+| **B** steer | all | trace-first | **8/12** | 1.00 | **32.0K** | 7.9 | 43s |
+| **C** no-explore | hide explore | trace-first | 8/12 | **2.08** | **9.2K** | 9.0 | 44s |
+| **D** trace-centric | hide explore+context | trace-first | 8/12 | 2.00 | 6.6K | 10.5 | 46s |
+| **E** control-probe | hide explore+context | trace-first | 0/4 | 2.50 | 27.8K | **20.0** | **72s** |
+
+## What it says
+
+1. **Steering works for adoption, not for payload.** B lifted trace use **2/12 → 8/12** (and 4/4 on
+   the genuinely path-shaped questions — the 2 non-adopters, flutter "what widgets" and vapor "name
+   the route", aren't from→to questions). But B's payload (32.0K) is *bigger* than control (28.8K)
+   and it's slightly slower — because the agent calls trace **and still calls explore**. Steering
+   adds a trace hop without displacing the explore dump.
+2. **`explore` is the payload, and it's load-bearing — but 3–5× too heavy.** Removing it (C) cuts
+   payload **71%** (32K→9.2K) — confirming it's the bloat. But reads **double** (1.0→2.1) and turns
+   rise: the agent Reads files to recover the bodies explore had inlined. So explore isn't
+   redundant; it's the only one-call body-supplier, just delivered with a 32K sledgehammer.
+3. **`context` is the most redundant of the three — as a body-supplier.** Removing it on top of
+   explore (D vs C) left reads flat (2.08→2.00) but raised turns (9.0→10.5). It supplies no unique
+   bodies; it earns its keep only as a round-trip-saver (the composed orient call).
+4. **Removing tools makes flow questions SLOWER, not faster.** Turns climb monotonically
+   A→D (7.6→10.5) and duration with them — the Read + trace-follow-up round-trips cost more
+   wall-clock than the saved payload. Leaner payload ≠ faster.
+5. **`trace` is definitively NOT sufficient.** The non-flow probe (E) thrashed without the survey
+   tools — **20 turns, 72s** reconstructing an overview from search/node/files. Survey questions
+   need a survey tool; trace can't substitute.
+
+## Verdict on the three design questions
+
+- **Do we need all three?** Yes — but for different reasons. trace = flow tool (real, under-adopted).
+  explore = the one-call body-supplier (load-bearing, over-heavy). context = round-trip-saving
+  opener (redundant for bodies, useful for orientation).
+- **Are they competing?** Yes: explore competes with trace and *wins by default* — even when steered,
+  the agent traces **and** explores, so the payload win never lands until explore is displaced.
+- **Could trace be all we need?** No. E rules it out for non-flow questions; C/D rule it out even
+  for flow (reads double without explore's bodies).
+
+**Three cheap fixes are now ruled out by data:** "trace is all we need" (false), "just steer to
+trace" (B: slower + bigger than control), and "remove explore" (C/D: more reads/turns, slower).
+
+## The fix the data points to → next experiment
+
+The only path that wins: **make `trace` self-sufficient by inlining per-hop bodies** (capped per
+hop → still path-scoped) so one trace call supplies what explore does *and* what the Read fallback
+recovers — displacing both for flow questions. Keep **one** survey tool (context; demote explore to
+deep-survey, not the flow default) for the non-flow class E proved is load-bearing.
+
+- **Experiment:** enriched body-inlining `trace` + steering vs control.
+- **Target:** C/D's lean payload (~7–9K, not 32K) **without** C/D's extra reads/turns, and **beat A
+  on wall-clock** (the bar B/C/D all failed).
+- **Metric:** payload, reads (must stay ≈ A's ~1.0, not rise to 2.0), turns, duration.
+
+## Reproduce (ablation)
+
+```bash
+bash scripts/agent-eval/arms-matrix.sh     # 52 runs into /tmp/arms (RUNS=2 default)
+node scripts/agent-eval/parse-arms.mjs     # the arm-comparison tables above
+```
+
+---
+
+# Validation — body-inlining trace (arm F)
+
+The ablation pointed to one fix: make `trace` self-sufficient by inlining per-hop **bodies**
+(capped per hop → still path-scoped) so one trace call displaces both the explore dump and the
+Read fallback. Implemented in `handleTrace` (`sourceRangeAt`, 28 lines / 1200 chars per hop, with a
+`… (+N more lines)` marker). Arm **F** = arm B's surface (all tools + trace-first steering) run on
+the body-inlining build, so **F vs B isolates the enrichment**.
+
+| arm | adoption | reads | cgOut | turns | dur | cost |
+|---|--:|--:|--:|--:|--:|--:|
+| A all/none | 2/12 | 1.25 | 28.8K | 7.6 | 38s | $0.390 |
+| B all/steer (thin trace) | 8/12 | 1.00 | 32.0K | 7.9 | 43s | $0.411 |
+| **F all/steer (body trace)** | 5/12 | **1.17** | **25.1K** | **6.8** | **37s** | **$0.348** |
+| C no-explore | 8/12 | 2.08 | 9.2K | 9.0 | 44s | $0.356 |
+| D trace-centric | 8/12 | 2.00 | 6.6K | 10.5 | 46s | $0.368 |
+
+**F is the best-balanced arm:** lowest turns (6.8), fastest (37s), cheapest, payload leaner than
+A/B — and it hits the target the ablation set: **C/D-class efficiency without C/D's Read penalty**
+(F reads 1.17 vs C/D's ~2.0). It gets there not by *removing* a tool but by giving the agent a
+complete trace so it *stops early*.
+
+**The win is clearest where trace connects** — excalidraw (the validated 6-hop path):
+
+| arm | sequence | turns | reads | dur |
+|---|---|--:|--:|--:|
+| B (thin) | `trace → context → explore → Grep → Read` | 7 | 1 | 47s |
+| **F (body) r1** | `trace → context` | **4** | **0** | **31s** |
+| F (body) r2 | `trace → trace → explore` | 5 | 0 | 42s |
+
+The body-trace ended the investigation in `trace → context` (run 1) — 0 reads, 0 grep, 0 explore.
+
+**Connectivity is the cap.** On flows that break at *unbridged* dynamic dispatch — aspnet-realworld
+(MediatR `_mediator.Send → Handle`), vapor-spi (closure routing) — trace returns "no path" and the
+agent falls back to explore, so F ≈ B (no regression, no gain). F's aggregate lift is therefore
+**gated by dynamic-dispatch coverage**: the more flows the graph connects end-to-end, the more often
+the self-sufficient trace fires. (n=2/arm — adoption and per-repo numbers are noisy; excalidraw and
+spring-halo, the connecting repos, are 2/2 trace in both B and F.)
+
+## Verdict & ship list
+
+1. **Ship the body-inlining trace** — strict improvement (best-balanced arm; clean 0-read/4-turn win
+   on connecting traces; no regression on non-connecting ones).
+2. **Strengthen the steering.** Arm A (shipped server-instructions, which *already* say "trace first
+   for flow") adopted trace only 2/12 — the guidance is too buried. The explicit
+   `--append-system-prompt` used in B–F lifted it. Port that into `server-instructions.ts` +
+   `instructions-template.ts` + `.cursor/rules/codegraph.mdc` (house rule: all three together),
+   flow-gated so non-flow survey questions still go context/explore (arm E proved they must).
+3. **Next frontier to widen F's reach:** bridge more dynamic dispatch (MediatR/.NET, Vapor routing) —
+   every newly-connected flow converts an F≈B repo into an F-win repo.
+
+## Reproduce (arm F)
+
+```bash
+bash scripts/agent-eval/arms-F.sh          # 12 runs (RUNS=2); needs the body-inlining build
+node scripts/agent-eval/parse-arms.mjs     # F appears alongside A/B/C/D/E
+```
+
+---
+
+# Steering port — the negative result (arm G)
+
+F's win used `--append-system-prompt`, which real users don't get. Arm **G** = arm A's invocation
+(NO append-prompt) on a build where the steering was ported into the production channels
+(`server-instructions.ts` + the `context`/`trace` tool descriptions + `instructions-template.ts` +
+`.cursor/rules`). Three wording iterations, 12 runs each:
+
+| arm | adoption | reads | payload | turns | dur |
+|---|--:|--:|--:|--:|--:|
+| A (shipped instructions) | 2/12 | 1.25 | 28.8K | 7.6 | **38s** |
+| F (body-trace + append-prompt) | 5/12 | **1.17** | 25.1K | 6.8 | **37s** |
+| G v1 — anti-explore wording | 6/12 | 2.08 | 13.8K | 8.8 | 46s |
+| G v2 — restore explore as fallback | 6/12 | 1.67 | 22.0K | 7.8 | 46s |
+| G v3 — restore context as opener | 6/12 | 2.08 | 11.7K | 8.9 | 46s |
+
+**Production-instruction steering does not reproduce F, and regresses the A baseline.** All three G
+variants pin at **~46s** (slower than A's 38s and F's 37s) with reads at 1.7–2.1 (vs A 1.25, F 1.17).
+Wording only shuffled the slack between Read and explore — v1 suppressed explore → Read; v2/v3
+restored explore → over-investigation — never landing F's lean `trace → context`.
+
+**Two root causes:**
+1. **Salience.** The same trace-first wording works as a top-of-prompt `--append-system-prompt` (F)
+   but not as an MCP `initialize` instruction / tool description (G). An MCP server has no
+   higher-salience channel — this is an architectural limit, not a wording bug.
+2. **Forcing trace-first backfires where trace doesn't connect.** Steering pushed trace onto
+   MediatR (`_mediator.Send`) and Spring interface-DI (`@Autowired` iface → impl) flows, where trace
+   returns no-path; the forced trace is then a wasted round-trip *before* the fallback → slower.
+   The **unsteered** agent (A) is better-calibrated: it traces only when trace will obviously
+   connect (2/12) and explores otherwise.
+
+## Arm H — body-trace alone (the ship candidate) regresses
+
+The clean ship test: body-inlining trace + ORIGINAL instructions + no steering (= A's invocation,
+only the trace *tool* changed). H vs A isolates the body-trace feature with nothing else moving.
+
+| arm | adoption | reads | payload | turns | dur |
+|---|--:|--:|--:|--:|--:|
+| A (no body-trace) | 2/12 | 1.25 | 28.8K | 7.6 | **38s** |
+| H (body-trace, no steering) | 3/12 | 1.50 | 29.7K | 8.0 | **45s** |
+| F (body-trace + append-prompt) | 5/12 | 1.17 | 25.1K | 6.8 | 37s |
+
+**Body-trace alone does NOT beat A — it mildly regresses** (45s vs 38s). The sequences show why:
+unsteered, the agent treats trace as just one more call in its usual loop — excalidraw H was
+`context → trace → explore → node×3 → Grep → Read` (77s) — so the bigger body-trace payload is pure
+added cost, not offset by fewer follow-ups. The body-trace only pays off when the agent **leads with
+trace and stops after it**, which only the append-prompt (F) achieved.
+
+## Final verdict
+
+The body-inlining trace is a real win (F) but its value is **entirely contingent on
+lead-with-and-stop-after-trace steering we cannot deliver through any production MCP channel**
+(append-prompt salience ≫ server-instructions / tool-descriptions; G failed three times). On its own
+(H) it regresses. So:
+
+- **SHIP: the `CODEGRAPH_MCP_TOOLS` allowlist** — independent, clean, validated.
+- **DON'T ship the body-inlining trace or the steering as-is** — measured neutral-to-negative
+  without a steering channel we don't have.
+- **The real lever is connectivity, not steering** — trace earns its keep only when flows connect
+  end-to-end; dynamic-dispatch synthesizers (MediatR/.NET, Spring interface-DI, Vapor closures) help
+  the *unsteered* agent, which already traces when trace will connect.
+- **One untested lever** to rescue the body-trace: steer via the trace tool's OWN OUTPUT (the
+  highest-salience channel — the agent reads it fresh, right at the decision point) with a strong
+  leading "complete flow — answer from this, don't explore" banner. Instructions/descriptions are
+  too far from the action; the tool result is not. Unproven; the only remaining shot at making the
+  body-trace pay off in production.
+
+measure-first paid off three times: it killed three cheap fixes in the ablation, stopped a steering
+change that would have shipped an ~8s/query regression (G), and stopped shipping the body-trace
+itself on a confounded assumption (H showed it needs steering we can't deliver).
+
+## Reproduce (arm G)
+
+```bash
+ARM=G bash scripts/agent-eval/arms-F.sh    # production-instruction steering, no append-prompt
+node scripts/agent-eval/parse-arms.mjs
+```
+
+---
+
+# Arm I — sufficiency, not steering (the shippable win)
+
+An LLM stops investigating when its context is *sufficient*, not when it's told to stop. So arm I
+makes the trace OUTPUT complete instead of steering — same invocation as H (original instructions,
+**no steering**), only the trace tool changed:
+1. **Hop bodies no longer clipped** at 28 lines (that clip is why H re-fetched `mutateElement`).
+2. **The destination's own callees are inlined** — the "last mile" the agent otherwise explores/Reads
+   for (excalidraw: `renderStaticScene → _renderStaticScene / renderStaticSceneThrottled`).
+
+| arm | adoption | reads | greps | payload | turns | dur | cost |
+|---|--:|--:|--:|--:|--:|--:|--:|
+| A baseline | 2/12 | 1.25 | 1.17 | 28.8K | 7.6 | 38s | $0.390 |
+| H body-trace alone | 3/12 | 1.50 | 0.42 | 29.7K | 8.0 | 45s | $0.398 |
+| **I body-trace + dest callees** | 2/12 | **1.17** | **0.25** | 27.2K | **7.0** | 39s | **$0.359** |
+| F body-trace + append-steer | 5/12 | 1.17 | 0.17 | 25.1K | 6.8 | 37s | $0.348 |
+
+**I ≥ A on every axis** (reads, greps, turns, cost down; wall-clock flat) and **≈ F on outcomes with
+zero steering** — despite *lower* trace adoption (2/12 vs F's 5/12). The destination-callees fix
+turned the body-trace from a net-negative (H, 45s) into a net-positive (I, 39s): one richer trace
+call now displaces the explore+node+Read follow-ups it used to trigger. excalidraw I-r2 was
+`context → trace → explore` — **0 reads, 5 turns**, stopped because the data was present. The residual
+reads (I-r1) are the `canvasNonce` data-flow — the def-use frontier the graph deliberately omits.
+
+This confirms the thesis: **completeness stops the agent; steering doesn't.** Every steering arm
+(B/F append-prompt, G instructions) was either unshippable or a regression; the sufficiency arm (I)
+ships and needs no steering.
+
+## Revised final verdict (supersedes the arm-G/H verdict above)
+
+- **SHIP: body-inlining trace + destination callees** (arm I) — ≥ A on all axes, no steering, no
+  regression; makes the self-sufficient-trace property real (one trace call answers the flow).
+- **SHIP: the `CODEGRAPH_MCP_TOOLS` allowlist** — independent, validated.
+- **DON'T ship steering** (instructions or tool descriptions) — three variants regressed; MCP can't
+  deliver append-prompt salience, and forcing trace where it doesn't connect backfires.
+- **Connectivity is the multiplier** — arm I helps most where the trace connects; MediatR/.NET,
+  Spring interface-DI, and Vapor closures are the next synthesizers, and they help the *unsteered*
+  agent (which already traces when trace will connect).
+
+## Reproduce (arm I)
+
+```bash
+ARM=I bash scripts/agent-eval/arms-F.sh    # body-trace + destination callees, no steering
+node scripts/agent-eval/parse-arms.mjs
+```
+
+---
+
+# Current-build with/without A/B — the 7 README repos (2026-05-24)
+
+Re-ran the published README benchmark on the **current build** (all 7 repos freshly reindexed),
+same queries, **median of 4 runs/arm** (headless: codegraph-only MCP vs empty MCP):
+
+| repo | time with→without | tools w→wo | tokens w→wo (saved) | cost w→wo (saved) |
+|---|---|--:|--:|--:|
+| vscode | 1m10s→2m26s | 8→55 | 601k→2.8M (78%) | $0.60→$0.80 (26%) |
+| excalidraw | 48s→2m58s | 3→79 | 344k→3.5M (90%) | $0.43→$0.90 (52%) |
+| django | 1m19s→1m38s | 9→19 | 739k→1.2M (36%) | $0.59→$0.67 (12%) |
+| tokio | 53s→3m2s | 4→53 | 379k→2.6M (86%) | $0.42→$2.41 (82%) |
+| okhttp | 42s→1m1s | 6→11 | 636k→730k (13%) | $0.47→$0.47 (2%) |
+| gin | 44s→1m0s | 6→10 | 444k→675k (34%) | $0.37→$0.47 (21%) |
+| alamofire | 1m17s→2m27s | 12→69 | 1.0M→2.8M (64%) | $0.61→$1.14 (47%) |
+
+**Average saved: 35% cost · 57% tokens · 46% time · 71% tool calls** — reproduces the published
+README headline (35% / 59% / 49% / 70%); the current build holds the benchmark with no regression.
+
+**Cost is lower, not "flat"** (corrects the earlier note). But the **mechanism is volume, not
+cache-ability**: codegraph answers in far fewer turns over a much smaller accumulated context, while
+the without-arm fans out across many more turns (55–79 tool calls on the big repos), each
+re-processing a large, growing context. The without-arm's token volume is *mostly* cheap cache-reads,
+which is why **token-count savings (57%) look bigger than cost savings (35%)**. Per-repo margin tracks
+how hard the without-arm thrashes that run (tokio blew up to $2.41/3m; django thrashed less).
+
+**Measurement gotcha:** `result.usage` in this Claude Code version is the **last turn only**, not
+cumulative — using it under-counts tokens badly (an earlier excalidraw cut reported "−34% tokens"
+off this bug; the real figure is ~90%). Sum **per-turn assistant `usage`** for the true total.
+`total_cost_usd` and `duration_ms` are already cumulative/correct.
+
+Reproduce:
+```bash
+bash scripts/agent-eval/bench-readme.sh      # 7 repos × with/without × 4 runs (RUNS=4) → /tmp/ab-readme
+node scripts/agent-eval/parse-bench-readme.mjs   # medians + % saved (summed per-turn tokens)
+```

+ 111 - 0
docs/benchmarks/codegraph-ab-matrix.md

@@ -0,0 +1,111 @@
+# CodeGraph A/B benchmark — with vs without, every language × S/M/L
+
+**Date:** 2026-05-23 · **Branch:** `architectural-improvements`
+
+A headless agent (Claude Opus, `--permission-mode bypassPermissions`) answers one
+**canonical flow question** per repo — twice: **with** the codegraph MCP server, and
+**without** any MCP (built-in Read/Grep/Glob/Bash only). Same model, same prompt; codegraph
+is the only variable. Each cell was **re-indexed fresh** first, so the "with" arm reflects the
+current resolvers.
+
+## Headline
+
+**Across 37 cells, codegraph cut total file reads from 158 → 40 — 75% fewer.** It never
+*increased* reads in any cell. The mechanism: a few sub-millisecond codegraph calls replace a
+read-and-grep exploration. Token cost stays roughly flat (codegraph calls trade for reads) —
+the win is **fewer tool calls + lower wall-clock**, which is the design target.
+
+The gap widens with repo size and flow complexity: on medium/large repos the without-codegraph
+arm often **thrashes** — many greps/globs, shell `find`/`grep` (Bash), and occasionally spawning
+a **sub-agent** — while the with-codegraph arm answers in 2–6 calls. On tiny repos (a handful of
+files) the two arms tie or codegraph is marginally slower (MCP/index overhead doesn't pay off
+when the whole flow fits in one or two files) — but reads still drop.
+
+## How to read the table
+
+- **R / G / Gl / B / Ag** = Read / Grep / Glob / Bash / sub-agent (Task) tool calls.
+- **cg-calls** = codegraph MCP calls in the "with" arm (the trade for reads/greps).
+- **dur** = wall-clock seconds. **files** = indexed file count (the size proxy).
+- **reads saved** = without-reads − with-reads.
+- One run per arm (a **snapshot** — run-to-run variance is real; treat ±1–2 reads and ±10s as
+  noise, look at the pattern across cells). 2-runs/arm headline numbers for several of these flows
+  live in `docs/design/dynamic-dispatch-coverage-playbook.md` §7.
+
+## Results
+
+| Language | Size | Repo | files | **with** R/G | cg-calls | dur | **without** R/G | dur | reads saved |
+|---|---|---|--:|---|--:|--:|---|--:|--:|
+| C | L | `c-redis` | 884 | 0R / 4G | 4 | 48s | 4R / 9G / 1Gl | 50s | 4 |
+| C# | S | `aspnet-realworld` | 78 | 0R / 0G | 2 | 40s | 2R / 1G / 2Gl | 31s | 2 |
+| C# | M | `aspnet-eshop` | 262 | 0R / 0G | 5 | 39s | 6R / 2G / 3Gl / 1B | 61s | 6 |
+| C# | L | `aspnet-jellyfin` | 2081 | 4R / 0G | 2 | 61s | 13R / 0G / 4Gl / 21B / 1Ag | 132s | 9 |
+| C++ | M | `cpp-leveldb` | 134 | 0R / 0G | 3 | 40s | 2R / 3G | 52s | 2 |
+| Dart | S | `flutter_module_books` | 6 | 1R / 0G | 2 | 37s | 1R / 0G / 1Gl | 20s | 0 |
+| Dart | M | `compass_app` | 212 | 2R / 0G | 2 | 31s | 3R / 1G / 3Gl | 47s | 1 |
+| Go | S | `gin-realworld` | 21 | 2R / 1G | 3 | 31s | 4R / 0G / 1B | 44s | 2 |
+| Go | M | `gin-vueadmin` | 625 | 0R / 0G | 2 | 31s | 3R / 3G / 2Gl | 47s | 3 |
+| Go | L | `gin-gitness` | 4438 | 3R / 3G | 4 | 52s | 7R / 4G / 3Gl | 60s | 4 |
+| Java | S | `spring-realworld` | 117 | 0R / 0G | 4 | 31s | 8R / 1G / 1Gl | 50s | 8 |
+| Java | M | `spring-mall` | 536 | 1R / 0G | 5 | 51s | 5R / 0G / 4Gl | 64s | 4 |
+| Java | L | `spring-halo` | 2444 | 0R / 1G | 8 | 75s | 9R / 5G / 8B | 148s | 9 |
+| Kotlin | S | `kotlin-petclinic` | 43 | 1R / 0G | 1 | 23s | 3R / 0G / 2Gl | 26s | 2 |
+| Kotlin | M | `Jetcaster` | 166 | 1R / 0G | 3 | 36s | 1R / 0G / 2Gl | 34s | 0 |
+| Lua | S | `lualine.nvim` | 123 | 1R / 0G | 4 | 48s | 4R / 0G / 1Gl | 45s | 3 |
+| Lua | M | `telescope.nvim` | 84 | 0R / 0G | 2 | 33s | 2R / 0G / 1Gl | 26s | 2 |
+| Luau | S | `Knit` | 11 | 0R / 0G | 4 | 36s | 5R / 0G / 2Gl | 57s | 5 |
+| PHP | S | `laravel-realworld` | 114 | 3R / 0G / 1Gl | 2 | 41s | 6R / 2G / 3Gl | 38s | 3 |
+| PHP | M | `laravel-firefly` | 2047 | 4R / 4G | 5 | 79s | 5R / 3G / 3Gl / 2B | 70s | 1 |
+| PHP | L | `laravel-bookstack` | 2160 | 0R / 1G | 5 | 42s | 3R / 2G / 2Gl | 46s | 3 |
+| Python | S | `django-realworld` | 44 | 1R / 1G | 2 | 30s | 8R / 0G / 1Gl | 35s | 7 |
+| Python | M | `django-wagtail` | 1672 | 3R / 0G | 5 | 73s | 7R / 5G / 2Gl / 1B | 63s | 4 |
+| Python | L | `django-saleor` | 4429 | 1R / 2G | 3 | 59s | 6R / 5G / 2Gl / 1B | 72s | 5 |
+| Ruby | S | `rails-realworld` | 59 | 0R / 0G | 2 | 34s | 4R / 0G / 3Gl | 40s | 4 |
+| Ruby | M | `rails-spree` | 2905 | 1R / 2G | 8 | 60s | 3R / 4G / 3Gl | 56s | 2 |
+| Ruby | L | `rails-forem` | 4658 | 3R / 1G | 3 | 54s | 3R / 2G / 1Gl | 49s | 0 |
+| Rust | S | `rust-axum-realworld` | 13 | 1R / 0G | 4 | 28s | 3R / 1G / 1Gl | 49s | 2 |
+| Rust | M | `rust-actix-examples` | 176 | 1R / 0G | 5 | 42s | 4R / 1G / 2B | 35s | 3 |
+| Rust | L | `rust-cratesio` | 1053 | 0R / 0G | 3 | 20s | 1R / 2G | 15s | 1 |
+| Scala | S | `computer-database` | 10 | 1R / 0G | 4 | 47s | 2R / 0G / 1B | 28s | 1 |
+| Swift | S | `vapor-template` | 14 | 0R / 0G | 1 | 16s | 2R / 0G / 1Gl | 22s | 2 |
+| Swift | M | `vapor-steampress` | 100 | 1R / 0G | 8 | 53s | 3R / 3G / 2B | 57s | 2 |
+| Swift | L | `vapor-spi` | 542 | 2R / 0G | 5 | 49s | 2R / 3G / 2Gl | 36s | 0 |
+| TypeScript/JS | S | `express-realworld` | 39 | 1R / 0G | 1 | 16s | 2R / 1G / 1Gl | 27s | 1 |
+| TypeScript/JS | M | `excalidraw` | 643 | 0R / 0G | 4 | 53s | 9R / 7G | 98s | 9 |
+| TypeScript/JS | L | `nest-immich` | 2759 | 1R / 1G | 6 | 50s | 3R / 1G / 2Gl | 57s | 2 |
+
+**Totals (37 cells):** with codegraph **40 reads / 21 greps**, without **158 reads / 71 greps** —
+**75% fewer reads, ~70% fewer greps.** Codegraph never increased reads in any cell, and the
+without-arm additionally ran shell `find`/`grep` (Bash) and a sub-agent that the with-arm never
+needed. (74 agent runs, ~$29 total.)
+
+## Observations
+
+- **Biggest wins are medium/large backends with a real route→handler→service flow:** excalidraw
+  (0R vs 9R/7G), spring-halo (0R vs 9R + 8 Bash), spring-realworld (0R vs 8R), django-realworld
+  (1R vs 8R), aspnet-jellyfin (4R vs 13R + 21 Bash + a spawned sub-agent), aspnet-eshop (0R vs 6R).
+- **Without codegraph, large repos make the agent thrash:** it falls back to shell `find`/`grep`
+  (Bash) and on jellyfin even spawned a sub-agent — exactly the behavior codegraph is meant to
+  prevent. The with-arm answers those in 2–6 codegraph calls.
+- **Tie zone = tiny repos** (Dart books 6 files, Kotlin Jetcaster, Ruby forem, Swift spi): the whole
+  flow fits in 1–2 files, so reading is already cheap; codegraph ties on reads and is sometimes a
+  few seconds slower (MCP + index overhead). This matches the design note that codegraph's value
+  scales with repo size.
+- **Duration tracks reads on the big repos** (jellyfin 61s vs 132s, spring-halo 75s vs 148s,
+  excalidraw 53s vs 98s) and is noise on small ones.
+- Some "with" cells still read 2–4 files (jellyfin, gitness, laravel-firefly, forem) — the residual
+  is the documented frontier (anonymous handlers, deep service chains, dynamic finders); codegraph
+  gets the agent to the right file, then it reads one to confirm a detail.
+
+## Coverage note
+
+All 14 README frameworks and every flow-relevant language are validated (see the playbook). The
+sizes here are by indexed file count; a few languages lack a clean third size in the corpus
+(Dart/Kotlin = S/M, Scala/Luau = S only, C = L only, C++ = M only) — those cells are omitted rather
+than faked.
+
+## Reproduce
+
+Driver + parser: `/tmp/ab-matrix/run.sh` (matrix of `lang|size|repo|question`) and
+`/tmp/ab-matrix/parse-matrix.mjs`. Each cell: `rm -rf .codegraph && codegraph init -i`, then
+`scripts/agent-eval/run-all.sh <repo> "<question>" headless` (with = codegraph-only MCP, without =
+empty MCP), parsed from the stream-json logs.

+ 179 - 0
docs/design/callback-edge-synthesis.md

@@ -0,0 +1,179 @@
+# Design + status: general callback / observer edge synthesis
+
+**Status:** Phases 1–3 implemented & validated as a **prototype, uncommitted on `main`**
+(as of 2026-05-22). This doc is the handoff for continuing the work.
+**Motivation:** close the dynamic-dispatch hole that static extraction leaves for
+observer / event-emitter / signal patterns, where a *dispatcher* invokes callbacks
+registered elsewhere through a shared store — so flows like "how does an update
+reach the screen" actually exist in the graph.
+
+---
+
+## TL;DR for a new session
+
+We synthesize `dispatcher → callback` edges that static parsing misses. It works:
+
+- **Field observer** (excalidraw `Scene.onUpdate`/`triggerUpdate`): synthesizes
+  `triggerUpdate → triggerRender`. `trace(mutateElement, triggerRender)` now = 3 hops.
+- **EventEmitter** (express `on('mount', …)`/`emit('mount')`): synthesizes `use → onmount`.
+- Precision is high: excalidraw got **1** synthesized edge out of 27k (the correct one);
+  node count moved +3 after Phase 3 (no explosion).
+
+**Files touched (all uncommitted on `main`):**
+- `src/resolution/callback-synthesizer.ts` — the whole-graph synthesis pass (Phase 1 + 2).
+- `src/resolution/index.ts` — calls `synthesizeCallbackEdges()` at the end of
+  `resolveAndPersistBatched()` (after base edges are persisted) + the import.
+- `src/extraction/tree-sitter.ts` — `visitFunctionBody` now extracts **named** nested
+  functions (Phase 3), so inline named handlers become linkable nodes.
+
+**How to reproduce / test:**
+```bash
+npm run build
+rm -rf /tmp/codegraph-corpus/excalidraw/.codegraph
+( cd /tmp/codegraph-corpus/excalidraw && codegraph init -i )
+# synthesized edges (provenance='heuristic', metadata.synthesizedBy in {callback,event-emitter}):
+sqlite3 /tmp/codegraph-corpus/excalidraw/.codegraph/codegraph.db \
+  "select s.name||' → '||t.name||'  '||coalesce(e.metadata,'') from edges e \
+   join nodes s on e.source=s.id join nodes t on e.target=t.id where e.provenance='heuristic';"
+# end-to-end trace (uses the dev probes):
+node scripts/agent-eval/probe-trace.mjs /tmp/codegraph-corpus/excalidraw triggerUpdate triggerRender
+```
+Probe scripts (dev-only, in `scripts/agent-eval/`): `probe-node.mjs` (symbol + trail),
+`probe-trace.mjs` (call path), `probe-context.mjs`, `probe-explore.mjs`. EventEmitter
+fixture lives at `/tmp/cb-fixture/bus.js` (ephemeral — recreate or move into `__tests__/`).
+
+---
+
+## The hole
+
+```ts
+class Scene {
+  private callbacks = new Set<Callback>();
+  onUpdate(cb: Callback) { this.callbacks.add(cb); }          // REGISTRAR
+  triggerUpdate() { for (const cb of this.callbacks) cb(); }  // DISPATCHER
+}
+this.scene.onUpdate(this.triggerRender);                      // REGISTRATION SITE
+```
+
+The runtime edge `triggerUpdate → triggerRender` does not exist statically:
+`triggerUpdate`'s only literal call is `cb()` (anonymous). Measured: `triggerUpdate`'s
+only callee was `randomInteger`; `trace(triggerUpdate, triggerRender)` returned no path.
+
+## Why it's a whole-graph pass, not a `FrameworkResolver.resolve()`
+
+`resolve(ref)` answers "what does this **named** ref point to," one ref at a time. The
+callback edge has **no ref to resolve** (`cb()` is anonymous) and needs **cross-file,
+multi-site correlation** (registrar, registration, dispatcher). So it's a whole-graph
+pass after base resolution, language-level (any OO observer), living in
+`src/resolution/callback-synthesizer.ts` — **not** under `frameworks/`.
+
+> Sibling mechanism for the *other* dynamic-dispatch class — **named** attribute/
+> descriptor dispatch (e.g. django `self._iterable_class(...)`) — is the
+> `claimsReference` hook (`resolution/types.ts` + `resolution/index.ts` pre-filter)
+> + a `FrameworkResolver.resolve()` (django ORM resolver in `frameworks/python.ts`).
+> That one *does* fit `resolve()` because the ref is named. Both are part of the same
+> coverage effort; see the "Related work" section.
+
+---
+
+## As-built algorithm (and where it diverged from the original design)
+
+### Field-observer channels (`fieldChannelEdges`, Phase 1)
+1. **Candidates** by method/function **name** — registrar `^(on[A-Z]\w*|subscribe|
+   addListener|addEventListener|register|watch|listen|addCallback)$`; dispatcher
+   contains `(emit|trigger|notify|dispatch|fire|publish|flush)`.
+2. **Confirm by body** (read via `ctx.readFile` + slice node lines): registrar has
+   `this.<F>.add|push|set(`; dispatcher has `for (… of [Array.from(]this.<F>)` + a call,
+   or `this.<F>.forEach(`.
+3. **Pairing — DIVERGENCE:** the design said pair by *class*; the build pairs by
+   **same file + same field `F`** (file as a class proxy — getting the containing class
+   reliably was harder). Works for the common 1-class-per-file case; revisit for
+   multi-class files.
+4. **Registrations:** `queries.getIncomingEdges(registrar.id, ['calls'])` → for each,
+   read the caller's source at the edge line and **regex-recover the arg**
+   (`<registrarName>\s*\(\s*(?:this\.)?(\w+)`). DIVERGENCE: design preferred tree-sitter
+   re-parse; build uses regex (named refs only — arrows/inline args are missed here).
+5. **Synthesize** `dispatcher → fn` (`getNodesByName(arg)` → method|function). Capped at
+   `MAX_CALLBACKS_PER_CHANNEL = 40`.
+
+### EventEmitter channels (`eventEmitterEdges`, Phase 2)
+- **File-oriented scan** (`ctx.getAllFiles()` + `readFile`, substring pre-filter on
+  `.emit(`/`.on(`/etc). `ON_RE` = `\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*
+  (?:function\s+(\w+)|(?:this\.)?(\w+))`; `EMIT_RE` = `\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]`.
+- Dispatcher = **enclosing function** of the `emit('e')` call (`enclosingFn` finds the
+  tightest function/method/component node containing the line). Handler = `getNodesByName`
+  of the on-handler name.
+- Correlate by **event-name literal**; synthesize dispatcher → handler.
+- **Precision — DIVERGENCE:** design proposed receiver-type matching; build uses an
+  **event fan-out cap** (`EVENT_FANOUT_CAP = 6`) — skip events with >6 handlers or
+  dispatchers (generic names like `error`/`change` would over-link without type info).
+
+### Provenance — DIVERGENCE
+`Edge.provenance` is a fixed enum (`'tree-sitter'|'scip'|'heuristic'`), so synthesized
+edges use **`provenance: 'heuristic'`** + `metadata: { synthesizedBy: 'callback'|
+'event-emitter', via/event/field }`. The design's `'callback-synthesis'` provenance and
+high/medium/low **confidence tiers were NOT implemented** — the fan-out cap +
+registrar-name uniqueness + named-only handlers are the precision guards instead.
+
+### Phase 3 — inline callback extraction (`tree-sitter.ts`)
+The real blocker for EventEmitter on real repos: inline handlers
+(`on('mount', function onmount(){})`) weren't **nodes**, so nothing could link to them.
+Root cause: `visitFunctionBody` walked *through* nested functions without extracting them.
+Fix: in `visitForCallsAndStructure`, when a body node is a `functionType` and
+`extractName` returns a real name, call `extractFunction` (which extracts it and walks
+its own body) and return. **Named only** — anonymous arrows fall through to the existing
+recursion (so their inner calls stay attributed to the enclosing fn). This bounded it:
+excalidraw +3 nodes, no explosion, no regression.
+
+---
+
+## Validation results (actual)
+
+| Repo | Result |
+|---|---|
+| excalidraw | 1 synthesized edge `triggerUpdate → triggerRender` (of 27,214); `trace(mutateElement, triggerRender)` = 3 hops; nodes 9,286 → 9,289 |
+| express | after Phase 3: `use → onmount` `{event-emitter, event:"mount"}` (`onmount` now extracted at `application.js:109`) |
+| `/tmp/cb-fixture/bus.js` | `tick → handleRefresh`, `persist → handleSave` (named-method EventEmitter handlers) |
+| excalidraw / express | no Phase-1 regression; node counts stable |
+
+---
+
+## Remaining work (prioritized for the next session)
+
+1. **Anonymous-arrow handlers** — `on('e', () => foo())` still produce no edge (no node,
+   intentionally not extracted in Phase 3). The fix is **synthesizer link-through-body**:
+   parse the arrow's body and link `dispatcher → (calls inside the arrow)`. Highest
+   remaining recall win; handles the most common modern callback shape.
+2. **Wire into `resolveAndPersist`** (incremental sync) — synthesis currently runs only
+   in `resolveAndPersistBatched` (full index). Incremental re-index won't refresh
+   synthesized edges.
+3. **Receiver-type matching** for EventEmitter precision (replace/augment the fan-out
+   cap) — use `type_of` edges so `x.emit('change')` only links to `y.on('change', fn)`
+   when `x`,`y` are the same type. Lets the fan-out cap relax.
+4. **Tree-sitter arg recovery** (replace the regex in field-channel Stage 4) — robust for
+   arrows, multi-arg, line-wrapped calls.
+5. **Single-callback fields** (`this.onChange = cb; … this.onChange()`) — scalar-store
+   variant of the field observer; not built.
+6. **Broad precision/recall audit** — run across the full corpus; tally synthesized edges
+   per repo, spot-check, confirm no explosion on EventEmitter-heavy repos.
+7. **Tests + CHANGELOG** — the fixture is a ready vitest case for the synthesizer; add
+   extractor tests for Phase 3 (named-nested-fn extraction; confirm other languages
+   unaffected — the change is in the shared walker), resolver tests for the django side.
+
+## Edge cases / model
+- **Over-approximation across instances** is accepted (reachability, not instance
+  precision). `unregister`/`off` ignored.
+- Synthesized edges are **additive** — never replace static edges; tooling can filter on
+  `provenance='heuristic'` + `metadata.synthesizedBy`.
+
+## Related work (same coverage effort)
+This is one half of closing dynamic-dispatch coverage. The other artifacts on `main`:
+- **Named attribute/descriptor resolver**: `claimsReference` (`resolution/types.ts`,
+  pre-filter in `resolution/index.ts`) + django ORM resolver (`frameworks/python.ts`,
+  `_iterable_class` → `ModelIterable.__iter__`).
+- **Retrieval/UX changes** (separate from coverage): `explore` whole-small-file + glue
+  fixes, `node`-with-trail, `codegraph_trace`, `context` call-paths — all in
+  `src/mcp/tools.ts` / `src/context/index.ts`.
+- **Full investigation context + findings:** auto-memory
+  `project_codegraph_read_displacement` (why coverage — not prompting/hooks/new-tools —
+  is the lever for getting agents to use codegraph over Read).

+ 548 - 0
docs/design/dynamic-dispatch-coverage-playbook.md

@@ -0,0 +1,548 @@
+# 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 1–3 vs 4–11), **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 **1–4 reads /
+  0 grep / 47–53s** vs without **4–5 reads / 2–3 grep / 66–85s** — 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 **4–7 reads / 1–4 grep / 58–81s** vs without **7–9 reads
+  / 6 grep / 82–86s** — 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 **2–3 reads / 1–2 grep /
+  51–60s** vs without **4–6 / 3–5 / 60–74s**. 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 / 26–30s vs without 3 / 3 / 52–53s — 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 **1–2 reads / 0 grep / 63–75s** vs without **6–7 / 1–6 /
+  77–79s**. 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 **0–1 read / 0 grep /
+  3–4 codegraph / 30–39s** (context→[search]→trace→node) vs without **3 read / 2 grep / 33–36s** — eliminates
+  grep, cuts reads to 0–1 (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 / 20–22s** vs without **1 read / 2 grep + glob / 28–32s** — 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 **0–2 read / 0 grep / 32–40s** vs without **3 read / 0–1 grep + glob / 33–41s** — 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 / 26–30s** (both runs fully clean) vs without **1–4 read / 0–2 grep +
+  glob/bash, one run spawned a sub-agent / 34–48s**. 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 **0–1 read / 0 grep / 1 codegraph / 11–18s** (a
+  single `context` call answers it) vs without **2 read / 0–1 grep + glob / 20–28s**. **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 / 18–24s** vs without **1 read
+  (+glob) / 24–25s** — 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 / 17–22s** vs without **2–3 read / 1–2 grep + glob / 16–17s**. **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.

+ 21 - 0
scripts/agent-eval/arms-F.sh

@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Arm F (body-inlining trace + trace-first steering) across the same 6 repos as
+# arms-matrix.sh, so F vs B isolates the trace-enrichment effect (same surface,
+# old thin trace in B vs body-inlining trace here).
+set -uo pipefail
+H="$(cd "$(dirname "$0")" && pwd)"; RUNS="${RUNS:-2}"; C="${CORPUS:-/tmp/codegraph-corpus}"
+ROWS=(
+"$C/flutter-samples/add_to_app/books/flutter_module_books|How does the books UI build and what child widgets does it show?"
+"$C/aspnet-realworld|How is creating an article handled? Trace the controller to the service."
+"$C/spring-mall|How is a product-list request handled? Trace the controller to the service."
+"$C/vapor-spi|How is a package-show request handled? Name the route and controller."
+"$C/excalidraw|How does updating an element re-render the canvas on screen? Trace the flow."
+"$C/spring-halo|How is publishing a post handled? Trace the controller to the service."
+)
+ARM="${ARM:-F}"
+echo "### ARM $ARM START $(date) RUNS=$RUNS"
+for row in "${ROWS[@]}"; do
+  repo="${row%%|*}"; q="${row#*|}"
+  for r in $(seq 1 "$RUNS"); do bash "$H/run-arms.sh" "$repo" "$q" "$ARM" "$r"; done
+done
+echo "### ARM $ARM COMPLETE $(date)"

+ 37 - 0
scripts/agent-eval/arms-matrix.sh

@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Drive the tool-surface ablation across the chosen repos × arms (A–E).
+# Arms A–D ask the canonical FLOW question; arm E asks a NON-flow survey
+# question (the control probe — should degrade without explore+context).
+# Output: /tmp/arms/<repo>/<arm>-r<n>.jsonl  (parse with parse-arms.mjs).
+set -uo pipefail
+HARNESS="$(cd "$(dirname "$0")" && pwd)"
+RUNS="${RUNS:-2}"
+C="${CORPUS:-/tmp/codegraph-corpus}"
+NFQ='What are the main modules/components of this codebase and what does each one do? Give an overview of how it is organized.'
+
+# repo-path|flow-question  (2 small, 2 medium, 2 large — spans the size range)
+ROWS=(
+"$C/flutter-samples/add_to_app/books/flutter_module_books|How does the books UI build and what child widgets does it show?"
+"$C/aspnet-realworld|How is creating an article handled? Trace the controller to the service."
+"$C/spring-mall|How is a product-list request handled? Trace the controller to the service."
+"$C/vapor-spi|How is a package-show request handled? Name the route and controller."
+"$C/excalidraw|How does updating an element re-render the canvas on screen? Trace the flow."
+"$C/spring-halo|How is publishing a post handled? Trace the controller to the service."
+)
+
+echo "### ARMS MATRIX START $(date) RUNS=$RUNS"
+for row in "${ROWS[@]}"; do
+  repo="${row%%|*}"; q="${row#*|}"
+  for arm in A B C D; do
+    for r in $(seq 1 "$RUNS"); do
+      bash "$HARNESS/run-arms.sh" "$repo" "$q" "$arm" "$r"
+    done
+  done
+done
+# E: non-flow control probe on two repos (must degrade without explore+context)
+for repo in "$C/excalidraw" "$C/spring-mall"; do
+  for r in $(seq 1 "$RUNS"); do
+    bash "$HARNESS/run-arms.sh" "$repo" "$NFQ" E "$r"
+  done
+done
+echo "### ARMS MATRIX COMPLETE $(date)"

+ 28 - 0
scripts/agent-eval/bench-readme.sh

@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Re-run the README "Benchmark Results" A/B (with vs without codegraph) on the
+# current build: the 7 README repos, same queries, RUNS per arm (default 4).
+# Output → /tmp/ab-readme/<repo>/run<n>/run-headless-{with,without}.jsonl
+# Aggregate with parse-bench-readme.mjs. Repos must be cloned + indexed under
+# $CORPUS (default /tmp/codegraph-corpus) by the build under test.
+set -uo pipefail
+H="$(cd "$(dirname "$0")" && pwd)"
+C="${CORPUS:-/tmp/codegraph-corpus}"
+RUNS="${RUNS:-4}"
+ROWS=(
+"vscode|How does the extension host communicate with the main process?"
+"excalidraw|How does Excalidraw render and update canvas elements?"
+"django|How does Django's ORM build and execute a query from a QuerySet?"
+"tokio|How does tokio schedule and run async tasks on its runtime?"
+"okhttp|How does OkHttp process a request through its interceptor chain?"
+"gin|How does gin route requests through its middleware chain?"
+"alamofire|How does Alamofire build, send, and validate a request?"
+)
+echo "### README A/B START $(date) RUNS=$RUNS"
+for row in "${ROWS[@]}"; do
+  repo="${row%%|*}"; q="${row#*|}"
+  echo "===== $repo ====="
+  for run in $(seq 1 "$RUNS"); do
+    AGENT_EVAL_OUT="/tmp/ab-readme/$repo/run$run" bash "$H/run-all.sh" "$C/$repo" "$q" headless 2>&1 | grep -E "exit [0-9]" || echo "  run$run: (no exit line)"
+  done
+done
+echo "### README A/B DONE $(date)"

+ 19 - 0
scripts/agent-eval/block-read-hook.sh

@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+# PreToolUse hook (experiment): deny Read of codegraph-indexed source files and
+# steer the agent to codegraph_explore/codegraph_node instead. Tests whether
+# codegraph can FULLY replace Read for code-understanding once the escape hatch
+# is removed. Non-source reads (config, .env, markdown, new files) pass through.
+#
+# Wire via:  claude ... --settings scripts/agent-eval/hook-settings.json
+set -uo pipefail
+input="$(cat)"
+fp="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
+
+case "$fp" in
+  *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.py|*.go|*.rs|*.java|*.rb|*.php|*.swift|*.kt|*.kts|*.c|*.cc|*.cpp|*.h|*.hpp|*.cs|*.lua|*.vue|*.svelte)
+    msg="Read is disabled for source files in this session — codegraph already has this file indexed (with line numbers, kept in sync on every change). Use codegraph_explore (several related symbols at once) or codegraph_node (one symbol's full source). If a symbol you need wasn't in a prior explore, run ANOTHER codegraph_explore with its exact name instead of reading the file."
+    jq -n --arg m "$msg" '{reason:$m, hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$m}}'
+    exit 0
+    ;;
+esac
+exit 0

+ 15 - 0
scripts/agent-eval/hook-settings.json

@@ -0,0 +1,15 @@
+{
+  "hooks": {
+    "PreToolUse": [
+      {
+        "matcher": "Read",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "bash /Users/colby/Development/Personal/codegraph/scripts/agent-eval/block-read-hook.sh"
+          }
+        ]
+      }
+    ]
+  }
+}

+ 116 - 0
scripts/agent-eval/parse-arms.mjs

@@ -0,0 +1,116 @@
+#!/usr/bin/env node
+// Analyze the tool-surface ablation (/tmp/arms/<repo>/<arm>-r<n>.jsonl).
+// Compares arms A–E on trace adoption, Read/Grep fallback, codegraph payload,
+// round-trips, and duration — averaged across runs per arm.
+//
+// The decisive signal is READS: if removing a tool raises Reads on a question
+// class, that tool was load-bearing for it (not redundant). If removing it
+// changes nothing, it was redundant.
+//
+//   A control       all tools            no steering   (baseline)
+//   B steer         all tools            trace-first   (adoption)
+//   C no-explore    hide explore         trace-first   (is explore redundant?)
+//   D trace-centric hide explore+context trace-first   (is the survey pair redundant?)
+//   E control-probe hide explore+context trace-first   (NON-flow Q — should degrade)
+//
+// Usage: node scripts/agent-eval/parse-arms.mjs [/tmp/arms]
+import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
+import { join } from 'path';
+
+const ROOT = process.argv[2] || '/tmp/arms';
+const cgShort = (n) => n.replace('mcp__codegraph__codegraph_', '').replace('mcp__codegraph__', '');
+
+function parse(file) {
+  if (!existsSync(file)) return null;
+  const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
+  const calls = []; let result = null, initCg = 0;
+  for (const l of lines) {
+    let ev; try { ev = JSON.parse(l); } catch { continue; }
+    if (ev.type === 'system' && ev.subtype === 'init') initCg = (ev.tools || []).filter(t => /codegraph/.test(t)).length;
+    if (ev.type === 'assistant') for (const b of (ev.message?.content || [])) if (b.type === 'tool_use')
+      calls.push({ id: b.id, name: b.name, out: 0 });
+    if (ev.type === 'user') for (const b of (ev.message?.content || [])) if (b.type === 'tool_result') {
+      const c = b.content;
+      const txt = typeof c === 'string' ? c : Array.isArray(c) ? c.map(x => x?.text || '').join('') : '';
+      const call = calls.find(k => k.id === b.tool_use_id); if (call) call.out = txt.length;
+    }
+    if (ev.type === 'result') result = ev;
+  }
+  const cg = calls.filter(c => c.name.includes('codegraph'));
+  return {
+    initCg,
+    reads: calls.filter(c => c.name === 'Read').length,
+    greps: calls.filter(c => c.name === 'Grep').length + calls.filter(c => c.name === 'Glob').length,
+    cgCalls: cg.length,
+    cgSeq: cg.map(c => cgShort(c.name)),
+    cgOut: cg.reduce((s, c) => s + c.out, 0),
+    traceUsed: cg.some(c => c.name.includes('trace')),
+    turns: result?.num_turns ?? null,
+    dur: result?.duration_ms ? Math.round(result.duration_ms / 1000) : null,
+    cost: result?.total_cost_usd || 0,
+    ok: result?.subtype === 'success',
+  };
+}
+
+// repo -> arm -> [runs]
+const data = {};
+if (!existsSync(ROOT)) { console.error(`no ${ROOT}`); process.exit(1); }
+for (const repo of readdirSync(ROOT)) {
+  const rdir = join(ROOT, repo);
+  if (!statSync(rdir).isDirectory()) continue;
+  for (const f of readdirSync(rdir)) {
+    const m = f.match(/^([A-I])-r(\d+)\.jsonl$/); if (!m) continue;
+    const p = parse(join(rdir, f)); if (!p || !p.ok) continue;
+    (((data[repo] ??= {})[m[1]]) ??= []).push(p);
+  }
+}
+
+const avg = (a, f) => a.length ? a.reduce((s, x) => s + (f(x) || 0), 0) / a.length : 0;
+const k = (n) => (n / 1000).toFixed(1);
+const pad = (s, n) => String(s).padEnd(n);
+const ARMS = ['A', 'H', 'I', 'B', 'F', 'G', 'C', 'D', 'E'];
+const LABEL = { A: 'A all/none(old)', H: 'H body-trace/none', I: 'I bodytrace+dest', B: 'B all/steer(thin)', F: 'F all/steer(body)', G: 'G ported(noprompt)', C: 'C no-explore', D: 'D trace-centric', E: 'E nonflow-probe' };
+
+// ---- per repo × arm ----
+console.log('\n=== PER REPO × ARM (avg over runs) ===');
+console.log(pad('repo', 22), pad('arm', 16), 'tools', 'trace', pad('reads', 6), pad('cgOutK', 7), pad('turns', 6), 'dur');
+for (const repo of Object.keys(data).sort()) {
+  for (const arm of ARMS) {
+    const runs = data[repo][arm]; if (!runs?.length) continue;
+    console.log(
+      pad(repo, 22), pad(LABEL[arm], 16),
+      pad(runs[0].initCg, 5),
+      pad(runs.filter(r => r.traceUsed).length + '/' + runs.length, 5),
+      pad(avg(runs, r => r.reads).toFixed(1), 6),
+      pad(k(avg(runs, r => r.cgOut)), 7),
+      pad(avg(runs, r => r.turns).toFixed(1), 6),
+      avg(runs, r => r.dur).toFixed(0) + 's',
+    );
+  }
+}
+
+// ---- aggregate per arm (flow arms A–D over the flow repos; E shown apart) ----
+console.log('\n=== AGGREGATE PER ARM (mean across repos) ===');
+console.log(pad('arm', 16), pad('adoption', 9), pad('reads', 7), pad('greps', 7), pad('cgOutK', 8), pad('turns', 7), pad('dur', 6), 'cost');
+for (const arm of ARMS) {
+  const all = [];
+  for (const repo of Object.keys(data)) for (const r of (data[repo][arm] || [])) all.push({ ...r, repo });
+  if (!all.length) continue;
+  const repos = new Set(all.map(r => r.repo)).size;
+  const adopt = all.filter(r => r.traceUsed).length;
+  console.log(
+    pad(LABEL[arm], 16),
+    pad(`${adopt}/${all.length}`, 9),
+    pad(avg(all, r => r.reads).toFixed(2), 7),
+    pad(avg(all, r => r.greps).toFixed(2), 7),
+    pad(k(avg(all, r => r.cgOut)), 8),
+    pad(avg(all, r => r.turns).toFixed(1), 7),
+    pad(avg(all, r => r.dur).toFixed(0) + 's', 6),
+    '$' + avg(all, r => r.cost).toFixed(3),
+    `  (${repos} repos)`,
+  );
+}
+
+console.log('\nRead the signal: B vs A = does steering alone fix adoption + cut payload.');
+console.log('C vs B = is explore redundant (reads should NOT jump). D vs C = is context redundant.');
+console.log('E = non-flow under trace-centric; reads SHOULD jump (proves survey tools are load-bearing).');

+ 67 - 0
scripts/agent-eval/parse-bench-readme.mjs

@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+// Aggregate the README A/B (bench-readme.sh output): per repo, median of N runs
+// per arm → time, tool calls, tokens, cost, and % saved. Plus an average row.
+//
+// Tokens = SUM of per-turn assistant `usage` (input + output + cache read +
+// cache creation) — the cumulative "total tokens processed". NOTE: `result.usage`
+// is last-turn-only in current Claude Code, so it under-counts badly; don't use it.
+// `total_cost_usd` and `duration_ms` are already cumulative.
+//
+// Usage: node parse-bench-readme.mjs [/tmp/ab-readme]
+import { readFileSync, existsSync, readdirSync } from 'fs';
+import { join } from 'path';
+const ROOT = process.argv[2] || '/tmp/ab-readme';
+const REPOS = ['vscode', 'excalidraw', 'django', 'tokio', 'okhttp', 'gin', 'alamofire'];
+
+function parse(file) {
+  if (!existsSync(file)) return null;
+  const L = readFileSync(file, 'utf8').split('\n').filter(Boolean);
+  let tools = 0, reads = 0, grep = 0, cg = 0, tokens = 0, r = null;
+  for (const l of L) { let e; try { e = JSON.parse(l); } catch { continue; }
+    if (e.type === 'assistant') {
+      const u = e.message?.usage;
+      if (u) tokens += (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
+      for (const b of (e.message?.content || [])) if (b.type === 'tool_use') {
+        const n = b.name;
+        if (n === 'ToolSearch') continue;
+        tools++;
+        if (n === 'Read') reads++;
+        else if (n === 'Grep' || n === 'Glob') grep++;
+        else if (/codegraph/.test(n)) cg++;
+      }
+    }
+    if (e.type === 'result') r = e;
+  }
+  if (!r || r.subtype !== 'success') return null;
+  return { dur: r.duration_ms / 1000, tools, reads, grep, cg, tokens, cost: r.total_cost_usd || 0 };
+}
+const median = (arr) => { const v = [...arr].sort((a, b) => a - b); const n = v.length; return n === 0 ? 0 : n % 2 ? v[(n - 1) / 2] : (v[n / 2 - 1] + v[n / 2]) / 2; };
+const fmtTime = (s) => s >= 60 ? `${Math.floor(s / 60)}m ${Math.round(s % 60)}s` : `${Math.round(s)}s`;
+const fmtTok = (t) => t >= 1e6 ? `${(t / 1e6).toFixed(1)}M` : `${Math.round(t / 1000)}k`;
+const pct = (w, wo) => wo > 0 ? Math.round((1 - w / wo) * 100) : 0;
+
+console.log('repo        n(w/wo)  time WITH→WITHOUT      tools W→WO   tokens W→WO (saved)     cost W→WO (saved)');
+const savings = { cost: [], tokens: [], time: [], tools: [] };
+for (const repo of REPOS) {
+  const dir = join(ROOT, repo);
+  const runDirs = existsSync(dir) ? readdirSync(dir).filter(d => /^run\d+$/.test(d)) : [];
+  const W = [], WO = [];
+  for (const rd of runDirs) {
+    const w = parse(join(dir, rd, 'run-headless-with.jsonl')); if (w) W.push(w);
+    const wo = parse(join(dir, rd, 'run-headless-without.jsonl')); if (wo) WO.push(wo);
+  }
+  if (!W.length || !WO.length) { console.log(`${repo.padEnd(11)} (incomplete: w=${W.length} wo=${WO.length})`); continue; }
+  const m = (arr, k) => median(arr.map(x => x[k]));
+  const wT = m(W, 'dur'), woT = m(WO, 'dur'), wTok = m(W, 'tokens'), woTok = m(WO, 'tokens');
+  const wC = m(W, 'cost'), woC = m(WO, 'cost'), wTl = m(W, 'tools'), woTl = m(WO, 'tools');
+  savings.time.push(pct(wT, woT)); savings.tokens.push(pct(wTok, woTok)); savings.cost.push(pct(wC, woC)); savings.tools.push(pct(wTl, woTl));
+  console.log(
+    `${repo.padEnd(11)} ${W.length}/${WO.length}      ` +
+    `${(fmtTime(wT) + '→' + fmtTime(woT)).padEnd(22)}` +
+    `${(Math.round(wTl) + '→' + Math.round(woTl)).padEnd(12)}` +
+    `${(fmtTok(wTok) + '→' + fmtTok(woTok) + ' (' + pct(wTok, woTok) + '%)').padEnd(24)}` +
+    `$${wC.toFixed(2)}→$${woC.toFixed(2)} (${pct(wC, woC)}%)`
+  );
+}
+const avg = (a) => a.length ? Math.round(a.reduce((s, x) => s + x, 0) / a.length) : 0;
+console.log(`\nAVERAGE saved:  cost ${avg(savings.cost)}%  ·  tokens ${avg(savings.tokens)}%  ·  time ${avg(savings.time)}%  ·  tool calls ${avg(savings.tools)}%`);

+ 21 - 0
scripts/agent-eval/probe-context.mjs

@@ -0,0 +1,21 @@
+#!/usr/bin/env node
+// Probe codegraph_context (with call-paths) against an index using the built dist.
+// Usage: node probe-context.mjs <repo-with-.codegraph> <task words...>
+import { pathToFileURL } from 'node:url';
+import { resolve } from 'node:path';
+
+const [, , repo, ...taskParts] = process.argv;
+const task = taskParts.join(' ');
+if (!repo || !task) { console.error('usage: probe-context.mjs <repo> <task...>'); process.exit(1); }
+
+const load = async (rel) => import(pathToFileURL(resolve(rel)).href);
+const idx = await load('dist/index.js');
+const tools = await load('dist/mcp/tools.js');
+const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
+const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
+
+const cg = CodeGraph.openSync(repo);
+const h = new ToolHandler(cg);
+const res = await h.execute('codegraph_context', { task });
+console.log(res.content?.[0]?.text ?? '(no text)');
+try { cg.close?.(); } catch {}

+ 40 - 0
scripts/agent-eval/probe-explore.mjs

@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+// One-shot probe: run handleExplore against an existing index using the built
+// dist, print the output + a few stats. Lets us verify explore's coverage fix
+// without a full agent run. Usage: node probe-explore.mjs <repo-with-.codegraph> "<query>"
+import { pathToFileURL } from 'node:url';
+import { resolve } from 'node:path';
+
+const [, , repo, query] = process.argv;
+if (!repo || !query) {
+  console.error('usage: probe-explore.mjs <repo> "<query>"');
+  process.exit(1);
+}
+
+const load = async (rel) => import(pathToFileURL(resolve(rel)).href);
+const idx = await load('dist/index.js');
+const tools = await load('dist/mcp/tools.js');
+
+// esModuleInterop: dynamic import of CJS yields { default: module.exports, ...named }
+const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
+const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
+
+if (typeof CodeGraph?.openSync !== 'function') {
+  console.error('could not resolve CodeGraph.openSync; index keys:', Object.keys(idx), 'default keys:', idx.default && Object.keys(idx.default));
+  process.exit(2);
+}
+if (typeof ToolHandler !== 'function') {
+  console.error('could not resolve ToolHandler; tools keys:', Object.keys(tools));
+  process.exit(2);
+}
+
+const cg = CodeGraph.openSync(repo);
+const h = new ToolHandler(cg);
+const res = await h.execute('codegraph_explore', { query });
+const text = res.content?.[0]?.text ?? '(no text)';
+console.log(text);
+console.error('\n--- PROBE STATS ---');
+console.error('output chars:', text.length);
+console.error('triggerRender body present (-> setState({})):', /triggerRender[\s\S]{0,400}setState\(\{\}\)/.test(text));
+console.error('App.tsx in source section:', /#### .*App\.tsx —/.test(text));
+try { cg.close?.(); } catch {}

+ 20 - 0
scripts/agent-eval/probe-node.mjs

@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+// Probe codegraph_node (with trail) against an index using the built dist.
+// Usage: node probe-node.mjs <repo-with-.codegraph> <symbol> [code]
+import { pathToFileURL } from 'node:url';
+import { resolve } from 'node:path';
+
+const [, , repo, symbol, code] = process.argv;
+if (!repo || !symbol) { console.error('usage: probe-node.mjs <repo> <symbol> [code]'); process.exit(1); }
+
+const load = async (rel) => import(pathToFileURL(resolve(rel)).href);
+const idx = await load('dist/index.js');
+const tools = await load('dist/mcp/tools.js');
+const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
+const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
+
+const cg = CodeGraph.openSync(repo);
+const h = new ToolHandler(cg);
+const res = await h.execute('codegraph_node', { symbol, includeCode: code === 'code' });
+console.log(res.content?.[0]?.text ?? '(no text)');
+try { cg.close?.(); } catch {}

+ 20 - 0
scripts/agent-eval/probe-trace.mjs

@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+// Probe codegraph_trace against an index using the built dist.
+// Usage: node probe-trace.mjs <repo-with-.codegraph> <from> <to>
+import { pathToFileURL } from 'node:url';
+import { resolve } from 'node:path';
+
+const [, , repo, from, to] = process.argv;
+if (!repo || !from || !to) { console.error('usage: probe-trace.mjs <repo> <from> <to>'); process.exit(1); }
+
+const load = async (rel) => import(pathToFileURL(resolve(rel)).href);
+const idx = await load('dist/index.js');
+const tools = await load('dist/mcp/tools.js');
+const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
+const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
+
+const cg = CodeGraph.openSync(repo);
+const h = new ToolHandler(cg);
+const res = await h.execute('codegraph_trace', { from, to });
+console.log(res.content?.[0]?.text ?? '(no text)');
+try { cg.close?.(); } catch {}

+ 56 - 0
scripts/agent-eval/run-arms.sh

@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Tool-surface ablation — run ONE repo+question under ONE arm.
+#
+# Arms vary (exposed codegraph tools, trace-first steering). Tools are trimmed
+# SERVER-SIDE via CODEGRAPH_MCP_TOOLS in the MCP config's `env` block, so an
+# ablated tool is genuinely absent from ListTools — no deferred-ToolSearch or
+# denied-call confound (which --disallowedTools would introduce). Steering is
+# injected with --append-system-prompt, so no rebuild of the shipped
+# server-instructions is needed to A/B it.
+#
+#   A control       all tools            no steering
+#   B steer         all tools            trace-first
+#   C no-explore    hide explore         trace-first
+#   D trace-centric hide explore+context trace-first
+#   E control-probe hide explore+context trace-first  (caller passes a NON-flow Q)
+#
+# Usage: run-arms.sh <repo-path> "<question>" <A|B|C|D|E> [run-id]
+set -uo pipefail
+REPO="${1:?repo path}"; Q="${2:?question}"; ARM="${3:?arm A-E}"; RID="${4:-1}"
+CG_BIN="${CG_BIN:-$(command -v codegraph)}"
+OUT="${ARMS_OUT:-/tmp/arms}/$(basename "$REPO")"
+mkdir -p "$OUT"
+[ -n "$CG_BIN" ] || { echo "no codegraph binary (set CG_BIN)"; exit 1; }
+[ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO"; exit 1; }
+
+STEER='Flow questions ("how does X reach/become Y", "trace the flow", request to handler, state to render): call codegraph_trace(from,to) FIRST — one call returns the whole path. Use codegraph_context/search only to locate the two endpoint symbols if you do not know them. Do NOT reconstruct the path with repeated search/callers/explore.'
+KEEP_NO_EXPLORE="trace,search,node,context,callers,callees,impact,files,status"
+KEEP_TRACE_CENTRIC="trace,search,node,callers,callees,impact,files,status"
+
+case "$ARM" in
+  A|G|H|I) TOOLS="";            STEERING="" ;;  # no steering; H = body-trace, I = body-trace + destination callees (sufficiency)
+  B|F) TOOLS="";                STEERING="$STEER" ;;  # F = B's surface, run on the body-inlining trace build
+  C) TOOLS="$KEEP_NO_EXPLORE";  STEERING="$STEER" ;;
+  D|E) TOOLS="$KEEP_TRACE_CENTRIC"; STEERING="$STEER" ;;
+  *) echo "bad arm '$ARM' (want A|B|C|D|E)"; exit 1 ;;
+esac
+
+CFG="$OUT/mcp-$ARM.json"
+if [ -n "$TOOLS" ]; then
+  cat > "$CFG" <<JSON
+{"mcpServers":{"codegraph":{"command":"$CG_BIN","args":["serve","--mcp","--path","$REPO"],"env":{"CODEGRAPH_MCP_TOOLS":"$TOOLS"}}}}
+JSON
+else
+  cat > "$CFG" <<JSON
+{"mcpServers":{"codegraph":{"command":"$CG_BIN","args":["serve","--mcp","--path","$REPO"]}}}
+JSON
+fi
+
+LOG="$OUT/$ARM-r$RID.jsonl"; ERR="$OUT/$ARM-r$RID.err"
+ARGS=( -p "$Q" --output-format stream-json --verbose
+       --permission-mode bypassPermissions --model opus --max-budget-usd 4
+       --strict-mcp-config --mcp-config "$CFG" )
+[ -n "$STEERING" ] && ARGS+=( --append-system-prompt "$STEERING" )
+
+( cd "$REPO" && claude "${ARGS[@]}" > "$LOG" 2>"$ERR" )
+echo "[$(basename "$REPO") $ARM r$RID] exit $? -> $LOG ($(wc -l < "$LOG" | tr -d ' ') lines)"

+ 137 - 0
scripts/agent-eval/seq-matrix.mjs

@@ -0,0 +1,137 @@
+#!/usr/bin/env node
+// Mine the surviving A/B stream-json logs (/tmp/ab-matrix/<Cell>/run-headless-*.jsonl)
+// for what the aggregate matrix can't see: the call SEQUENCE and per-call output SIZE.
+//
+// Answers three questions:
+//   1. Trace adoption — on a flow question, does the with-arm actually call codegraph_trace?
+//   2. Payload size vs repo size — is trace path-scoped (tiny, size-independent) while
+//      explore is breadth-scoped (grows with the repo / over-returns on small repos)?
+//   3. Round-trips — num_turns with vs without (the real wall-clock driver).
+//
+// Usage: node scripts/agent-eval/seq-matrix.mjs [/tmp/ab-matrix]
+import { readFileSync, readdirSync, existsSync } from 'fs';
+import { join } from 'path';
+
+const AB = process.argv[2] || '/tmp/ab-matrix';
+const MD = new URL('../../docs/benchmarks/codegraph-ab-matrix.md', import.meta.url).pathname;
+
+// repo -> {lang,size,files} from the published matrix table
+const repoMeta = {};
+if (existsSync(MD)) for (const line of readFileSync(MD, 'utf8').split('\n')) {
+  const m = line.match(/^\|\s*([^|]+?)\s*\|\s*(S|M|L)\s*\|\s*`([^`]+)`\s*\|\s*(\d+)\s*\|/);
+  if (m) repoMeta[m[3]] = { lang: m[1].trim(), size: m[2], files: +m[4] };
+}
+
+const cgShort = (n) => n.replace('mcp__codegraph__codegraph_', '').replace('mcp__codegraph__', '');
+const tag = (n) => n === 'Read' ? 'R' : n === 'Grep' ? 'G' : n === 'Glob' ? 'Gl'
+  : n === 'Bash' ? 'B' : n === 'Task' ? 'Ag' : n === 'ToolSearch' ? 'TS'
+  : n.includes('codegraph') ? cgShort(n) : n;
+
+function parse(file) {
+  if (!existsSync(file)) return null;
+  const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
+  const calls = []; let result = null, initCg = 0;
+  for (const l of lines) {
+    let ev; try { ev = JSON.parse(l); } catch { continue; }
+    if (ev.type === 'system' && ev.subtype === 'init') initCg = (ev.tools || []).filter(t => /codegraph/.test(t)).length;
+    if (ev.type === 'assistant') for (const b of (ev.message?.content || [])) if (b.type === 'tool_use') {
+      const i = b.input || {};
+      const q = i.query ?? i.symbol ?? i.task ?? (i.from && i.to ? `${i.from}->${i.to}` : (i.file_path || i.command || ''));
+      calls.push({ id: b.id, name: b.name, q: String(q ?? '').slice(0, 38), out: 0 });
+    }
+    if (ev.type === 'user') for (const b of (ev.message?.content || [])) if (b.type === 'tool_result') {
+      const c = b.content;
+      const txt = typeof c === 'string' ? c : Array.isArray(c) ? c.map(x => x?.text || '').join('') : '';
+      const call = calls.find(k => k.id === b.tool_use_id); if (call) call.out = txt.length;
+    }
+    if (ev.type === 'result') result = ev;
+  }
+  const cg = calls.filter(c => c.name.includes('codegraph'));
+  const perTool = {};
+  for (const c of cg) { const k = cgShort(c.name); (perTool[k] ??= { n: 0, out: 0 }); perTool[k].n++; perTool[k].out += c.out; }
+  const traceIdx = cg.findIndex(c => c.name.includes('trace'));
+  const u = result?.usage || {};
+  return {
+    initCg, cg, perTool,
+    cgSeq: cg.map(c => cgShort(c.name)),
+    seq: calls.map(c => tag(c.name)),
+    reads: calls.filter(c => c.name === 'Read').length,
+    greps: calls.filter(c => c.name === 'Grep').length,
+    cgOut: cg.reduce((s, c) => s + c.out, 0),
+    traceUsed: traceIdx >= 0,
+    afterTrace: traceIdx >= 0 ? cg.slice(traceIdx + 1).map(c => cgShort(c.name)) : null,
+    turns: result?.num_turns ?? null,
+    dur: result?.duration_ms ? Math.round(result.duration_ms / 1000) : null,
+    cost: result?.total_cost_usd || 0,
+  };
+}
+
+const cells = [];
+for (const d of readdirSync(AB)) {
+  const dir = join(AB, d);
+  if (!existsSync(join(dir, 'run-headless-with.jsonl'))) continue;
+  const log = existsSync(join(AB, d + '.log')) ? readFileSync(join(AB, d + '.log'), 'utf8') : '';
+  const repo = (log.match(/repo:\s*\S*\/([^\s/]+)/) || [])[1] || d;
+  const question = (log.match(/question:\s*(.+)/) || [])[1] || '';
+  cells.push({ cell: d, repo, question, ...(repoMeta[repo] || {}),
+    with: parse(join(dir, 'run-headless-with.jsonl')),
+    without: parse(join(dir, 'run-headless-without.jsonl')) });
+}
+cells.sort((a, b) => (a.files || 0) - (b.files || 0));
+
+const k = (n) => (n / 1000).toFixed(1);
+const pad = (s, n) => String(s).padEnd(n);
+
+// ---- per-cell sequence table ----
+console.log('\n=== PER-CELL: with-arm codegraph sequence + payload (sorted by repo size) ===');
+console.log(pad('repo', 22), pad('files', 6), 'trace', pad('cg-call sequence', 40), pad('cgOutK', 7), 'turns(w/wo)');
+for (const c of cells) {
+  const w = c.with;
+  console.log(
+    pad(c.repo, 22), pad(c.files ?? '?', 6),
+    pad(w.traceUsed ? 'YES' : 'no', 5),
+    pad(w.cgSeq.join(',') || '(none)', 40),
+    pad(k(w.cgOut), 7),
+    `${w.turns}/${c.without?.turns}`,
+  );
+}
+
+// ---- trace adoption ----
+const flow = cells; // every matrix question is a canonical flow question by design
+const used = flow.filter(c => c.with.traceUsed);
+console.log(`\n=== TRACE ADOPTION (all ${flow.length} cells are flow questions) ===`);
+console.log(`trace called in ${used.length}/${flow.length} cells`);
+console.log('used trace:', used.map(c => c.repo).join(', ') || '(none)');
+if (used.length) console.log('after-trace follow-ups:', used.map(c => `${c.repo}[${c.with.afterTrace.join(',') || 'none'}]`).join('  '));
+
+// ---- payload size by repo-size tier ----
+const tier = (f) => f < 200 ? 'S(<200)' : f < 2000 ? 'M(<2000)' : 'L(>=2000)';
+const byTier = {};
+for (const c of cells) { (byTier[tier(c.files || 0)] ??= []).push(c.with.cgOut); }
+console.log('\n=== with-arm TOTAL codegraph payload by repo-size tier ===');
+for (const t of ['S(<200)', 'M(<2000)', 'L(>=2000)']) {
+  const a = byTier[t] || []; if (!a.length) continue;
+  const avg = a.reduce((s, x) => s + x, 0) / a.length;
+  console.log(`  ${pad(t, 10)} n=${a.length}  avg cgOut=${k(avg)}K  range ${k(Math.min(...a))}-${k(Math.max(...a))}K`);
+}
+
+// ---- per-tool usage + avg payload (breadth vs path evidence) ----
+const tot = {};
+for (const c of cells) for (const [name, v] of Object.entries(c.with.perTool)) {
+  (tot[name] ??= { n: 0, out: 0 }); tot[name].n += v.n; tot[name].out += v.out;
+}
+console.log('\n=== codegraph tool usage across all cells (n calls, avg payload/call) ===');
+for (const [name, v] of Object.entries(tot).sort((a, b) => b[1].n - a[1].n)) {
+  console.log(`  ${pad(name, 10)} calls=${pad(v.n, 4)} avg=${k(v.out / v.n)}K/call  total=${k(v.out)}K`);
+}
+
+// ---- round-trips ----
+const sum = (arr, f) => arr.reduce((s, x) => s + (f(x) || 0), 0);
+const wTurns = sum(cells, c => c.with.turns), woTurns = sum(cells, c => c.without?.turns);
+const wCalls = sum(cells, c => c.with.cg.length);
+const tsAll = cells.every(c => c.with.seq[0] === 'TS');
+console.log('\n=== ROUND-TRIPS ===');
+console.log(`turns: with=${wTurns}  without=${woTurns}  (${((1 - wTurns / woTurns) * 100).toFixed(0)}% fewer with)`);
+console.log(`avg turns/cell: with=${(wTurns / cells.length).toFixed(1)}  without=${(woTurns / cells.length).toFixed(1)}`);
+console.log(`total codegraph calls=${wCalls} (avg ${(wCalls / cells.length).toFixed(1)}/cell)`);
+console.log(`every with-arm opens with a ToolSearch round-trip (deferred tools): ${tsAll ? 'YES — 1 fixed tax/run' : 'no'}`);

+ 111 - 1
src/context/index.ts

@@ -259,7 +259,7 @@ export class ContextBuilder {
 
     // Return formatted output or raw context
     if (opts.format === 'markdown') {
-      return formatContextAsMarkdown(context);
+      return formatContextAsMarkdown(context) + this.buildCallPathsSection(subgraph);
     } else if (opts.format === 'json') {
       return formatContextAsJson(context);
     }
@@ -267,6 +267,116 @@ export class ContextBuilder {
     return context;
   }
 
+  /**
+   * Surface short call-paths among the symbols this context already found,
+   * derived in-memory from the subgraph's `calls` edges (no extra queries).
+   *
+   * This bakes the value of path-finding INTO the always-loaded `context` tool.
+   * Agents reliably read context's output but do NOT discover/adopt a standalone
+   * trace tool (in deferred-MCP harnesses they only ToolSearch-select tools they
+   * already know). Delivering the flow here means "how does X reach Y" is
+   * answered without the agent needing to find, load, or choose a new tool.
+   * Chains stop where the static call graph ends (e.g. dynamic dispatch) — that
+   * truncation is honest, and the agent can codegraph_node the last hop to bridge.
+   */
+  private buildCallPathsSection(subgraph: Subgraph): string {
+    const adj = new Map<string, string[]>();
+    for (const e of subgraph.edges) {
+      if (e.kind !== 'calls') continue;
+      if (!subgraph.nodes.has(e.source) || !subgraph.nodes.has(e.target)) continue;
+      const list = adj.get(e.source);
+      if (list) list.push(e.target);
+      else adj.set(e.source, [e.target]);
+    }
+    if (adj.size === 0) return '';
+
+    const MAX_HOPS = 6;
+    const chains: string[][] = [];
+    let budget = 2000; // bound DFS work on dense subgraphs
+    const dfs = (id: string, path: string[], seen: Set<string>): void => {
+      if (budget-- <= 0) return;
+      const next = (adj.get(id) ?? []).filter((t) => !seen.has(t));
+      if (next.length === 0 || path.length >= MAX_HOPS) {
+        if (path.length >= 3) chains.push([...path]); // >=3 nodes = a real flow, not a single call
+        return;
+      }
+      for (const t of next) {
+        seen.add(t);
+        dfs(t, [...path, t], seen);
+        seen.delete(t);
+      }
+    };
+    const starts = (subgraph.roots.length > 0
+      ? subgraph.roots.filter((id) => adj.has(id))
+      : [...adj.keys()]
+    ).slice(0, 5);
+    for (const s of starts) dfs(s, [s], new Set([s]));
+    if (chains.length === 0) return '';
+
+    // Keep only chains that connect TWO OR MORE query-relevant symbols (roots).
+    // A chain from a root into an arbitrary callee (render → onMagicFrameGenerate)
+    // is structurally valid but tangential to the question; requiring ≥2 roots
+    // keeps the chain anchored to what the user actually asked about. Rank by
+    // #roots then length, and drop any that are a sub-path of a longer kept chain.
+    const rootSet = new Set(subgraph.roots);
+    const rootCount = (c: string[]): number => c.reduce((n, id) => n + (rootSet.has(id) ? 1 : 0), 0);
+    const relevant = chains.filter((c) => rootCount(c) >= 2);
+    relevant.sort((a, b) => rootCount(b) - rootCount(a) || b.length - a.length);
+    const kept: string[][] = [];
+    for (const c of relevant) {
+      const key = c.join('>');
+      if (kept.some((k) => k.join('>').includes(key))) continue;
+      kept.push(c);
+      if (kept.length >= 3) break;
+    }
+    if (kept.length === 0) return '';
+    const name = (id: string): string => subgraph.nodes.get(id)?.name ?? id;
+
+    // Synthesized (dynamic-dispatch) hops are real `calls` edges but invisible to
+    // static parsing — mark them inline so the agent sees WHERE the callback was
+    // wired up (`registered @file:line`) instead of grepping for it. Keyed by
+    // "source>target".
+    const synthByPair = new Map<string, string>();
+    for (const e of subgraph.edges) {
+      if (e.kind !== 'calls' || e.provenance !== 'heuristic') continue;
+      const m = e.metadata as Record<string, unknown> | undefined;
+      if (!m?.synthesizedBy) continue;
+      const at = typeof m.registeredAt === 'string' ? ` @${m.registeredAt}` : '';
+      const label = m.synthesizedBy === 'callback'
+        ? `callback via ${m.via ? `\`${String(m.via)}\`` : 'registrar'}${at}`
+        : m.synthesizedBy === 'react-render'
+        ? `React re-render via setState${at}`
+        : m.synthesizedBy === 'jsx-render'
+        ? `renders <${String(m.via || 'child')}>`
+        : m.synthesizedBy === 'vue-handler'
+        ? `Vue @${String(m.event || 'event')} handler`
+        : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
+      synthByPair.set(`${e.source}>${e.target}`, label);
+    }
+    const renderChain = (c: string[]): string => {
+      let s = name(c[0]!);
+      for (let i = 1; i < c.length; i++) {
+        const synth = synthByPair.get(`${c[i - 1]}>${c[i]}`);
+        s += synth ? ` →[${synth}] ${name(c[i]!)}` : ` → ${name(c[i]!)}`;
+      }
+      return s;
+    };
+    const hasSynth = kept.some((c) => c.some((_, i) => i > 0 && synthByPair.has(`${c[i - 1]}>${c[i]}`)));
+    const lines = [
+      '',
+      '## Call paths',
+      '',
+      'Execution flow among the key symbols (traced through the call graph):',
+      '',
+      ...kept.map((c) => `- ${renderChain(c)}`),
+      '',
+      hasSynth
+        ? '_Hops marked `[callback/event …]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._'
+        : '_codegraph_node any symbol above for its source + its own callers/callees._',
+    ];
+    return '\n' + lines.join('\n') + '\n';
+  }
+
   /**
    * Find relevant subgraph for a query
    *

+ 17 - 0
src/extraction/grammars.ts

@@ -100,11 +100,25 @@ export const EXTENSION_MAP: Record<string, Language> = {
  * from EXTENSION_MAP so parser support and indexing selection never drift.
  */
 export function isSourceFile(filePath: string): boolean {
+  if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless
   const dot = filePath.lastIndexOf('.');
   if (dot < 0) return false;
   return filePath.slice(dot).toLowerCase() in EXTENSION_MAP;
 }
 
+/**
+ * Play Framework routes file: the extensionless `conf/routes` (and included
+ * `conf/*.routes`). No grammar — route extraction is done by the Play framework
+ * resolver, so it's processed through the no-grammar (`yaml`-style) path.
+ */
+export function isPlayRoutesFile(filePath: string): boolean {
+  return (
+    filePath === 'conf/routes' ||
+    filePath.endsWith('/conf/routes') ||
+    filePath.endsWith('.routes')
+  );
+}
+
 /**
  * Caches for loaded grammars and parsers
  */
@@ -208,6 +222,9 @@ export function getParser(language: Language): Parser | null {
  * Detect language from file extension
  */
 export function detectLanguage(filePath: string, source?: string): Language {
+  // Play `conf/routes` has no grammar — route through the no-symbol path; the
+  // Play framework resolver extracts route nodes from it.
+  if (isPlayRoutesFile(filePath)) return 'yaml';
   const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
   const lang = EXTENSION_MAP[ext] || 'unknown';
 

+ 77 - 3
src/extraction/tree-sitter.ts

@@ -412,6 +412,20 @@ export class TreeSitterExtractor {
 
     const id = generateNodeId(this.filePath, kind, name, node.startPosition.row + 1);
 
+    // Some grammars (e.g. Dart) model a function/method body as a *sibling* of
+    // the signature node, so the declaration node's own range is just the
+    // signature line. Extend endLine to the resolved body when it sits beyond
+    // the node so the node spans its body — required for any body-level analysis
+    // (callees, the callback synthesizer's body scan, context slices). Guarded to
+    // only ever extend: for child-body grammars the body is within range (no-op).
+    let endLine = node.endPosition.row + 1;
+    if (kind === 'function' || kind === 'method') {
+      const body = this.extractor?.resolveBody?.(node, this.extractor.bodyField);
+      if (body && body.endPosition.row + 1 > endLine) {
+        endLine = body.endPosition.row + 1;
+      }
+    }
+
     const newNode: Node = {
       id,
       kind,
@@ -420,7 +434,7 @@ export class TreeSitterExtractor {
       filePath: this.filePath,
       language: this.language,
       startLine: node.startPosition.row + 1,
-      endLine: node.endPosition.row + 1,
+      endLine,
       startColumn: node.startPosition.column,
       endColumn: node.endPosition.column,
       updatedAt: Date.now(),
@@ -516,7 +530,7 @@ export class TreeSitterExtractor {
   /**
    * Extract a function
    */
-  private extractFunction(node: SyntaxNode): void {
+  private extractFunction(node: SyntaxNode, nameOverride?: string): void {
     if (!this.extractor) return;
 
     // If the language provides getReceiverType and this function has a receiver
@@ -526,12 +540,17 @@ export class TreeSitterExtractor {
       return;
     }
 
-    let name = extractName(node, this.source, this.extractor);
+    // nameOverride is supplied only for explicitly-named anonymous functions the
+    // caller resolved itself (e.g. arrow values of exported-const object members
+    // — SvelteKit actions). Inline-object arrows reached by the general walker
+    // get no override, so they still fall through to the <anonymous> skip below.
+    let name = nameOverride ?? extractName(node, this.source, this.extractor);
     // For arrow functions and function expressions assigned to variables,
     // resolve the name from the parent variable_declarator.
     // e.g. `export const useAuth = () => { ... }` — the arrow_function node
     // has no `name` field; the name lives on the variable_declarator.
     if (
+      !nameOverride &&
       name === '<anonymous>' &&
       (node.type === 'arrow_function' || node.type === 'function_expression')
     ) {
@@ -1057,6 +1076,25 @@ export class TreeSitterExtractor {
             if (varNode) {
               this.extractVariableTypeAnnotation(child, varNode.id);
             }
+
+            // Exported const object-of-functions: `export const actions =
+            // { default: async () => {} }` (SvelteKit form actions / handler maps
+            // / route tables). Extract each function-valued property as a function
+            // named by its key + walk its body so its calls (e.g. api.post) are
+            // captured. Scoped to EXPORTED consts to exclude the inline-object
+            // noise (`ctx.set({...})`) the object-method skip deliberately avoids.
+            if (isExported && valueNode &&
+                (valueNode.type === 'object' || valueNode.type === 'object_expression')) {
+              for (let j = 0; j < valueNode.namedChildCount; j++) {
+                const pair = valueNode.namedChild(j);
+                if (pair?.type !== 'pair') continue;
+                const v = getChildByField(pair, 'value');
+                const k = getChildByField(pair, 'key');
+                if (k && v && (v.type === 'arrow_function' || v.type === 'function_expression')) {
+                  this.extractFunction(v, getNodeText(k, this.source).replace(/^['"`]|['"`]$/g, ''));
+                }
+              }
+            }
           }
         }
       }
@@ -1678,6 +1716,21 @@ export class TreeSitterExtractor {
         }
       }
 
+      // Nested NAMED functions inside a body — function declarations and named
+      // function expressions like `.on('mount', function onmount(){})` — become
+      // their own nodes so the graph can link to them (callback handlers, local
+      // helpers). Anonymous arrows/expressions fall through to the default
+      // recursion below, keeping their inner calls attributed to the enclosing
+      // function: this bounds the new nodes to NAMED functions only (no explosion,
+      // no lost edges). extractFunction walks the nested body itself, so we return.
+      if (this.extractor!.functionTypes.includes(nodeType)) {
+        const nestedName = extractName(node, this.source, this.extractor!);
+        if (nestedName && nestedName !== '<anonymous>') {
+          this.extractFunction(node);
+          return;
+        }
+      }
+
       // Extract structural nodes found inside function bodies.
       // Each extract method visits its own children, so we return after extracting.
       if (this.extractor!.classTypes.includes(nodeType)) {
@@ -1746,6 +1799,27 @@ export class TreeSitterExtractor {
         }
       }
 
+      // C++ base classes: `class Derived : public Base, private Other` →
+      // base_class_clause holds access specifiers + base type(s). Emit an extends
+      // ref per base type (skip the public/private/protected keywords).
+      if (child.type === 'base_class_clause') {
+        for (const t of child.namedChildren) {
+          if (
+            t.type === 'type_identifier' ||
+            t.type === 'qualified_identifier' ||
+            t.type === 'template_type'
+          ) {
+            this.unresolvedReferences.push({
+              fromNodeId: classId,
+              referenceName: getNodeText(t, this.source),
+              referenceKind: 'extends',
+              line: t.startPosition.row + 1,
+              column: t.startPosition.column,
+            });
+          }
+        }
+      }
+
       if (
         child.type === 'implements_clause' ||
         child.type === 'class_interface_clause' ||

+ 2 - 1
src/installer/instructions-template.ts

@@ -34,6 +34,7 @@ Use codegraph for **structural** questions — what calls what, what would break
 | "Where is X defined?" / "Find symbol named X" | \`codegraph_search\` |
 | "What calls function Y?" | \`codegraph_callers\` |
 | "What does Y call?" | \`codegraph_callees\` |
+| "How does X reach/become Y? / trace the flow from X to Y" | \`codegraph_trace\` (one call = the whole path, incl. callback/React/JSX dynamic hops) |
 | "What would break if I changed Z?" | \`codegraph_impact\` |
 | "Show me Y's signature / source / docstring" | \`codegraph_node\` |
 | "Give me focused context for a task/area" | \`codegraph_context\` |
@@ -43,7 +44,7 @@ Use codegraph for **structural** questions — what calls what, what would break
 
 ### Rules of thumb
 
-- **Answer directly — don't delegate exploration.** For "how does X work" / architecture / trace questions, answer with 2-3 codegraph calls: \`codegraph_context\` first, then ONE \`codegraph_explore\` for the source of the symbols it surfaces. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer.
+- **Answer directly — don't delegate exploration.** For "how does X work" / architecture questions, answer with 2-3 codegraph calls: \`codegraph_context\` first, then ONE \`codegraph_explore\` for the source of the symbols it surfaces. For a specific **flow** ("how does X reach Y") start with \`codegraph_trace\` from→to — one call returns the whole path with dynamic hops bridged — then ONE \`codegraph_explore\` for the bodies; don't rebuild the path with \`codegraph_search\` + \`codegraph_callers\`. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer.
 - **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context.
 - **Don't grep first** when looking up a symbol by name. \`codegraph_search\` is faster and returns kind + location + signature in one call.
 - **Don't chain \`codegraph_search\` + \`codegraph_node\`** when you just want context — \`codegraph_context\` is one call.

+ 2 - 0
src/mcp/server-instructions.ts

@@ -38,6 +38,7 @@ of calls; a grep/read exploration is dozens.
 
 - **"What is the symbol named X?"** → \`codegraph_search\`
 - **"What's the deal with this task / feature / area?"** → \`codegraph_context\` (PRIMARY — composes search + node + callers + callees in one call)
+- **"How does X reach/become Y? / trace the flow / the path from X to Y"** → \`codegraph_trace\` (ONE call returns the whole call path, including dynamic-dispatch hops — callbacks, React re-render, JSX children — that grep can't follow)
 - **"What calls this?"** → \`codegraph_callers\`
 - **"What does this call?"** → \`codegraph_callees\`
 - **"What would changing this break?"** → \`codegraph_impact\`
@@ -48,6 +49,7 @@ of calls; a grep/read exploration is dozens.
 
 ## Common chains
 
+- **Flow / "how does X reach Y"**: \`codegraph_trace\` from→to FIRST — one call returns the entire path with dynamic-dispatch hops bridged. Then ONE \`codegraph_explore\` for the hop bodies if you need them. Do NOT reconstruct the path with \`codegraph_search\` + \`codegraph_callers\` — that's exactly what trace does in a single call.
 - **Onboarding**: \`codegraph_context\` first. If still unclear, \`codegraph_explore\` for breadth, then \`codegraph_node\` on specific symbols.
 - **Refactor planning**: \`codegraph_search\` → \`codegraph_callers\` → \`codegraph_impact\`. The blast-radius answer comes from impact, not from walking callers manually.
 - **Debugging a regression**: \`codegraph_callers\` of the suspected symbol; widen with \`codegraph_impact\` if an unexpected call appears.

+ 534 - 20
src/mcp/tools.ts

@@ -135,12 +135,17 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
   }
   if (fileCount < 5000) {
     return {
-      maxOutputChars: 13000,
-      defaultMaxFiles: 6,
-      maxCharsPerFile: 2500,
-      gapThreshold: 10,
-      maxSymbolsInFileHeader: 8,
-      maxEdgesPerRelationshipKind: 8,
+      // Sized so ONE explore can cover a flow that centers on a god-file (e.g.
+      // excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
+      // a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
+      // smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
+      // cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
+      maxOutputChars: 28000,
+      defaultMaxFiles: 10,
+      maxCharsPerFile: 6500,
+      gapThreshold: 12,
+      maxSymbolsInFileHeader: 10,
+      maxEdgesPerRelationshipKind: 10,
       includeRelationships: true,
       includeAdditionalFiles: true,
       includeCompletenessSignal: true,
@@ -413,7 +418,7 @@ export const tools: ToolDefinition[] = [
   },
   {
     name: 'codegraph_node',
-    description: 'Get detailed info about ONE symbol (location, signature, docstring). Pass includeCode=true for source: a function/method returns its body; a class/interface/struct/enum returns a compact member OUTLINE (fields + method signatures + line numbers), not every method body — Read or codegraph_node a specific member for its body. Keep includeCode=false to minimize context. For SEVERAL related symbols, make ONE codegraph_explore (or codegraph_context) call instead of many node calls — repeated node calls each re-read the whole context and cost far more.',
+    description: 'Get ONE symbol\'s details (location, signature, docstring) PLUS its TRAIL — what it calls and what calls it, each with file:line. Pass includeCode=true for source (functions return their body; containers return a member outline). Use this to WALK the call graph hop-by-hop — node a symbol, then node one of its trail entries — the structural, no-Read way to follow "what calls/triggers/handles X" across files. For a broad first overview of many symbols at once use codegraph_explore; use node to drill along a specific path from there. (If a trail is empty on a non-leaf, that hop is likely dynamic dispatch — read just that line.) Source returned with includeCode is the verbatim live file content — identical to Read.',
     inputSchema: {
       type: 'object',
       properties: {
@@ -433,7 +438,7 @@ export const tools: ToolDefinition[] = [
   },
   {
     name: 'codegraph_explore',
-    description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts".',
+    description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts". The code it returns is the VERBATIM live file source (byte-for-byte identical to Read), line-numbered — not a summary; treat files it shows as already Read, no need to re-open them.',
     inputSchema: {
       type: 'object',
       properties: {
@@ -494,6 +499,25 @@ export const tools: ToolDefinition[] = [
       },
     },
   },
+  {
+    name: 'codegraph_trace',
+    description: 'Trace the CALL PATH between two symbols — "how does <from> reach/become <to>?" Returns the chain of functions from one to the other (each hop with file:line and its body inlined, plus the outgoing calls of the destination itself) in ONE call. This is something grep/Read structurally cannot do: there is no text pattern for "the path from A to B". Ideal for flow questions — how an update triggers a render, how a request reaches a handler, how a QuerySet becomes SQL. If no static path exists the chain likely breaks at dynamic dispatch (callbacks/descriptors/metaclasses); the tool says where and points you to codegraph_node to bridge it.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        from: {
+          type: 'string',
+          description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
+        },
+        to: {
+          type: 'string',
+          description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
+        },
+        projectPath: projectPathProperty,
+      },
+      required: ['from', 'to'],
+    },
+  },
 ];
 
 /**
@@ -533,19 +557,46 @@ export class ToolHandler {
     return this.cg !== null;
   }
 
+  /**
+   * Optional allowlist of exposed tools, parsed from the CODEGRAPH_MCP_TOOLS
+   * env var (comma-separated short names, e.g. "trace,search,node,context").
+   * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
+   * trim the tool surface without rebuilding the client config; the ablated
+   * tool is then truly absent from ListTools rather than merely denied on call.
+   * Matching is on the short form, so "trace" and "codegraph_trace" both work.
+   */
+  private toolAllowlist(): Set<string> | null {
+    const raw = process.env.CODEGRAPH_MCP_TOOLS;
+    if (!raw || !raw.trim()) return null;
+    const short = (s: string) => s.trim().replace(/^codegraph_/, '');
+    const set = new Set(raw.split(',').map(short).filter(Boolean));
+    return set.size ? set : null;
+  }
+
+  /** Whether a tool name passes the CODEGRAPH_MCP_TOOLS allowlist (if any). */
+  private isToolAllowed(name: string): boolean {
+    const allow = this.toolAllowlist();
+    return !allow || allow.has(name.replace(/^codegraph_/, ''));
+  }
+
   /**
    * Get tool definitions with dynamic descriptions based on project size.
    * The codegraph_explore tool description includes a budget recommendation
-   * scaled to the number of indexed files.
+   * scaled to the number of indexed files. Honors the CODEGRAPH_MCP_TOOLS
+   * allowlist so a trimmed surface is reflected in ListTools.
    */
   getTools(): ToolDefinition[] {
-    if (!this.cg) return tools;
+    const allow = this.toolAllowlist();
+    const visible = allow
+      ? tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
+      : tools;
+    if (!this.cg) return visible;
 
     try {
       const stats = this.cg.getStats();
       const budget = getExploreBudget(stats.fileCount);
 
-      return tools.map(tool => {
+      return visible.map(tool => {
         if (tool.name === 'codegraph_explore') {
           return {
             ...tool,
@@ -555,7 +606,7 @@ export class ToolHandler {
         return tool;
       });
     } catch {
-      return tools;
+      return visible;
     }
   }
 
@@ -696,6 +747,11 @@ export class ToolHandler {
    */
   async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
     try {
+      // Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
+      // surface rejects ablated tools defensively even if a client cached them.
+      if (!this.isToolAllowed(toolName)) {
+        return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`);
+      }
       // Cross-cutting input validation. All tools accept an optional
       // `projectPath` and most accept either `query`, `task`, or
       // `symbol` — bound their lengths centrally so individual handlers
@@ -734,6 +790,8 @@ export class ToolHandler {
           return await this.handleStatus(args);
         case 'codegraph_files':
           return await this.handleFiles(args);
+        case 'codegraph_trace':
+          return await this.handleTrace(args);
         default:
           return this.errorResult(`Unknown tool: ${toolName}`);
       }
@@ -947,6 +1005,352 @@ export class ToolHandler {
     return this.textResult(this.truncateOutput(formatted));
   }
 
+  /**
+   * Handle codegraph_trace — shortest CALL PATH between two symbols.
+   *
+   * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
+   * each hop annotated with file:line and the call-site line. This is the
+   * capability grep/Read structurally cannot provide. When no static path
+   * exists, the chain has almost certainly broken at dynamic dispatch
+   * (callbacks, descriptors, metaclasses) — we say so and surface the start
+   * symbol's outgoing calls so the agent bridges the one missing hop with
+   * codegraph_node rather than blindly reading.
+   */
+  private async handleTrace(args: Record<string, unknown>): Promise<ToolResult> {
+    const from = this.validateString(args.from, 'from');
+    if (typeof from !== 'string') return from;
+    const to = this.validateString(args.to, 'to');
+    if (typeof to !== 'string') return to;
+
+    const cg = this.getCodeGraph(args.projectPath as string | undefined);
+    const fromMatches = this.findAllSymbols(cg, from);
+    if (fromMatches.nodes.length === 0) return this.textResult(`Symbol "${from}" not found in the codebase`);
+    const toMatches = this.findAllSymbols(cg, to);
+    if (toMatches.nodes.length === 0) return this.textResult(`Symbol "${to}" not found in the codebase`);
+
+    // Trace along call edges only — a true call path. Names can map to several
+    // nodes, so try a few from×to candidate pairs until a usable path turns up.
+    //
+    // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
+    // is almost always a spurious wander through unrelated code (django's
+    // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
+    // the real execution flow — and a confident-but-wrong 15-hop trace is worse
+    // than none. Over-cap paths are rejected and reported as "no direct path"
+    // (which, on real code, means the flow breaks at dynamic dispatch).
+    const edgeKinds: Edge['kind'][] = ['calls'];
+    const MAX_HOPS = 7;
+    const fromTry = fromMatches.nodes.slice(0, 3);
+    const toTry = toMatches.nodes.slice(0, 3);
+    let path: Array<{ node: Node; edge: Edge | null }> | null = null;
+    let overCap: Array<{ node: Node; edge: Edge | null }> | null = null;
+    for (const f of fromTry) {
+      for (const t of toTry) {
+        const p = cg.findPath(f.id, t.id, edgeKinds);
+        if (!p || p.length <= 1) continue;
+        if (p.length <= MAX_HOPS) { path = p; break; }
+        if (!overCap || p.length < overCap.length) overCap = p;
+      }
+      if (path) break;
+    }
+
+    if (!path) {
+      // No static path — almost always a dynamic-dispatch break. Surface the
+      // start symbol's outgoing calls so the agent can bridge the gap.
+      const start = fromTry[0]!;
+      const callees = cg.getCallees(start.id).slice(0, 10)
+        .map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`);
+      const lines = [
+        `No direct call path from "${from}" to "${to}".`,
+        '',
+        (overCap
+          ? `(Only a ${overCap.length}-hop indirect chain connects them — almost certainly a BFS wander through unrelated code, not the real flow.) `
+          : '') +
+        'The direct chain most likely breaks at **dynamic dispatch** (a callback, descriptor, ' +
+        'metaclass, or attribute-as-callable) that static parsing cannot resolve into an edge. ' +
+        `Inspect \`${start.name}\` (${start.filePath}:${start.startLine}) with codegraph_node ` +
+        '(includeCode=true) — its body usually shows the dynamic call to follow next.',
+      ];
+      if (callees.length > 0) {
+        lines.push('', `**${start.name} statically calls:** ${callees.join(', ')}`);
+      }
+      return this.textResult(lines.join('\n') + fromMatches.note + toMatches.note);
+    }
+
+    const lines: string[] = [
+      `## Trace: ${from} → ${to}`,
+      '',
+      `Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
+      '',
+      `${path.length} hops:`,
+      '',
+    ];
+    // Inline what each hop needs so the agent doesn't Read/Grep to get it: the
+    // call-site source line, the registration site for dynamic-dispatch hops, AND
+    // the hop's own body (capped per hop so the trace stays path-scoped). Earlier
+    // versions inlined only the call-site line, which left agents calling explore
+    // or Read for the bodies — the exact follow-up the ablation experiment measured.
+    const fileCache = new Map<string, string[]>();
+    for (let i = 0; i < path.length; i++) {
+      const step = path[i]!;
+      if (step.edge) {
+        const synth = this.synthEdgeNote(step.edge);
+        if (synth) {
+          lines.push(`   ↓ ${synth.label}`);
+          if (synth.registeredAt) {
+            const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
+            lines.push(`     ↳ registered at ${synth.registeredAt}${regSrc ? `   ${regSrc}` : ''}`);
+          }
+        } else {
+          // The call happens in the PREVIOUS hop's file at edge.line.
+          const prev = path[i - 1];
+          const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
+          const callSrc = this.sourceLineAt(cg, ref, fileCache);
+          lines.push(`   ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? `   ${callSrc}` : ''}`);
+        }
+      }
+      lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine}-${step.node.endLine})`);
+      const body = this.sourceRangeAt(cg, step.node.filePath, step.node.startLine, step.node.endLine, fileCache, 60, 1800);
+      if (body) lines.push(body);
+    }
+    // The "last mile": what the destination does next. Agents otherwise explore/Read
+    // for exactly this (e.g. renderStaticScene → _renderStaticScene → the canvas draw),
+    // so inlining the destination's callees is what actually stops the investigation —
+    // sufficiency, not a "don't explore" instruction.
+    const dest = path[path.length - 1]!.node;
+    const destCallees = cg.getCallees(dest.id)
+      .filter(c => !path.some(p => p.node.id === c.node.id))
+      .slice(0, 6);
+    if (destCallees.length > 0) {
+      lines.push('', `### \`${dest.name}\` then calls (the destination's immediate work):`);
+      for (const c of destCallees) {
+        lines.push('', `- ${c.node.name} (${c.node.filePath}:${c.node.startLine}-${c.node.endLine})`);
+        const body = this.sourceRangeAt(cg, c.node.filePath, c.node.startLine, c.node.endLine, fileCache, 16, 600);
+        if (body) lines.push(body);
+      }
+    }
+    lines.push('', '> Full path + every hop body + the destination\'s calls are inlined above — the complete flow. Answer from it; a Read is only needed to chase a specific local variable\'s data-flow.');
+    return this.textResult(this.truncateOutput(lines.join('\n')));
+  }
+
+  /**
+   * Describe a synthesized (dynamic-dispatch) edge for human output: how the
+   * callback was wired up — the bridge static parsing can't see. Returns null
+   * for ordinary static edges. Used by trace + the node trail so a synthesized
+   * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
+   */
+  private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
+    if (!edge || edge.provenance !== 'heuristic') return null;
+    const m = edge.metadata as Record<string, unknown> | undefined;
+    const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
+    const at = registeredAt ? ` @${registeredAt}` : '';
+    if (m?.synthesizedBy === 'callback') {
+      const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
+      const field = m.field ? ` on .${String(m.field)}` : '';
+      return {
+        label: `callback — registered via ${via}${field} (dynamic dispatch)`,
+        compact: `dynamic: callback via ${via}${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'event-emitter') {
+      const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
+      return {
+        label: `event ${ev} — emit → handler (dynamic dispatch)`,
+        compact: `dynamic: event ${ev}${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'react-render') {
+      return {
+        label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
+        compact: `dynamic: React re-render via setState${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'jsx-render') {
+      const child = m.via ? `<${String(m.via)}>` : 'a child component';
+      return {
+        label: `renders ${child} (JSX child — dynamic dispatch)`,
+        compact: `dynamic: renders ${child}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'vue-handler') {
+      const ev = m.event ? `@${String(m.event)}` : 'a template event';
+      return {
+        label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
+        compact: `dynamic: Vue ${ev} handler`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'interface-impl') {
+      return {
+        label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
+        compact: `dynamic: interface → impl${at}`,
+        registeredAt,
+      };
+    }
+    return null;
+  }
+
+  /**
+   * Read one trimmed source line at "relpath:line" (relative to the project
+   * root). `cache` holds split file contents so a multi-hop trace reads each
+   * file at most once. Returns null if the file/line can't be resolved.
+   */
+  private sourceLineAt(cg: CodeGraph, ref: string | undefined, cache: Map<string, string[]>): string | null {
+    if (!ref) return null;
+    const i = ref.lastIndexOf(':');
+    if (i < 0) return null;
+    const filePath = ref.slice(0, i);
+    const line = parseInt(ref.slice(i + 1), 10);
+    if (!Number.isFinite(line) || line < 1) return null;
+    let fileLines = cache.get(filePath);
+    if (!fileLines) {
+      const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
+      if (!abs || !existsSync(abs)) return null;
+      try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
+      cache.set(filePath, fileLines);
+    }
+    const raw = fileLines[line - 1];
+    if (raw == null) return null;
+    const t = raw.trim();
+    return t.length > 160 ? t.slice(0, 157) + '…' : t;
+  }
+
+  /**
+   * Read a hop's body — filePath lines [startLine..endLine] — for inlining into
+   * a trace, capped (lines + chars) so the whole path stays path-scoped even on
+   * a 7-hop chain. Dedents to the body's own indentation and marks truncation.
+   * Shares `cache` with sourceLineAt so each file is read at most once per trace.
+   */
+  private sourceRangeAt(
+    cg: CodeGraph,
+    filePath: string,
+    startLine: number,
+    endLine: number,
+    cache: Map<string, string[]>,
+    maxLines = 28,
+    maxChars = 1200
+  ): string | null {
+    if (!Number.isFinite(startLine) || startLine < 1) return null;
+    let fileLines = cache.get(filePath);
+    if (!fileLines) {
+      const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
+      if (!abs || !existsSync(abs)) return null;
+      try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
+      cache.set(filePath, fileLines);
+    }
+    const end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
+    let slice = fileLines.slice(startLine - 1, end);
+    if (slice.length === 0) return null;
+    let omitted = 0;
+    if (slice.length > maxLines) { omitted = slice.length - maxLines; slice = slice.slice(0, maxLines); }
+    const nonBlank = slice.filter(l => l.trim().length > 0);
+    const dedent = nonBlank.length ? Math.min(...nonBlank.map(l => l.length - l.trimStart().length)) : 0;
+    let text = slice.map((l, i) => `      ${startLine + i}\t${l.slice(dedent)}`).join('\n');
+    if (text.length > maxChars) {
+      text = text.slice(0, maxChars).replace(/\n[^\n]*$/, '');
+      omitted = Math.max(omitted, 1);
+    }
+    if (omitted > 0) text += `\n      … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
+    return text;
+  }
+
+  /**
+   * Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
+   * symbol names that usually spans the flow it's investigating (e.g.
+   * "PmsProductController getList PmsProductService list PmsProductServiceImpl").
+   * Surface the longest call chain AMONG those named symbols — scoped to what the
+   * agent explicitly named, so (unlike a fuzzy relevance set) there's no
+   * wrong-feature wandering. Rides synthesized edges, so controller→service-
+   * interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
+   *
+   * Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
+   * CO-NAMING: the agent names the class too, so we keep only `list` candidates
+   * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
+   * dropping unrelated `OmsOrderService::list`.
+   */
+  private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): string {
+    try {
+      const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
+      // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
+      // names (Class.method / Class::method) — the agent's most precise input,
+      // resolved exactly by findAllSymbols. (The old strip mangled Class.method
+      // into Class, throwing the method away.)
+      const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte)$/i;
+      const tokens = [...new Set(
+        query.split(/[\s,()[\]]+/)
+          .map((t) => t.replace(FILE_EXT, '').trim())
+          .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
+      )].slice(0, 16);
+      if (tokens.length < 2) return '';
+      // Pool of name SEGMENTS (Class + method from every token) used to
+      // disambiguate an ambiguous SIMPLE name: keep a candidate only if its
+      // CONTAINER class is itself named in the query.
+      const segPool = new Set<string>();
+      for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
+      const named = new Map<string, Node>();
+      for (const t of tokens) {
+        const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
+        // A qualified or otherwise-specific name (<=3 hits) keeps all; an
+        // ambiguous simple name keeps only candidates whose container is named.
+        const pick = cands.length <= 3
+          ? cands
+          : cands.filter((n) => {
+              const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
+              const container = segs.length >= 2 ? segs[segs.length - 2] : '';
+              return !!container && segPool.has(container);
+            });
+        for (const n of pick.slice(0, 6)) named.set(n.id, n);
+        if (named.size > 40) break;
+      }
+      if (named.size < 2) return '';
+      const MAX_HOPS = 7;
+      let best: Array<{ node: Node; edge: Edge | null }> | null = null;
+      // BFS the full call graph (incl. synth edges) from each named seed, but
+      // only ACCEPT a sink that is also named — both ends anchored to symbols the
+      // agent named, so the chain stays on-topic while bridging intermediates
+      // (e.g. the exact interface overload) that the token resolution missed.
+      for (const seed of [...named.values()].slice(0, 8)) {
+        const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
+        parent.set(seed.id, { prev: null, edge: null, node: seed });
+        const q: Array<{ id: string; depth: number; streak: number }> = [{ id: seed.id, depth: 0, streak: 0 }];
+        let deep: string | null = null, deepDepth = 0;
+        const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
+        for (let h = 0; h < q.length && parent.size < 1500; h++) {
+          const { id, depth, streak } = q[h]!;
+          if (id !== seed.id && named.has(id) && depth > deepDepth) { deep = id; deepDepth = depth; }
+          if (depth >= MAX_HOPS - 1) continue;
+          for (const c of cg.getCallees(id)) {
+            if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
+            const newStreak = named.has(c.node.id) ? 0 : streak + 1;
+            if (newStreak > MAX_BRIDGE) continue;
+            parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
+            q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
+          }
+        }
+        if (!deep) continue;
+        const chain: Array<{ node: Node; edge: Edge | null }> = [];
+        let cur: string | null = deep;
+        while (cur) { const p = parent.get(cur); if (!p) break; chain.push({ node: p.node, edge: p.edge }); cur = p.prev; }
+        chain.reverse();
+        if (!best || chain.length > best.length) best = chain;
+      }
+      if (!best || best.length < 3) return '';
+      const out = ['## Flow (call path among the symbols you queried)', ''];
+      for (let i = 0; i < best.length; i++) {
+        const step = best[i]!;
+        if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(`   ↓ ${sy ? sy.compact : step.edge.kind}`); }
+        out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
+      }
+      out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
+      return out.join('\n');
+    } catch {
+      return '';
+    }
+  }
+
   /**
    * Handle codegraph_explore — deep exploration in a single call
    *
@@ -991,6 +1395,38 @@ export class ToolHandler {
       return this.textResult(`No relevant code found for "${query}"`);
     }
 
+    // Graph-aware glue: findRelevantContext builds the subgraph from name/text
+    // search, so a method that BRIDGES named symbols — e.g. App.tsx's
+    // triggerRender, which calls the named triggerUpdate — is never a search hit
+    // and gets missed, forcing the agent to Read the file to trace it. Pull in
+    // the callers/callees of the entry (root) nodes, but ONLY those that live in
+    // files the subgraph already surfaces (where the agent reads to fill gaps),
+    // so we add wiring without dragging in unrelated files. These get an
+    // importance boost below so they survive the per-file cluster budget.
+    const glueNodeIds = new Set<string>();
+    const subgraphFiles = new Set<string>();
+    for (const n of subgraph.nodes.values()) subgraphFiles.add(n.filePath);
+    const GLUE_NODE_CAP = 60;
+    for (const rootId of subgraph.roots) {
+      if (glueNodeIds.size >= GLUE_NODE_CAP) break;
+      let neighbors: Node[] = [];
+      try {
+        neighbors = [
+          ...cg.getCallers(rootId).map(c => c.node),
+          ...cg.getCallees(rootId).map(c => c.node),
+        ];
+      } catch {
+        continue;
+      }
+      for (const nb of neighbors) {
+        if (glueNodeIds.size >= GLUE_NODE_CAP) break;
+        if (subgraph.nodes.has(nb.id)) continue;
+        if (!subgraphFiles.has(nb.filePath)) continue;
+        subgraph.nodes.set(nb.id, nb);
+        glueNodeIds.add(nb.id);
+      }
+    }
+
     // Step 2: Group nodes by file, score by relevance
     const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
     const entryNodeIds = new Set(subgraph.roots);
@@ -1100,6 +1536,8 @@ export class ToolHandler {
     // Step 4: Read contiguous file sections
     lines.push('### Source Code');
     lines.push('');
+    lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
+    lines.push('');
 
     let totalChars = lines.join('\n').length;
     let filesIncluded = 0;
@@ -1122,6 +1560,38 @@ export class ToolHandler {
       const fileLines = fileContent.split('\n');
       const lang = group.nodes[0]?.language || '';
 
+      // Whole-small-file rule: if a relevant file is small enough to afford,
+      // return it ENTIRELY instead of clustering. Clustering exists to tame
+      // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
+      // lossy subset of a file the agent will just Read in full anyway — costing
+      // a round-trip and a re-read every later turn. Reserve clustering for files
+      // too big to ship whole. Still bounded by the total maxOutputChars check.
+      const WHOLE_FILE_MAX_LINES = 220;
+      const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
+      if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
+        const body = fileContent.replace(/\n+$/, '');
+        let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
+        const uniqSymbols = [...new Set(
+          group.nodes
+            .filter(n => n.kind !== 'import' && n.kind !== 'export')
+            .map(n => `${n.name}(${n.kind})`)
+        )];
+        const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
+        const omitted = uniqSymbols.length - headerNames.length;
+        const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
+
+        if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
+          const remaining = budget.maxOutputChars - totalChars - 200;
+          if (remaining < 500) break;
+          wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
+          anyFileTrimmed = true;
+        }
+        lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
+        totalChars += wholeSection.length + 200;
+        filesIncluded++;
+        continue;
+      }
+
       // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
       // Sort by start line, then merge overlapping/adjacent ranges (within the
       // adaptive gap threshold). Include both node ranges AND edge source
@@ -1149,6 +1619,7 @@ export class ToolHandler {
         .map(n => {
           let importance = 1;
           if (entryNodeIds.has(n.id)) importance = 10;
+          else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
           else if (connectedToEntry.has(n.id)) importance = 3;
           return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
         });
@@ -1345,7 +1816,7 @@ export class ToolHandler {
         .sort((a, b) => b[1].score - a[1].score);
       const remainingFiles = [...remainingRelevant, ...peripheralFiles];
       if (remainingFiles.length > 0) {
-        lines.push('### Additional relevant files (not shown)');
+        lines.push('### Not shown above — explore these names for their source');
         lines.push('');
         for (const [filePath, group] of remainingFiles.slice(0, 10)) {
           const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
@@ -1364,10 +1835,10 @@ export class ToolHandler {
     if (budget.includeCompletenessSignal) {
       lines.push('');
       lines.push('---');
-      lines.push(`> **Complete source code is included above for ${filesIncluded} files.** You do NOT need to re-read these files — the relevant sections are already shown in full. Only use Read/Grep for files listed under "Additional relevant files" if you need more detail.`);
+      lines.push(`> **Complete source for ${filesIncluded} files is included above — do NOT re-read them.** If your question also needs files/symbols listed under "Not shown above" (or any area this call didn't cover), make ANOTHER codegraph_explore targeting those names — it returns the same source with line numbers and is cheaper and more complete than reading. Reserve Read for a single specific line range explore can't surface.`);
     } else if (anyFileTrimmed) {
       lines.push('');
-      lines.push(`> Some file sections were trimmed for size. Use \`codegraph_node\` or Read for the full source if needed.`);
+      lines.push(`> Some file sections were trimmed for size. For a specific symbol you still need, run another \`codegraph_explore\` (or \`codegraph_node\`) with its exact name — line-numbered source, cheaper and more complete than Read.`);
     }
 
     // Add explore budget note based on project size
@@ -1376,7 +1847,7 @@ export class ToolHandler {
         const stats = cg.getStats();
         const callBudget = getExploreBudget(stats.fileCount);
         lines.push('');
-        lines.push(`> **Explore budget: ${callBudget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${callBudget} calls — do NOT make additional explore calls beyond this budget.`);
+        lines.push(`> **Explore budget: ${callBudget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).** Each call covers ~6 files; if your question spans more, spend your remaining calls on the uncovered area BEFORE falling back to Read — another explore is cheaper and more complete than reading those files. Synthesize once you've used ${callBudget}.`);
       } catch {
         // Stats unavailable — skip budget note
       }
@@ -1388,12 +1859,12 @@ export class ToolHandler {
     // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
     // payload persists in the agent's context and is re-read as cache-input
     // on every subsequent turn, so the overrun is paid many times over.
-    const output = lines.join('\n');
+    const output = this.buildFlowFromNamedSymbols(cg, query) + lines.join('\n');
     if (output.length > budget.maxOutputChars) {
       const cut = output.slice(0, budget.maxOutputChars);
       const lastNewline = cut.lastIndexOf('\n');
       const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
-      return this.textResult(safe + '\n\n... (explore output truncated to budget — use codegraph_node or Read for more)');
+      return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
     }
     return this.textResult(output);
   }
@@ -1432,10 +1903,50 @@ export class ToolHandler {
       }
     }
 
-    const formatted = this.formatNodeDetails(match.node, code, outline) + match.note;
+    const trail = this.formatTrail(cg, match.node);
+    const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
     return this.textResult(this.truncateOutput(formatted));
   }
 
+  /**
+   * Build the "trail" for a symbol: its direct callees (what it calls) and
+   * callers (what calls it), each with file:line — so codegraph_node doubles as
+   * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
+   * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
+   * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
+   * dynamic dispatch the static graph couldn't resolve — that absence is itself
+   * a signal (read that one hop) rather than a dead end.
+   */
+  private formatTrail(cg: CodeGraph, node: Node): string {
+    const TRAIL_CAP = 12;
+    const fmt = (e: { node: Node; edge: Edge }) => {
+      const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
+      const synth = this.synthEdgeNote(e.edge);
+      return synth ? `${base} [${synth.compact}]` : base;
+    };
+    const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
+      const seen = new Set<string>([node.id]);
+      const out: Array<{ node: Node; edge: Edge }> = [];
+      for (const e of edges) {
+        if (seen.has(e.node.id)) continue;
+        seen.add(e.node.id);
+        out.push(e);
+      }
+      return out;
+    };
+    const callees = collect(cg.getCallees(node.id));
+    const callers = collect(cg.getCallers(node.id));
+    if (callees.length === 0 && callers.length === 0) return '';
+    const lines: string[] = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
+    if (callees.length > 0) {
+      lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
+    }
+    if (callers.length > 0) {
+      lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
+    }
+    return lines.join('\n');
+  }
+
   /**
    * Handle codegraph_status
    */
@@ -1930,7 +2441,10 @@ export class ToolHandler {
       lines.push('', outline, '',
         `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
     } else if (code) {
-      lines.push('', '```' + node.language, code, '```');
+      // Line-numbered (cat -n style, like codegraph_explore and Read) so the
+      // agent can cite/edit exact lines without re-Reading the file for them.
+      const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
+      lines.push('', '```' + node.language, numbered, '```');
     }
 
     return lines.join('\n');

+ 548 - 0
src/resolution/callback-synthesizer.ts

@@ -0,0 +1,548 @@
+/**
+ * Callback / observer edge synthesis — Phase 1 + 2.
+ *
+ * Closes dynamic-dispatch holes where a dispatcher invokes callbacks registered
+ * elsewhere. Two channel shapes:
+ *
+ *  (1) Field-backed observer (Phase 1):
+ *      onUpdate(cb) { this.callbacks.add(cb); }            // registrar
+ *      triggerUpdate() { for (cb of this.callbacks) cb(); } // dispatcher
+ *      scene.onUpdate(this.triggerRender)                  // registration
+ *      → synthesize triggerUpdate → triggerRender
+ *
+ *  (2) String-keyed EventEmitter (Phase 2):
+ *      this.on('mount', function onmount(){...})           // registration
+ *      fn.emit('mount', this)                              // dispatch
+ *      → synthesize (method containing emit('mount')) → onmount
+ *
+ * Whole-graph pass after base resolution. High-precision/low-recall by design:
+ * named callbacks only; field channels paired by file+field; EventEmitter
+ * channels capped by event fan-out (generic names like 'error' skipped — they
+ * need receiver-type matching, deferred to Phase 3). All synthesized edges are
+ * tagged `provenance:'heuristic'`. See docs/design/callback-edge-synthesis.md.
+ */
+import type { Edge, Node } from '../types';
+import type { QueryBuilder } from '../db/queries';
+import type { ResolutionContext } from './types';
+
+const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
+const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
+const MAX_CALLBACKS_PER_CHANNEL = 40;
+const EVENT_FANOUT_CAP = 6; // skip events with more handlers/dispatchers than this (too generic without type info)
+
+const ON_RE = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:function\s+(\w+)|(?:this\.)?(\w+))/g;
+const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
+const SETSTATE_RE = /this\.setState\s*\(/;
+const FLUTTER_SETSTATE_RE = /\bsetState\s*\(/; // Flutter: setState((){…}) / this.setState
+const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g;
+const MAX_JSX_CHILDREN = 30;
+// Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
+// event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
+// are already caught by JSX_TAG_RE via the SFC component node.
+const VUE_KEBAB_RE = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g;
+const VUE_HANDLER_RE = /(?:@|v-on:)([a-zA-Z][\w-]*)(?:\.[\w]+)*\s*=\s*"([^"]+)"/g;
+// Composable/hook destructure: `const { close: closeSidebar } = useSidebarControl()`.
+// Captures the destructure body + the called composable; only `use*` calls qualify.
+const VUE_DESTRUCTURE_RE = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*(\w+)\s*\(/g;
+
+function kebabToPascal(s: string): string {
+  return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
+}
+
+function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
+  if (!startLine || !endLine) return null;
+  return content.split('\n').slice(startLine - 1, endLine).join('\n');
+}
+
+function registrarField(src: string): string | null {
+  const m = src.match(/this\.(\w+)\.(?:add|push|set)\(/);
+  return m ? m[1]! : null;
+}
+
+function dispatcherField(src: string): string | null {
+  const forOf = src.match(/\bof\s+(?:Array\.from\(\s*)?this\.(\w+)/);
+  if (forOf && /\b\w+\s*\(/.test(src)) return forOf[1]!;
+  const forEach = src.match(/this\.(\w+)\.forEach\(/);
+  if (forEach) return forEach[1]!;
+  return null;
+}
+
+const FN_KINDS = new Set(['method', 'function', 'component']);
+
+/** Innermost function/method node whose line range contains `line`. */
+function enclosingFn(nodesInFile: Node[], line: number): Node | null {
+  let best: Node | null = null;
+  for (const n of nodesInFile) {
+    if (!FN_KINDS.has(n.kind)) continue;
+    const end = n.endLine ?? n.startLine;
+    if (n.startLine <= line && end >= line) {
+      if (!best || n.startLine >= best.startLine) best = n; // prefer the tightest (latest-starting) encloser
+    }
+  }
+  return best;
+}
+
+/** Phase 1: field-backed observer channels (registrar/dispatcher share a store). */
+function fieldChannelEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
+  const candidates = [...queries.getNodesByKind('method'), ...queries.getNodesByKind('function')];
+  const registrars: Array<{ node: Node; field: string }> = [];
+  const dispatchers: Array<{ node: Node; field: string }> = [];
+
+  for (const m of candidates) {
+    const isReg = REGISTRAR_NAME.test(m.name);
+    const isDisp = DISPATCHER_NAME.test(m.name);
+    if (!isReg && !isDisp) continue;
+    const content = ctx.readFile(m.filePath);
+    const src = content && sliceLines(content, m.startLine, m.endLine);
+    if (!src) continue;
+    if (isReg) { const f = registrarField(src); if (f) registrars.push({ node: m, field: f }); }
+    if (isDisp) { const f = dispatcherField(src); if (f) dispatchers.push({ node: m, field: f }); }
+  }
+
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const reg of registrars) {
+    const chDispatchers = dispatchers.filter(
+      (d) => d.node.filePath === reg.node.filePath && d.field === reg.field
+    );
+    if (chDispatchers.length === 0) continue;
+    const argRe = new RegExp(`${reg.node.name}\\s*\\(\\s*(?:this\\.)?(\\w+)`);
+    let added = 0;
+    for (const e of queries.getIncomingEdges(reg.node.id, ['calls'])) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      if (!e.line) continue;
+      const caller = queries.getNodeById(e.source);
+      if (!caller) continue;
+      const line = ctx.readFile(caller.filePath)?.split('\n')[e.line - 1];
+      const am = line?.match(argRe);
+      if (!am) continue;
+      const fn = ctx.getNodesByName(am[1]!).find((n) => n.kind === 'method' || n.kind === 'function');
+      if (!fn) continue;
+      for (const disp of chDispatchers) {
+        if (disp.node.id === fn.id) continue;
+        const key = `${disp.node.id}>${fn.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: disp.node.id, target: fn.id, kind: 'calls', line: disp.node.startLine,
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'callback', via: reg.node.name, field: reg.field,
+            // Where the callback was wired up (`scene.onUpdate(this.triggerRender)`).
+            // This is the #1 thing an agent reads/greps to explain the flow — surface
+            // it so node/trace/context can show it without a callers() + Read round-trip.
+            registeredAt: `${caller.filePath}:${e.line}`,
+          },
+        });
+        added++;
+      }
+    }
+  }
+  return edges;
+}
+
+/** Phase 2: string-keyed EventEmitter channels (on('e', fn) ↔ emit('e')). */
+function eventEmitterEdges(ctx: ResolutionContext): Edge[] {
+  const emitsByEvent = new Map<string, Set<string>>();          // event → dispatcher node ids
+  const handlersByEvent = new Map<string, Map<string, string>>(); // event → handler id → registration site (file:line)
+
+  for (const file of ctx.getAllFiles()) {
+    const content = ctx.readFile(file);
+    if (!content) continue;
+    const hasEmit = content.includes('.emit(') || content.includes('.fire(') || content.includes('.dispatchEvent(');
+    const hasOn = content.includes('.on(') || content.includes('.once(') || content.includes('.addListener(');
+    if (!hasEmit && !hasOn) continue;
+    const nodesInFile = ctx.getNodesInFile(file);
+    const lineOf = (idx: number) => content.slice(0, idx).split('\n').length;
+
+    if (hasEmit) {
+      EMIT_RE.lastIndex = 0;
+      let m: RegExpExecArray | null;
+      while ((m = EMIT_RE.exec(content))) {
+        const disp = enclosingFn(nodesInFile, lineOf(m.index));
+        if (!disp) continue;
+        const set = emitsByEvent.get(m[1]!) ?? new Set<string>();
+        set.add(disp.id); emitsByEvent.set(m[1]!, set);
+      }
+    }
+    if (hasOn) {
+      ON_RE.lastIndex = 0;
+      let m: RegExpExecArray | null;
+      while ((m = ON_RE.exec(content))) {
+        const handlerName = m[2] || m[3];
+        if (!handlerName) continue;
+        const handler = ctx.getNodesByName(handlerName).find((n) => n.kind === 'function' || n.kind === 'method');
+        if (!handler) continue;
+        const map = handlersByEvent.get(m[1]!) ?? new Map<string, string>();
+        map.set(handler.id, `${file}:${lineOf(m.index)}`); handlersByEvent.set(m[1]!, map);
+      }
+    }
+  }
+
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const [event, dispatchers] of emitsByEvent) {
+    const handlers = handlersByEvent.get(event);
+    if (!handlers) continue;
+    // Precision guard: a generic event name with many handlers/dispatchers can't
+    // be matched without receiver-type info (Phase 3) — skip rather than over-link.
+    if (dispatchers.size > EVENT_FANOUT_CAP || handlers.size > EVENT_FANOUT_CAP) continue;
+    for (const d of dispatchers) for (const [h, registeredAt] of handlers) {
+      if (d === h) continue;
+      const key = `${d}>${h}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({ source: d, target: h, kind: 'calls', provenance: 'heuristic', metadata: { synthesizedBy: 'event-emitter', event, registeredAt } });
+    }
+  }
+  return edges;
+}
+
+/**
+ * Phase 4: React class-component re-render. `this.setState(...)` re-runs the
+ * component's `render()`, but that hop is React-internal — no static edge — so a
+ * flow like "mutation → setState → canvas repaint" dead-ends at setState even
+ * though `render → getRenderableElements → …` is fully call-connected after it.
+ * Bridge it: for each class that has a `render` method, link every sibling method
+ * whose body calls `this.setState(` → `render`. The setState gate keeps this to
+ * React class components (a non-React class with a `render` method won't call
+ * `this.setState`). Over-approximation (all setState methods reach render) is
+ * accepted — it's reachability-correct, like the callback channels.
+ */
+function reactRenderEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const cls of queries.getNodesByKind('class')) {
+    const children = queries.getOutgoingEdges(cls.id, ['contains'])
+      .map((e) => queries.getNodeById(e.target))
+      .filter((n): n is Node => !!n && n.kind === 'method');
+    const render = children.find((n) => n.name === 'render');
+    if (!render) continue;
+    let added = 0;
+    for (const m of children) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      if (m.id === render.id) continue;
+      const content = ctx.readFile(m.filePath);
+      const src = content && sliceLines(content, m.startLine, m.endLine);
+      if (!src || !SETSTATE_RE.test(src)) continue;
+      const key = `${m.id}>${render.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: m.id, target: render.id, kind: 'calls', line: m.startLine,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'react-render', via: 'setState', registeredAt: `${render.filePath}:${render.startLine}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
+/**
+ * Phase 4b: Flutter setState → build (the Dart analog of react-render). In a
+ * StatefulWidget's State class, `setState(() {…})` re-runs `build(context)`, but
+ * that hop is framework-internal (Flutter calls build), so a flow like
+ * "onPressed → _increment → setState → rebuilt UI" dead-ends at setState. Bridge
+ * it: for each Dart class with a `build` method, link every sibling method whose
+ * body calls `setState(` → `build`. The setState gate + `.dart` file keep this to
+ * Flutter State classes. Over-approximation accepted (reachability-correct).
+ */
+function flutterBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const cls of queries.getNodesByKind('class')) {
+    const children = queries.getOutgoingEdges(cls.id, ['contains'])
+      .map((e) => queries.getNodeById(e.target))
+      .filter((n): n is Node => !!n && n.kind === 'method');
+    const build = children.find((n) => n.name === 'build');
+    if (!build || !build.filePath.endsWith('.dart')) continue;
+    let added = 0;
+    for (const m of children) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      if (m.id === build.id) continue;
+      const content = ctx.readFile(m.filePath);
+      const src = content && sliceLines(content, m.startLine, m.endLine);
+      if (!src || !FLUTTER_SETSTATE_RE.test(src)) continue;
+      const key = `${m.id}>${build.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: m.id, target: build.id, kind: 'calls', line: m.startLine,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'flutter-build', via: 'setState', registeredAt: `${build.filePath}:${build.startLine}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
+/**
+ * Phase 4c: C++ virtual override. A call through a base/interface pointer
+ * (`db->Get(...)`, `iter->Next()`) dispatches at runtime to a subclass override,
+ * but that hop is a vtable indirection — no static call edge — so a flow stops at
+ * the abstract base method. Bridge it like react-render: for each C++ class that
+ * `extends` a base, link each base method → the subclass method of the same name
+ * (the override), so trace/callees from the interface method reach the
+ * implementation(s). Over-approximation accepted (reachability-correct); capped
+ * per class and gated to C++ to avoid touching other languages' dispatch.
+ */
+function cppOverrideEdges(queries: QueryBuilder): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const methodsOf = (classId: string): Node[] =>
+    queries
+      .getOutgoingEdges(classId, ['contains'])
+      .map((e) => queries.getNodeById(e.target))
+      .filter((n): n is Node => !!n && n.kind === 'method');
+  for (const cls of queries.getNodesByKind('class')) {
+    const subMethods = methodsOf(cls.id).filter((n) => n.language === 'cpp');
+    if (subMethods.length === 0) continue;
+    for (const ext of queries.getOutgoingEdges(cls.id, ['extends'])) {
+      const base = queries.getNodeById(ext.target);
+      if (!base || base.language !== 'cpp' || base.id === cls.id) continue;
+      const baseMethods = new Map(methodsOf(base.id).map((m) => [m.name, m]));
+      let added = 0;
+      for (const m of subMethods) {
+        if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+        const bm = baseMethods.get(m.name);
+        if (!bm || bm.id === m.id) continue;
+        const key = `${bm.id}>${m.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: bm.id,
+          target: m.id,
+          kind: 'calls',
+          line: bm.startLine,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'cpp-override', via: m.name, registeredAt: `${m.filePath}:${m.startLine}` },
+        });
+        added++;
+      }
+    }
+  }
+  return edges;
+}
+
+/**
+ * Phase 5.5: interface / abstract dispatch (Java, Kotlin). A call through an
+ * injected interface (`@Autowired FooService svc; svc.list()`) or an abstract
+ * base dispatches at runtime to the implementing class's override — a vtable
+ * indirection with no static call edge — so a request→service flow stops at the
+ * interface method. Bridge it like cpp-override: for each class that
+ * `implements` an interface (or `extends` an abstract base), link each
+ * base/interface method → the class's same-name method (the override) so
+ * trace/callees reach the implementation. Over-approximation accepted
+ * (reachability-correct); capped per class, gated to JVM languages.
+ */
+const IFACE_OVERRIDE_LANGS = new Set(['java', 'kotlin']);
+function interfaceOverrideEdges(queries: QueryBuilder): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const methodsOf = (classId: string): Node[] =>
+    queries
+      .getOutgoingEdges(classId, ['contains'])
+      .map((e) => queries.getNodeById(e.target))
+      .filter((n): n is Node => !!n && n.kind === 'method');
+  for (const cls of queries.getNodesByKind('class')) {
+    const implMethods = methodsOf(cls.id).filter((n) => IFACE_OVERRIDE_LANGS.has(n.language));
+    if (implMethods.length === 0) continue;
+    for (const sup of queries.getOutgoingEdges(cls.id, ['implements', 'extends'])) {
+      const base = queries.getNodeById(sup.target);
+      if (!base || !IFACE_OVERRIDE_LANGS.has(base.language) || base.id === cls.id) continue;
+      // Group impl methods by name to handle OVERLOADS: an interface `list()` and
+      // `list(params)` are distinct nodes and a call may resolve to either, so
+      // link every base overload → every same-name impl overload (keying by name
+      // alone would drop all but one and miss the resolved overload).
+      const implByName = new Map<string, Node[]>();
+      for (const m of implMethods) {
+        const arr = implByName.get(m.name);
+        if (arr) arr.push(m); else implByName.set(m.name, [m]);
+      }
+      let added = 0;
+      for (const bm of methodsOf(base.id)) {
+        if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+        for (const m of implByName.get(bm.name) ?? []) {
+          if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+          if (bm.id === m.id) continue;
+          const key = `${bm.id}>${m.id}`;
+          if (seen.has(key)) continue;
+          seen.add(key);
+          edges.push({
+            source: bm.id,
+            target: m.id,
+            kind: 'calls',
+            line: bm.startLine,
+            provenance: 'heuristic',
+            metadata: { synthesizedBy: 'interface-impl', via: m.name, registeredAt: `${m.filePath}:${m.startLine}` },
+          });
+          added++;
+        }
+      }
+    }
+  }
+  return edges;
+}
+
+/**
+ * Phase 5: React JSX child rendering. A component that returns `<Child .../>`
+ * mounts Child — React calls it — but JSX instantiation isn't a static call edge,
+ * so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the
+ * JSX hop. Link parent → each capitalized JSX child it renders. File-oriented
+ * (read each JSX file once). Precision gate: the child name must resolve to a
+ * component/function/class node — TS generics like `Array<Foo>` resolve to a type
+ * (or nothing) and are dropped.
+ */
+function reactJsxChildEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const PARENT_KINDS = new Set(['method', 'function', 'component']);
+  for (const file of ctx.getAllFiles()) {
+    const content = ctx.readFile(file);
+    if (!content || (!content.includes('</') && !content.includes('/>'))) continue; // JSX-file gate
+    const parents = ctx.getNodesInFile(file).filter((n) => PARENT_KINDS.has(n.kind));
+    for (const parent of parents) {
+      const src = sliceLines(content, parent.startLine, parent.endLine);
+      if (!src || (!src.includes('</') && !src.includes('/>'))) continue;
+      const names = new Set<string>();
+      JSX_TAG_RE.lastIndex = 0;
+      let m: RegExpExecArray | null;
+      while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!);
+      let added = 0;
+      for (const name of names) {
+        if (added >= MAX_JSX_CHILDREN) break;
+        const child = ctx.getNodesByName(name).find(
+          (n) => n.kind === 'component' || n.kind === 'function' || n.kind === 'class'
+        );
+        if (!child || child.id === parent.id) continue;
+        const key = `${parent.id}>${child.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: parent.id, target: child.id, kind: 'calls', line: parent.startLine,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'jsx-render', via: name },
+        });
+        added++;
+      }
+    }
+  }
+  return edges;
+}
+
+/**
+ * Phase 6: Vue SFC templates. The `.vue` extractor only parses `<script>`, so
+ * template usage is invisible — child components and event handlers used ONLY in
+ * the template have no edge to them. PascalCase children (`<VPNav/>`) are already
+ * caught by reactJsxChildEdges (which scans the SFC component node), so this adds
+ * the two Vue-specific shapes:
+ *   - kebab-case children: `<el-button>` → `ElButton` component (renders).
+ *   - event bindings: `@click="onClick"` / `v-on:submit="save"` → handler method.
+ * Scoped to the `<template>` block of `.vue` files; resolution gate (kebab→
+ * component, handler→function/method) keeps precision; inline arrows / `$emit`
+ * skipped.
+ */
+function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const COMPONENT_KINDS = new Set(['component', 'function', 'class']);
+  const HANDLER_KINDS = new Set(['method', 'function']);
+  // A composable's returned member may be a fn (`function close(){}`) or an
+  // arrow assigned to a const (`const close = () => {}`).
+  const RETURN_KINDS = new Set(['method', 'function', 'variable', 'constant']);
+  for (const file of ctx.getAllFiles()) {
+    if (!file.endsWith('.vue')) continue;
+    const content = ctx.readFile(file);
+    const tpl = content && content.match(/<template[^>]*>([\s\S]*)<\/template>/i)?.[1];
+    if (!tpl) continue;
+    const comp = ctx.getNodesInFile(file).find((n) => n.kind === 'component');
+    if (!comp) continue;
+
+    // Composable-destructure map: alias → { composable, key }. Lets us resolve a
+    // template handler that isn't a local function but a destructured composable
+    // return (`@click="closeSidebar"` ← `const { close: closeSidebar } = useSidebarControl()`).
+    const script = content.match(/<script[^>]*>([\s\S]*?)<\/script>/i)?.[1] ?? '';
+    const destructured = new Map<string, { composable: string; key: string }>();
+    VUE_DESTRUCTURE_RE.lastIndex = 0;
+    let dm: RegExpExecArray | null;
+    while ((dm = VUE_DESTRUCTURE_RE.exec(script))) {
+      if (!/^use[A-Z]/.test(dm[2]!)) continue; // composables / hooks only
+      for (const part of dm[1]!.split(',')) {
+        const pm = part.trim().match(/^(\w+)\s*(?::\s*(\w+))?$/); // key | key: alias
+        if (pm) destructured.set(pm[2] || pm[1]!, { composable: dm[2]!, key: pm[1]! });
+      }
+    }
+
+    let added = 0;
+    const addEdge = (target: Node | undefined, meta: Record<string, unknown>) => {
+      if (added >= MAX_JSX_CHILDREN || !target || target.id === comp.id) return;
+      const k = `${comp.id}>${target.id}>${meta.synthesizedBy}`;
+      if (seen.has(k)) return;
+      seen.add(k);
+      edges.push({ source: comp.id, target: target.id, kind: 'calls', line: comp.startLine, provenance: 'heuristic', metadata: meta });
+      added++;
+    };
+    // Prefer a target in THIS SFC (handlers live in the same file's script) —
+    // avoids cross-file mis-match when a name repeats across a monorepo.
+    const resolve = (name: string, kinds: Set<string>): Node | undefined => {
+      const matches = ctx.getNodesByName(name).filter((n) => kinds.has(n.kind));
+      return matches.find((n) => n.filePath === file) ?? matches[0];
+    };
+
+    let m: RegExpExecArray | null;
+    VUE_KEBAB_RE.lastIndex = 0;
+    while ((m = VUE_KEBAB_RE.exec(tpl))) addEdge(resolve(kebabToPascal(m[1]!), COMPONENT_KINDS), { synthesizedBy: 'jsx-render', via: m[1] });
+    VUE_HANDLER_RE.lastIndex = 0;
+    while ((m = VUE_HANDLER_RE.exec(tpl))) {
+      const event = m[1]!;
+      const expr = m[2]!.trim();
+      if (expr.includes('=>') || expr.startsWith('$')) continue; // inline arrow / $emit
+      const name = expr.match(/^([A-Za-z_]\w*)/)?.[1];
+      if (!name) continue;
+      const direct = resolve(name, HANDLER_KINDS);
+      if (direct) { addEdge(direct, { synthesizedBy: 'vue-handler', event }); continue; }
+      // Composable-destructure handler → resolve to the composable's returned fn.
+      const d = destructured.get(name);
+      if (!d) continue;
+      const composable = resolve(d.composable, HANDLER_KINDS);
+      // Resolve to the SPECIFIC returned member (e.g. `close`) defined in the
+      // composable's file. No fallback to the composable itself — the component
+      // already has a static `useX()` call edge, so that would just be redundant
+      // and less precise.
+      const keyFn = composable
+        ? ctx.getNodesByName(d.key).find((n) => RETURN_KINDS.has(n.kind) && n.filePath === composable.filePath)
+        : undefined;
+      if (keyFn) addEdge(keyFn, { synthesizedBy: 'vue-handler', event, via: d.composable });
+    }
+  }
+  return edges;
+}
+
+/**
+ * Synthesize dispatcher→callback edges (field observers + EventEmitters +
+ * React re-render + JSX children + Vue templates). Returns the count added.
+ * Never throws into indexing — callers wrap in try/catch.
+ */
+export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
+  const fieldEdges = fieldChannelEdges(queries, ctx);
+  const emitterEdges = eventEmitterEdges(ctx);
+  const renderEdges = reactRenderEdges(queries, ctx);
+  const jsxEdges = reactJsxChildEdges(ctx);
+  const vueEdges = vueTemplateEdges(ctx);
+  const flutterEdges = flutterBuildEdges(queries, ctx);
+  const cppEdges = cppOverrideEdges(queries);
+  const ifaceEdges = interfaceOverrideEdges(queries);
+
+  const merged: Edge[] = [];
+  const seen = new Set<string>();
+  for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges, ...vueEdges, ...flutterEdges, ...cppEdges, ...ifaceEdges]) {
+    const key = `${e.source}>${e.target}`;
+    if (seen.has(key)) continue;
+    seen.add(key);
+    merged.push(e);
+  }
+  if (merged.length > 0) queries.insertEdges(merged);
+  return merged.length;
+}

+ 38 - 9
src/resolution/frameworks/csharp.ts

@@ -43,8 +43,22 @@ export const aspnetResolver: FrameworkResolver = {
       return true;
     }
 
-    // Check for Controllers directory
-    return allFiles.some((f) => f.includes('/Controllers/') && f.endsWith('Controller.cs'));
+    // ASP.NET signatures in controller/entrypoint SOURCE — covers feature-folder
+    // apps with no `/Controllers/` dir and a subdir `Program.cs` that the
+    // root-only checks above miss (e.g. realworld: Features/*/FooController.cs).
+    // `.csproj` often isn't in the indexed source set, so source-scan is the
+    // reliable signal.
+    for (const file of allFiles) {
+      if (!/(?:Controller|Program|Startup)\.cs$/.test(file)) continue;
+      const c = context.readFile(file);
+      if (c && (
+        /\[(?:ApiController|Route|Http(?:Get|Post|Put|Patch|Delete))\b/.test(c) ||
+        c.includes('ControllerBase') || c.includes(': Controller') ||
+        c.includes('MapControllers') || c.includes('WebApplication') ||
+        c.includes('Microsoft.AspNetCore')
+      )) return true;
+    }
+    return false;
   },
 
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
@@ -123,12 +137,20 @@ export const aspnetResolver: FrameworkResolver = {
     const now = Date.now();
     const safe = stripCommentsForRegex(content, 'csharp');
 
-    // [HttpGet("path")], [HttpPost("path")], etc.
-    const attrRegex = /\[(HttpGet|HttpPost|HttpPut|HttpPatch|HttpDelete)\s*\(\s*"([^"]+)"\s*\)\]/g;
+    // Class-level [Route("api/[controller]")] prefix — joined onto each action.
+    let classPrefix = '';
+    const cls = /\[Route\s*\(\s*"([^"]+)"[^)]*\)\]\s*(?:\[[^\]]*\]\s*)*(?:public\s+|sealed\s+|abstract\s+|partial\s+)*class\b/.exec(safe);
+    if (cls) classPrefix = cls[1]!;
+
+    // [HttpGet], [HttpGet("path")], [HttpPost("path", Name="x")] — BARE or with a
+    // path. (The old regex required a string, so bare attributes — with the route
+    // on the class [Route] — were missed; eShopOnWeb was 24 bare / 2 string.)
+    const attrRegex = /\[(HttpGet|HttpPost|HttpPut|HttpPatch|HttpDelete)(?:\s*\(\s*"([^"]+)"[^)]*\))?\s*\]/g;
     let match: RegExpExecArray | null;
     while ((match = attrRegex.exec(safe)) !== null) {
-      const [, verb, routePath] = match;
-      const method = verb!.replace(/^Http/, '').toUpperCase();
+      const verb = match[1]!;
+      const method = verb.replace(/^Http/, '').toUpperCase();
+      const routePath = joinCsPath(classPrefix, match[2] || '');
       const line = safe.slice(0, match.index).split('\n').length;
 
       const routeNode: Node = {
@@ -146,9 +168,10 @@ export const aspnetResolver: FrameworkResolver = {
       };
       nodes.push(routeNode);
 
-      // Capture the next method declaration
-      const tail = safe.slice(match.index + match[0].length);
-      const methodMatch = tail.match(/(?:public|private|protected|internal)\s+[\w<>,\s\[\]]+?\s+(\w+)\s*\(/);
+      // Next method declaration (skip stacked attributes; C# puts the return type
+      // before the name). Bounded so we don't grab a far one.
+      const tail = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
+      const methodMatch = tail.match(/(?:public|private|protected|internal)\s+[\w<>,\s\[\]?.]+?\s+(\w+)\s*\(/);
       if (methodMatch) {
         references.push({
           fromNodeId: routeNode.id,
@@ -202,6 +225,12 @@ export const aspnetResolver: FrameworkResolver = {
   },
 };
 
+/** Join a class-level [Route] prefix and an action's path into one normalized `/path`. */
+function joinCsPath(prefix: string, sub: string): string {
+  const parts = [prefix, sub].map((p) => p.replace(/^\/+|\/+$/g, '')).filter(Boolean);
+  return '/' + parts.join('/');
+}
+
 /** Extract last identifier from an expression like `MyService.Handler` or `Handler`. */
 function extractCSharpTailIdent(expr: string): string | null {
   const cleaned = expr.trim().replace(/\s+/g, '');

+ 52 - 11
src/resolution/frameworks/drupal.ts

@@ -297,23 +297,64 @@ export const drupalResolver: FrameworkResolver = {
   name: 'drupal',
   languages: ['php', 'yaml'],
 
+  // Drupal route handlers are FQCNs (`\Drupal\…\Class::method`, the single-colon
+  // controller-service form `\Drupal\…\Class:method`, or a bare `\…\FormClass`)
+  // and hook refs are canonical `hook_*` names — none match a declared symbol, so
+  // resolveOne's pre-filter would drop them before resolve() runs. Claim the
+  // shapes resolve() handles (mirrors the Rails `controller#action` claim).
+  claimsReference(name: string): boolean {
+    return (
+      name.startsWith('hook_') ||
+      name.includes('\\') ||
+      /^[A-Za-z_]\w*::?\w+$/.test(name)
+    );
+  },
+
   detect(context: ResolutionContext): boolean {
+    // Primary: composer.json identifies a Drupal project/module/theme/profile.
+    // A contrib module often has an EMPTY `require` (no `drupal/*` dep) but still
+    // declares `"name": "drupal/<module>"` and `"type": "drupal-module"`, so check
+    // those too — checking deps alone misses every standalone contrib module.
     const composer = context.readFile('composer.json');
-    if (!composer) return false;
-    try {
-      const json = JSON.parse(composer) as { require?: Record<string, string>; 'require-dev'?: Record<string, string> };
-      const deps = { ...json.require, ...(json['require-dev'] ?? {}) };
-      return Object.keys(deps).some((k) => k.startsWith('drupal/'));
-    } catch {
-      return false;
+    if (composer) {
+      try {
+        const json = JSON.parse(composer) as {
+          name?: string;
+          type?: string;
+          require?: Record<string, string>;
+          'require-dev'?: Record<string, string>;
+        };
+        if (typeof json.name === 'string' && json.name.startsWith('drupal/')) return true;
+        if (typeof json.type === 'string' && json.type.startsWith('drupal-')) return true;
+        const deps = { ...json.require, ...(json['require-dev'] ?? {}) };
+        if (Object.keys(deps).some((k) => k.startsWith('drupal/'))) return true;
+      } catch {
+        // malformed composer.json — fall through to file-based detection
+      }
     }
+
+    // Fallback (composer-less module, or a non-Drupal composer.json): the
+    // unmistakable Drupal signature is a `*.info.yml` manifest alongside a
+    // Drupal PHP/route file. Require both so a stray `.info.yml` elsewhere
+    // doesn't trigger a false positive.
+    const files = context.getAllFiles();
+    const hasInfoYml = files.some((f) => f.endsWith('.info.yml'));
+    if (!hasInfoYml) return false;
+    return files.some(
+      (f) =>
+        f.endsWith('.routing.yml') ||
+        f.endsWith('.module') ||
+        f.endsWith('.install') ||
+        f.endsWith('.theme')
+    );
   },
 
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
     const name = ref.referenceName;
 
-    // _controller: '\Drupal\module\...\ClassName::methodName'
-    const controllerMatch = name.match(/^\\?(?:Drupal\\[^:]+\\)?([^\\:]+)::(\w+)$/);
+    // _controller: '\Drupal\module\...\ClassName::methodName' (double colon) or the
+    // single-colon controller-service form '\Drupal\...\ClassName:methodName'.
+    const controllerMatch = name.match(/^\\?(?:Drupal\\[^:]+\\)?([^\\:]+):{1,2}(\w+)$/);
     if (controllerMatch) {
       const [, className, methodName] = controllerMatch;
       const classNodes = context.getNodesByName(className!);
@@ -328,8 +369,8 @@ export const drupalResolver: FrameworkResolver = {
       }
     }
 
-    // _form / _entity_form: '\Drupal\module\...\ClassName'  (no ::method)
-    if (name.includes('\\') && !name.includes('::')) {
+    // _form / _entity_form: '\Drupal\module\...\ClassName'  (bare FQCN, no method)
+    if (name.includes('\\') && !name.includes(':')) {
       const className = lastSegment(name);
       if (className) {
         const classNodes = context.getNodesByName(className);

+ 98 - 23
src/resolution/frameworks/express.ts

@@ -14,6 +14,39 @@ function extractTailIdent(expr: string): string | null {
   return m ? m[1]! : null;
 }
 
+/**
+ * Index of the delimiter matching the one at `open`, skipping string/template
+ * literals so a `)` or `}` inside a string doesn't throw off the balance.
+ */
+function matchDelim(s: string, open: number, oc: string, cc: string): number {
+  let depth = 0;
+  for (let i = open; i < s.length; i++) {
+    const ch = s[i];
+    if (ch === '"' || ch === "'" || ch === '`') {
+      const q = ch;
+      i++;
+      while (i < s.length && s[i] !== q) { if (s[i] === '\\') i++; i++; }
+      continue;
+    }
+    if (ch === oc) depth++;
+    else if (ch === cc) { depth--; if (depth === 0) return i; }
+  }
+  return -1;
+}
+
+// Express res/req methods + common JS builtins — calls to these inside a handler
+// body are framework/noise, not the business flow we want to surface as route edges.
+const RESERVED_CALLS = new Set([
+  'json', 'jsonp', 'send', 'sendStatus', 'sendFile', 'status', 'end', 'redirect',
+  'render', 'set', 'get', 'header', 'type', 'format', 'attachment', 'download',
+  'cookie', 'clearCookie', 'append', 'location', 'vary', 'links', 'accepts', 'is',
+  'next', 'then', 'catch', 'finally', 'resolve', 'reject', 'all', 'race',
+  'map', 'filter', 'forEach', 'reduce', 'find', 'push', 'pop', 'slice', 'splice',
+  'includes', 'keys', 'values', 'entries', 'assign', 'parse', 'stringify',
+  'log', 'error', 'warn', 'info', 'String', 'Number', 'Boolean', 'Array', 'Object',
+  'Date', 'Math', 'JSON', 'Promise', 'require', 'fail', 'redirect',
+]);
+
 export const expressResolver: FrameworkResolver = {
   name: 'express',
   languages: ['javascript', 'typescript'],
@@ -105,41 +138,83 @@ export const expressResolver: FrameworkResolver = {
     const now = Date.now();
     const lang = detectLanguage(filePath);
     const safe = stripCommentsForRegex(content, lang);
-    // (app|router).METHOD('/path', handler-expr)
-    const regex = /\b(app|router)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g;
+    // Match the route head up to the first arg: (app|router).METHOD('/path',
+    // (NOT the whole call — handlers are often inline arrows whose `)`/`{}` the
+    // old single-regex couldn't span, so inline-handler routes connected to nothing.)
+    const head = /\b(app|router)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g;
     let match: RegExpExecArray | null;
-    while ((match = regex.exec(safe)) !== null) {
-      const [, _obj, method, routePath, handlers] = match;
-      if (method === 'use' && !routePath!.startsWith('/')) continue;
+    while ((match = head.exec(safe)) !== null) {
+      const method = match[2]!;
+      const routePath = match[3]!;
+      if (method === 'use' && !routePath.startsWith('/')) continue;
       const line = safe.slice(0, match.index).split('\n').length;
       const routeNode: Node = {
-        id: `route:${filePath}:${line}:${method!.toUpperCase()}:${routePath}`,
+        id: `route:${filePath}:${line}:${method.toUpperCase()}:${routePath}`,
         kind: 'route',
-        name: `${method!.toUpperCase()} ${routePath}`,
-        qualifiedName: `${filePath}::${method!.toUpperCase()}:${routePath}`,
+        name: `${method.toUpperCase()} ${routePath}`,
+        qualifiedName: `${filePath}::${method.toUpperCase()}:${routePath}`,
         filePath,
         startLine: line,
         endLine: line,
         startColumn: 0,
         endColumn: match[0].length,
-        language: detectLanguage(filePath),
+        language: lang,
         updatedAt: now,
       };
       nodes.push(routeNode);
-      // Handler is the LAST comma-separated argument; earlier ones are middleware.
-      const parts = handlers!.split(',').map((s) => s.trim()).filter(Boolean);
-      const last = parts[parts.length - 1];
-      const handlerName = last ? extractTailIdent(last) : null;
-      if (handlerName) {
-        references.push({
-          fromNodeId: routeNode.id,
-          referenceName: handlerName,
-          referenceKind: 'references',
-          line,
-          column: 0,
-          filePath,
-          language: detectLanguage(filePath),
-        });
+
+      // The full argument list = balanced parens from the route call's open paren.
+      const openParen = safe.indexOf('(', match.index);
+      const closeParen = openParen >= 0 ? matchDelim(safe, openParen, '(', ')') : -1;
+      const args = closeParen > openParen ? safe.slice(openParen + 1, closeParen) : '';
+      const arrowAt = args.indexOf('=>');
+
+      if (arrowAt >= 0) {
+        // Inline arrow handler (`router.post('/x', async (req,res) => {…})`). The
+        // arrow is anonymous, so its body — the actual request→service flow — would
+        // be lost. Attribute the body's calls to the route node as `calls` edges so
+        // `trace(route, service)` connects. Body = balanced `{…}` after `=>`, or the
+        // single-expression tail for `=> expr` arrows.
+        const afterArrow = args.slice(arrowAt + 2);
+        const braceAt = afterArrow.indexOf('{');
+        let body = afterArrow;
+        if (braceAt >= 0 && afterArrow.slice(0, braceAt).trim() === '') {
+          const end = matchDelim(afterArrow, braceAt, '{', '}');
+          if (end > braceAt) body = afterArrow.slice(braceAt + 1, end);
+        }
+        const callRe = /\b([A-Za-z_$][\w$]*)\s*\(/g;
+        const seen = new Set<string>();
+        let cm: RegExpExecArray | null;
+        while ((cm = callRe.exec(body)) !== null) {
+          const name = cm[1]!;
+          if (seen.has(name) || RESERVED_CALLS.has(name)) continue;
+          seen.add(name);
+          references.push({
+            fromNodeId: routeNode.id,
+            referenceName: name,
+            referenceKind: 'calls',
+            line,
+            column: 0,
+            filePath,
+            language: lang,
+          });
+        }
+      } else {
+        // Named handler: the LAST comma-separated arg (earlier ones are middleware).
+        const parts = args.split(',').map((s) => s.trim()).filter(Boolean);
+        const last = parts[parts.length - 1];
+        const handlerName = last ? extractTailIdent(last) : null;
+        if (handlerName) {
+          references.push({
+            fromNodeId: routeNode.id,
+            referenceName: handlerName,
+            referenceKind: 'references',
+            line,
+            column: 0,
+            filePath,
+            language: lang,
+          });
+        }
       }
     }
     return { nodes, references };

+ 6 - 3
src/resolution/frameworks/go.ts

@@ -87,9 +87,12 @@ export const goResolver: FrameworkResolver = {
     const now = Date.now();
     const safe = stripCommentsForRegex(content, 'go');
 
-    // (router|r|mux|app).METHOD("/path", handler)
-    // Handles Gin (GET/POST/...), Chi (Get/Post/...), net/http (HandleFunc/Handle).
-    const routeRegex = /\b(?:router|r|mux|app|e)\.(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Get|Post|Put|Patch|Delete|Handle|HandleFunc)\s*\(\s*"([^"]+)"\s*,\s*([^)]+)\)/g;
+    // <anyVar>.METHOD("/path", handler) — Gin (GET/POST/...), Chi (Get/Post/...),
+    // net/http (HandleFunc/Handle). The receiver is ANY identifier, not just
+    // router|r|mux|app|e: real apps route on GROUP vars (`v1.GET`, `PublicGroup.GET`,
+    // `userRouter.POST`), which the fixed name list missed (gin-vue-admin: 4 routes
+    // for 625 files). The verb + string-path + handler-arg gates keep it route-specific.
+    const routeRegex = /\b\w+\.(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Get|Post|Put|Patch|Delete|Handle|HandleFunc)\s*\(\s*"([^"]+)"\s*,\s*([^)]+)\)/g;
     let match: RegExpExecArray | null;
     while ((match = routeRegex.exec(safe)) !== null) {
       const [, rawMethod, routePath, handlerExpr] = match;

+ 3 - 0
src/resolution/frameworks/index.ts

@@ -16,6 +16,7 @@ import { vueResolver } from './vue';
 import { djangoResolver, flaskResolver, fastapiResolver } from './python';
 import { railsResolver } from './ruby';
 import { springResolver } from './java';
+import { playResolver } from './play';
 import { goResolver } from './go';
 import { rustResolver } from './rust';
 import { aspnetResolver } from './csharp';
@@ -42,6 +43,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   railsResolver,
   // Java
   springResolver,
+  playResolver,
   // Go
   goResolver,
   // Rust
@@ -117,6 +119,7 @@ export { vueResolver } from './vue';
 export { djangoResolver, flaskResolver, fastapiResolver } from './python';
 export { railsResolver } from './ruby';
 export { springResolver } from './java';
+export { playResolver } from './play';
 export { goResolver } from './go';
 export { rustResolver } from './rust';
 export { aspnetResolver } from './csharp';

+ 71 - 14
src/resolution/frameworks/java.ts

@@ -10,7 +10,7 @@ import { stripCommentsForRegex } from '../strip-comments';
 
 export const springResolver: FrameworkResolver = {
   name: 'spring',
-  languages: ['java'],
+  languages: ['java', 'kotlin'],
 
   detect(context: ResolutionContext): boolean {
     // Check for pom.xml with Spring
@@ -119,21 +119,35 @@ export const springResolver: FrameworkResolver = {
   },
 
   extract(filePath, content) {
-    if (!filePath.endsWith('.java')) return { nodes: [], references: [] };
+    // Spring Boot is used from both Java and Kotlin (identical @GetMapping etc.
+    // annotations); the difference is method syntax — Kotlin `fun name(...)` vs
+    // Java `public X name(...)` — handled in the method regex below.
+    if (!filePath.endsWith('.java') && !filePath.endsWith('.kt')) return { nodes: [], references: [] };
     const nodes: Node[] = [];
     const references: UnresolvedRef[] = [];
     const now = Date.now();
+    const lang: 'java' | 'kotlin' = filePath.endsWith('.kt') ? 'kotlin' : 'java';
     const safe = stripCommentsForRegex(content, 'java');
 
-    // @GetMapping("/path"), @PostMapping(value = "/path"), @RequestMapping("/path")
-    const mappingRegex = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\s*\(\s*(?:value\s*=\s*|path\s*=\s*)?["']([^"']+)["'][^)]*\)/g;
+    // Class-level @RequestMapping prefix (an @RequestMapping whose tail leads to a
+    // `class`). Joined onto each method's path — and, crucially, NOT treated as a
+    // route itself (the old regex did, creating one bogus class route and missing
+    // every BARE method mapping like `@PostMapping` with the path on the class).
+    let classPrefix = '';
+    const cls = /@RequestMapping\s*\(([^)]*)\)\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+|open\s+|data\s+|sealed\s+)*class\b/.exec(safe);
+    if (cls) classPrefix = parseMappingPath(cls[1]!);
+
+    const VERB: Record<string, string> = {
+      GetMapping: 'GET', PostMapping: 'POST', PutMapping: 'PUT', PatchMapping: 'PATCH', DeleteMapping: 'DELETE',
+    };
+    // Verb-specific method mappings — always method-level, BARE or with a path.
+    const mappingRegex = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b\s*(\([^)]*\))?/g;
     let match: RegExpExecArray | null;
     while ((match = mappingRegex.exec(safe)) !== null) {
-      const [, mappingName, routePath] = match;
+      const method = VERB[match[1]!]!;
+      const sub = parseMappingPath((match[2] || '').replace(/^\(|\)$/g, ''));
+      const routePath = joinPath(classPrefix, sub);
       const line = safe.slice(0, match.index).split('\n').length;
-      const method =
-        mappingName === 'RequestMapping' ? 'ANY' : mappingName!.replace(/Mapping$/, '').toUpperCase();
-
       const routeNode: Node = {
         id: `route:${filePath}:${line}:${method}:${routePath}`,
         kind: 'route',
@@ -144,27 +158,58 @@ export const springResolver: FrameworkResolver = {
         endLine: line,
         startColumn: 0,
         endColumn: match[0].length,
-        language: 'java',
+        language: lang,
         updatedAt: now,
       };
       nodes.push(routeNode);
 
-      // Look for the next public/private/protected method after the annotation
-      const tail = safe.slice(match.index + match[0].length);
-      const methodMatch = tail.match(/\b(?:public|private|protected)\s+[^;{]*?\s+(\w+)\s*\(/);
+      // Method it decorates: first declared method after (skip stacked annotations;
+      // Java puts the return type before the name). Bounded so we don't grab a far one.
+      const tail = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
+      const methodMatch = tail.match(/\bfun\s+(\w+)\s*\(|\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
       if (methodMatch) {
         references.push({
           fromNodeId: routeNode.id,
-          referenceName: methodMatch[1]!,
+          referenceName: (methodMatch[1] ?? methodMatch[2])!,
           referenceKind: 'references',
           line,
           column: 0,
           filePath,
-          language: 'java',
+          language: lang,
         });
       }
     }
 
+    // Method-level @RequestMapping (older style: `@RequestMapping(value="/x",
+    // method=RequestMethod.GET)` on a method). The class-level @RequestMapping is
+    // the prefix (handled above) — skip it here so it isn't double-counted.
+    const reqRe = /@RequestMapping\b\s*(\([^)]*\))?/g;
+    while ((match = reqRe.exec(safe)) !== null) {
+      const args = (match[1] || '').replace(/^\(|\)$/g, '');
+      const after = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
+      if (/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+|open\s+|data\s+|sealed\s+)*class\b/.test(after)) continue; // class-level prefix
+      const methodMatch = after.match(/\bfun\s+(\w+)\s*\(|\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
+      if (!methodMatch) continue;
+      const verbM = args.match(/method\s*=\s*(?:RequestMethod\.)?(\w+)/);
+      const method = verbM ? verbM[1]!.toUpperCase() : 'ANY';
+      const routePath = joinPath(classPrefix, parseMappingPath(args));
+      const line = safe.slice(0, match.index).split('\n').length;
+      const routeNode: Node = {
+        id: `route:${filePath}:${line}:${method}:${routePath}`,
+        kind: 'route',
+        name: `${method} ${routePath}`,
+        qualifiedName: `${filePath}::route:${routePath}`,
+        filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: lang, updatedAt: now,
+      };
+      nodes.push(routeNode);
+      references.push({
+        fromNodeId: routeNode.id,
+        referenceName: (methodMatch[1] ?? methodMatch[2])!,
+        referenceKind: 'references',
+        line, column: 0, filePath, language: lang,
+      });
+    }
+
     return { nodes, references };
   },
 };
@@ -179,6 +224,18 @@ const COMPONENT_DIRS = ['/component/', '/components/', '/config/'];
 const CLASS_KINDS = new Set(['class']);
 const SERVICE_KINDS = new Set(['class', 'interface']);
 
+/** Path string from a mapping's args (`"/x"`, `value = "/x"`, `path = "/x"`); '' if bare. */
+function parseMappingPath(args: string): string {
+  const m = args.match(/["']([^"']*)["']/);
+  return m ? m[1]! : '';
+}
+
+/** Join a class-level prefix and a method sub-path into one normalized `/path`. */
+function joinPath(prefix: string, sub: string): string {
+  const parts = [prefix, sub].map((p) => p.replace(/^\/+|\/+$/g, '')).filter(Boolean);
+  return '/' + parts.join('/');
+}
+
 /**
  * Resolve a symbol by name using indexed queries instead of scanning all files.
  */

+ 18 - 8
src/resolution/frameworks/laravel.ts

@@ -44,6 +44,13 @@ export const laravelResolver: FrameworkResolver = {
     return context.fileExists('artisan') || context.fileExists('app/Http/Kernel.php');
   },
 
+  // `Controller@method` route refs name no declared symbol, so resolveOne's
+  // pre-filter would drop them before resolve() runs (Pattern 4). Claim them —
+  // same hook the django ORM / Rails routing work needed.
+  claimsReference(name: string): boolean {
+    return /^[A-Za-z_][A-Za-z0-9_]*Controller@\w+$/.test(name);
+  },
+
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
     // Pattern 1: Model::method() - Eloquent static calls
     const modelMatch = ref.referenceName.match(/^([A-Z][a-zA-Z]+)::(\w+)$/);
@@ -185,18 +192,21 @@ export const laravelResolver: FrameworkResolver = {
  */
 function extractLaravelHandler(expr: string): string | null {
   const trimmed = expr.trim();
+  const short = (s: string) => s.split('\\').pop()!; // strip namespace
 
-  // [Class::class, 'method'] — grab the string literal
-  const tupleMatch = trimmed.match(/^\[\s*[^,]+,\s*['"]([^'"]+)['"]\s*\]/);
-  if (tupleMatch) return tupleMatch[1]!;
+  // [Class::class, 'method'] → `Class@method` (PRECISE — keep the controller, so
+  // common action names like `index`/`show` resolve to the RIGHT controller, not
+  // whichever one name-matching happens to pick first).
+  const tupleMatch = trimmed.match(/^\[\s*([A-Za-z_\\][\w\\]*)::class\s*,\s*['"]([^'"]+)['"]\s*\]/);
+  if (tupleMatch) return `${short(tupleMatch[1]!)}@${tupleMatch[2]!}`;
 
-  // 'Controller@method'
+  // 'Controller@method' (possibly namespaced) → `Controller@method`
   const atMatch = trimmed.match(/^['"]([^'"@]+)@([^'"]+)['"]$/);
-  if (atMatch) return atMatch[2]!;
+  if (atMatch) return `${short(atMatch[1]!)}@${atMatch[2]!}`;
 
-  // Controller::class
-  const classMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)::class/);
-  if (classMatch) return classMatch[1]!;
+  // Class::class (Route::resource controller) → `Class`
+  const classMatch = trimmed.match(/^([A-Za-z_\\][\w\\]*)::class/);
+  if (classMatch) return short(classMatch[1]!);
 
   return null;
 }

+ 112 - 0
src/resolution/frameworks/play.ts

@@ -0,0 +1,112 @@
+/**
+ * Play Framework (Scala/Java) resolver.
+ *
+ * Play declares HTTP routes in a dedicated `conf/routes` file (and included
+ * `conf/*.routes`), Rails-style:
+ *
+ *   GET   /computers        controllers.Application.list(p: Int ?= 0)
+ *   POST  /computers        controllers.Application.save
+ *   GET   /assets/*file     controllers.Assets.versioned(path = "/public", file: Asset)
+ *
+ * The file is extensionless, so the file walk only indexes it because
+ * `isPlayRoutesFile` (grammars.ts) opts it in; it's processed through the
+ * no-grammar path and this resolver extracts the routes. Each route references
+ * its handler as `Controller.method` (the package prefix is dropped), resolved
+ * to the action method in the controller class.
+ */
+
+import { Node } from '../../types';
+import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
+import { isPlayRoutesFile } from '../../extraction/grammars';
+
+const ROUTE_LINE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)\s+(.+)$/;
+const METHOD_KINDS = new Set(['method', 'function']);
+const CLASS_KINDS = new Set(['class']);
+
+export const playResolver: FrameworkResolver = {
+  name: 'play',
+  // `yaml` so this resolver runs on conf/routes (detectLanguage maps it to yaml);
+  // `scala`/`java` so it's active in Play projects of either language.
+  languages: ['scala', 'java', 'yaml'],
+
+  detect(context: ResolutionContext): boolean {
+    const buildSbt = context.readFile('build.sbt');
+    if (buildSbt && /playframework|"play"|sbt-plugin|PlayScala|PlayJava/i.test(buildSbt)) return true;
+    if (context.fileExists('conf/routes')) return true;
+    if (context.fileExists('conf/application.conf')) return true;
+    return false;
+  },
+
+  // The handler is `Controller.method` (a class-qualified action), which names no
+  // bare declared symbol, so resolveOne's pre-filter could drop it — claim it.
+  claimsReference(name: string): boolean {
+    return /^[A-Za-z_]\w*\.[A-Za-z_]\w*$/.test(name);
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    const m = ref.referenceName.match(/^([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/);
+    if (!m) return null;
+    const [, className, methodName] = m;
+    const classNodes = context.getNodesByName(className!).filter((n) => CLASS_KINDS.has(n.kind));
+    for (const cls of classNodes) {
+      const method = context
+        .getNodesInFile(cls.filePath)
+        .find((n) => METHOD_KINDS.has(n.kind) && n.name === methodName);
+      if (method) {
+        return { original: ref, targetNodeId: method.id, confidence: 0.9, resolvedBy: 'framework' };
+      }
+    }
+    return null;
+  },
+
+  extract(filePath: string, content: string): { nodes: Node[]; references: UnresolvedRef[] } {
+    if (!isPlayRoutesFile(filePath)) return { nodes: [], references: [] };
+    const nodes: Node[] = [];
+    const references: UnresolvedRef[] = [];
+    const now = Date.now();
+
+    const lines = content.split('\n');
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i]!.trim();
+      // Skip comments and `->` route includes (a sub-router mount, not an action).
+      if (!line || line.startsWith('#') || line.startsWith('->')) continue;
+      const m = line.match(ROUTE_LINE);
+      if (!m) continue;
+      const [, method, routePath, action] = m;
+
+      // action: `controllers.Application.list(p: Int ?= 0)` → drop args, keep the
+      // last `Controller.method` segment (package prefix is irrelevant for lookup).
+      const fqn = action!.split('(')[0]!.trim();
+      const parts = fqn.split('.').filter(Boolean);
+      if (parts.length < 2) continue;
+      const handlerRef = parts.slice(-2).join('.'); // Application.list
+
+      const lineNum = i + 1;
+      const routeNode: Node = {
+        id: `route:${filePath}:${lineNum}:${method}:${routePath}`,
+        kind: 'route',
+        name: `${method} ${routePath}`,
+        qualifiedName: `${filePath}::${method}:${routePath}`,
+        filePath,
+        startLine: lineNum,
+        endLine: lineNum,
+        startColumn: 0,
+        endColumn: 0,
+        language: 'scala',
+        updatedAt: now,
+      };
+      nodes.push(routeNode);
+      references.push({
+        fromNodeId: routeNode.id,
+        referenceName: handlerRef,
+        referenceKind: 'references',
+        line: lineNum,
+        column: 0,
+        filePath,
+        language: 'scala',
+      });
+    }
+
+    return { nodes, references };
+  },
+};

+ 136 - 14
src/resolution/frameworks/python.ts

@@ -35,9 +35,25 @@ export const djangoResolver: FrameworkResolver = {
       const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FORM_DIRS, context);
       if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
     }
+    // ORM dynamic dispatch: QuerySet._fetch_all (and siblings) call
+    // `self._iterable_class(self)` — a runtime dispatch to the iterable class
+    // (default ModelIterable) whose __iter__ runs the SQL compiler. Static
+    // parsing can't resolve an attribute-as-callable, so it leaves an unresolved
+    // `_iterable_class` ref and a hole in the QuerySet→compiler chain. Bridge it
+    // to ModelIterable.__iter__ so the flow actually exists in the graph.
+    if (ref.referenceName === '_iterable_class') {
+      const target = resolveModelIterableIter(context);
+      if (target) return { original: ref, targetNodeId: target, confidence: 0.7, resolvedBy: 'framework' };
+    }
     return null;
   },
 
+  // Let the ORM dynamic-dispatch ref reach resolve() despite no symbol being
+  // named `_iterable_class` (it's a QuerySet attribute, not a declared method).
+  claimsReference(name) {
+    return name === '_iterable_class';
+  },
+
   extract(filePath, content) {
     if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
 
@@ -86,10 +102,54 @@ export const djangoResolver: FrameworkResolver = {
       }
     }
 
+    // DRF router registration: `router.register(r'articles', ArticleViewSet)` →
+    // route → the ViewSet class (the core CRUD endpoints, which path()/url() miss).
+    // The STRING first arg separates this from `admin.site.register(Model, Admin)`
+    // (whose first arg is a model class, not a string); the View/ViewSet suffix on
+    // the 2nd arg keeps it to DRF viewsets.
+    const routerRegex = /\.register\s*\(\s*r?['"]([^'"]+)['"]\s*,\s*([\w.]+)/g;
+    while ((match = routerRegex.exec(safe)) !== null) {
+      const prefix = match[1]!.replace(/^\^|\/?\$$/g, '');
+      const viewset = match[2]!.split('.').pop()!;
+      if (!/View(Set)?$/.test(viewset)) continue;
+      const line = safe.slice(0, match.index).split('\n').length;
+      const routeNode: Node = {
+        id: `route:${filePath}:${line}:VIEWSET:${prefix}`,
+        kind: 'route',
+        name: `VIEWSET /${prefix}`,
+        qualifiedName: `${filePath}::route:${prefix}`,
+        filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length,
+        language: 'python', updatedAt: now,
+      };
+      nodes.push(routeNode);
+      references.push({
+        fromNodeId: routeNode.id,
+        referenceName: viewset,
+        referenceKind: 'references',
+        line, column: 0, filePath, language: 'python',
+      });
+    }
+
     return { nodes, references };
   },
 };
 
+/**
+ * Find ModelIterable.__iter__ — the default iterable QuerySet invokes via
+ * `self._iterable_class(self)`. Its __iter__ statically calls the SQL compiler,
+ * so linking the dynamic dispatch here closes the QuerySet→SQL call chain.
+ * (Over-approximates to the default iterable; .values()/.values_list() swap in
+ * other BaseIterable subclasses, but ModelIterable is the canonical path.)
+ */
+function resolveModelIterableIter(context: ResolutionContext): string | null {
+  const cls = context.getNodesByName('ModelIterable').find((n) => n.kind === 'class');
+  if (!cls) return null;
+  const iter = context.getNodesByName('__iter__').find(
+    (n) => n.filePath === cls.filePath && n.startLine >= cls.startLine && n.startLine <= cls.endLine
+  );
+  return iter ? iter.id : null;
+}
+
 /**
  * Parse a Django URL handler expression and return the symbol/module to link.
  * Returns null for shapes we can't confidently link (e.g. lambdas).
@@ -117,13 +177,20 @@ export const flaskResolver: FrameworkResolver = {
   languages: ['python'],
 
   detect(context) {
-    const requirements = context.readFile('requirements.txt');
-    if (requirements && /\bflask\b/i.test(requirements)) return true;
-    const pyproject = context.readFile('pyproject.toml');
-    if (pyproject && /\bflask\b/i.test(pyproject)) return true;
-    for (const file of ['app.py', 'application.py', 'main.py', '__init__.py']) {
-      const content = context.readFile(file);
-      if (content && content.includes('Flask(__name__)')) return true;
+    for (const f of ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py']) {
+      const c = context.readFile(f);
+      if (c && /\bflask\b/i.test(c)) return true;
+    }
+    // Any app entrypoint (root OR subdir, e.g. conduit/app.py) that imports flask
+    // and instantiates Flask(...) — covers Flask(__name__), Flask(__name__.split…),
+    // and the app-factory pattern. Bounded to entrypoint-named files.
+    const entrypoints = context
+      .getAllFiles()
+      .filter((f) => /(?:^|\/)(app|application|main|wsgi|__init__)\.py$/.test(f))
+      .slice(0, 50);
+    for (const f of entrypoints) {
+      const c = context.readFile(f);
+      if (c && /\bFlask\s*\(/.test(c) && /\bimport\s+flask\b|\bfrom\s+flask\b/.test(c)) return true;
     }
     return false;
   },
@@ -138,15 +205,23 @@ export const flaskResolver: FrameworkResolver = {
 
   extract(filePath, content) {
     if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
-    return extractDecoratorRoutes(filePath, stripCommentsForRegex(content, 'python'), {
-      // Flask: @x.route('/path', methods=[...])
-      decoratorRegex: /@(\w+)\.route\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*methods\s*=\s*\[([^\]]+)\])?\s*\)\s*\n\s*(?:async\s+)?def\s+(\w+)/g,
+    const safe = stripCommentsForRegex(content, 'python');
+    const decorator = extractDecoratorRoutes(filePath, safe, {
+      // Flask: @x.route('/path', methods=[...] | (...)) — the handler is the next
+      // `def`, allowing intervening decorators (@login_required) and stacked
+      // @x.route() lines. methods may be a list OR a tuple (methods=('GET',)).
+      decoratorRegex: /@(\w+)\.route\s*\(\s*['"]([^'"]*)['"](?:\s*,\s*methods\s*=\s*[[(]([^\])]+)[\])])?\s*\)/g,
       defaultMethod: 'GET',
       methodFromGroup: 3,
       pathGroup: 2,
-      handlerGroup: 4,
+      findHandler: true,
       language: 'python',
     });
+    const restful = extractFlaskRestful(filePath, safe);
+    return {
+      nodes: [...decorator.nodes, ...restful.nodes],
+      references: [...decorator.references, ...restful.references],
+    };
   },
 };
 
@@ -181,8 +256,9 @@ export const fastapiResolver: FrameworkResolver = {
   extract(filePath, content) {
     if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
     return extractDecoratorRoutes(filePath, stripCommentsForRegex(content, 'python'), {
-      // FastAPI: @x.METHOD('/path') -> handler on the next def line
-      decoratorRegex: /@(\w+)\.(get|post|put|patch|delete|options|head)\s*\(\s*['"]([^'"]+)['"]/g,
+      // FastAPI: @x.METHOD('/path') -> handler on the next def line. Path may be
+      // empty ("") for routes mounted at the router/prefix root.
+      decoratorRegex: /@(\w+)\.(get|post|put|patch|delete|options|head)\s*\(\s*['"]([^'"]*)['"]/g,
       defaultMethod: '',
       methodGroup: 2,
       pathGroup: 3,
@@ -218,7 +294,7 @@ function extractDecoratorRoutes(filePath: string, content: string, opts: Decorat
       if (m) method = m[1]!.toUpperCase();
     }
     const line = content.slice(0, match.index).split('\n').length;
-    const name = method ? `${method} ${routePath}` : routePath!;
+    const name = method ? `${method} ${routePath || '/'}` : (routePath || '/');
     const routeNode: Node = {
       id: `route:${filePath}:${line}:${method}:${routePath}`,
       kind: 'route',
@@ -257,6 +333,52 @@ function extractDecoratorRoutes(filePath: string, content: string, opts: Decorat
   return { nodes, references };
 }
 
+/**
+ * Flask-RESTful: `api.add_resource(ResourceClass, '/path'[, '/path2'])`
+ * (and variants like redash's `add_org_resource`). The ResourceClass holds the
+ * HTTP-verb methods (get/post/…), so the route references the class — its verb
+ * methods resolve as the handlers via the class. Method is ANY (the class
+ * decides which verbs it serves).
+ */
+function extractFlaskRestful(filePath: string, safe: string): FrameworkExtractionResult {
+  const nodes: Node[] = [];
+  const references: UnresolvedRef[] = [];
+  const now = Date.now();
+  const re = /\.add\w*[Rr]esource\s*\(\s*(\w+)\s*,\s*((?:['"][^'"]+['"]\s*,?\s*)+)/g;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(safe)) !== null) {
+    const className = m[1]!;
+    const paths = (m[2]!.match(/['"]([^'"]+)['"]/g) || []).map((s) => s.slice(1, -1));
+    const line = safe.slice(0, m.index).split('\n').length;
+    for (const routePath of paths) {
+      const routeNode: Node = {
+        id: `route:${filePath}:${line}:ANY:${routePath}`,
+        kind: 'route',
+        name: `ANY ${routePath}`,
+        qualifiedName: `${filePath}::ANY:${routePath}`,
+        filePath,
+        startLine: line,
+        endLine: line,
+        startColumn: 0,
+        endColumn: 0,
+        language: 'python',
+        updatedAt: now,
+      };
+      nodes.push(routeNode);
+      references.push({
+        fromNodeId: routeNode.id,
+        referenceName: className,
+        referenceKind: 'references',
+        line,
+        column: 0,
+        filePath,
+        language: 'python',
+      });
+    }
+  }
+  return { nodes, references };
+}
+
 // Directory patterns
 const MODEL_DIRS = ['models', 'app/models', 'src/models'];
 const VIEW_DIRS = ['views', 'app/views', 'src/views', 'api/views'];

+ 97 - 3
src/resolution/frameworks/react.ts

@@ -76,6 +76,7 @@ export const reactResolver: FrameworkResolver = {
 
   extract(filePath, content) {
     const nodes: Node[] = [];
+    const references: UnresolvedRef[] = [];
     const now = Date.now();
 
     // Extract component definitions
@@ -143,6 +144,89 @@ export const reactResolver: FrameworkResolver = {
       });
     }
 
+    // React Router: <Route path="/x" component={Comp}/> (v5) or
+    // <Route path="/x" element={<Comp/>}/> (v6). Attributes appear in any order,
+    // and element={...} contains a nested `>`, so scan a window after each
+    // <Route rather than trying to match the whole (possibly multi-line) tag.
+    const routeTagRegex = /<Route\b/g;
+    let routeMatch: RegExpExecArray | null;
+    while ((routeMatch = routeTagRegex.exec(content)) !== null) {
+      const window = content.slice(routeMatch.index, routeMatch.index + 400);
+      const pathMatch = window.match(/\bpath\s*=\s*["']([^"']+)["']/);
+      if (!pathMatch) continue; // index/layout routes without a path
+      const routePath = pathMatch[1]!;
+      const compMatch =
+        window.match(/\bcomponent\s*=\s*\{\s*([A-Z][A-Za-z0-9_]*)/) ||
+        window.match(/\belement\s*=\s*\{\s*<\s*([A-Z][A-Za-z0-9_]*)/);
+      const line = content.slice(0, routeMatch.index).split('\n').length;
+      const routeNode: Node = {
+        id: `route:${filePath}:${line}:${routePath}`,
+        kind: 'route',
+        name: routePath,
+        qualifiedName: `${filePath}::route:${routePath}`,
+        filePath,
+        startLine: line,
+        endLine: line,
+        startColumn: 0,
+        endColumn: 0,
+        language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
+        updatedAt: now,
+      };
+      nodes.push(routeNode);
+      if (compMatch) {
+        references.push({
+          fromNodeId: routeNode.id,
+          referenceName: compMatch[1]!,
+          referenceKind: 'references',
+          line,
+          column: 0,
+          filePath,
+          language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
+        });
+      }
+    }
+
+    // React Router data-router (v6.4+): createBrowserRouter([{ path, element }]).
+    // Only scan files that use the data-router API, then pull each route object's
+    // `path` + `element={<Comp/>}` / `Component: Comp` (a forward window confirms
+    // it's a route object, not a stray `path:` field).
+    if (/\b(?:createBrowserRouter|createHashRouter|createMemoryRouter|createRoutesFromElements)\b/.test(content)) {
+      const objPathRe = /\bpath\s*:\s*['"]([^'"]*)['"]/g;
+      let om: RegExpExecArray | null;
+      while ((om = objPathRe.exec(content)) !== null) {
+        const win = content.slice(om.index, om.index + 300);
+        const compMatch =
+          win.match(/\belement\s*:\s*<\s*([A-Z][A-Za-z0-9_]*)/) ||
+          win.match(/\bComponent\s*:\s*([A-Z][A-Za-z0-9_]*)/);
+        if (!compMatch) continue; // require a component → it's a real route object
+        const routePath = om[1] || '/';
+        const line = content.slice(0, om.index).split('\n').length;
+        const routeNode: Node = {
+          id: `route:${filePath}:${line}:${routePath}`,
+          kind: 'route',
+          name: routePath,
+          qualifiedName: `${filePath}::route:${routePath}`,
+          filePath,
+          startLine: line,
+          endLine: line,
+          startColumn: 0,
+          endColumn: 0,
+          language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
+          updatedAt: now,
+        };
+        nodes.push(routeNode);
+        references.push({
+          fromNodeId: routeNode.id,
+          referenceName: compMatch[1]!,
+          referenceKind: 'references',
+          line,
+          column: 0,
+          filePath,
+          language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
+        });
+      }
+    }
+
     // Extract Next.js pages/routes (pages directory convention)
     if (filePath.includes('pages/') || filePath.includes('app/')) {
       // Default export in pages becomes a route
@@ -169,7 +253,7 @@ export const reactResolver: FrameworkResolver = {
       }
     }
 
-    return { nodes, references: [] };
+    return { nodes, references };
   },
 };
 
@@ -279,7 +363,17 @@ function filePathToRoute(filePath: string): string | null {
   // app/page.tsx -> /
   // app/about/page.tsx -> /about
 
-  if (filePath.includes('pages/')) {
+  // Only real page-component files are routes. Exclude non-page extensions
+  // (.mjs/.json/.cjs), config files (next.config.ts, vite.config.ts…), and
+  // Next.js special files (_app/_document). This also stops a `*.config.mjs`
+  // with `export default` in a dir like `nextjs-pages/` from being a "route".
+  const base = filePath.split('/').pop() ?? '';
+  if (!/\.(tsx?|jsx?)$/.test(base)) return null;
+  if (base.startsWith('_') || /\.config\.[a-z]+$/.test(base)) return null;
+
+  // Match pages/ and app/ as PATH SEGMENTS (not a substring — `nextjs-pages/`
+  // must not count as a `pages/` router dir).
+  if (/(?:^|\/)pages\//.test(filePath)) {
     let route = filePath
       .replace(/^.*pages\//, '/')
       .replace(/\/index\.(tsx?|jsx?)$/, '')
@@ -290,7 +384,7 @@ function filePathToRoute(filePath: string): string | null {
     return route;
   }
 
-  if (filePath.includes('app/')) {
+  if (/(?:^|\/)app\//.test(filePath)) {
     // App router - only page.tsx files are routes
     if (!filePath.includes('page.')) {
       return null;

+ 103 - 2
src/resolution/frameworks/ruby.ts

@@ -12,6 +12,13 @@ export const railsResolver: FrameworkResolver = {
   name: 'rails',
   languages: ['ruby'],
 
+  // `controller#action` route refs name no declared symbol, so resolveOne's
+  // pre-filter would drop them before resolve() runs. Claim them (like the django
+  // `_iterable_class` hook) so they reach Pattern 0.
+  claimsReference(name: string): boolean {
+    return /^[\w/]+#\w+$/.test(name);
+  },
+
   detect(context: ResolutionContext): boolean {
     // Check for Gemfile with rails
     const gemfile = context.readFile('Gemfile');
@@ -32,6 +39,18 @@ export const railsResolver: FrameworkResolver = {
   },
 
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    // Pattern 0: route action `controller#action` (from RESTful `resources` or an
+    // explicit route) → the action method in that controller. Precise — avoids the
+    // bare-`action` ambiguity (every controller has an `index`/`show`).
+    const ca = ref.referenceName.match(/^([\w/]+)#(\w+)$/);
+    if (ca) {
+      const result = resolveControllerAction(ca[1]!, ca[2]!, context);
+      if (result) {
+        return { original: ref, targetNodeId: result, confidence: 0.85, resolvedBy: 'framework' };
+      }
+      return null;
+    }
+
     // Pattern 1: Model references (ActiveRecord)
     if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
       const result = resolveModel(ref.referenceName, context);
@@ -99,7 +118,7 @@ export const railsResolver: FrameworkResolver = {
     const routeRegex = /\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*(?:,\s*to:\s*|=>\s*)['"]([^#'"]+)#([^'"]+)['"]/g;
     let match: RegExpExecArray | null;
     while ((match = routeRegex.exec(safe)) !== null) {
-      const [, method, routePath, _controller, action] = match;
+      const [, method, routePath, ctrl, action] = match;
       const line = safe.slice(0, match.index).split('\n').length;
       const upper = method!.toUpperCase();
       const routeNode: Node = {
@@ -119,7 +138,7 @@ export const railsResolver: FrameworkResolver = {
 
       references.push({
         fromNodeId: routeNode.id,
-        referenceName: action!,
+        referenceName: `${ctrl}#${action}`, // precise controller#action, not bare action
         referenceKind: 'references',
         line,
         column: 0,
@@ -128,12 +147,94 @@ export const railsResolver: FrameworkResolver = {
       });
     }
 
+    // RESTful resources: `resources :articles` / `resource :user` (the dominant
+    // Rails routing) generate a controller action per REST verb. The old resolver
+    // only saw explicit `get '/x' => 'c#a'` routes, so resource-routed apps had
+    // ZERO route nodes. Expand each into its actions → `controller#action` refs.
+    const resRegex = /\b(resources?)\s+:(\w+)([^\n]*)/g;
+    while ((match = resRegex.exec(safe)) !== null) {
+      const plural = match[1] === 'resources';
+      const resName = match[2]!;
+      const tail = match[3] || '';
+      let actions = plural ? PLURAL_ACTIONS : SINGULAR_ACTIONS;
+      const only = tail.match(/only:\s*\[([^\]]*)\]/);
+      const except = tail.match(/except:\s*\[([^\]]*)\]/);
+      const symList = (s: string) => new Set(s.split(',').map((x) => x.trim().replace(/^:/, '')));
+      if (only) { const s = symList(only[1]!); actions = actions.filter((a) => s.has(a)); }
+      else if (except) { const s = symList(except[1]!); actions = actions.filter((a) => !s.has(a)); }
+      // `resources :articles` → ArticlesController; `resource :user` → UsersController.
+      const ctrl = plural ? resName : pluralize(resName);
+      const line = safe.slice(0, match.index).split('\n').length;
+      for (const action of actions) {
+        const spec = RESTFUL_ROUTES[action]!;
+        const path = spec.path(resName);
+        const routeNode: Node = {
+          id: `route:${filePath}:${line}:${spec.method}:${ctrl}#${action}`,
+          kind: 'route',
+          name: `${spec.method} ${path}`,
+          qualifiedName: `${filePath}::route:${ctrl}#${action}`,
+          filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length,
+          language: 'ruby', updatedAt: now,
+        };
+        nodes.push(routeNode);
+        references.push({
+          fromNodeId: routeNode.id,
+          referenceName: `${ctrl}#${action}`,
+          referenceKind: 'references',
+          line, column: 0, filePath, language: 'ruby',
+        });
+      }
+    }
+
     return { nodes, references };
   },
 };
 
 // Helper functions
 
+// RESTful action → HTTP verb + path. `resources` gets all seven; a singular
+// `resource` omits `index`.
+const RESTFUL_ROUTES: Record<string, { method: string; path: (r: string) => string }> = {
+  index:   { method: 'GET',    path: (r) => `/${r}` },
+  create:  { method: 'POST',   path: (r) => `/${r}` },
+  new:     { method: 'GET',    path: (r) => `/${r}/new` },
+  show:    { method: 'GET',    path: (r) => `/${r}/:id` },
+  edit:    { method: 'GET',    path: (r) => `/${r}/:id/edit` },
+  update:  { method: 'PATCH',  path: (r) => `/${r}/:id` },
+  destroy: { method: 'DELETE', path: (r) => `/${r}/:id` },
+};
+const PLURAL_ACTIONS = ['index', 'create', 'new', 'show', 'edit', 'update', 'destroy'];
+const SINGULAR_ACTIONS = ['create', 'new', 'show', 'edit', 'update', 'destroy'];
+
+/** Naive ActiveSupport-style pluralize — covers the common resource names. */
+function pluralize(w: string): string {
+  if (/[^aeiou]y$/.test(w)) return w.slice(0, -1) + 'ies';
+  if (/(s|x|z|ch|sh)$/.test(w)) return w + 'es';
+  return w + 's';
+}
+
+/** snake_case → CamelCase (`user_profiles` → `UserProfiles`). */
+function camelize(s: string): string {
+  return s.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
+}
+
+/** Resolve a `controller#action` route ref to the action method in that controller. */
+function resolveControllerAction(ctrlPath: string, action: string, context: ResolutionContext): string | null {
+  // Rails convention: `articles` → app/controllers/articles_controller.rb.
+  const direct = `app/controllers/${ctrlPath}_controller.rb`;
+  if (context.fileExists(direct)) {
+    const m = context.getNodesInFile(direct).find((n) => (n.kind === 'method' || n.kind === 'function') && n.name === action);
+    if (m) return m.id;
+  }
+  // Fall back: controller class by name, then the action method in its file.
+  const cls = camelize(ctrlPath.split('/').pop()!) + 'Controller';
+  for (const ctrl of context.getNodesByName(cls).filter((n) => n.kind === 'class')) {
+    const m = context.getNodesInFile(ctrl.filePath).find((n) => (n.kind === 'method' || n.kind === 'function') && n.name === action);
+    if (m) return m.id;
+  }
+  return null;
+}
+
 function resolveModel(name: string, context: ResolutionContext): string | null {
   // Try direct file path lookup first (Rails convention: CamelCase -> snake_case.rb)
   const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);

+ 104 - 8
src/resolution/frameworks/rust.ts

@@ -135,13 +135,64 @@ export const rustResolver: FrameworkResolver = {
       }
     }
 
-    // Axum: .route("/path", get(handler))
-    const axumRegex = /\.route\s*\(\s*"([^"]+)"\s*,\s*(get|post|put|patch|delete)\s*\(\s*(\w+)/g;
-    while ((match = axumRegex.exec(safe)) !== null) {
-      const [, routePath, method, handler] = match;
+    // Axum: .route("/path", get(h1).post(h2)…) — balanced-paren scan the route
+    // call, then emit one route node per chained method. Handlers may be
+    // namespaced (`get(module::handler)`, `get(self::list)`); take the last
+    // path segment so the ref names the fn, not the module.
+    const routeOpenRegex = /\.route\s*\(/g;
+    while ((match = routeOpenRegex.exec(safe)) !== null) {
+      const openIdx = safe.indexOf('(', match.index);
+      if (openIdx < 0) continue;
+      const closeIdx = findMatchingParen(safe, openIdx);
+      if (closeIdx < 0) continue;
+
+      const args = safe.slice(openIdx + 1, closeIdx);
+      const pathMatch = args.match(/^\s*"([^"]+)"\s*,/);
+      if (!pathMatch) continue;
+      const routePath = pathMatch[1]!;
       const line = safe.slice(0, match.index).split('\n').length;
-      const upper = method!.toUpperCase();
 
+      const methodBody = args.slice(pathMatch[0].length);
+      const methodHandlerRegex = /\b(get|post|put|patch|delete|head|options|trace)\s*\(\s*([A-Za-z_][\w:]*)/g;
+      let mh: RegExpExecArray | null;
+      while ((mh = methodHandlerRegex.exec(methodBody)) !== null) {
+        const upper = mh[1]!.toUpperCase();
+        const handler = mh[2]!.split('::').filter(Boolean).pop();
+        if (!handler) continue;
+
+        const routeNode: Node = {
+          id: `route:${filePath}:${line}:${upper}:${routePath}`,
+          kind: 'route',
+          name: `${upper} ${routePath}`,
+          qualifiedName: `${filePath}::route:${routePath}`,
+          filePath,
+          startLine: line,
+          endLine: line,
+          startColumn: 0,
+          endColumn: 0,
+          language: 'rust',
+          updatedAt: now,
+        };
+        nodes.push(routeNode);
+
+        references.push({
+          fromNodeId: routeNode.id,
+          referenceName: handler,
+          referenceKind: 'references',
+          line,
+          column: 0,
+          filePath,
+          language: 'rust',
+        });
+      }
+    }
+
+    // Actix-web builder API (the dominant actix routing style; attribute macros
+    // are handled above). The handler lives in `.to(handler)`, not `get(handler)`.
+    const pushActixRoute = (routePath: string, method: string, handlerExpr: string, line: number) => {
+      const handler = handlerExpr.split('::').filter(Boolean).pop();
+      if (!handler) return;
+      const upper = method.toUpperCase();
       const routeNode: Node = {
         id: `route:${filePath}:${line}:${upper}:${routePath}`,
         kind: 'route',
@@ -151,21 +202,53 @@ export const rustResolver: FrameworkResolver = {
         startLine: line,
         endLine: line,
         startColumn: 0,
-        endColumn: match[0].length,
+        endColumn: 0,
         language: 'rust',
         updatedAt: now,
       };
       nodes.push(routeNode);
-
       references.push({
         fromNodeId: routeNode.id,
-        referenceName: handler!,
+        referenceName: handler,
         referenceKind: 'references',
         line,
         column: 0,
         filePath,
         language: 'rust',
       });
+    };
+
+    // web::resource("/path") { .route(web::METHOD().to(h)) | .to(h) } — possibly chained.
+    const resourceRegex = /web::resource\s*\(\s*"([^"]+)"\s*\)/g;
+    while ((match = resourceRegex.exec(safe)) !== null) {
+      const routePath = match[1]!;
+      const startLine = safe.slice(0, match.index).split('\n').length;
+      const after = match.index + match[0].length;
+      // Bound the resource's method chain at the next resource() to avoid bleed.
+      const nextRes = safe.indexOf('web::resource', after);
+      const end = Math.min(after + 500, nextRes === -1 ? safe.length : nextRes);
+      const chain = safe.slice(after, end);
+
+      const methodTo = /web::(get|post|put|patch|delete|head)\s*\(\s*\)\s*\.to\s*\(\s*([A-Za-z_][\w:]*)/g;
+      let m2: RegExpExecArray | null;
+      let found = false;
+      while ((m2 = methodTo.exec(chain)) !== null) {
+        const mLine = startLine + chain.slice(0, m2.index).split('\n').length - 1;
+        pushActixRoute(routePath, m2[1]!, m2[2]!, mLine);
+        found = true;
+      }
+      // Direct `.resource("/x").to(handler)` (all methods) when no explicit verb route.
+      if (!found) {
+        const direct = chain.match(/^\s*\.to\s*\(\s*([A-Za-z_][\w:]*)/);
+        if (direct) pushActixRoute(routePath, 'ANY', direct[1]!, startLine);
+      }
+    }
+
+    // App-level: .route("/path", web::METHOD().to(handler)).
+    const appRouteRegex = /\.route\s*\(\s*"([^"]+)"\s*,\s*web::(get|post|put|patch|delete|head)\s*\(\s*\)\s*\.to\s*\(\s*([A-Za-z_][\w:]*)/g;
+    while ((match = appRouteRegex.exec(safe)) !== null) {
+      const line = safe.slice(0, match.index).split('\n').length;
+      pushActixRoute(match[1]!, match[2]!, match[3]!, line);
     }
 
     return { nodes, references };
@@ -181,6 +264,19 @@ const FUNCTION_KINDS = new Set(['function']);
 const SERVICE_KINDS = new Set(['struct', 'trait']);
 const STRUCT_KINDS = new Set(['struct']);
 
+/** Index of the ')' that matches the '(' at openIdx, or -1 if unbalanced. */
+function findMatchingParen(s: string, openIdx: number): number {
+  let depth = 0;
+  for (let i = openIdx; i < s.length; i++) {
+    if (s[i] === '(') depth++;
+    else if (s[i] === ')') {
+      depth--;
+      if (depth === 0) return i;
+    }
+  }
+  return -1;
+}
+
 /**
  * Resolve a symbol by name using indexed queries instead of scanning all files.
  */

+ 31 - 6
src/resolution/frameworks/swift.ts

@@ -341,13 +341,39 @@ export const vaporResolver: FrameworkResolver = {
     const now = Date.now();
     const safe = stripCommentsForRegex(content, 'swift');
 
-    // Vapor: (app|router|routes).METHOD("path", use: handler)
-    const routeRegex = /\b(?:app|router|routes)\.(get|post|put|patch|delete)\s*\(\s*"([^"]+)"\s*,\s*use:\s*([A-Za-z_][A-Za-z0-9_.]*)/g;
+    // Build a group-var → path-prefix map first. Modern Vapor routes live on a
+    // grouped builder (`let todos = routes.grouped("todos"); todos.get(use: index)`
+    // or `routes.group("todos") { todos in todos.get(use: index) }`), so the path
+    // comes from the group, not the call. Roots (app/routes/router) have no prefix.
+    const groupPrefix = new Map<string, string>();
+    const segJoin = (existing: string, segsStr: string): string => {
+      const segs = (segsStr.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1));
+      return existing + segs.map((s) => '/' + s).join('');
+    };
+    let gm: RegExpExecArray | null;
+    // let X = Y.grouped("a", "b")
+    const groupedRegex = /\blet\s+(\w+)\s*=\s*(\w+)\.grouped\s*\(([^)]*)\)/g;
+    while ((gm = groupedRegex.exec(safe)) !== null) {
+      groupPrefix.set(gm[1]!, segJoin(groupPrefix.get(gm[2]!) ?? '', gm[3]!));
+    }
+    // Y.group("a") { X in ... }
+    const groupClosureRegex = /\b(\w+)\.group\s*\(([^)]*)\)\s*\{\s*(\w+)\s+in/g;
+    while ((gm = groupClosureRegex.exec(safe)) !== null) {
+      groupPrefix.set(gm[3]!, segJoin(groupPrefix.get(gm[1]!) ?? '', gm[2]!));
+    }
+
+    // Vapor: <builder>.METHOD([path segs,] use: handler). Any receiver (app,
+    // routes, or a grouped var); path segments optional and may be non-string
+    // (`BlogUser.parameter`, `:id`, a path constant) so accept any comma-separated
+    // args before `use:` — the label keeps only the string parts. `use:`
+    // discriminates a real route from Environment.get("X")/req.parameters.get("X").
+    const routeRegex = /\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,\s*)*)use:\s*([A-Za-z_][\w.]*)/g;
     let match: RegExpExecArray | null;
     while ((match = routeRegex.exec(safe)) !== null) {
-      const [, method, routePath, handlerExpr] = match;
+      const [, receiver, method, segsStr, handlerExpr] = match;
       const line = safe.slice(0, match.index).split('\n').length;
       const upper = method!.toUpperCase();
+      const routePath = (groupPrefix.get(receiver!) ?? '') + segJoin('', segsStr!) || '/';
 
       const routeNode: Node = {
         id: `route:${filePath}:${line}:${upper}:${routePath}`,
@@ -364,9 +390,8 @@ export const vaporResolver: FrameworkResolver = {
       };
       nodes.push(routeNode);
 
-      // Last segment of dotted path (e.g. UserController.list -> list)
-      const parts = handlerExpr!.split('.');
-      const handlerName = parts[parts.length - 1];
+      // Last segment of a dotted handler (self.list / UserController.list -> list)
+      const handlerName = handlerExpr!.split('.').pop();
       if (handlerName) {
         references.push({
           fromNodeId: routeNode.id,

+ 23 - 2
src/resolution/index.ts

@@ -19,6 +19,7 @@ import {
 import { matchReference } from './name-matcher';
 import { resolveViaImport, extractImportMappings, extractReExports } from './import-resolver';
 import { detectFrameworks } from './frameworks';
+import { synthesizeCallbackEdges } from './callback-synthesizer';
 import { loadProjectAliases, type AliasMap } from './path-aliases';
 import { logDebug } from '../errors';
 import type { ReExport } from './types';
@@ -493,7 +494,11 @@ export class ReferenceResolver {
     // from './barrel'` where the barrel has `export { signIn as login }
     // from './auth'`) intentionally call a name that has no
     // declaration anywhere — only the renamed upstream symbol does.
-    if (!this.hasAnyPossibleMatch(ref.referenceName) && !this.matchesAnyImport(ref)) {
+    if (
+      !this.hasAnyPossibleMatch(ref.referenceName) &&
+      !this.matchesAnyImport(ref) &&
+      !this.frameworks.some((f) => f.claimsReference?.(ref.referenceName))
+    ) {
       return null;
     }
 
@@ -681,6 +686,16 @@ export class ReferenceResolver {
       }
     }
 
+    // Dynamic-edge synthesis: now that all base `calls` edges are persisted,
+    // synthesize observer/callback dispatch edges (dispatcher → registered
+    // callbacks) that static parsing leaves out. Best-effort — never fail the
+    // index on it. See docs/design/callback-edge-synthesis.md.
+    try {
+      aggregateStats.byMethod['callback-synthesis'] = synthesizeCallbackEdges(this.queries, this.context);
+    } catch {
+      // synthesis is additive and optional; ignore failures
+    }
+
     return {
       resolved: [],
       unresolved: [],
@@ -743,7 +758,13 @@ export class ReferenceResolver {
           }
         }
       }
-      if (PYTHON_BUILT_IN_METHODS.has(name)) {
+      // A bare name colliding with a builtin method (index, get, update, count…)
+      // is only a builtin when NOTHING in the codebase declares it. A declared
+      // symbol with that exact name — e.g. a Flask/FastAPI view `def index()` or
+      // `def get()` — is a real reference target. Mirrors the knownNames guard on
+      // the dotted branch above; without it, every handler named after a builtin
+      // method silently loses its route→handler edge.
+      if (PYTHON_BUILT_IN_METHODS.has(name) && !this.knownNames?.has(name)) {
         return true;
       }
     }

+ 8 - 0
src/resolution/types.ts

@@ -131,6 +131,14 @@ export interface FrameworkResolver {
   detect(context: ResolutionContext): boolean;
   /** Resolve a reference using framework-specific patterns */
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null;
+  /**
+   * Opt a reference NAME through the resolver's name-exists pre-filter, even when
+   * no node is named that. Needed for dynamic dispatch where the call target is
+   * an attribute/descriptor, not a declared symbol (e.g. Django's
+   * `self._iterable_class(...)`, React effect callbacks). Returning true lets the
+   * ref reach `resolve()` instead of being dropped for having no name match.
+   */
+  claimsReference?(name: string): boolean;
   /**
    * Extract framework-specific nodes and references from a file.
    *