Kaynağa Gözat

Merge pull request #1655 from colbymchenry/feature/steps-servers

feat(ui): codegraph ui — the browser viewer, and Steps and Screens across frameworks
Colby Mchenry 6 gün önce
ebeveyn
işleme
66325ed6ad
100 değiştirilmiş dosya ile 29198 ekleme ve 30 silme
  1. 8 0
      .gitignore
  2. 185 0
      CHANGELOG.md
  3. 28 3
      CLAUDE.md
  4. 76 2
      README.md
  5. 12 0
      TELEMETRY.md
  6. 451 0
      __tests__/branch-guards-languages.test.ts
  7. 509 0
      __tests__/branch-guards.test.ts
  8. 358 0
      __tests__/cli-ui-command.test.ts
  9. 485 0
      __tests__/dead-code.test.ts
  10. 752 0
      __tests__/expo-router.test.ts
  11. 42 4
      __tests__/frameworks.test.ts
  12. 20 0
      __tests__/is-test-file.test.ts
  13. 78 0
      __tests__/namespace-object-resolution.test.ts
  14. 324 0
      __tests__/nextjs.test.ts
  15. 136 0
      __tests__/react-hook-handlers.test.ts
  16. 127 0
      __tests__/react-native-bridge.test.ts
  17. 481 0
      __tests__/react-router.test.ts
  18. 68 0
      __tests__/rn-event-channel.test.ts
  19. 67 0
      __tests__/store-exported-later.test.ts
  20. 220 0
      __tests__/sveltekit-router.test.ts
  21. 355 0
      __tests__/tanstack-router.test.ts
  22. 622 0
      __tests__/type-hierarchy.test.ts
  23. 69 0
      __tests__/ui-conditions.test.ts
  24. 268 0
      __tests__/ui-effects.test.ts
  25. 342 0
      __tests__/ui-entry-model.test.ts
  26. 393 0
      __tests__/ui-entrypoints-api.test.ts
  27. 479 0
      __tests__/ui-events-api.test.ts
  28. 531 0
      __tests__/ui-export-svg.test.ts
  29. 304 0
      __tests__/ui-file-model.test.ts
  30. 303 0
      __tests__/ui-filecode-api.test.ts
  31. 403 0
      __tests__/ui-filecode-model.test.ts
  32. 618 0
      __tests__/ui-flow-api.test.ts
  33. 489 0
      __tests__/ui-flow-model.test.ts
  34. 458 0
      __tests__/ui-highlight.test.ts
  35. 446 0
      __tests__/ui-map-api.test.ts
  36. 475 0
      __tests__/ui-map-model.test.ts
  37. 746 0
      __tests__/ui-package.test.ts
  38. 330 0
      __tests__/ui-program-model.test.ts
  39. 659 0
      __tests__/ui-screens-model.test.ts
  40. 414 0
      __tests__/ui-search-model.test.ts
  41. 1216 0
      __tests__/ui-server-api.test.ts
  42. 566 0
      __tests__/ui-server.test.ts
  43. 582 0
      __tests__/ui-steps-api-servers.test.ts
  44. 381 0
      __tests__/ui-steps-api.test.ts
  45. 474 0
      __tests__/ui-steps-cross-tier.test.ts
  46. 322 0
      __tests__/ui-steps-model.test.ts
  47. 270 0
      __tests__/ui-steps-program.test.ts
  48. 577 0
      __tests__/ui-symbol-model.test.ts
  49. 179 0
      __tests__/ui-trails-model.test.ts
  50. 562 0
      __tests__/ui-trails.test.ts
  51. 301 0
      __tests__/vue-router.test.ts
  52. BIN
      assets/codegraph-ui-symbol-view.png
  53. 2 1
      codegraph-kernel/src/buffers.rs
  54. 29 1
      codegraph-kernel/src/tsjs/extractors.rs
  55. 6 1
      codegraph-kernel/src/tsjs/fnref.rs
  56. 46 1
      codegraph-kernel/src/tsjs/mod.rs
  57. 44 1
      docs/design/callback-edge-synthesis.md
  58. 95 0
      docs/design/cg57-highlighting-parity.md
  59. BIN
      docs/design/cg57-highlighting-parity/csharp.png
  60. BIN
      docs/design/cg57-highlighting-parity/go.png
  61. BIN
      docs/design/cg57-highlighting-parity/php.png
  62. BIN
      docs/design/cg57-highlighting-parity/python.png
  63. BIN
      docs/design/cg57-highlighting-parity/ruby.png
  64. BIN
      docs/design/cg57-highlighting-parity/rust.png
  65. BIN
      docs/design/cg57-highlighting-parity/swift.png
  66. BIN
      docs/design/cg57-highlighting-parity/typescript.png
  67. 1141 0
      docs/design/codegraph-ui-design-spec.md
  68. 5 0
      docs/design/dynamic-dispatch-coverage-playbook.md
  69. 253 0
      docs/design/framework-coverage.md
  70. 504 0
      docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md
  71. 341 0
      docs/plans/2026-08-29-steps-in-code-order.md
  72. 840 3
      package-lock.json
  73. 8 1
      package.json
  74. 9 0
      scripts/build-bundle.sh
  75. 157 0
      scripts/check-ui-build.mjs
  76. 192 0
      scripts/check-ui-package.mjs
  77. 30 0
      scripts/pack-npm.sh
  78. 47 0
      scripts/sync-ui-version.mjs
  79. 112 0
      scripts/try-repo.sh
  80. 1 0
      site/astro.config.mjs
  81. 1 0
      site/src/content/docs/getting-started/next-steps.md
  82. 156 0
      site/src/content/docs/guides/viewer.md
  83. 17 0
      site/src/content/docs/reference/cli.md
  84. 191 0
      src/bin/codegraph.ts
  85. 7 1
      src/context/index.ts
  86. 822 3
      src/db/queries.ts
  87. 14 0
      src/errors.ts
  88. 1 1
      src/extraction/extraction-version.ts
  89. 14 1
      src/extraction/function-ref.ts
  90. 13 0
      src/extraction/grammars.ts
  91. 465 0
      src/extraction/syntax-tokens.ts
  92. 76 2
      src/extraction/tree-sitter.ts
  93. 2300 0
      src/graph/branch-guards.ts
  94. 886 0
      src/graph/dead-code.ts
  95. 359 0
      src/graph/dynamic-boundary-report.ts
  96. 29 0
      src/graph/index.ts
  97. 682 0
      src/graph/named-symbol-flow.ts
  98. 2 2
      src/graph/traversal.ts
  99. 482 0
      src/graph/type-hierarchy.ts
  100. 258 2
      src/index.ts

+ 8 - 0
.gitignore

@@ -4,6 +4,9 @@ node_modules/
 # Build output
 dist/
 
+# svelte-package's scratch dir (ui/ library build)
+.svelte-kit/
+
 .cmem
 
 # IDE
@@ -79,3 +82,8 @@ target-linux/
 .kommandr/kommandr.db
 .kommandr/kommandr.db-wal
 .kommandr/kommandr.db-shm
+
+# Stray git bundles from the Windows VM transfer (a lost `:` in an scp target
+# once committed three of these, 7.9 MB, into a feature branch).
+*.bundle
+*.gitbundle

+ 185 - 0
CHANGELOG.md

@@ -12,6 +12,191 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
+### New Features
+
+- **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps.
+
+- **Where the code chooses, the picture says so once.** A helper that ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'` sends the app to one of two screens, but the Steps picture drew that as two separate arrows, each carrying the whole condition with one of them negated and both cut off at the same forty characters — and before you clicked anything, neither arrow was labelled at all, so nothing said it was a choice. Now sibling arrows out of one box that are the arms of one `if`, `switch` or ternary are drawn as the choice they are: the condition is written once under the box that decides it, and each arrow out says only which way it is — `yes`, `no`, or a case's own value. They are the only arrows labelled before you select anything, so the picture reads at a glance without becoming a wall of text. A one-sided guard — an early exit, an `if` with only one side drawn — still carries its condition on the arrow, and an arrow that is reached whether or not the condition holds never claims a side. Nothing needs a re-index: the decision is read from the source at request time.
+
+- **A dialog's buttons fire what they run.** `Alert.prompt('Add Folder', …, [{ onPress: (name) => createBackgroundFolder(name) }])` is two facts: the prompt is a call that leaves the index, and its button fires the handler. The handler's line now arrives from the dialog's own box — with the condition on it — instead of from the screen, so the confirm-then-act chains a mobile app is full of read as chains: the delete alert leads to the delete, which leads to the request it sends. The same holds for anything bound inside the arguments of a call that leaves the index. Nothing needs a re-index: it is read from the source at request time.
+
+- **The Steps tab draws a handler in the order its code runs.** The picture of what an endpoint sets in motion put the lookup, the token signing, the 200 and the 401 side by side, because each is one step from the anchor — true, and not how the code reads. Now a handler, an endpoint or any function opens as the same picture laid out by *when* things happen: a line means **and then**, so the 200 sits below the token signing it is built from and the 401 branches off the check that chose it. Where the code forks both ways, a small box asks the condition once and each line out of it answers — `yes`, `no`, a case's value — so the two arms of one `if` read as one decision instead of two lines that each carry the whole predicate, one of them negated; selecting a step next to the decision lights the whole fork through it. A lone guard — an early exit, an `if` with one drawn side — keeps its condition on the line, and an arm that answers the request, returns or throws simply has nothing leaving it. A call written inside another call's arguments happens first, so the token is signed before the reply that carries it. A helper is drawn where it is called (`via generateToken`), a body that repeats says so (`for each item of items`), and work registered to run later (`later · then`) or started at once (`together · Promise.all`) says that rather than pretending to be a sequence. A screen still opens as before — its handlers fire on events and have no order between them — and either reading is one click, or one `&view=order` / `&view=tree` in the link, away. Nothing to re-index: it is read from the source at request time, and where the conditions cannot be read the picture is a plain sequence rather than an invented structure.
+
+- **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; `<Link href>`, an internal `<a href>`, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading.
+
+- **TanStack Router apps land on the Screens tab too.** Routes are read both ways a TanStack app declares them: file-based, where `createFileRoute('/posts/$postId')` carries the whole path as a literal, and code-based, where each `createRoute({ path, getParentRoute })` names a fragment that is composed through its parent into `/posts/$postId`. `navigate({ to })` from `useNavigate`, a thrown `redirect({ to })` from a loader or `beforeLoad`, and `<Link to>` / `<Navigate to>` are the transitions between them. TanStack is the one router here whose destination is the route PATTERN rather than a filled address — `<Link to="/posts/$postId" params={{ postId }}>` names the route and passes the values beside it — so a destination is read as a pattern and matched against the route it names. Addresses that are not pages are left off the map: a `_auth` segment is a pathless layout and never appears in the URL, a `(group)` folder is invisible, a `__root` route wraps everything without being a page, and a file that renders an `<Outlet />` is the layout around an address while the index route beside it is the page at it. A computed `to`, a pattern no route serves, and a `navigate({ search })` that only changes the query are left out rather than guessed. Re-index after upgrading.
+
+- **Vue Router and SvelteKit apps land on the Screens tab too.** Both drew their screens as islands with no transitions, so the tab stayed hidden; now the navigation between pages is read for each. **Vue:** the routes are read out of `createRouter({ routes: [...] })` — path, name, and the view each entry names, including a lazy `component: () => import('@/views/Login')` — and `router.push` / `router.replace` / `$router.push`, Nuxt's `navigateTo`, and `<router-link>` / `<RouterLink>` / `<NuxtLink>` are the transitions. Vue apps usually navigate by route NAME rather than by path, so `router.push({ name: 'profile' })` and `:to="{ name: 'profile' }"` resolve by name, and `router.push({ path: '/', query })` by path. **SvelteKit:** `goto('/login')`, `redirect(303, '/article/' + slug)` from a load or a form action — whose destination is its *second* argument, after the status — and the plain `<a href="/login">` that is a link in a SvelteKit app. A SvelteKit page also opens with a body now — it is joined to the page file that serves it and to the `+page.server.js` beside it — so its Steps picture draws its loader's work, its form actions, and the auth guard the loader performs (`redirect(302, '/login')` under `if (!locals.user)`) as a transition to the sign-in page, with the condition on the arrow. A computed destination, a path or name nothing declares, a relative path in a nested route, and a conditional whose two arms go to different pages are left out rather than guessed. Re-index after upgrading.
+
+- **A React Router app lands on the Screens tab too.** `<Route path='/payment' component={PaymentScreen}>` (v5), `<Route path='/payment' element={<PaymentScreen/>}>` (v6) and `createBrowserRouter([{ path, element }])` already named a project's screens; now the navigation between them is drawn as well. `history.push('/placeorder')` and `history.replace`, `navigate('/placeorder')` from `useNavigate`, `redirect()` in a loader or an action, and `<Link to>` / `<NavLink to>` / `<Navigate to>` / react-router-bootstrap's `<LinkContainer to>` each become a transition — attributed back to the screen it starts on, with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. A route with an optional parameter (`/cart/:id?`) is reached by both `/cart` and `/cart/5`. Until now a React Router project's screens were drawn as islands with no transitions at all, and a screen's Steps picture left out every page it sends you to — a checkout step showed its saved payment method but not that it goes on to place the order. A computed destination (`history.push(redirect)`), a path no route serves, and a relative path inside a nested route are left out rather than guessed, and an ordinary `paths.push('/x')` on an array is never mistaken for navigation. Re-index after upgrading.
+
+- **Double-click a box to go there.** On the Steps tab a double-click on any step starts the picture from it — the same as the panel's *Start here* — so an endpoint the page calls, or another screen drawn as a boundary, opens as its own chapter in one gesture; on the Screens tab a double-click on a screen opens what happens from it. A boundary's panel now says it is not entered rather than that nothing leaves it.
+
+- **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up.
+
+- **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per status a handler can send (`201`, `404`) whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time.
+
+- **Calls are read as written, so the database is the database.** The index keeps only the last segment of a deep member call (`create` for `prisma.user.create`), and a bare name matches by name alone — often to the wrong `create`. The Steps walk now reads each call from the source as written, classifies `prisma.user.create`, `this.usersRepository.save`, `session.commit`, `owners.save` and `_context.TodoItems.Add` by the whole chain and by the receiver's declared type (`OwnerRepository owners`, `private readonly usersService: UsersService`, `val owners: OwnerRepository`, read from the class body), and follows `this.usersService.findByEmail(…)` into the class the type names instead of the name-only guess. A hop resolved this way says so in the panel.
+
+- **Conditions and arguments for Python, Java, Kotlin, C#, Go and C.** The `when` on a call — `if` / `elif` / `else`, `switch` / `when` / `match`, the ternary and Kotlin's `if` expression, `try` / `except` / `catch`, `and` / `or`, and the early exits before it (`if err != nil { return }` reads as `err == nil`, `if not item.title: raise` as `item.title`) — and what each call passes (`HTTPException(status_code=422, detail="bad price")`, `c.JSON(http.StatusCreated, gin.H{…})`) are now read for those languages too, in `codegraph ui`'s rails, Flow strip and Steps tab and in `codegraph_explore`'s Flow section. As before: read from the source as it stands, never stored, and a language without rules yields nothing rather than a wrong label.
+
+- **A Steps tab in `codegraph ui` — what happens from here.** Pick a screen (or search any symbol and choose *What happens from here*) and the viewer draws everything it sets in motion as typed steps: the handlers wired to its taps and listeners, the calls that cross into native code, the native events that come back, the store actions it writes, and the calls that leave the app into the network, storage, the device or telemetry — one box per step, an arrow for every way one leads to the next, and on each arrow the condition under which it happens. The plumbing between two steps (hooks, helpers, the components in between) is folded into the arrow and listed in the side panel, exactly as the Screens tab folds a tap's chain into one transition — and every call the panel lists says what it passes, read from the source as written (`SecureStore.setItemAsync('userEmail', values.email)`, `axios.post('/auth/login', { email, password })`), so a step is not just *that* something was stored or sent but *what*. And each handler says what fires it — the JSX prop and its element (`onPress · <Button>`), the option it is written under (`onSubmit · useFormik(…)`), the listener or effect it runs from — read from the source at the call site, so `onPress={() => handleLogin(values)}` and Formik's `onSubmit` make `handleLogin` a step of its own with the event on the arrow into it. Any step is the next anchor, any link opens as a Flow strip, a cap the walk hit is announced on the step it hit it at, and the picture travels in the URL. React Native + Expo apps get the full picture today; any project gets handlers, stores and calls that leave the index.
+
+- **React Native apps: Swift native modules and their events connect end to end.** A JS call like `captureView.finalizeCaptureSession()` — where `captureView` is bound to `NativeModules.CaptureView` and the module is a Swift class exposed through an `RCT_EXTERN_MODULE` shim — now resolves to the Swift method itself instead of stopping at the constant, so `codegraph_explore`, the Flow strip and the Steps view follow the code into native. Native → JS events now also land on listeners written inline (`addListener('onZipComplete', (data) => { … })`), attributed to the component that registers them. Re-index after upgrading to pick the new edges up.
+
+### Fixes
+
+- **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up.
+
+- **A handler called from under a binding says what it passes.** A press that runs `tryCatchSync(onClosePress)` drew a box for the wrapper and stopped — leaving the one thing a reader asks ("what is being wrapped?") unsaid, even though every other call-shaped site already prints its arguments. The panel and tooltip now say `tryCatchSync(onClosePress)` — the argument is the answer.
+
+- **A step the walk stopped at keeps its whole name.** A boundary — another screen, or a cap the walk hit — ends its name with an ellipsis by design, but the box was not sized for it, so a longer name lost its last letters instead (`/scan-to-verif…` for `/scan-to-verify …`). The anchor's start mark clipped a long path the same way (`/sheets/forgot-passw…`). The box now makes room for both.
+
+- **A screen that talks to native code keeps its own navigations.** In a React Native or Expo app, a `router.push` written inside a listener for a native event was credited to whichever screen had *started* that round trip, not to the screen the push is written on. In one app that moved seven transitions off the capture screen and onto the review screen it opens — leaving the review screen looking as though nothing in the app could reach it, stranded in the "no transition reaches this" band at the bottom of the Screens tab, and printing Swift conditions like `Thread.isMainThread` on a JavaScript navigation. A navigation now belongs to the screen whose file it is written in; an event arriving from native code, from an HTTP call or off a queue is no longer read backwards as if it were a caller.
+
+- **A link written under a condition says so on the Screens tab.** A checkout stepper whose tabs are each enabled by their own prop, and a navbar whose admin links only render for an admin, both read as **always** — every transition written in markup was drawn with no condition at all, while the ones written as calls carried theirs. They are read the same way now: a store's checkout tabs say `step1` … `step4`, its navbar says `userInfo && userInfo.isAdmin` for the admin links and `!userInfo` for sign-in, and 59 of that store's 74 transitions carry the condition they actually run under, up from 20. A template language with no condition rules of its own still says nothing rather than guessing.
+
+- **A link in markup no longer reads as a helper's return value.** `<Link to='/shipping'>` was labelled `return /shipping`, which in this picture means the destination came back from somewhere else and was inferred. It is written right there, so it now reads `link /shipping` — and an internal `<a href>` reads `a`. Only a destination that genuinely arrives from elsewhere still says `return`.
+
+- **A link that goes to one of several places now draws all of them.** A destination written as a choice — `!isAdmin ? keyword ? \`/search/${keyword}/page/${x}\` : \`/page/${x}\` : \`/admin/productlist/${x}\`, which is how a paginator shared between a storefront and an admin list is written — used to draw nothing at all, because one edge carried one destination and picking an arm would have been a guess. Every arm is now its own transition, labelled with the path THAT arm takes, so a store's paginated addresses are on the map instead of sitting there unreachable. The same goes for `redirect(307, user ? \`/profile/@${user.username}\` : '/login')` in a loader, and for `router.push(cond ? '/a' : '/b')`. Arms that name the same route still make one transition, and an arm nothing can read still contributes nothing.
+
+- **API endpoints are no longer drawn on the Screens tab.** A store's thirty Express endpoints sat beside its nineteen pages as boxes nothing navigates to and nothing leaves — in a picture that is only about navigation — and they stretched the row of unreachable pages hundreds of boxes wide. A screen is now a route named by its path alone; a route named with the method that reaches it (`GET /api/orders`, `POST /api/users/login`, `ANY /api/users`) is a request, not somewhere a user can be. Every route still appears on Entry points, which is the list of everything a request or a user can arrive at.
+
+- **A screen you could reach but never leave.** Three separate things left a page's own navigation off the map, and a store's home and sign-in pages showed nothing leaving them. **One component, several addresses:** a screen rendered at more than one route — a listing page that is also the search and the paginated results — handed all of its navigation to whichever route happened to be written first, and the rest were drawn as dead ends; every address it serves now gets it. **A link written as a choice:** `<Link to={redirect ? \`/register?redirect=${redirect}\` : '/register'}>` is how a link that carries state is written, and markup was read by a weaker reader than calls were, so it saw nothing; both now use the same one. **A destination whose other half is unknowable:** `const redirect = location.search ? location.search.split('=')[1] : '/'` followed by `history.push(redirect)` is how every app sends a user on after signing in — the `/` is where it lands by default, and reading neither half lost the whole transition. Where both halves ARE readable and disagree, it is still a fork and still nothing.
+
+- **A conditional inside a conditional no longer reads the wrong arm.** A paginator written `!isAdmin ? keyword ? '/search/…' : '/page/…' : '/admin/…'` was split at the first `:` rather than the matching one, so an admin's page links pointed at the storefront's pagination. The arms are paired properly now, and a three-way choice — which is more destinations than one link can name — is left alone.
+
+- **In a repository with several apps, a link no longer points into a different one.** Every app has a `/` and most have a `/login`, and the route table was built for the whole repository at once, so whichever app was indexed first claimed each address — a `<Link to="/posts">` in one app resolved to another app's `/posts`. Measured on a monorepo of 477 apps: **82% of navigations pointed at a route belonging to a different app**, and all of them now point within their own. Screens transitions are also no longer attributed to an unrelated page when several routes are declared in one file, as a code-based route tree or an Express router file is.
+
+- **A SvelteKit layout is no longer a second screen at a page's address.** `+layout.svelte` and `+error.svelte` sit at the same path as the `+page.svelte` beside them and were each indexed as a route, so one address appeared in the index two and three times over. Only a page is a route now.
+
+- **A framework whose package lives in a subfolder is detected again.** In a project that keeps its dependencies one level down — a `frontend/` and a `backend/`, or an `apps/web/` — the framework check ran once before any file had been indexed, found no folders to look in, and remembered that empty answer for the rest of the run. Every React, React Router and Next.js behaviour that depends on knowing the framework is there silently did nothing for those projects.
+
+- **A server action, or any handler written inside a wrapper, draws what it really does.** `const signIn = validatedAction(schema, async (data) => { … })` showed one call out of nine, because the constant held a reference to its schema and that counted as having a body of its own. Its picture is now whole — the lookup, the early returns, the `Promise.all`, the redirect.
+
+- **A call is no longer followed to a same-named method of its own class.** `crypto.createHash('sha256').update(…)` inside a service that happens to have an `update` method was followed into that method, so a login endpoint read as though it updated the user, extra replies and all. A method of your own class is written `this.update(…)`; a receiver that is not `this` now ends the walk instead of guessing.
+
+- **A step is ordered by the call it was actually reached at.** An endpoint whose handler is written inline at the registration (`router.post('/users/login', async (req, res) => { … })`) had every call in the handler read as part of the registration itself, which put its reply before the work that produces it.
+
+- **Express routes behind `app.use('/api', router)` are named by the path a request takes.** A router mounted at a prefix — through as many `router.use('/users', usersRouter)` levels as the app nests, by import or `require` — now names its routes `POST /api/users` instead of `POST /`, and the chained form `router.route('/:id').get(getProduct).put(protect, updateProduct)` (split across lines or not) registers one route per method. Entry points, the Steps tab and the client-to-route pairing all read the real paths.
+
+- **An Express handler written through a wrapper is the endpoint's handler.** `const authUser = asyncHandler(async (req, res) => { … })` — the `express-async-handler` idiom — now names the handler in Entry points and starts the Steps walk at it, with the database reads, the token check and the `401` row read from the arrow's body.
+
+- **A framework declared in a workspace's `package.json` is detected.** React, Next.js, Expo Router, Express and NestJS are found when their dependency lives in `frontend/`, `backend/`, `apps/web/` or `packages/api/` rather than at the repository root, so a monorepo's pages and endpoints exist in the index.
+
+- **FastAPI routes are named by the path a request takes.** `APIRouter(prefix="/items")` and a literal `app.include_router(router, prefix="/api/v1")` — nested through an aggregate router, through a module import or an alias — now compose onto the route names (`GET /api/v1/items/{id}` instead of `GET /{id}`); a prefix that is a setting rather than a string leaves that mount alone rather than guessing.
+
+- **A client call with a type argument binds too.** `useSWR<Team>('/api/team', fetcher)` and `ky.get<User>('/api/users/1')` reach their routes like the untyped forms.
+
+- **ASP.NET Minimal API endpoint groups are routes.** The handler-first form — `groupBuilder.MapPost(CreateTodoItem)`, `MapPut(UpdateTodoItem, "{id}")` inside an `IEndpointGroup` / `EndpointGroupBase` class (the Clean Architecture template and its descendants) — now registers `POST /api/TodoItems` and `PUT /api/TodoItems/{id}`, with the `/api/` head read from the app's own `MapGroup($"/api/{groupName}")` and a class's `RoutePrefix` honoured, each bound to its handler so the Steps tab starts there and lists its `TypedResults` replies by status code.
+
+- **A server action written through a wrapper starts its transition on the right page.** `export const signIn = validatedAction(schema, async (data) => { … redirect('/dashboard') })` — the arrow inside is no symbol of its own — now belongs to `signIn` on the Screens tab, and `signIn` is attributed to the page whose component hands it to `useActionState(signIn, …)`, read from the source when the graph holds no such edge.
+
+- **A row reads in the code's order.** The boxes one step away from a handler now sit left to right as the code runs them — the database read, the token signed while the reply is built, the reply, the error reply — instead of in an arbitrary order; a call written inside another call's arguments (`generateToken(…)` in `res.json({ token: generateToken(…) })`) comes before it, and its link says `inside res.json(…)` in the panel and the tooltip.
+
+- **Each way an endpoint answers is its own box.** A handler that replies `200` or `401` now draws two reply boxes instead of one `200 · 401`, so each line from the handler carries its own condition on the picture — `→ user && (await user.matchPassword(password))` into the 200, `→ NOT (…)` into the 401 — the way a screen's transitions do; replies whose status the code does not spell out share one box labelled by the call.
+
+- **A reply that sets no status is a 200.** `res.json(user)`, `res.send(…)`, `reply.send(…)`, `NextResponse.json(…)`, a `JSONResponse` or `jsonify(…)` with no status in the chain now count as `200`, so an endpoint's success reply has a box of its own beside the `401`'s and its row carries its code; a status set by the statement just before (`res.status(202); res.json(user)`) is that reply's. An Express handler written inline at the registration keeps its own replies too — they were filtered out with the framework noise.
+
+- **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.
+
+- **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does.
+
+- **A FastAPI service that lives in one directory of a monorepo is detected.** `backend/pyproject.toml` and `backend/app/main.py` count, not only files at the repository root — the official full-stack template's routes now appear in Entry points and the Steps tab.
+
+- **The Steps walk stays out of test doubles.** A production handler that calls an interface method is no longer followed into `TestUserDataRepository` (or any implementation in a test folder); the counts in the summary no longer say "outside the indexs".
+
+- **React handlers written with `useCallback` are now symbols.** `const handleSubmit = useCallback(() => {…}, [])` — the way nearly every handler in a React or React Native component is written — is extracted as a function named by its binding (also `React.useCallback`, `useEffectEvent`), so `onPress={handleSubmit}` and `addListener('x', handleSubmit)` resolve to it, its calls are its own rather than the component's, and a tap's handler shows up in `codegraph_explore`, the Screens tab and the Steps tab. A JSX attribute value (`onPress={handleSubmit}`, `renderItem={renderRow}`) and a handler a hook hands back in an object (`return { handleSubmit, handleRetake }`) are now function-as-value references from the component or hook, so the graph knows which functions are wired as handlers.
+
+- **Stores exported on a later line are read like any other.** `const useStore = create((set, get) => ({ … }))` followed by `export default useStore` (or `export { useStore }`) now has its actions extracted as functions, the same as an `export const` store — previously the two-statement form, common in React Native apps, left every action invisible.
+
+- **API objects exported as a default namespace resolve through to their functions.** `import Api from './api'` + `Api.upload()` where the module ends in `const Api = { upload, createFolder }; export default Api` now links the call to `upload` itself (through the object's own imports), and a default import of any const named by an `export default NAME` statement finds that const rather than guessing the file's first exported function.
+
+- **A Screens tab in `codegraph ui` — the app the way its user meets it.** One box per screen, an arrow for every way of getting from one to another, and on each arrow the condition under which it happens. Click a screen and each of its transitions is labelled beside the screen at the other end of the line with the last condition checked before it happens — `→ isCollected` above `/object-detail` — laid out so that no two labels overlap and none sits under a line; hover a label, a line, or its row in the side panel for the whole condition and the chain the tap travels through (`HomeSearchResults → ItemCard → openObjectDetail`), with a link to each navigation call. A screen that returns to where it came from is drawn around the boxes rather than through them, shared chrome (a top bar rendered on ten screens) is one node a row above what it opens rather than the same arrows from every box, a screen that opens many others is wide enough to follow each line back to it and its lines take separate paths through the gap so they fan out instead of stacking, hovering picks the line nearest the pointer, and a helper that chooses the destination after login shows its fork. Projects whose graph holds screen navigation land on this tab. Expo Router apps today.
+
+- **The Map covers a multi-root project.** A React Native app's `ios/` beside its `src/` — or any second root holding a fifth of the code — is now on the picture, one level deeper, instead of the map silently drawing only the larger root.
+
+- **Conditions read as words, and each scenario gets its own row.** In the Screens and Steps tabs a condition is now written the way you would say it — `WHEN NOT (isUploadInProgress || elapsed < 5000) AND user?.organization_id`, the joining words set apart from the code — with the code inside each guard left as written, and a transition or step with several call sites (four early returns that each go home) is listed as four rows, the clauses they all share said once above them, instead of one string joined with `||`. A guard that is itself an either/or keeps its parentheses everywhere conditions are shown, including `codegraph_explore`'s Flow section.
+
+- **Every call now says when it happens.** In `codegraph ui`, a symbol's callee and caller rails and the Flow strip's connectors carry the branch conditions the call site sits under — `when !isUploading && isCollected` — and `codegraph_explore`'s Flow section prints the same on each hop (`↓ calls (when isCollected)`). The conditions come from the `if` / `else` / ternary / `switch` / `&&` branches around the call, the early returns before it (`if (busy) return` reads as `!busy`), and Swift's `guard`; an inline callback inherits the conditions of the place it is defined. Read from the source as it is now, never stored: nothing about your index changes. TypeScript, JavaScript and Swift today.
+
+- **Expo Router apps: screens and navigation are in the graph.** Every screen file under `app/` (or `src/app/`) is now a route node named by its path — `/object-detail`, `/item/[id]`, with `(group)` folders stripped — linked to the component it renders. Calls like `router.push('/object-detail?…')`, `router.navigate({ pathname: '/item/[id]', params })`, template-literal hrefs, an href held in a local `const`, and `router.push(await pickRoute())` where the helper returns screen paths (one edge per screen it can return) resolve to the screen they open as a new `navigates` edge that remembers the href, so "where does tapping this go" and "who opens this screen" are one hop in `codegraph_explore`, `callers`, and the viewer's Flow strip instead of a dead end at a string. Re-index after upgrading to pick the new edges up.
+
+- **Read your graph in a browser: `codegraph ui`.** Point it at a project you've already indexed and it opens a viewer for it on your own machine. Pick a symbol and you see who calls it on the left, its real source in the middle with a marker on every line that calls something, and what it calls on the right, each one drawn level with the line that calls it. Hover either end and both light up; click anything to step into it. Test callers fold into a single line so real callers stay in view, edges CodeGraph isn't confident about are folded away as "uncertain" rather than shown as fact, and a symbol no test reaches within three caller hops says so on a badge. A blast-radius strip counts what a change would reach. Search with `/` or Cmd-K across every symbol and file, start from suggested entry points (routes, hubs, files that run code when imported), and follow a trail of the path you walked that lives in the URL, so you can send someone the exact route you took. Click any file path for that file's outline in source order between everything it depends on and everything that depends on it.
+
+  Run `codegraph ui` in an indexed project, or `codegraph ui /path/to/project` for one indexed elsewhere (`codegraph web` is an alias). It takes port 4747, or the next free one; `--port <n>` pins a specific port and `--no-open` just prints the URL for a headless box or an SSH session. Set `CODEGRAPH_BROWSER=<command>` to choose the browser, or `CODEGRAPH_BROWSER=none` to never open one.
+
+  The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It opens an index that already exists, never creates one, and never changes your graph or a line of your code — the one thing it writes is a trail you asked it to save (see below), and `--read-only` turns even that off. It sends nothing anywhere.
+
+- **A map of the whole project, in `codegraph ui`.** The Map tab draws your repository at module granularity — one box per directory — with dependencies pointing down, so the top of the picture is what runs first and the bottom is what everything else stands on. Nothing is placed by hand and nothing floats: a module sits one layer above whatever it depends on, line weight is how many calls, imports and type references cross the link, and the same project always draws the same picture. Hover a link for what crosses it, including the busiest symbol pairs behind the weight; click a module to isolate its links, list its dependencies and dependents with counts, and jump straight into one of its files.
+
+  It says what it leaves out. Links carrying only a handful of references stay hidden until you select a module they touch, references CodeGraph isn't confident about are excluded from every count on the screen and the number is printed, and mutual dependencies, module loops and circular imports between files are listed rather than straightened away. The vertical order rests on the dependencies your code actually writes down — imports, qualified names, inheritance, typed receivers — because a method name shared by two unrelated folders should not be able to move a box.
+
+  It opens on your project's source directory; a picker switches to any other top-level folder or the whole repository, a checkbox brings tests in, and `depth` splits a large folder into its sub-folders — useful on a monorepo. What you're looking at lives in the address, so the view is shareable.
+
+- **Ask how one symbol reaches another, in `codegraph ui`.** Type "how does execute reach getFile" into the search box — or `execute -> getFile` — and the Flow strip draws the call path between them, left to right, one card per hop. Each card is opened at the exact line that makes the next call rather than at the top of the function, so reading the strip is reading the handful of lines that actually carry the work; the identifier being called is a link, and clicking a card opens it in the symbol screen with the trail already set to the path you've read so far.
+
+  A dashed link is a hop nobody can see in the source — a callback, an interface dispatch, a React re-render, a JSX child — and it names the mechanism and, where CodeGraph knows it, the exact line the handler was wired at. When a name means several definitions, the strip says so and names the one the path runs through, offers the alternatives in a picker, and can draw them together as one branching diagram.
+
+  The **"Read as flow"** button on the trail turns a walk you did by hand into the same strip. It is the same path finder `codegraph_explore` leads its answers with, so the picture and what your agent tells you can't disagree.
+
+- **When a path runs out, the Flow strip says where — and why.** A flow that doesn't reach what you asked about now ends in a small block: *"Where the graph stops."* It names the kind of dispatch that ended it — a computed member call, a `getattr`, a reflective invoke, a typed message bus — and the line it's on, and the card beside it opens at that exact line so you can read the code the block is talking about. Where the key is written in the source (`handlers['save']`) it shows the key and shortlists the symbols that could be on the other side, marking any you already named; where the key is a runtime value it says so rather than guessing.
+
+  It also lists what CodeGraph chose not to follow: name-only matches it wasn't confident enough about, with their confidence, and a count of the other calls the symbol makes that this path didn't need. Nothing is invented — no edge is guessed and none is added to your graph — and a flow that does reach what you asked for never shows the block at all. It's the same finding `codegraph_explore` announces to your agent when a flow breaks, so the screen and the answer agree.
+
+- **Read a whole file, with its call graph in the margin, in `codegraph ui`.** The file screen gained a **Source** tab: the file itself, top to bottom, with the same gutter markers as the symbol view and the same right-hand list of what each line calls, positioned level with the line that calls it. A 6,800-line file scrolls at full speed — only the lines on screen are ever drawn, and the text pages in behind you while the markers are there from the first frame.
+
+  In the left margin is an arc for every call that stays inside the file, drawn from the calling line to the line the callee is defined on. Nothing is laid out by an algorithm — the author already put the symbols in order, so source order does the work, and this is the one place a file's internal call structure is legible at a glance. Hover a line to light the arcs the function under your cursor takes part in, and click an arc to jump to the other end. On a file with more than forty of them the picture narrows to the symbol you're reading instead of drawing a wash of overlapping sweeps, with the total in the header. A rail on the far left lists the file's symbols and follows you as you scroll, when the window is wide enough for it.
+
+- **The viewer keeps up with your project while it's open.** Save a file and `codegraph ui` says so within about a third of a second: a banner on the file's screen explaining that the index hasn't caught up yet, and the file's **current** source in place of a body sliced at line numbers it no longer has — the call arcs, gutter markers and call list go away with the old numbering rather than pointing at the wrong lines. It's the same answer `codegraph_node` gives your agent about a file that changed after its last sync.
+
+  When anything re-indexes the project — your agent's background sync, `codegraph sync`, a git hook — whatever is on screen re-reads the graph and a small "Index updated · reloaded" note appears. A symbol that moved because you added a line above it is followed to its new place, with your trail intact, instead of turning into a dead link.
+
+  Nothing polls: the viewer watches for these two things and is told about them. If it loses touch with the server it retries a few times with a growing delay, then stops and says "Not live" in the top bar rather than hammering a port that isn't answering.
+
+- **Take a flow or a map with you: copy it as an image, or save it as an SVG.** The Flow strip and the Map both gained **Copy image** and **Download SVG**. Copy image puts a PNG on your clipboard, ready to paste into a pull-request comment or a chat — the fastest way to say "here is what your change actually touches" without asking anyone to install anything. Download SVG saves a file for a README: it is real text rather than a bitmap, so it stays sharp at any size and the symbol names in it are selectable.
+
+  Both render the light theme whichever one you are reading in, because the image is going to be read on somebody else's screen, and both carry a caption saying what the picture is. What comes out is exactly what is on screen — the same hops, the same dashed dynamic-dispatch links with their wiring sites, the same "where the graph stops" block, the same modules dimmed or brought forward by your selection — because the image is drawn from the same measurements the screen is, not photographed off it. An eight-hop strip comes out around half a megabyte, well inside what GitHub takes inline.
+
+- **"Where does anything start?" has a screen now.** The **Entry points** tab in `codegraph ui` (or press `e`) is the first thing worth opening on a codebase you have never seen. Every route with the symbol that serves it and the `file:line` you will find it at, grouped by the file the URL is registered in — your router, not your handlers — and headed with the framework CodeGraph detected it from. Under that, the files that actually *do* something when they load (a CLI, a worker entry, a build script), the tests ranked by how much of the project each one exercises, and the symbols the most code depends on.
+
+  None of it is guessed from a filename: a file "runs something" because the graph recorded a call from the file itself, and a project with fewer than three routes simply has no Routes section rather than an empty one. Every list says how much of itself it is showing, and says "at least" wherever the real total can only be a floor.
+
+  Any row that names a symbol can start a **flow**: press `Flow ›`, then type a second symbol or press `→ here` on another row, and you get the path between them — so "how does `POST /v1/payroll/cycles/{cycleID}/run` reach the database" is two clicks. Typing into the search box now finds entry points too, under their own heading below the symbol matches, so a URL comes back with its handler attached instead of on its own.
+
+- **Syntax colouring in `codegraph ui` now comes from CodeGraph's own reading of your code.** The viewer used to run a second syntax highlighter over source CodeGraph had already parsed, with its own separate set of grammars. It doesn't any more: the colouring is taken straight from the parse that built your graph, so a file is coloured by exactly the grammar that decided what its symbols are. Three things you will notice — the name a definition declares now stands out on the line that declares it, wherever it appears; calls written inside a string (`${user.name()}`, `#{...}`, `$"{...}"`) are read as code and are now clickable links like every other call site; and built-in type words such as `string`, `int` and `void` look the same in every language instead of one way in Go and another in TypeScript. A big file paints far faster, most visibly in TypeScript, which was by a wide margin the slowest before.
+
+  Two formats change for the worse and it is worth saying so: Liquid, Razor, YAML, Twig, XML and `.properties` files are shown without colouring now, and in `.svelte`, `.vue` and `.astro` files the `<script>` block is coloured but the surrounding markup is not. Nothing about navigation changes there — call sites in those files still link, exactly as before.
+
+  This also takes about 3 MB of grammar files and two dependencies out of the install.
+
+- **The viewer's screens are now a component library other tools can render.** The Symbol view, the Flow strip and the Map are packaged as `@colbymchenry/codegraph-ui` — the same components `codegraph ui` draws, not a copy of them — so another application can show you a symbol's callers, a call path or your architecture over its own copy of the graph. Everything a screen knows arrives through one small interface it is handed, so the tool doing the rendering decides where the data comes from and where a click goes; a design-token stylesheet ships with it so the screens can be themed to match whatever they are embedded in. It is versioned with the engine, so the reader and the graph it reads always match.
+
+  Nothing changes for `codegraph ui` itself — it is the same viewer, now the library's first user. The package is prepared, not yet on npm.
+
+- **See what a type is built on, and what is built on it, in `codegraph ui`.** Open a class, interface, struct, trait or enum and a small tree now sits above its members: what it extends and implements, going all the way up rather than stopping at the direct parent, and everything that extends or implements it, going down. Inheritance is drawn with a solid line and implementation with a dashed one, so the two never read as the same relationship, and every row opens the type it names.
+
+  For an interface, the list below it is the answer to a question source code cannot give you: a call through that interface can land on any of them, and where there are enough of them to make a static answer meaningless the block says so in a sentence. Go's implicit interface satisfaction is included — a struct that satisfies an interface without either file mentioning the other appears in the fan, marked as matched by CodeGraph rather than written down, along with the line it was matched at. Long fans fold behind a "+N more implementations" button rather than being cut off, and if there is more below than was walked the tree says that too.
+
+  Members that redeclare something from a type above are marked in the outline ("overrides Base", or "satisfies Clock" for an interface), so a 40-member class shows at a glance which parts are its own and which are a contract it is filling. The number of implementations shown here is the same number `codegraph_explore` reports to your agent when it announces an interface dispatch.
+
+- **Find the code nothing reaches, in `codegraph ui`.** A new **Dead code** tab lists the symbols no import, call or reference anywhere in your project reaches — biggest first, grouped by the file they live in, with the number of lines each one would take with it. A class nobody uses brings its methods along as a single finding rather than eleven. Every row opens the code.
+
+  The screen is built to be believed rather than to look impressive. A line above the list says, and keeps saying, that this means "no static reference in the index" and not "unused" — reflection, a framework registry and a template can all reach code a graph cannot follow. Under the list, every reason a candidate was left off is printed with its count, so you can see the list is twenty findings out of two and a half thousand candidates rather than twenty out of twenty-one.
+
+  Those exclusions are the feature. Anything exported, or declared in a header, is off the list by default, because something outside your repository can import it — one switch adds them back, with a warning band. So are test and generated files, abstract and interface declarations, anything a decorator registers, members that override something further up, names the language calls for you (`constructor`, `__enter__`, `main`), vendored directories, and files nothing in your project reaches at all. Two more rules catch what the graph itself missed: a name CodeGraph failed to resolve somewhere is never called unreferenced, and neither is a name shared with a symbol that *is* used — the twin may simply have been picked instead. Last, before any row is shown, the files that could reach it are read and the identifier counted: written down twice, something uses it and we did not see it.
+
+  On the **Map**, a module nothing depends on now says so in its own count line instead of counting itself — usually your entry points, sometimes something you forgot to delete. Tool-generated files and modules are dimmed wherever they appear: on the map, in its file list, in search results and on the file screen.
+
+- **Keep a walk you want to come back to: saved trails in `codegraph ui`.** Follow a path through the code, press **Save trail** on the trail bar, give it a name, and it's kept. Saved trails are listed on the empty screen and on the Entry points tab, above the suggestions — a walk somebody named beats any ranking — and opening one puts you back at the symbol you left with the whole path in the trail bar. Explaining "how a request is served" to a new teammate is now a link and a name rather than a paragraph.
+
+  A saved trail survives your project changing. Each step is remembered by what it is — its qualified name, its kind, the file it was in — rather than by where it sat, so editing the file above a function doesn't lose it. When something does move, the trail says so on its own row: which step moved to another file, which one was renamed or deleted, and which part of the walk still opens. It never quietly stitches over a missing step, because a trail is a path and a step that skips one would show a call that doesn't exist.
+
+  Trails are plain JSON, one file per trail, under `.codegraph/ui/trails/` — already ignored by git, so they stay yours by default. **Export** hands you the file if you'd rather commit one for the team. This is the only thing the viewer writes: it still never indexes, never changes your graph, and never touches a line of your code. Start it with `codegraph ui --read-only` and it won't write even that — saved trails can still be opened, just not saved or deleted.
+
+### Fixes
+
+- Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.
 
 ## [1.6.0] - 2026-08-26
 

Dosya farkı çok büyük olduğundan ihmal edildi
+ 28 - 3
CLAUDE.md


+ 76 - 2
README.md

@@ -53,6 +53,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.
 - [Language Support](#language-support)
 - [Why CodeGraph?](#why-codegraph)
 - [Key Features](#key-features)
+- [Read your graph in the browser](#read-your-graph-in-the-browser)
 - [Framework-aware Routes](#framework-aware-routes)
 - [Mixed iOS / React Native / Expo bridging](#mixed-ios--react-native--expo-bridging)
 - [Quick Start](#quick-start)
@@ -126,6 +127,16 @@ codegraph init
 
 Auto-sync is enabled by default. CodeGraph watches the project and updates the graph on every file change — while your agent edits code, or you add, modify, or delete files. **The index is never stale, and there is nothing to re-run.**
 
+### 5. See what your agent sees
+
+```bash
+codegraph ui
+```
+
+Opens the graph in your browser at `http://127.0.0.1:4747` — callers on the left, the symbol's
+source in the middle, what it calls on the right. See
+[Read your graph in the browser](#read-your-graph-in-the-browser).
+
 ### Uninstall
 
 Changed your mind? One command removes CodeGraph from every agent it configured **and** the CLI itself — every install it finds (standalone bundle, npm global package, launcher link), shown to you before anything is deleted:
@@ -312,6 +323,55 @@ The handful of cases where manual `codegraph sync` makes sense: the watcher is d
 
 ---
 
+## Read your graph in the browser
+
+`codegraph ui` opens a viewer for a project you have already indexed. It is the same graph
+your agent reads, on screen: pick a symbol and you see **who calls it on the left**, its
+**verbatim source in the middle**, and **what it calls on the right — each one drawn level
+with the line that calls it**.
+
+```bash
+codegraph init          # once per project, if you haven't already
+codegraph ui            # opens http://127.0.0.1:4747 in your browser
+```
+
+<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/codegraph-ui-symbol-view.png?v=1" alt="The CodeGraph viewer: callers on the left, the symbol's source in the middle with a marker on every calling line, and the symbols it calls on the right, each level with its call site" width="100%">
+
+What you get on that screen:
+
+- **Callers, grouped by file**, each with the exact line it calls from — click one to jump there. Test callers fold into a single line so real callers stay in view.
+- **The real source**, syntax-highlighted, with a marker in the gutter on every line that calls something.
+- **Callees on the right**, positioned at the line that calls them, joined by a hairline. Hover either end and both light up.
+- **Blast radius** — direct dependents, everything within three hops, and how many files and test files that touches.
+- **Honest edges.** A guess CodeGraph isn't sure about is folded away as "uncertain" rather than shown as fact, and a symbol no test reaches within three hops says so.
+- **Search** (`/` or ⌘K) over every symbol and file, and a **trail** of the path you walked that lives in the URL, so you can send someone the exact route you took. Typing a name also surfaces matching **entry points** under their own heading, so a URL comes back with the symbol that serves it rather than on its own.
+- **Entry points** — the first screen on a codebase you have never opened, and the answer to "where does anything start". Every route with its handler and the line it is registered on, grouped by router file and named with the framework it was detected from; the files that run something at import time (a CLI, a worker entry, a script); the tests, ranked by how much of the project each one exercises; and the symbols the most code depends on. Nothing is guessed from a filename — it is all read out of the graph, and a project with no routes says so instead of drawing an empty list. Any row that names a symbol can start a **flow**: pick a second symbol and you get the path between them, so "how does `POST /v1/payroll/cycles/{cycleID}/run` reach the database" is two clicks.
+- Click any file path to open the **file view**: everything that file depends on, its outline in source order, and everything that depends on it. Its **Source** tab shows the whole file with the same gutter markers, plus an arc in the left margin for every call that stays inside the file — the one place a file's internal call structure is legible, because source order does the layout. A 6,800-line file scrolls at full speed.
+- **Ask for a path.** Type "how does execute reach getFile" (or `execute -> getFile`) and you get the **flow**: one card per hop, each opened at the line that makes the next call. Hops that no static edge records — a callback, an interface dispatch, a React re-render — are drawn dashed and name where the handler was wired. "Read as flow" turns a walk you did by hand into the same strip.
+- **And when the path runs out, it says where.** A flow that doesn't get there ends in "Where the graph stops": the kind of dispatch that ended it (a computed member call, a `getattr`, a reflective invoke, a message bus), its line, the key when the source spells one out, and a shortlist of what could be on the other side — plus the name-only matches CodeGraph refused to follow, with their confidence. Nothing is guessed, and a flow that does connect never shows it.
+- **What happens from here.** On an app with screens, the **Screens** tab draws one box per screen and an arrow for every way of getting from one to another, each labelled with the condition under which it happens. The **Steps** tab does the same for what happens *on* a screen: pick one (or any symbol) and you get its handlers, the calls that cross into native code, the native events that come back, the store actions it writes and the requests that leave the app, as typed steps with the plumbing between them folded into the arrows — the whole capture-to-upload flow of a React Native app on one picture, with every step a click from the next anchor or a Flow strip.
+- **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away.
+- **Take the picture with you.** A flow strip or a map can be copied as an image straight into a pull-request comment, or saved as an SVG for a README — always in the light theme, whichever one you are reading in, with a caption saying what the picture is. The SVG is real text, so it stays sharp at any size and the names in it are selectable.
+- **Keep a walk.** Press **Save trail** on the trail bar, name it, and the path is kept — listed on the empty screen and on Entry points, above the suggestions, and reopened at the symbol you left with the whole walk restored. Steps are remembered by what they are, not where they sat, so a saved trail survives editing the code it describes; when something does move it says which step moved, which was renamed away, and how much of the walk still opens. Trails are plain JSON under `.codegraph/ui/trails/` (git already ignores it), and **Export** hands you the file if you would rather commit one.
+- **It keeps up.** Save a file and a banner appears within about a third of a second saying the index hasn't caught up yet — and the screen switches to the file's current source rather than a body sliced at lines it no longer has. When something re-indexes, whatever is on screen refetches itself and says "Index updated · reloaded". A symbol that moved because you added a line above it is followed, not lost. Nothing polls: the viewer watches, and if it loses touch with the server it retries a few times and then says so instead of hammering it.
+
+Options: `--port <n>` to pin a port (without it the viewer takes 4747, or the next free one),
+`--no-open` to just print the URL for a headless box or an SSH session, and
+`CODEGRAPH_BROWSER=<command>` to choose the browser (`CODEGRAPH_BROWSER=none` never opens one).
+`codegraph web` is an alias for the same command.
+
+**Privacy:** the viewer listens on `127.0.0.1` only, so nothing on your network can reach it,
+and requests claiming to come from any other host are refused. It opens an index that already
+exists, never creates one, and never changes your graph or a line of your code. The one thing
+it writes is a trail you asked it to save, into `.codegraph/ui/trails/`; `codegraph ui
+--read-only` refuses even that. **It sends nothing anywhere**: no code, no paths, no analytics.
+There is no account and no cloud in this feature at all.
+
+The viewer reads an index that already exists — it never creates one — so `codegraph init` has
+to have run first. `codegraph ui /path/to/project` points it at a project you indexed elsewhere.
+
+---
+
 ## Framework-aware Routes
 
 CodeGraph detects web-framework routing files and emits `route` nodes linked by `references` edges to their handler classes or functions. Querying callers of a view/controller now surfaces the URL pattern that binds it.
@@ -332,10 +392,23 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
 | **Axum / actix / Rocket** | `.route("/x", get(handler))` |
 | **ASP.NET** | `[HttpGet("/x")]` attributes on action methods |
 | **Vapor** | `app.get("x", use: handler)` |
-| **React Router** / **SvelteKit** | Route component nodes |
-| **Vue Router** / **Nuxt** | `pages/` file-based routes, `server/api/` endpoints, route middleware |
 | **Astro** | `src/pages/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) |
 
+### Routers — routes *and* the navigation between them
+
+These frameworks additionally emit **`navigates`** edges: the function that sends a user somewhere is linked to the screen it names, so "where does tapping this go" is one hop in the graph rather than a search. Each reads a literal destination — a computed one, or a path no route serves, is left unresolved rather than guessed — and a link written in markup is marked as inferred.
+
+| Router | Routes from | Navigation from |
+|---|---|---|
+| **Expo Router** | Every screen file under `app/` (`app/item/[id].tsx` → `/item/[id]`, groups stripped), bound to its default-export component | `router.push` / `replace` / `navigate`, template hrefs, `{ pathname }` objects, and a helper's returned href |
+| **Next.js** | App Router `app/**/page.tsx` and Pages Router pages (`(group)` stripped, `[slug]` → `:slug`); `app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens | `router.push` / `replace` / `prefetch`, `redirect()` / `permanentRedirect()` in a server action or page, `NextResponse.redirect(new URL(…))` in middleware, `<Link href>` and internal `<a href>` |
+| **React Router** | `<Route path component/element>` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `<Link to>` / `<NavLink to>` / `<Navigate to>` / react-router-bootstrap's `<LinkContainer to>` |
+| **TanStack Router** | `createFileRoute('/posts/$postId')` (file-based) and `createRoute({ path, getParentRoute })` composed up its parent chain (code-based); `_pathless` segments, `(group)` folders, `__root` and `<Outlet/>` layouts are not addresses | `navigate({ to })`, a thrown `redirect({ to })`, `<Link to>` / `<Navigate to>` — where `to` is the route PATTERN and the values ride beside it in `params` |
+| **Vue Router** / **Nuxt** | `createRouter({ routes: [...] })` with the view each entry names, plus Nuxt `pages/` file-based routes, `server/api/` endpoints and route middleware | `router.push` / `replace`, `$router.push`, Nuxt's `navigateTo`, `<router-link>` / `<RouterLink>` / `<NuxtLink>` — **by route name** (`push({ name: 'profile' })`) as well as by path |
+| **SvelteKit** | `src/routes/**/+page.svelte` (`[slug]` → `:slug`, `[[opt]]` → `:opt?`), joined to the `+page.server.js` beside it so a loader's guard belongs to its page | `goto('/x')`, `redirect(status, '/x')` from a load or form action, and the plain `<a href>` that is a link in a SvelteKit app |
+
+In a repository holding several apps, each app's routes are matched only against navigation written inside that app.
+
 ---
 
 ## Mixed iOS / React Native / Expo bridging
@@ -516,6 +589,7 @@ codegraph uninit [path]           # Remove CodeGraph from a project (--force to
 codegraph index [path]            # Full index (--force to re-index, --quiet for less output)
 codegraph sync [path]             # Incremental update
 codegraph status [path]           # Show statistics
+codegraph ui [path]               # Open the browser viewer for an indexed project (alias: web; --port, --no-open, --read-only)
 codegraph unlock [path]           # Remove a stale lock file that's blocking indexing
 codegraph query <search>          # Search symbols (--kind, --limit, --json)
 codegraph explore <query>         # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)

+ 12 - 0
TELEMETRY.md

@@ -65,6 +65,18 @@ And one of four events:
 Usage is **aggregated locally into daily totals** before anything is sent — there is no
 per-call event stream, and nothing is sent in real time.
 
+### The browser viewer sends nothing
+
+`codegraph ui` (the local viewer) has no telemetry of its own. The server it starts
+makes no outbound connections at all, and the page in your browser talks only to that
+server on `127.0.0.1`: nothing about the symbols you open, the searches you type, or the
+path you walk leaves your machine, and none of it is recorded anywhere. The only thing
+telemetry ever learns about the viewer is what it learns about every command: that a
+command named `ui` was run, once, on a day, in the daily `usage_rollup` above. The
+command never triggers a send of its own, and `codegraph telemetry off`,
+`CODEGRAPH_TELEMETRY=0`, or `DO_NOT_TRACK=1` switches off even that count, as it does
+everything else on this page.
+
 ## What is never collected
 
 - **No source code.** No file paths, file names, directory names, repository names or

+ 451 - 0
__tests__/branch-guards-languages.test.ts

@@ -0,0 +1,451 @@
+/**
+ * Branch guards, call sites and decorators for the server languages — Python,
+ * Java, Kotlin, C#, Go, C — read from source the way the Steps view reads
+ * them. Every language gets the same four readings the JS rules give: the
+ * conditions a site runs under (early exits before it included), what it is
+ * passed, what is called as written, and what is written on its definition.
+ */
+import { describe, it, expect, beforeAll } from 'vitest';
+import { initGrammars } from '../src/extraction/grammars';
+import { callSiteInSource, decoratorsInSource, guardsInSource, guardLabel, loopsInSource, memberTypesInSource, supportsBranchGuards } from '../src/graph/branch-guards';
+import type { Language } from '../src/types';
+
+beforeAll(async () => {
+  await initGrammars();
+});
+
+function lineOf(src: string, needle: string): number {
+  const i = src.split('\n').findIndex((l) => l.includes(needle));
+  if (i < 0) throw new Error(`no line contains ${needle}`);
+  return i + 1;
+}
+
+async function labelAt(src: string, needle: string, language: Language): Promise<string> {
+  const line = lineOf(src, needle);
+  const column = src.split('\n')[line - 1]!.indexOf(needle);
+  return guardLabel(await guardsInSource(src, language, line, column));
+}
+
+async function siteAt(src: string, needle: string, language: Language) {
+  const line = lineOf(src, needle);
+  const column = src.split('\n')[line - 1]!.indexOf(needle);
+  return callSiteInSource(src, language, line, column);
+}
+
+describe('languages with rules', () => {
+  it('names them', () => {
+    for (const l of ['python', 'java', 'kotlin', 'csharp', 'go', 'c', 'cpp']) expect(supportsBranchGuards(l)).toBe(true);
+    expect(supportsBranchGuards('ruby')).toBe(false);
+    expect(supportsBranchGuards('php')).toBe(false);
+  });
+});
+
+describe('Python', () => {
+  const src = `
+@router.post("/", dependencies=[Depends(auth)])
+def create_item(session: SessionDep, item_in: ItemCreate) -> Any:
+    if not item_in.title:
+        raise HTTPException(status_code=400, detail="no title")
+    try:
+        item = Item.model_validate(item_in, update={"owner_id": 1})
+    except ValueError as e:
+        return None
+    if item.count > 0 and item.ok:
+        session.add(item)
+    elif item.count == 0:
+        session.delete(item)
+    else:
+        pass
+    match item.kind:
+        case "a":
+            session.commit()
+        case _:
+            pass
+    x = a if cond else b
+    for i in items:
+        if i is None:
+            continue
+        session.refresh(i)
+    return item
+`;
+  it('reads if / elif / match / early exits / the ternary form / the loop guard', async () => {
+    expect(await labelAt(src, 'raise HTTPException', 'python')).toBe('not item_in.title');
+    expect(await labelAt(src, 'session.add(item)', 'python')).toBe('item_in.title && item.count > 0 and item.ok');
+    expect(await labelAt(src, 'session.delete(item)', 'python')).toBe('item_in.title && !(item.count > 0 and item.ok) && item.count == 0');
+    expect(await labelAt(src, 'session.commit()', 'python')).toBe('item_in.title && item.kind == "a"');
+    expect(await labelAt(src, 'session.refresh(i)', 'python')).toBe('item_in.title && i is not None');
+    expect(await labelAt(src, 'return None', 'python')).toBe('item_in.title && on error');
+  });
+  it('reads the call as written, with keyword arguments', async () => {
+    expect(await siteAt(src, 'HTTPException(', 'python')).toMatchObject({ callee: 'HTTPException', args: 'status_code=400, detail="no title"' });
+    expect(await siteAt(src, 'Item.model_validate', 'python')).toMatchObject({ callee: 'Item.model_validate', args: 'item_in, update={ "owner_id" }' });
+  });
+  it('reads the decorators on the definition', async () => {
+    expect(await decoratorsInSource(src, 'python', lineOf(src, 'def create_item'))).toEqual({
+      own: ['router.post("/", dependencies=[Depends(auth)])'],
+      class: [],
+    });
+  });
+});
+
+describe('Java', () => {
+  const src = `
+@RestController
+@RequestMapping("/api")
+public class OwnerController {
+  @PostMapping("/owners/new")
+  @PreAuthorize("hasRole('ADMIN')")
+  public String processCreationForm(@Valid Owner owner, BindingResult result) {
+    if (result.hasErrors()) {
+      return VIEWS;
+    }
+    try {
+      this.owners.save(owner);
+    } catch (IllegalStateException e) {
+      throw new ResponseStatusException(HttpStatus.NOT_FOUND, "x");
+    }
+    switch (owner.kind) {
+      case A: owners.delete(owner); break;
+      default: return "b";
+    }
+    String s = cond ? a() : b();
+    Owner o = new Owner("x", 3);
+    return cond && !late ? "redirect:/owners/" + owner.getId() : "x";
+  }
+}
+`;
+  it('reads early exits, try/catch, switch and the ternary', async () => {
+    expect(await labelAt(src, 'this.owners.save', 'java')).toBe('!result.hasErrors()');
+    // A negated guard on one call with nested parentheses stays a bare `!`.
+    const nested = 'class A {\n  void f(Owner owner, int ownerId) {\n    if (!Objects.equals(owner.getId(), ownerId)) {\n      return;\n    }\n    owners.save(owner);\n  }\n}\n';
+    expect(await labelAt(nested, 'owners.save', 'java')).toBe('Objects.equals(owner.getId(), ownerId)');
+    expect(await labelAt(src, 'new ResponseStatusException', 'java')).toBe('!result.hasErrors() && on error');
+    expect(await labelAt(src, 'owners.delete(owner)', 'java')).toBe('!result.hasErrors() && owner.kind == A');
+    expect(await labelAt(src, 'return "b"', 'java')).toBe('!result.hasErrors() && owner.kind: default');
+    expect(await labelAt(src, 'a() : b()', 'java')).toBe('!result.hasErrors() && cond');
+    expect(await labelAt(src, 'owner.getId()', 'java')).toBe('!result.hasErrors() && cond && !late');
+  });
+  it('reads the call as written', async () => {
+    expect(await siteAt(src, 'new Owner(', 'java')).toMatchObject({ callee: 'Owner', args: '"x", 3' });
+    expect(await siteAt(src, 'this.owners.save', 'java')).toMatchObject({ callee: 'this.owners.save', args: 'owner' });
+    expect(await siteAt(src, 'new ResponseStatusException', 'java')).toMatchObject({ callee: 'ResponseStatusException', args: 'HttpStatus.NOT_FOUND, "x"' });
+  });
+  it('reads the annotations on the method and its class', async () => {
+    expect(await decoratorsInSource(src, 'java', lineOf(src, 'public String processCreationForm'))).toEqual({
+      own: ['PostMapping("/owners/new")', 'PreAuthorize("hasRole(\'ADMIN\')")'],
+      class: ['RestController', 'RequestMapping("/api")'],
+    });
+  });
+});
+
+describe('Kotlin', () => {
+  const src = `
+@RestController
+class OwnerController(val owners: OwnerRepository) {
+  @PostMapping("/owners/new")
+  fun processCreationForm(@Valid owner: Owner, result: BindingResult): String {
+    if (result.hasErrors()) {
+      return VIEWS
+    }
+    try { owners.save(owner) } catch (e: IllegalStateException) { throw NotFound("x") }
+    when (owner.kind) {
+      A -> owners.delete(owner)
+      else -> return "b"
+    }
+    val s = if (cond) a() else b()
+    owner.let { owners.save(it) }
+    return "redirect:/owners/"
+  }
+}
+`;
+  it('reads early exits, try/catch, when and the if-expression', async () => {
+    expect(await labelAt(src, 'owners.save(owner)', 'kotlin')).toBe('!result.hasErrors()');
+    expect(await labelAt(src, 'NotFound("x")', 'kotlin')).toBe('!result.hasErrors() && on error');
+    expect(await labelAt(src, 'owners.delete(owner)', 'kotlin')).toBe('!result.hasErrors() && owner.kind == A');
+    expect(await labelAt(src, 'return "b"', 'kotlin')).toBe('!result.hasErrors() && owner.kind: else');
+    expect(await labelAt(src, 'a() else', 'kotlin')).toBe('!result.hasErrors() && cond');
+    expect(await labelAt(src, 'b()', 'kotlin')).toBe('!result.hasErrors() && !cond');
+    // A lambda is inline: the conditions around it are the conditions it runs under.
+    expect(await labelAt(src, 'owners.save(it)', 'kotlin')).toBe('!result.hasErrors()');
+  });
+  it('reads the call as written', async () => {
+    expect(await siteAt(src, 'owners.delete(owner)', 'kotlin')).toMatchObject({ callee: 'owners.delete', args: 'owner' });
+    // A trailing lambda is `{ … }`, as Swift's closure is — not its body.
+    const lambda = 'class A(val prefs: DataStore<P>) {\n  suspend fun set(b: Boolean) {\n    prefs.updateData { it.copy { bookmarked = b } }\n  }\n}\n';
+    expect(await siteAt(lambda, 'prefs.updateData', 'kotlin')).toMatchObject({ callee: 'prefs.updateData', args: '{ … }' });
+  });
+  it('reads the annotations', async () => {
+    expect(await decoratorsInSource(src, 'kotlin', lineOf(src, 'fun processCreationForm'))).toEqual({
+      own: ['PostMapping("/owners/new")'],
+      class: ['RestController'],
+    });
+  });
+});
+
+describe('C#', () => {
+  const src = `
+[ApiController]
+public class TodoController : ControllerBase {
+  [HttpPost("items")]
+  [Authorize(Roles = "Admin")]
+  public async Task<IActionResult> Create([FromBody] Item item) {
+    if (item == null) return BadRequest();
+    try { await _context.Items.AddAsync(item); } catch (DbUpdateException e) { return Conflict(); }
+    switch (item.Kind) { case 1: _bus.Publish(item); break; default: break; }
+    var x = cond ? Ok(item) : NotFound();
+    return item.Ok && !late ? Created("x", item) : StatusCode(500);
+  }
+}
+`;
+  it('reads early exits, try/catch, switch and the conditional', async () => {
+    expect(await labelAt(src, '_context.Items.AddAsync', 'csharp')).toBe('item != null');
+    expect(await labelAt(src, 'Conflict()', 'csharp')).toBe('item != null && on error');
+    expect(await labelAt(src, '_bus.Publish', 'csharp')).toBe('item != null && item.Kind == 1');
+    expect(await labelAt(src, 'Ok(item)', 'csharp')).toBe('item != null && cond');
+    expect(await labelAt(src, 'NotFound()', 'csharp')).toBe('item != null && !cond');
+    expect(await labelAt(src, 'Created("x"', 'csharp')).toBe('item != null && item.Ok && !late');
+    expect(await labelAt(src, 'StatusCode(500)', 'csharp')).toBe('item != null && !(item.Ok && !late)');
+  });
+  it('reads the call as written', async () => {
+    expect(await siteAt(src, '_context.Items.AddAsync', 'csharp')).toMatchObject({ callee: '_context.Items.AddAsync', args: 'item' });
+    expect(await siteAt(src, 'Created("x"', 'csharp')).toMatchObject({ callee: 'Created', args: '"x", item' });
+  });
+  it('reads the attributes on the action and its controller', async () => {
+    expect(await decoratorsInSource(src, 'csharp', lineOf(src, 'public async Task<IActionResult> Create'))).toEqual({
+      own: ['HttpPost("items")', 'Authorize(Roles = "Admin")'],
+      class: ['ApiController'],
+    });
+  });
+});
+
+describe('Go', () => {
+  const src = `
+package main
+func createUser(c *gin.Context) {
+  if err := c.BindJSON(&u); err != nil {
+    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+    return
+  }
+  if u.Name == "" && !ok {
+    c.AbortWithStatus(404)
+  } else if u.Age > 3 {
+    db.Create(&u)
+  } else {
+    db.Save(&u)
+  }
+  switch u.Kind {
+  case "a":
+    q.Publish("x", u)
+  default:
+    return
+  }
+  go worker(u)
+  c.JSON(http.StatusCreated, u)
+}
+`;
+  it('reads the idiomatic error guard flipped, else-if chains and the switch', async () => {
+    expect(await labelAt(src, 'c.JSON(http.StatusBadRequest', 'go')).toBe('err != nil');
+    expect(await labelAt(src, 'c.AbortWithStatus', 'go')).toBe('err == nil && u.Name == "" && !ok');
+    expect(await labelAt(src, 'db.Create', 'go')).toBe('err == nil && !(u.Name == "" && !ok) && u.Age > 3');
+    expect(await labelAt(src, 'db.Save', 'go')).toBe('err == nil && !(u.Name == "" && !ok) && !(u.Age > 3)');
+    expect(await labelAt(src, 'q.Publish', 'go')).toBe('err == nil && u.Kind == "a"');
+    expect(await labelAt(src, 'worker(u)', 'go')).toBe('err == nil');
+  });
+  it('reads the call as written, a composite literal as its type', async () => {
+    expect(await siteAt(src, 'c.JSON(http.StatusBadRequest', 'go')).toMatchObject({ callee: 'c.JSON', args: 'http.StatusBadRequest, gin.H{…}' });
+  });
+});
+
+describe('C', () => {
+  const src = `
+int main(int argc, char **argv) {
+  FILE *f = fopen(argv[1], "r");
+  if (!f) { perror("open"); return 1; }
+  if (argc > 2 && flag) fprintf(stderr, "x %d", argc);
+  else exit(2);
+  switch (argc) { case 1: fclose(f); break; default: break; }
+  int x = argc ? read(fd, buf, 10) : 0;
+  return 0;
+}
+`;
+  it('reads the null-check guard, if/else, switch and the ternary', async () => {
+    expect(await labelAt(src, 'perror(', 'c')).toBe('!f');
+    expect(await labelAt(src, 'fprintf(', 'c')).toBe('f && argc > 2 && flag');
+    expect(await labelAt(src, 'exit(2)', 'c')).toBe('f && !(argc > 2 && flag)');
+    expect(await labelAt(src, 'fclose(f)', 'c')).toBe('f && argc == 1');
+    expect(await labelAt(src, 'read(fd', 'c')).toBe('f && argc');
+  });
+  it('reads the call as written', async () => {
+    expect(await siteAt(src, 'fprintf(', 'c')).toMatchObject({ callee: 'fprintf', args: 'stderr, "x %d", argc' });
+  });
+});
+
+describe('member types', () => {
+  it('TypeScript: constructor parameter properties and typed fields', async () => {
+    const src = `
+@Injectable()
+export class CatsService {
+  private readonly log: Logger = new Logger()
+  constructor(
+    @InjectRepository(Cat) private readonly catsRepository: Repository<Cat>,
+    private readonly mailer: MailerService,
+    plain: string
+  ) {}
+  async create(dto) {
+    return this.catsRepository.save(dto)
+  }
+}
+`;
+    const types = await memberTypesInSource(src, 'typescript', lineOf(src, 'async create'));
+    expect(Object.fromEntries(types)).toEqual({ log: 'Logger', catsRepository: 'Repository<Cat>', mailer: 'MailerService' });
+  });
+  it('Java: fields and constructor parameters', async () => {
+    const src = `
+public class OwnerController {
+  private final OwnerRepository owners;
+  private VisitService visits;
+  public OwnerController(OwnerRepository owners, Clock clock) { this.owners = owners; }
+  public String create(Owner owner) { return owners.save(owner); }
+}
+`;
+    const types = await memberTypesInSource(src, 'java', lineOf(src, 'public String create'));
+    expect(Object.fromEntries(types)).toEqual({ owners: 'OwnerRepository', visits: 'VisitService', clock: 'Clock' });
+  });
+  it('Kotlin: the primary constructor and properties', async () => {
+    const src = `
+class OwnerController(val owners: OwnerRepository, private val visits: VisitService, plain: String) {
+  val clock: Clock = Clock.systemUTC()
+  fun create(owner: Owner): String = owners.save(owner)
+}
+`;
+    const types = await memberTypesInSource(src, 'kotlin', lineOf(src, 'fun create'));
+    expect(Object.fromEntries(types)).toEqual({ owners: 'OwnerRepository', visits: 'VisitService', clock: 'Clock' });
+  });
+  it('C#: fields, properties and constructor parameters', async () => {
+    const src = `
+public class OrderService : IOrderService {
+  private readonly IRepository<Order> _orderRepository;
+  public IEmailSender Mailer { get; }
+  public OrderService(IRepository<Order> orderRepository, IUriComposer uriComposer) { _orderRepository = orderRepository; }
+  public async Task Create(Order o) { await _orderRepository.AddAsync(o); }
+}
+`;
+    const types = await memberTypesInSource(src, 'csharp', lineOf(src, 'public async Task Create'));
+    expect(Object.fromEntries(types)).toEqual({ _orderRepository: 'IRepository<Order>', Mailer: 'IEmailSender', orderRepository: 'IRepository<Order>', uriComposer: 'IUriComposer' });
+  });
+});
+
+describe('loops a site is written inside', () => {
+  /** The loop headers at the site, outermost first, as `<kind> <text>`. */
+  async function loopsAt(src: string, needle: string, language: Language) {
+    const line = lineOf(src, needle);
+    const column = src.split('\n')[line - 1]!.indexOf(needle);
+    return (await loopsInSource(src, language, line, column)).map((l) => `${l.kind} ${l.text}`);
+  }
+
+  it('reads a JS for-of and a while', async () => {
+    const src = `
+function run(items) {
+  for (const item of items) {
+    save(item)
+  }
+  while (queue.length > 0) {
+    drain()
+  }
+}`;
+    expect(await loopsAt(src, 'save(item)', 'javascript')).toEqual(['each item of items']);
+    expect(await loopsAt(src, 'drain()', 'javascript')).toEqual(['while queue.length > 0']);
+  });
+
+  it('reads nested loops outermost first', async () => {
+    const src = `
+function run(rows) {
+  for (const row of rows) {
+    for (const cell of row) {
+      draw(cell)
+    }
+  }
+}`;
+    expect(await loopsAt(src, 'draw(cell)', 'javascript')).toEqual(['each row of rows', 'each cell of row']);
+  });
+
+  it('reads nothing for a site outside every loop', async () => {
+    const src = `
+function run(items) {
+  begin()
+  for (const item of items) { save(item) }
+}`;
+    expect(await loopsAt(src, 'begin()', 'javascript')).toEqual([]);
+  });
+
+  it('reads a Python for and a while', async () => {
+    const src = `
+def run(items):
+    for item in items:
+        save(item)
+    while pending:
+        drain()
+`;
+    expect(await loopsAt(src, 'save(item)', 'python')).toEqual(['each item in items']);
+    expect(await loopsAt(src, 'drain()', 'python')).toEqual(['while pending']);
+  });
+
+  it('reads a Java enhanced for', async () => {
+    const src = `
+class A {
+  void run(List<Item> items) {
+    for (Item item : items) {
+      save(item);
+    }
+  }
+}`;
+    expect(await loopsAt(src, 'save(item)', 'java')).toEqual(['each Item item : items']);
+  });
+
+  it('reads a Go range loop', async () => {
+    const src = `
+func run(items []Item) {
+	for _, item := range items {
+		save(item)
+	}
+}`;
+    expect(await loopsAt(src, 'save(item)', 'go')).toEqual(['each _, item := range items']);
+  });
+
+  it('reads a C# foreach', async () => {
+    const src = `
+class A {
+  void Run(List<Item> items) {
+    foreach (var item in items) {
+      Save(item);
+    }
+  }
+}`;
+    // The binding word is noise in a header a person reads: `var` goes.
+    expect(await loopsAt(src, 'Save(item)', 'csharp')).toEqual(['each item in items']);
+  });
+
+  it('reads a Swift for-in', async () => {
+    const src = `
+func run(items: [Item]) {
+  for item in items {
+    save(item)
+  }
+}`;
+    expect(await loopsAt(src, 'save(item)', 'swift')).toEqual(['each item in items']);
+  });
+
+  it('reads a Kotlin for', async () => {
+    const src = `
+fun run(items: List<Item>) {
+    for (item in items) {
+        save(item)
+    }
+}`;
+    expect(await loopsAt(src, 'save(item)', 'kotlin')).toEqual(['each item in items']);
+  });
+
+  it('reads nothing for a language without rules', async () => {
+    expect(await loopsInSource('def f\n  xs.each { save }\nend\n', 'ruby', 2, 2)).toEqual([]);
+  });
+});

+ 509 - 0
__tests__/branch-guards.test.ts

@@ -0,0 +1,509 @@
+import { describe, it, expect, beforeAll, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars } from '../src/extraction/grammars';
+import { callArgumentsInSource, guardsInSource, guardLabel, supportsBranchGuards, triggerInSource } from '../src/graph/branch-guards';
+import { buildNode } from '../src/ui-server/api/node';
+import { buildFlow } from '../src/ui-server/api/flow';
+
+beforeAll(async () => {
+  await initGrammars();
+});
+
+/** Line (1-based) of the first line containing `needle`. */
+function lineOf(src: string, needle: string): number {
+  const i = src.split('\n').findIndex((l) => l.includes(needle));
+  if (i < 0) throw new Error(`no line contains ${needle}`);
+  return i + 1;
+}
+
+async function labelAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
+  const line = lineOf(src, needle);
+  const column = src.split('\n')[line - 1]!.indexOf(needle);
+  return guardLabel(await guardsInSource(src, language, line, column));
+}
+
+describe('branch guards: JS/TS', () => {
+  const handlePress = `
+export function ItemCard(props) {
+  const handlePress = useCallback(() => {
+    if (isUploading) return
+    if (isCollected) {
+      openObjectDetail(item, folderName)
+      return
+    }
+    if (queueHasItems) {
+      handleAddToQueue()
+      return
+    }
+    handleStartCapture()
+  }, [])
+  return null
+}
+`;
+
+  it('reads an if branch and the early-return guards before it', async () => {
+    expect(await labelAt(handlePress, 'openObjectDetail(')).toBe('!isUploading && isCollected');
+  });
+
+  it('keeps a disjunctive guard in parentheses, so the join stays unambiguous', async () => {
+    const src = `
+function go(object) {
+  if (isUploading) return
+  if (!object?.id || !object?.name) {
+    bail()
+    return
+  }
+  proceed()
+}
+`;
+    expect(await labelAt(src, 'bail(')).toBe('!isUploading && (!object?.id || !object?.name)');
+    expect(await labelAt(src, 'proceed(')).toBe('!isUploading && !(!object?.id || !object?.name)');
+  });
+
+  it('turns each earlier early-return into a negated guard, in source order', async () => {
+    expect(await labelAt(handlePress, 'handleAddToQueue(')).toBe('!isUploading && !isCollected && queueHasItems');
+    expect(await labelAt(handlePress, 'handleStartCapture(')).toBe('!isUploading && !isCollected && !queueHasItems');
+  });
+
+  it('does not climb past a function that is declared or assigned to a name', async () => {
+    const src = `
+function outer() {
+  if (outerCond) {
+    const cb = () => {
+      if (inner) run()
+    }
+    function named() { if (deep) walk() }
+  }
+}`;
+    expect(await labelAt(src, 'run()')).toBe('inner');
+    expect(await labelAt(src, 'walk()')).toBe('deep');
+  });
+
+  it('an inline callback inherits the conditions its definition sits under', async () => {
+    const src = `
+function verify(total) {
+  if (selectedHasBarcode) {
+    if (total > 1) {
+      return { proceed: () => router.navigate('/barcode-matches') }
+    }
+    return { ok: true, proceed: () => captureObject(item) }
+  }
+  list.forEach((x) => { if (x.ok) keep(x) })
+}`;
+    expect(await labelAt(src, 'captureObject(item)')).toBe('selectedHasBarcode && !(total > 1)');
+    expect(await labelAt(src, "router.navigate(")).toBe('selectedHasBarcode && total > 1');
+    expect(await labelAt(src, 'keep(x)')).toBe('!selectedHasBarcode && x.ok');
+  });
+
+  it('reads else, else-if, and the arms of a ternary', async () => {
+    const src = `
+function f() {
+  if (a) { one() } else if (b) { two() } else { three() }
+  const x = ready ? go() : wait()
+}`;
+    expect(await labelAt(src, 'one()')).toBe('a');
+    expect(await labelAt(src, 'two()')).toBe('!a && b');
+    expect(await labelAt(src, 'three()')).toBe('!a && !b');
+    expect(await labelAt(src, 'go()')).toBe('ready');
+    expect(await labelAt(src, 'wait()')).toBe('!ready');
+  });
+
+  it('reads switch cases, && / || short-circuits, and catch', async () => {
+    const src = `
+function f() {
+  switch (mode) {
+    case 'verify': scan(); break
+    default: capture()
+  }
+  ok && fire()
+  ok || fallback()
+  try { risky() } catch (e) { report(e) }
+}`;
+    expect(await labelAt(src, 'scan()')).toBe("mode === 'verify'");
+    expect(await labelAt(src, 'capture()')).toBe('mode: default');
+    expect(await labelAt(src, 'fire()')).toBe('ok');
+    expect(await labelAt(src, 'fallback()')).toBe('!ok');
+    expect(await labelAt(src, 'report(e)')).toBe('on error');
+    expect(await labelAt(src, 'risky()')).toBe('');
+  });
+
+  it('negates readably: a bare !x guard reads as x, a compound one is parenthesised', async () => {
+    const src = `
+function f() {
+  if (!ready) return
+  if (a && b) { } else { alt() }
+  if (count > 0) go()
+  if (options?.verify !== false && (item.barcodes?.length ?? 0) > 0) verify()
+}`;
+    expect(await labelAt(src, 'alt()')).toBe('ready && !(a && b)');
+    expect(await labelAt(src, 'go()')).toBe('ready && count > 0');
+    expect(await labelAt(src, 'verify()')).toBe('ready && options?.verify !== false && (item.barcodes?.length ?? 0) > 0');
+  });
+
+  it('a call inside a condition is not guarded by that condition', async () => {
+    const src = `
+function f() {
+  if (isReady()) run()
+}`;
+    expect(await labelAt(src, 'isReady()')).toBe('');
+    expect(await labelAt(src, 'run()')).toBe('isReady()');
+  });
+
+  it('an if whose body does not always exit is not a guard', async () => {
+    const src = `
+function f() {
+  if (x) { log() }
+  go()
+}`;
+    expect(await labelAt(src, 'go()')).toBe('');
+  });
+
+  it('caps a very long condition', async () => {
+    const cond = 'a'.repeat(120);
+    const src = `function f() {\n  if (${cond}) go()\n}`;
+    const label = await labelAt(src, 'go()');
+    expect(label.length).toBeLessThan(90);
+    expect(label.endsWith('…')).toBe(true);
+  });
+});
+
+describe('branch guards: Swift', () => {
+  it('reads guard, if/else, ternary and switch', async () => {
+    const src = `
+func decide() {
+  guard ready else { bail(); return }
+  if isCollected { open() } else if other { two() } else { close() }
+  let x = flag ? a() : b()
+  switch mode { case .verify: scan() default: capture() }
+}`;
+    expect(await labelAt(src, 'bail()', 'swift')).toBe('!ready');
+    expect(await labelAt(src, 'open()', 'swift')).toBe('ready && isCollected');
+    expect(await labelAt(src, 'two()', 'swift')).toBe('ready && !isCollected && other');
+    expect(await labelAt(src, 'close()', 'swift')).toBe('ready && !isCollected && !other');
+    expect(await labelAt(src, 'a()', 'swift')).toBe('ready && flag');
+    expect(await labelAt(src, 'b()', 'swift')).toBe('ready && !flag');
+    expect(await labelAt(src, 'scan()', 'swift')).toBe('ready && mode == .verify');
+    expect(await labelAt(src, 'capture()', 'swift')).toBe('ready && mode: default');
+  });
+
+  it('joins multi-clause conditions and treats an early return as a guard', async () => {
+    const src = `
+func f() {
+  if let item = current, item.count > 0 { use(item) }
+  if busy { return }
+  go()
+}`;
+    expect(await labelAt(src, 'use(item)', 'swift')).toBe('let item = current, item.count > 0');
+    expect(await labelAt(src, 'go()', 'swift')).toBe('!busy');
+  });
+});
+
+describe('branch guards: the arms of one decision', () => {
+  /** The guards at the site, unjoined. */
+  async function guardsAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
+    const line = lineOf(src, needle);
+    const column = src.split('\n')[line - 1]!.indexOf(needle);
+    return guardsInSource(src, language, line, column);
+  }
+
+  const ifElse = `
+export async function authUser(req, res) {
+  const user = await User.findOne({ email })
+  if (user && (await user.matchPassword(password))) {
+    res.json({ token: generateToken(user._id) })
+  } else {
+    res.status(401)
+    throw new Error('Invalid email or password')
+  }
+}`;
+
+  it('gives an if and its else the same branch, with negated flipped', async () => {
+    const yes = await guardsAt(ifElse, 'res.json');
+    const no = await guardsAt(ifElse, 'res.status');
+    expect(yes).toHaveLength(1);
+    expect(no).toHaveLength(1);
+    expect(yes[0]!.text).toBe(no[0]!.text);
+    expect(yes[0]!.negated).toBe(false);
+    expect(no[0]!.negated).toBe(true);
+    // The identity of the FORK, not of the arm: both arms of one `if`.
+    expect(yes[0]!.branch).toBe(no[0]!.branch);
+    expect(yes[0]!.branch).toMatch(/^\d+:\d+$/);
+    // The else arm ends by throwing; the then arm runs on.
+    expect(no[0]!.armExit).toBe('throw');
+    expect(yes[0]!.armExit).toBeUndefined();
+  });
+
+  const earlyExit = `
+export async function createReview(req, res) {
+  const product = await Product.findById(req.params.id)
+  if (!product) {
+    res.status(404)
+    throw new Error('Product not found')
+  }
+  await product.save()
+}`;
+
+  it('gives an early exit and the code it guards the same branch', async () => {
+    const inside = await guardsAt(earlyExit, 'res.status');
+    const after = await guardsAt(earlyExit, 'product.save');
+    expect(inside).toHaveLength(1);
+    expect(after).toHaveLength(1);
+    expect(inside[0]!.branch).toBe(after[0]!.branch);
+    expect(inside[0]!.negated).toBe(false);
+    expect(after[0]!.negated).toBe(true);
+    // The arm NOT taken throws — what the rail draws as the fork's terminal.
+    expect(after[0]!.form).toBe('guard');
+    expect(after[0]!.exit).toBe('throw');
+    expect(inside[0]!.armExit).toBe('throw');
+  });
+
+  const switched = `
+export function route(kind) {
+  switch (kind) {
+    case 'a':
+      first()
+      break
+    case 'b':
+      second()
+      break
+    default:
+      other()
+  }
+}`;
+
+  it('gives every case of one switch the same branch', async () => {
+    const a = await guardsAt(switched, 'first()');
+    const b = await guardsAt(switched, 'second()');
+    const d = await guardsAt(switched, 'other()');
+    expect(a[0]!.branch).toBe(b[0]!.branch);
+    expect(a[0]!.branch).toBe(d[0]!.branch);
+    expect([a[0]!.text, b[0]!.text, d[0]!.text]).toEqual(['kind === \'a\'', 'kind === \'b\'', 'kind: default']);
+  });
+
+  it('gives two try/catch blocks branches of their own', async () => {
+    const src = `
+export async function save() {
+  try { await a() } catch (e) { first(e) }
+  try { await b() } catch (e) { second(e) }
+}`;
+    const one = await guardsAt(src, 'first(e)');
+    const two = await guardsAt(src, 'second(e)');
+    expect(one[0]!.text).toBe('on error');
+    expect(two[0]!.text).toBe('on error');
+    expect(one[0]!.branch).not.toBe(two[0]!.branch);
+  });
+
+  it('does not call an arm an exit because a later elif raises', async () => {
+    const src = `
+def handler(user):
+    if not user:
+        raise HTTPException(400)
+    elif not user.is_active:
+        raise HTTPException(400)
+    go(user)
+`;
+    // The `elif` arm raises; the arm it is written in runs on to `go(user)`.
+    const after = await guardsInSource(src, 'python', lineOf(src, 'go(user)'), 4);
+    expect(after.map((g) => g.armExit ?? null)).toEqual(after.map(() => null));
+  });
+
+  it('reads a Swift guard as an exit', async () => {
+    const src = `
+func load() {
+  guard let user = current else { return }
+  fetch(user)
+}`;
+    const after = await guardsAt(src, 'fetch(user)', 'swift');
+    expect(after[0]!.form).toBe('guard');
+    expect(after[0]!.exit).toBe('return');
+    expect(after[0]!.branch).toMatch(/^\d+:\d+$/);
+  });
+});
+
+describe('branch guards: unsupported', () => {
+  it('reports no guards for a language without rules', async () => {
+    expect(supportsBranchGuards('ruby')).toBe(false);
+    expect(await guardsInSource('def f\n  if x\n    go()\n  end\nend\n', 'ruby', 3, 4)).toEqual([]);
+  });
+});
+
+describe('branch guards: on the wire', () => {
+  let dir: string | undefined;
+  afterEach(() => {
+    if (dir) fs.rmSync(dir, { recursive: true, force: true });
+    dir = undefined;
+  });
+
+  it('labels symbol-view rails and flow connectors with the call site\'s conditions', async () => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-when-'));
+    fs.mkdirSync(path.join(dir, 'src'));
+    fs.writeFileSync(
+      path.join(dir, 'src', 'app.ts'),
+      'export function helper() { return 1 }\n' +
+        'export function other() { return 2 }\n' +
+        'export function run(ready: boolean, busy: boolean) {\n' +
+        '  if (busy) return\n' +
+        '  if (ready) {\n' +
+        '    helper()\n' +
+        '  } else {\n' +
+        '    other()\n' +
+        '  }\n' +
+        '}\n'
+    );
+    const cg = CodeGraph.initSync(dir);
+    await cg.indexAll();
+    const run = cg.getNodesByName('run')[0]!;
+    const helper = cg.getNodesByName('helper')[0]!;
+
+    type Rel = { node: { name: string }; edges: Array<{ when?: string }> };
+    const view = (await buildNode(cg, dir, run.id)) as { outgoing: { items: Rel[] } };
+    const byName = new Map(view.outgoing.items.map((r) => [r.node.name, r]));
+    expect(byName.get('helper')?.edges[0]?.when).toBe('!busy && ready');
+    expect(byName.get('other')?.edges[0]?.when).toBe('!busy && !ready');
+
+    const callee = (await buildNode(cg, dir, helper.id)) as { incoming: { items: Rel[] } };
+    expect(callee.incoming.items.find((r) => r.node.name === 'run')?.edges[0]?.when).toBe('!busy && ready');
+
+    const flow = await buildFlow(cg, dir, new URLSearchParams('from=run&to=helper'));
+    const hop = flow.flows[0]!.hops[1]!;
+    expect(hop.edge?.when).toBe('!busy && ready');
+    expect(hop.edge?.label).toBe('calls · when !busy && ready');
+
+    cg.close();
+  });
+});
+
+
+// =============================================================================
+// Call arguments — what a site passes
+// =============================================================================
+
+async function argsAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
+  const line = lineOf(src, needle);
+  const column = src.split('\n')[line - 1]!.indexOf(needle);
+  return callArgumentsInSource(src, language, line, column);
+}
+
+describe('call arguments', () => {
+  const login = `
+async function handleLogin(values) {
+  await SecureStore.setItemAsync('userEmail', values.email)
+  const res = await client.post('/auth/login', { email: values.email, password, ...rest })
+  Alert.alert(i18n.t('error_login_failed'), err.message, [{ text: 'OK' }])
+  router.push({ pathname: '/item/[id]', params: { id } })
+  captureView.finalizeCaptureSession()
+  run(() => go(), async (x) => x, new Thing(1))
+  const big = fetch(\`/api/\${id}\`, { method: 'POST', headers, body, mode, cache, credentials })
+}
+`;
+
+  it('keeps literals and names whole, folds objects to their keys, arrays and functions to a shape', async () => {
+    expect(await argsAt(login, 'SecureStore.setItemAsync(')).toBe("'userEmail', values.email");
+    expect(await argsAt(login, 'client.post(')).toBe("'/auth/login', { email, password, ...rest }");
+    expect(await argsAt(login, 'Alert.alert(')).toBe('i18n.t(…), err.message, […]');
+    expect(await argsAt(login, 'router.push(')).toBe('{ pathname, params }');
+    expect(await argsAt(login, 'run(')).toBe('() => …, () => …, new Thing(…)');
+    expect(await argsAt(login, 'fetch(')).toBe('`/api/${id}`, { method, headers, body, mode, … }');
+  });
+
+  it('an empty argument list is an empty string; a position outside a call is null', async () => {
+    expect(await argsAt(login, 'captureView.finalizeCaptureSession(')).toBe('');
+    expect(await argsAt(login, 'async function handleLogin')).toBeNull();
+  });
+
+  it('Swift: labels stay with their values, a trailing closure is a shape', async () => {
+    const src = `
+class CaptureEvents {
+  func emitZipComplete(result: ZipResult) {
+    sendEvent(withName: "onZipComplete", body: ["zipURL": result.url])
+    tracker.setup(side: side, angle: 45)
+    DispatchQueue.main.async { finish() }
+  }
+}
+`;
+    expect(await argsAt(src, 'sendEvent(', 'swift')).toBe('withName: "onZipComplete", body: […]');
+    expect(await argsAt(src, 'tracker.setup(', 'swift')).toBe('side: side, angle: 45');
+    expect(await argsAt(src, 'DispatchQueue.main.async', 'swift')).toBe('{ … }');
+  });
+});
+
+
+// =============================================================================
+// Triggers — what fires a site
+// =============================================================================
+
+async function triggerAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
+  const line = lineOf(src, needle);
+  const column = src.split('\n')[line - 1]!.indexOf(needle);
+  return triggerInSource(src, language, line, column);
+}
+
+describe('triggers', () => {
+  const login = `
+function LoginButton({ values }) {
+  const formik = useFormik({
+    initialValues: values,
+    onSubmit: (v) => {
+      handleLogin(v.email, v.password)
+    },
+  })
+  useEffect(() => {
+    warmUp()
+  }, [])
+  useEffect(() => {
+    const sub = nativeEmitter.addListener('onZipComplete', (data) => { finish(data) })
+    return () => sub.remove()
+  }, [])
+  const handleRemove = useCallback(() => {
+    removeCredential(values.email)
+  }, [values])
+  fetchThing().then(() => done())
+  return (
+    <View>
+      <Button onPress={formik.submitForm} />
+      <TouchableOpacity onPress={() => handleSelectAccount(account)} />
+      <Pressable onPress={handleRemove} />
+      <Row.Item onLongPress={() => { if (ok) confirm() }} />
+      <KeyboardAvoidingView behavior={isAndroid() ? 'height' : 'padding'} />
+      <FlatList renderItem={({ item }) => renderRow(item)} keyExtractor={keyOf} />
+    </View>
+  )
+}
+function warn() {
+  Alert.alert('Remove?', 'Sure?', [{ text: 'OK', onPress: () => removeAll() }], { cancelable: true })
+}
+`;
+
+  it('a call under a JSX prop: the prop and the element', async () => {
+    expect(await triggerAt(login, 'handleSelectAccount(')).toEqual({ kind: 'prop', name: 'onPress', of: 'TouchableOpacity' });
+    expect(await triggerAt(login, 'confirm()')).toEqual({ kind: 'prop', name: 'onLongPress', of: 'Row.Item' });
+    // A handler passed as a value: the site IS the attribute.
+    expect(await triggerAt(login, 'handleRemove} />')).toEqual({ kind: 'prop', name: 'onPress', of: 'Pressable' });
+    // A function under any prop fires later; a value computed in a prop runs at render.
+    expect(await triggerAt(login, 'renderRow(item)')).toEqual({ kind: 'prop', name: 'renderItem', of: 'FlatList' });
+    expect(await triggerAt(login, 'isAndroid()')).toBeNull();
+    expect(await triggerAt(login, 'keyOf}')).toBeNull();
+  });
+
+  it('a call under an on* option: the key and the call it configures', async () => {
+    expect(await triggerAt(login, 'handleLogin(')).toEqual({ kind: 'option', name: 'onSubmit', of: 'useFormik' });
+    // The option's object inside an array argument: still the call it configures.
+    expect(await triggerAt(login, 'removeAll()')).toEqual({ kind: 'option', name: 'onPress', of: 'Alert.alert' });
+  });
+
+  it('a call inside a runs-later callback: the callee and its first literal', async () => {
+    expect(await triggerAt(login, 'warmUp()')).toEqual({ kind: 'callback', name: 'useEffect', of: null });
+    expect(await triggerAt(login, 'finish(data)')).toEqual({ kind: 'callback', name: 'addListener', of: "'onZipComplete'" });
+    expect(await triggerAt(login, 'done()')).toEqual({ kind: 'callback', name: 'then', of: null });
+  });
+
+  it('a named handler is its own story: nothing fires the call inside it, from here', async () => {
+    expect(await triggerAt(login, 'removeCredential(')).toBeNull();
+    // A plain call in a component body is fired by nothing in particular.
+    expect(await triggerAt(login, 'fetchThing()')).toBeNull();
+    expect(await triggerAt(login, 'handleLogin(', 'swift')).toBeNull();
+  });
+});

+ 358 - 0
__tests__/cli-ui-command.test.ts

@@ -0,0 +1,358 @@
+/**
+ * `codegraph ui` — the CLI face of the viewer server (CG-41).
+ *
+ * Exercised end-to-end against the built binary, because the things worth
+ * pinning here are the ones that only exist once commander, the project
+ * resolver and the server are wired together: the help text, the friendly
+ * "not indexed" guidance, the sensitive-directory refusal, and whether
+ * `--no-open` actually stops a browser from being launched.
+ *
+ * The browser check works by pointing `CODEGRAPH_BROWSER` at a script that
+ * touches a marker file — so "did it try to open a browser" becomes an
+ * observable fact rather than a promise.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { execFileSync, spawn, type ChildProcess } from 'child_process';
+import * as fs from 'fs';
+import * as http from 'http';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { DEFAULT_UI_PORT as DEFAULT_PORT } from '../src/ui-server/constants';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+const BASE_ENV = {
+  ...process.env,
+  CODEGRAPH_NO_DAEMON: '1',
+  CODEGRAPH_WASM_RELAUNCHED: '1',
+  NO_COLOR: '1',
+};
+
+/** Run the CLI to completion, capturing stdout+stderr and the exit code. */
+function runCli(args: string[], env: Record<string, string> = {}): { code: number; output: string } {
+  try {
+    const output = execFileSync(process.execPath, [BIN, ...args], {
+      encoding: 'utf-8',
+      env: { ...BASE_ENV, ...env },
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    return { code: 0, output };
+  } catch (err) {
+    const e = err as { status?: number; stdout?: string; stderr?: string };
+    return { code: e.status ?? 1, output: `${e.stdout ?? ''}${e.stderr ?? ''}` };
+  }
+}
+
+/** GET a path from a running viewer, with a valid loopback Host. */
+function get(port: number, requestPath: string): Promise<{ status: number; body: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      { host: '127.0.0.1', port, path: requestPath, method: 'GET' },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+/**
+ * Start `codegraph ui` and wait for the URL it prints.
+ *
+ * The banner IS the readiness signal: the server is bound before the URL is
+ * printed, so anything the test does after this line is talking to a live
+ * socket.
+ */
+function startViewer(
+  args: string[],
+  env: Record<string, string>
+): Promise<{ child: ChildProcess; port: number; output: () => string }> {
+  return new Promise((resolve, reject) => {
+    const child = spawn(process.execPath, [BIN, 'ui', ...args], {
+      env: { ...BASE_ENV, ...env },
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    let output = '';
+    const timer = setTimeout(() => {
+      child.kill('SIGKILL');
+      reject(new Error(`codegraph ui never printed a URL. Output:\n${output}`));
+    }, 30_000);
+
+    const onChunk = (chunk: Buffer): void => {
+      output += chunk.toString('utf-8');
+      const match = output.match(/http:\/\/127\.0\.0\.1:(\d+)/);
+      if (match?.[1]) {
+        clearTimeout(timer);
+        resolve({ child, port: Number(match[1]), output: () => output });
+      }
+    };
+    child.stdout?.on('data', onChunk);
+    child.stderr?.on('data', onChunk);
+    child.on('error', (err) => {
+      clearTimeout(timer);
+      reject(err);
+    });
+    child.on('exit', (code) => {
+      clearTimeout(timer);
+      reject(new Error(`codegraph ui exited with ${code} before serving. Output:\n${output}`));
+    });
+  });
+}
+
+async function stopViewer(child: ChildProcess): Promise<void> {
+  if (child.exitCode !== null) return;
+  await new Promise<void>((resolve) => {
+    child.once('exit', () => resolve());
+    child.kill('SIGTERM');
+    // A viewer that ignores SIGTERM must not hang the suite.
+    setTimeout(() => {
+      child.kill('SIGKILL');
+      resolve();
+    }, 5_000).unref();
+  });
+}
+
+describe('codegraph ui — help', () => {
+  it('reads well and documents the flags', () => {
+    const { code, output } = runCli(['ui', '--help']);
+    expect(code).toBe(0);
+    expect(output).toContain('--port');
+    expect(output).toContain('--no-open');
+    expect(output).toContain('4747');
+    expect(output).toContain('127.0.0.1');
+    expect(output).toContain('read-only');
+    expect(output).toContain('Examples:');
+    expect(output).toContain('CODEGRAPH_BROWSER');
+  });
+
+  it('works through `codegraph help ui`', () => {
+    const viaHelpCommand = runCli(['help', 'ui']);
+    const viaFlag = runCli(['ui', '--help']);
+    expect(viaHelpCommand.code).toBe(0);
+    expect(viaHelpCommand.output).toBe(viaFlag.output);
+  });
+
+  it('is listed in the top-level help, and `web` is an alias', () => {
+    const top = runCli(['--help']);
+    expect(top.output).toContain('ui|web [options] [path]');
+    const viaAlias = runCli(['help', 'web']);
+    expect(viaAlias.code).toBe(0);
+    expect(viaAlias.output).toContain('--no-open');
+  });
+
+  it('rejects a nonsense --port with a plain message, not a stack trace', () => {
+    const { code, output } = runCli(['ui', '--port', 'banana']);
+    expect(code).toBe(1);
+    expect(output).toContain('--port must be a whole number');
+    expect(output).not.toContain('at Object.');
+  });
+});
+
+describe('codegraph ui — refusals', () => {
+  let unindexed: string;
+
+  beforeAll(() => {
+    unindexed = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-unindexed-'));
+    fs.writeFileSync(path.join(unindexed, 'a.ts'), 'export const a = 1;\n');
+  });
+
+  afterAll(() => {
+    fs.rmSync(unindexed, { recursive: true, force: true });
+  });
+
+  it('gives friendly guidance — never a stack trace — when there is no index', () => {
+    const { code, output } = runCli(['ui', unindexed]);
+    expect(code).toBe(1);
+    expect(output).toContain('No CodeGraph index found');
+    expect(output).toContain('codegraph init');
+    expect(output).not.toContain('at Object.');
+    expect(output).not.toContain('Error:');
+  });
+
+  // `/etc` is only sensitive on POSIX; on Windows it resolves to a
+  // non-existent `C:\etc` and the "no index" path handles it instead.
+  it.runIf(process.platform !== 'win32')('refuses a sensitive system directory', () => {
+    const { code, output } = runCli(['ui', '/etc']);
+    expect(code).toBe(1);
+    expect(output).toContain('Refusing to operate on sensitive');
+  });
+});
+
+describe('codegraph ui — serving', () => {
+  let projectDir: string;
+  let markerDir: string;
+  let opener: string;
+
+  beforeAll(async () => {
+    projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-cli-'));
+    fs.mkdirSync(path.join(projectDir, 'src'));
+    fs.writeFileSync(
+      path.join(projectDir, 'src', 'auth.ts'),
+      'export function parseToken(t: string){ return t.trim(); }\n'
+    );
+    const cg = CodeGraph.initSync(projectDir);
+    await cg.indexAll();
+    cg.close();
+
+    // A stand-in browser: records that it was launched, and with what.
+    markerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-open-'));
+    const markerFile = path.join(markerDir, 'opened.txt');
+    if (process.platform === 'win32') {
+      opener = path.join(markerDir, 'open.cmd');
+      fs.writeFileSync(opener, `@echo %1 > "${markerFile}"\r\n`);
+    } else {
+      opener = path.join(markerDir, 'open.sh');
+      fs.writeFileSync(opener, `#!/bin/sh\nprintf '%s' "$1" > "${markerFile}"\n`);
+      fs.chmodSync(opener, 0o755);
+    }
+  }, 120_000);
+
+  afterAll(() => {
+    fs.rmSync(projectDir, { recursive: true, force: true });
+    fs.rmSync(markerDir, { recursive: true, force: true });
+  });
+
+  const markerFile = (): string => path.join(markerDir, 'opened.txt');
+
+  /** The opener is async (detached); give it a moment before concluding. */
+  async function waitForMarker(timeoutMs: number): Promise<string | null> {
+    const deadline = Date.now() + timeoutMs;
+    for (;;) {
+      if (fs.existsSync(markerFile())) return fs.readFileSync(markerFile(), 'utf-8');
+      if (Date.now() > deadline) return null;
+      await new Promise((r) => setTimeout(r, 50));
+    }
+  }
+
+  it('serves the viewer and prints where it is', async () => {
+    const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
+    try {
+      const res = await get(viewer.port, '/');
+      expect(res.status).toBe(200);
+      expect(res.body).toContain('<div id="app">');
+
+      const banner = viewer.output();
+      expect(banner).toContain('CodeGraph viewer');
+      expect(banner).toContain(projectDir);
+      expect(banner).toContain('this machine only');
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+
+  it('honours --no-open: no browser is launched', async () => {
+    fs.rmSync(markerFile(), { force: true });
+    const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {
+      CODEGRAPH_BROWSER: opener,
+    });
+    try {
+      // Confirm the server is genuinely up before concluding "nothing opened" —
+      // otherwise this passes for the wrong reason.
+      expect((await get(viewer.port, '/')).status).toBe(200);
+      expect(await waitForMarker(1_500)).toBeNull();
+      expect(viewer.output()).toContain('Open that URL in a browser');
+      expect(viewer.output()).not.toContain('Opening your browser');
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+
+  it('opens the browser at the served URL when --no-open is absent', async () => {
+    fs.rmSync(markerFile(), { force: true });
+    const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: opener });
+    try {
+      const opened = await waitForMarker(10_000);
+      expect(opened).not.toBeNull();
+      expect(opened?.trim()).toContain(`http://127.0.0.1:${viewer.port}`);
+      expect(viewer.output()).toContain('Opening your browser');
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+
+  it('CODEGRAPH_BROWSER=none suppresses the launch like --no-open', async () => {
+    fs.rmSync(markerFile(), { force: true });
+    const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: 'none' });
+    try {
+      expect((await get(viewer.port, '/')).status).toBe(200);
+      expect(await waitForMarker(1_000)).toBeNull();
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+
+  it('moves off the default port when it is busy', async () => {
+    // Occupy 4747 so the fallback has something to fall back FROM. If a
+    // developer's own viewer already holds it, the bind fails and the
+    // assertion below is still exactly the right one: the new viewer must not
+    // be on 4747 either way.
+    const blocker = http.createServer(() => {});
+    const bound = await new Promise<boolean>((resolve) => {
+      blocker.once('error', () => resolve(false));
+      blocker.listen(DEFAULT_PORT, '127.0.0.1', () => resolve(true));
+    });
+
+    try {
+      const viewer = await startViewer(['--no-open', projectDir], {});
+      try {
+        expect(viewer.port).not.toBe(DEFAULT_PORT);
+        expect((await get(viewer.port, '/')).status).toBe(200);
+      } finally {
+        await stopViewer(viewer.child);
+      }
+    } finally {
+      if (bound) await new Promise<void>((resolve) => blocker.close(() => resolve()));
+    }
+  }, 60_000);
+
+  it('refuses to move off a port the user pinned with --port', async () => {
+    const blocker = http.createServer(() => {});
+    await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
+    const taken = (blocker.address() as { port: number }).port;
+    try {
+      const { code, output } = runCli(['ui', '--no-open', '--port', String(taken), projectDir]);
+      expect(code).toBe(1);
+      expect(output).toContain('already in use');
+      expect(output).not.toContain('at Object.');
+    } finally {
+      await new Promise<void>((resolve) => blocker.close(() => resolve()));
+    }
+  }, 60_000);
+
+  it('refuses a foreign Host end-to-end', async () => {
+    const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
+    try {
+      const res = await new Promise<{ status: number; body: string }>((resolve, reject) => {
+        const req = http.request(
+          {
+            host: '127.0.0.1',
+            port: viewer.port,
+            path: '/',
+            headers: { Host: 'evil.example' },
+            setHost: false,
+          },
+          (r) => {
+            const chunks: Buffer[] = [];
+            r.on('data', (c: Buffer) => chunks.push(c));
+            r.on('end', () =>
+              resolve({ status: r.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
+            );
+          }
+        );
+        req.on('error', reject);
+        req.end();
+      });
+      expect(res.status).toBe(403);
+      expect(res.body).not.toContain('<div id="app">');
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+});

+ 485 - 0
__tests__/dead-code.test.ts

@@ -0,0 +1,485 @@
+/**
+ * Dead code and islands (CG-59).
+ *
+ * Two halves, both against a real indexed fixture: the derivation in
+ * `src/graph/dead-code.ts`, and the `/api/deadcode` endpoint that renders it
+ * over a real loopback server, like the rest of the viewer's API suite.
+ *
+ * The fixture is shaped to produce, deliberately, one of each thing the report
+ * has to get RIGHT BY NOT CLAIMING IT:
+ *
+ * - a genuinely unreferenced helper (the only row that should survive);
+ * - a same-name pair where the resolver attaches the call to the wrong one —
+ *   the mis-resolution that makes a used method look unreached;
+ * - a method that overrides a base's, reached only through the base;
+ * - a decorated method, registered by a framework the graph cannot see;
+ * - a helper only a template mentions, so no edge records the use but the file
+ *   text does;
+ * - an exported function nothing here calls, which an outside caller may.
+ *
+ * Every one of those must be OFF the list, and the reason must be counted.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import {
+  buildDeadCodeReport,
+  isHeaderFile,
+  isImplicitEntryName,
+  isTestScope,
+  isVendoredPath,
+  mentionCount,
+  DEAD_CODE_KINDS,
+} from '../src/graph/dead-code';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+let cg: CodeGraph;
+
+function write(root: string, rel: string, body: string): void {
+  const full = path.join(root, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, body);
+}
+
+function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port: server.port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${server.port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+async function getDeadCode(query = ''): Promise<any> {
+  const res = await request(`/api/deadcode${query}`);
+  expect(res.type).toBe('application/json; charset=utf-8');
+  expect(res.status).toBe(200);
+  return JSON.parse(res.body);
+}
+
+const names = (report: { entries: Array<{ node: { name: string } }> }): string[] =>
+  report.entries.map((entry) => entry.node.name);
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-deadcode-'));
+  projectRoot = path.join(tempDir, 'project');
+
+  // The one genuinely dead symbol, plus a live one beside it so the file is
+  // reached and the island rule does not swallow the whole thing.
+  write(
+    projectRoot,
+    'src/util.ts',
+    `export function used(value: string): string {
+  return value.trim();
+}
+
+function neverCalledAnywhere(value: string): string {
+  return value.toUpperCase();
+}
+
+function alsoDeadButSmaller(): number {
+  return 1;
+}
+
+// Exported and never called here — an outside caller may import it, so the
+// default list must not claim it. It lives in a REACHED file on purpose: an
+// unreached file is an island, which is a different exclusion.
+export function publicEntryPoint(): string {
+  return 'hello';
+}
+`
+  );
+
+  // The mis-resolution: \`Facade.load\` calls \`this.inner.load()\`, and the
+  // resolver prefers a same-name definition in the call site's own file. One of
+  // the two ends up with no incoming edge and neither is unreferenced.
+  write(
+    projectRoot,
+    'src/inner.ts',
+    `export class Inner {
+  load(): string {
+    return 'inner';
+  }
+}
+`
+  );
+
+  // A base and an override: calls land on \`Base.run\`, never on \`Child.run\`.
+  write(
+    projectRoot,
+    'src/base.ts',
+    `export class Base {
+  run(): string {
+    return 'base';
+  }
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/child.ts',
+    `import { Base } from './base';
+
+export class Child extends Base {
+  run(): string {
+    return 'child';
+  }
+}
+`
+  );
+
+  write(
+    projectRoot,
+    'src/facade.ts',
+    `import { Inner } from './inner';
+import { Base } from './base';
+import { Child } from './child';
+import { used } from './util';
+
+function register(target: unknown, key: string): void {
+  void target;
+  void key;
+}
+
+export class Facade {
+  inner = new Inner();
+  child = new Child();
+
+  load(): string {
+    return this.inner.load();
+  }
+
+  go(): string {
+    const base: Base = this.child;
+    return used(base.run()) + this.load();
+  }
+
+  @register
+  onEvent(): void {
+    void 0;
+  }
+}
+`
+  );
+
+  // Mentioned in a template but never called anywhere the graph can see: the
+  // corroboration pass has to find the second mention in this file's own text.
+  write(
+    projectRoot,
+    'src/handlers.ts',
+    `export function mountHandlers(): string {
+  return TEMPLATE;
+}
+
+function onSubmit(): void {
+  void 0;
+}
+
+const TEMPLATE = '<form onsubmit="onSubmit()"></form>';
+`
+  );
+
+  // Nothing imports this file at all: its symbols' zero fan-in describes the
+  // file, not the symbol. That is the island rule, and it is the Map's job.
+  write(
+    projectRoot,
+    'src/orphan.ts',
+    `function strandedHelper(): string {
+  return 'nobody imports this file';
+}
+
+function alsoStranded(): number {
+  return strandedHelper().length;
+}
+`
+  );
+
+  write(
+    projectRoot,
+    'src/index.ts',
+    `import { Facade } from './facade';
+import { mountHandlers } from './handlers';
+
+export function start(): string {
+  return new Facade().go() + mountHandlers();
+}
+`
+  );
+
+  // A test helper file with a dependent, so `includeTests` is what decides
+  // whether its dead symbol shows — not the island rule.
+  write(
+    projectRoot,
+    'tests/helpers.ts',
+    `export function sharedHelper(): string {
+  return 'shared';
+}
+
+function helperNothingCalls(): void {
+  void 0;
+}
+`
+  );
+  write(
+    projectRoot,
+    'tests/facade.test.ts',
+    `import { Facade } from '../src/facade';
+import { sharedHelper } from './helpers';
+
+export function testFacade(): string {
+  return new Facade().go() + sharedHelper();
+}
+`
+  );
+
+  const init = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', 'tests/**/*.ts'], exclude: [] },
+  });
+  await init.indexAll();
+  init.resolveReferences();
+  init.close();
+
+  cg = CodeGraph.openSync(projectRoot);
+
+  const viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  cg?.close();
+  api?.close();
+  await server?.close();
+  if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('buildDeadCodeReport', () => {
+  it('finds the symbol nothing references', () => {
+    const report = buildDeadCodeReport(cg);
+    expect(names(report)).toContain('neverCalledAnywhere');
+  });
+
+  it('leaves nothing on the list that anything reaches', () => {
+    const report = buildDeadCodeReport(cg);
+    // `used`, `start`, `go` and `mountHandlers` are all called; `Inner.load`
+    // and `Facade.load` are the same-name pair; `Child.run` is an override.
+    for (const name of ['used', 'start', 'go', 'mountHandlers', 'load', 'run']) {
+      expect(names(report)).not.toContain(name);
+    }
+  });
+
+  it('excludes a symbol only its own file mentions, and counts it', () => {
+    const report = buildDeadCodeReport(cg);
+    expect(names(report)).not.toContain('onSubmit');
+    expect(report.excluded.mentioned).toBeGreaterThan(0);
+    expect(report.corroborated).toBe(true);
+  });
+
+  it('makes the claim when corroboration is switched off', () => {
+    // The rule that catches `onSubmit` is the only one that reads a file, so
+    // turning it off has to be visible in BOTH the list and the flag.
+    const report = buildDeadCodeReport(cg, { readSource: null });
+    expect(report.corroborated).toBe(false);
+    expect(report.excluded.mentioned).toBe(0);
+    expect(names(report)).toContain('onSubmit');
+  });
+
+  it('excludes exported symbols by default and includes them on request', () => {
+    const strict = buildDeadCodeReport(cg);
+    expect(names(strict)).not.toContain('publicEntryPoint');
+    expect(strict.excluded.exported).toBeGreaterThan(0);
+    expect(strict.includeExported).toBe(false);
+
+    const wide = buildDeadCodeReport(cg, { includeExported: true });
+    expect(names(wide)).toContain('publicEntryPoint');
+    expect(wide.includeExported).toBe(true);
+    expect(wide.excluded.exported).toBe(0);
+  });
+
+  it('excludes test files by default and includes them on request', () => {
+    expect(names(buildDeadCodeReport(cg))).not.toContain('helperNothingCalls');
+    expect(buildDeadCodeReport(cg).excluded.tests).toBeGreaterThan(0);
+    expect(names(buildDeadCodeReport(cg, { includeTests: true }))).toContain(
+      'helperNothingCalls'
+    );
+  });
+
+  it('says nothing about a file nothing in the index reaches', () => {
+    // An island's symbols have zero fan-in because the FILE is unreached, which
+    // is a fact about the file — the Map draws it, this list does not claim it.
+    const report = buildDeadCodeReport(cg, { includeExported: true });
+    expect(names(report)).not.toContain('strandedHelper');
+    expect(report.excluded.unreachableFile).toBeGreaterThan(0);
+  });
+
+  it('excludes a decorated member — a framework registers it', () => {
+    const report = buildDeadCodeReport(cg);
+    expect(names(report)).not.toContain('onEvent');
+    expect(report.excluded.decorated).toBeGreaterThan(0);
+  });
+
+  it('ranks by size and reports the real total when capped', () => {
+    const full = buildDeadCodeReport(cg);
+    const sizes = full.entries.map((entry) => entry.lines);
+    expect([...sizes].sort((a, b) => b - a)).toEqual(sizes);
+
+    const capped = buildDeadCodeReport(cg, { limit: 1 });
+    expect(capped.entries).toHaveLength(1);
+    expect(capped.total).toBe(full.total);
+    // The cap trims the tail, not the head: the biggest finding survives.
+    expect(capped.entries[0]?.node.name).toBe(full.entries[0]?.node.name);
+  });
+
+  it('every exclusion count is a number of candidates, and they add up', () => {
+    const report = buildDeadCodeReport(cg);
+    const excluded = Object.values(report.excluded).reduce((sum, n) => sum + n, 0);
+    expect(report.candidates).toBeGreaterThan(0);
+    expect(excluded + report.entries.length).toBeLessThanOrEqual(report.candidates);
+    expect(report.bounded).toBe(false);
+  });
+
+  it('restricts to the kinds asked for, and ignores nonsense', () => {
+    const classesOnly = buildDeadCodeReport(cg, { kinds: ['class'] });
+    expect(classesOnly.kinds).toEqual(['class']);
+    for (const entry of classesOnly.entries) expect(entry.node.kind).toBe('class');
+
+    // An unknown kind is not a 500 and not an empty list: it falls back to the
+    // default set, which is the answer the caller meant.
+    const nonsense = buildDeadCodeReport(cg, { kinds: ['banana' as never] });
+    expect(nonsense.kinds).toEqual([...DEAD_CODE_KINDS]);
+  });
+});
+
+describe('the rules that are pure', () => {
+  it('counts whole-identifier mentions only', () => {
+    expect(mentionCount('const load = 1; loader(); reload();', 'load')).toBe(1);
+    expect(mentionCount('a.load(); load();', 'load')).toBe(2);
+    expect(mentionCount('nothing here', 'load')).toBe(0);
+    // Stops early: the caller only ever needs to know "one, or more than one".
+    expect(mentionCount('x x x x x', 'x', 2)).toBe(2);
+  });
+
+  it('matches vendored directories as whole segments', () => {
+    expect(isVendoredPath('vendor/lib/a.go')).toBe(true);
+    expect(isVendoredPath('a/node_modules/b/c.js')).toBe(true);
+    expect(isVendoredPath('src/vendored-parser.ts')).toBe(false);
+  });
+
+  it('recognises headers as declaration surfaces', () => {
+    expect(isHeaderFile('src/tree_sitter/parser.h')).toBe(true);
+    expect(isHeaderFile('types/global.d.ts')).toBe(true);
+    expect(isHeaderFile('src/parser.c')).toBe(false);
+  });
+
+  it('recognises a test scope inside a file', () => {
+    expect(isTestScope('tests::row_sizes_match')).toBe(true);
+    expect(isTestScope('Fixtures.Tests.Helper')).toBe(true);
+    expect(isTestScope('Latest.value')).toBe(false);
+  });
+
+  it('recognises names the language calls by itself', () => {
+    expect(isImplicitEntryName('constructor')).toBe(true);
+    expect(isImplicitEntryName('__enter__')).toBe(true);
+    expect(isImplicitEntryName('ToString')).toBe(true);
+    expect(isImplicitEntryName('mainHandler')).toBe(false);
+  });
+});
+
+describe('GET /api/deadcode', () => {
+  it('groups the rows by file and keeps the totals honest', async () => {
+    const payload = await getDeadCode();
+    expect(payload.rows.total).toBe(payload.rows.items.length);
+    expect(payload.rows.shown).toBe(payload.rows.items.length);
+
+    // Every count equals a list length in the same payload.
+    const grouped = payload.groups.reduce((sum: number, g: any) => sum + g.rows.length, 0);
+    expect(grouped).toBe(payload.rows.shown);
+
+    const files = payload.groups.map((g: any) => g.file);
+    expect(new Set(files).size).toBe(files.length);
+    expect(files).toContain('src/util.ts');
+  });
+
+  it('carries the exclusions with their own wording', async () => {
+    const payload = await getDeadCode();
+    expect(payload.excluded.length).toBeGreaterThan(0);
+    for (const entry of payload.excluded) {
+      expect(entry.count).toBeGreaterThan(0);
+      expect(typeof entry.label).toBe('string');
+      expect(entry.label.length).toBeGreaterThan(0);
+    }
+    const sum = payload.excluded.reduce((n: number, e: any) => n + e.count, 0);
+    expect(payload.excludedTotal).toBe(sum);
+    expect(payload.candidates).toBeGreaterThanOrEqual(payload.excludedTotal);
+    expect(payload.corroborated).toBe(true);
+  });
+
+  it('widens on ?exported=1 and says which list it answered', async () => {
+    const strict = await getDeadCode();
+    const wide = await getDeadCode('?exported=1');
+    expect(strict.includeExported).toBe(false);
+    expect(wide.includeExported).toBe(true);
+    expect(wide.rows.total).toBeGreaterThan(strict.rows.total);
+    expect(wide.rows.items.some((r: any) => r.name === 'publicEntryPoint')).toBe(true);
+  });
+
+  it('honours ?limit= without lying about the total', async () => {
+    const full = await getDeadCode();
+    const capped = await getDeadCode('?limit=1');
+    expect(capped.rows.items).toHaveLength(1);
+    expect(capped.rows.total).toBe(full.rows.total);
+    expect(capped.rows.truncated).toBe(full.rows.total > 1);
+  });
+
+  it('is listed on the API index', async () => {
+    const res = await request('/api');
+    const body = JSON.parse(res.body);
+    expect(body.endpoints.some((e: any) => e.path === '/api/deadcode')).toBe(true);
+  });
+});
+
+describe('GET /api/map — generated files and islands', () => {
+  it('reports how many of a module’s files are tool-generated', async () => {
+    const res = await request('/api/map');
+    const payload = JSON.parse(res.body);
+    for (const module of payload.modules) {
+      expect(typeof module.generated).toBe('number');
+      expect(module.generated).toBeLessThanOrEqual(module.files);
+      // The dimmed rows are drawn from `fileList.items`, so the generated
+      // subset has to be a subset of exactly that list.
+      for (const file of module.generatedFiles) {
+        expect(module.fileList.items).toContain(file);
+      }
+    }
+  });
+});

+ 752 - 0
__tests__/expo-router.test.ts

@@ -0,0 +1,752 @@
+import { describe, it, expect, beforeAll, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildScreens } from '../src/ui-server/api/screens';
+import { buildSteps } from '../src/ui-server/api/steps';
+import {
+  expoRouterResolver,
+  routePathForFile,
+  defaultExportName,
+  readHrefArgument,
+  readHrefViaLocal,
+  normalizeHrefPath,
+} from '../src/resolution/frameworks/expo-router';
+import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
+import type { Node } from '../src/types';
+
+// =============================================================================
+// Route paths from file names
+// =============================================================================
+
+describe('expo-router: routePathForFile', () => {
+  it.each([
+    ['app/index.tsx', '/'],
+    ['src/app/index.tsx', '/'],
+    ['src/app/object-detail.tsx', '/object-detail'],
+    ['src/app/capture/index.tsx', '/capture'],
+    ['src/app/capture/review/index.tsx', '/capture/review'],
+    ['src/app/sheets/need-help.tsx', '/sheets/need-help'],
+    ['src/app/item/[id].tsx', '/item/[id]'],
+    ['src/app/docs/[...slug].tsx', '/docs/[...slug]'],
+    ['src/app/(tabs)/home.tsx', '/home'],
+    ['src/app/(auth)/(stack)/login.tsx', '/login'],
+    ['src/app/+not-found.tsx', '/+not-found'],
+    ['src/app/settings.ios.tsx', '/settings'],
+    ['src/app/legacy.js', '/legacy'],
+    ['apps/mobile/src/app/home.tsx', '/home'],
+  ])('%s → %s', (file, route) => {
+    expect(routePathForFile(file)).toBe(route);
+  });
+
+  it.each([
+    'src/app/_layout.tsx',
+    'src/app/(tabs)/_layout.tsx',
+    'src/app/+html.tsx',
+    'src/app/+native-intent.tsx',
+    'src/app/_private-helper.ts',
+    'src/app/home.test.tsx',
+    'src/app/__tests__/home.tsx',
+    'src/app/types.d.ts',
+    'src/app/styles.css',
+    'src/components/app/thing.tsx'.replace('components/app/', 'components/'), // no app dir
+    'src/appearance/theme.tsx',
+  ])('%s is not a screen', (file) => {
+    expect(routePathForFile(file)).toBeNull();
+  });
+});
+
+// =============================================================================
+// Default export → screen name
+// =============================================================================
+
+describe('expo-router: defaultExportName', () => {
+  it.each([
+    ['export default function ObjectDetail() {}', 'ObjectDetail'],
+    ['export default async function Screen() {}', 'Screen'],
+    ['export default class Legacy extends React.Component {}', 'Legacy'],
+    ['function Home() {}\nexport default Home', 'Home'],
+    ['function Home() {}\nexport default Home;', 'Home'],
+    ['export default memo(Home)', 'Home'],
+    ['export default React.memo(Home)', 'Home'],
+    ['export default observer(Home, opts)', 'Home'],
+    ['export { Home as default }', 'Home'],
+  ])('%s → %s', (src, name) => {
+    expect(defaultExportName(src)?.name).toBe(name);
+  });
+
+  it('yields null for an anonymous default export', () => {
+    expect(defaultExportName('export default () => null')).toBeNull();
+    expect(defaultExportName('export default function () {}')).toBeNull();
+  });
+});
+
+// =============================================================================
+// Reading the href argument
+// =============================================================================
+
+describe('expo-router: readHrefArgument', () => {
+  const read = (src: string, method = 'push', line = 1, column = 0) =>
+    readHrefArgument(src.split('\n'), line, column, method);
+
+  it('reads a plain string', () => {
+    expect(read("router.push('/capture-queue')")).toEqual({
+      path: '/capture-queue',
+      display: '/capture-queue',
+    });
+  });
+
+  it('drops the query string and hash from the path but keeps them for display', () => {
+    expect(read('router.push("/sheets/setup-guide?kind=lighting")')).toEqual({
+      path: '/sheets/setup-guide',
+      display: '/sheets/setup-guide?kind=lighting',
+    });
+  });
+
+  it('reads a template literal, keeping the static prefix and marking holes', () => {
+    const src =
+      'router.navigate(\n' +
+      '  `/object-detail?detectionItem=${encodeParam(JSON.stringify(item))}${folderParam}` as any\n' +
+      ')';
+    expect(read(src, 'navigate')).toEqual({
+      path: '/object-detail',
+      display: '/object-detail?detectionItem=${…}${…}',
+    });
+  });
+
+  it('keeps a hole that sits in the path itself', () => {
+    const r = read('router.push(`/terms-of-service/term/${id}`)');
+    expect(r?.display).toBe('/terms-of-service/term/${…}');
+    expect(r?.path.startsWith('/terms-of-service/term/')).toBe(true);
+  });
+
+  it('reads pathname out of an Href object', () => {
+    const src =
+      "router.push({\n  pathname: '/detection/result/[id]',\n  params: { id: result.id },\n})";
+    expect(read(src)).toEqual({
+      path: '/detection/result/[id]',
+      display: '/detection/result/[id]',
+    });
+  });
+
+  it('reads both arms of a conditional argument', () => {
+    const src =
+      'router.navigate(\n' +
+      '  (folder.id\n' +
+      "    ? `/sheets/create-detection-item?folderId=${folder.id}`\n" +
+      "    : '/sheets/create-detection-item') as any\n" +
+      ')';
+    const r = read(src, 'navigate');
+    expect(r?.path).toBe('/sheets/create-detection-item');
+    expect(r?.display).toBe('/sheets/create-detection-item?folderId=${…}');
+    expect(r?.alternates?.map((a) => a.path)).toEqual(['/sheets/create-detection-item']);
+  });
+
+  it('reads the literal arm when the other is not one — a place the code demonstrably goes', () => {
+    // Both arms readable is a fork, and `pageForHref` resolves it only when
+    // they name the same route. One arm readable is not a fork: `/home` is
+    // somewhere this call goes, and reporting it is not a guess. Dropping it
+    // cost every react-router app its post-login transition, which is written
+    // `const redirect = search ? search.split('=')[1] : '/'`.
+    const r = read("router.push(ready ? '/home' : fallback)");
+    expect(r?.path).toBe('/home');
+    expect(r?.alternate).toBeUndefined();
+    expect(read("router.push(ready ? fallback : '/home')")?.path).toBe('/home');
+    // Neither arm readable is still nothing.
+    expect(read('router.push(ready ? a : b)')).toBeNull();
+  });
+
+  it('pairs the arms of a NESTED conditional, and keeps all three', () => {
+    // Taking the first `:` split this between `keyword` and '/page', reading
+    // '/page' — a real path, from the wrong arm of the wrong conditional. Paired
+    // properly it is a paginator that goes to one of three places, and the
+    // picture draws all three rather than none.
+    const r = read("router.push(!isAdmin ? keyword ? '/search' : '/page' : '/admin')");
+    expect([r?.path, ...(r?.alternates ?? []).map((a) => a.path)]).toEqual(['/search', '/page', '/admin']);
+  });
+
+  it('reads only the first argument', () => {
+    expect(read("router.push('/home', { withAnchor: true })")?.path).toBe('/home');
+  });
+
+  it('starts scanning at the column so an earlier call on the line is skipped', () => {
+    const src = "list.push(x); router.push('/home')";
+    expect(read(src, 'push', 1, src.indexOf('router'))?.path).toBe('/home');
+  });
+
+  it('returns null for a non-literal argument', () => {
+    expect(read('router.push(href)')).toBeNull();
+    expect(read('router.push(buildHref(item))')).toBeNull();
+    expect(read('router.push({ pathname, params })')).toBeNull();
+    expect(read('router.push()')).toBeNull();
+  });
+
+  it('does not run past the call: a later literal is not this call\'s argument', () => {
+    expect(read("router.back()\nrouter.push('/home')", 'back')).toBeNull();
+  });
+});
+
+describe('expo-router: readHrefViaLocal', () => {
+  const viaLocal = (src: string, method = 'navigate') => {
+    const lines = src.split('\n');
+    const line = lines.findIndex((l) => l.includes(`.${method}(`)) + 1;
+    return readHrefViaLocal(lines, line, 0, method, 1);
+  };
+
+  it('reads a local const assigned a literal', () => {
+    expect(viaLocal("function f() {\n  const href = '/home'\n  router.navigate(href as any)\n}")?.path).toBe('/home');
+  });
+
+  it('reads a multi-line ternary initializer whose arms are literals', () => {
+    const src =
+      'function f(params) {\n' +
+      '  const href = params.length\n' +
+      '    ? `/barcode-scan?${params.join("&")}`\n' +
+      "    : '/barcode-scan'\n" +
+      '  if (options?.replace) {\n' +
+      '    router.navigate(href as any)\n' +
+      '  }\n}';
+    const r = viaLocal(src);
+    expect(r?.path).toBe('/barcode-scan');
+    expect(r?.alternates?.map((a) => a.path)).toEqual(['/barcode-scan']);
+  });
+
+  it('reads a typed declaration and an Href object initializer', () => {
+    expect(viaLocal("const href: Href = '/home'\nrouter.navigate(href)")?.path).toBe('/home');
+    expect(viaLocal("const href = { pathname: '/item/[id]', params: { id } }\nrouter.navigate(href)")?.path).toBe('/item/[id]');
+  });
+
+  it('refuses a computed initializer, a reassignment, and a non-identifier argument', () => {
+    expect(viaLocal("const href = build()\nrouter.navigate(href)")).toBeNull();
+    expect(viaLocal("const href = '/home'\nhref = other\nrouter.navigate(href)")).toBeNull();
+    expect(viaLocal("router.navigate(a.b)")).toBeNull();
+  });
+
+  it('is not confused by ?. and ?? in an initializer', () => {
+    expect(viaLocal("const href = options?.href ?? '/home'\nrouter.navigate(href)")).toBeNull();
+  });
+});
+
+// =============================================================================
+// Href normalization
+// =============================================================================
+
+describe('expo-router: normalizeHrefPath', () => {
+  it('strips trailing slash and group segments, decodes segments', () => {
+    expect(normalizeHrefPath('/capture/', 'src/services/nav.ts')).toEqual(['capture']);
+    expect(normalizeHrefPath('/(tabs)/home', 'src/services/nav.ts')).toEqual(['home']);
+    expect(normalizeHrefPath('/a%20b', 'src/services/nav.ts')).toEqual(['a b']);
+    expect(normalizeHrefPath('/', 'src/services/nav.ts')).toEqual([]);
+  });
+
+  it('resolves a relative href against the screen the call is in', () => {
+    expect(normalizeHrefPath('./review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
+    expect(normalizeHrefPath('review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
+    expect(normalizeHrefPath('../home', 'src/app/capture/review.tsx')).toEqual(['home']);
+  });
+
+  it('refuses a relative href from a non-screen file', () => {
+    expect(normalizeHrefPath('./review', 'src/services/nav.ts')).toBeNull();
+  });
+});
+
+// =============================================================================
+// extract(): route node + screen ref
+// =============================================================================
+
+describe('expo-router: extract', () => {
+  it('emits a route node named by path and a calls ref to the default export', () => {
+    const src = "import React from 'react'\n\nexport default function ObjectDetail() {\n  return null\n}\n";
+    const { nodes, references } = expoRouterResolver.extract!('src/app/object-detail.tsx', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0]!.kind).toBe('route');
+    expect(nodes[0]!.name).toBe('/object-detail');
+    expect(nodes[0]!.language).toBe('tsx');
+    expect(references).toHaveLength(1);
+    expect(references[0]!.fromNodeId).toBe(nodes[0]!.id);
+    expect(references[0]!.referenceName).toBe('ObjectDetail');
+    expect(references[0]!.referenceKind).toBe('calls');
+    expect(references[0]!.line).toBe(3);
+  });
+
+  it('emits nothing for a layout or a non-app file', () => {
+    expect(expoRouterResolver.extract!('src/app/_layout.tsx', 'export default function L() {}')).toEqual({
+      nodes: [],
+      references: [],
+    });
+    expect(expoRouterResolver.extract!('src/services/nav.ts', 'export default function x() {}')).toEqual({
+      nodes: [],
+      references: [],
+    });
+  });
+});
+
+// =============================================================================
+// resolve(): a navigation call → the route node, as a navigates edge
+// =============================================================================
+
+describe('expo-router: resolve', () => {
+  const route = (filePath: string): Node => expoRouterResolver.extract!(filePath, '').nodes[0]!;
+  const routes = [
+    route('src/app/index.tsx'),
+    route('src/app/object-detail.tsx'),
+    route('src/app/capture/index.tsx'),
+    route('src/app/item/[id].tsx'),
+    route('src/app/docs/[...slug].tsx'),
+  ];
+  const files: Record<string, string> = {
+    'src/services/nav.ts':
+      "import { router } from 'expo-router'\n" +
+      "export function openDetail(item) {\n" +
+      '  router.navigate(\n' +
+      '    `/object-detail?detectionItem=${encode(item)}` as any\n' +
+      '  )\n' +
+      '}\n' +
+      "export function openItem(id) { router.push(`/item/${id}`) }\n" +
+      "export function openDoc() { router.push({ pathname: '/docs/[...slug]', params: { slug: ['a'] } }) }\n" +
+      "export function openCapture() { router.replace('/capture/') }\n" +
+      "export function missing() { router.push('/nowhere') }\n" +
+      "export function computed(h) { router.push(h) }\n" +
+      "export function notNav(list) { list.push('/capture') }\n" +
+      "export function fork(x) { router.push(x ? '/capture' : '/object-detail') }\n" +
+      "export function sameScreen(x) { router.push(x ? '/capture?x=1' : '/capture/') }\n" +
+      "export function viaWrapper() { safePush('/capture/') }\n",
+  };
+  const context = {
+    getNodesByKind: (kind: Node['kind']) => (kind === 'route' ? routes : []),
+    getProjectRoot: () => '/proj',
+    readFile: (p: string) => files[p] ?? null,
+    getFileLines: (p: string) => files[p]?.split('\n') ?? null,
+    getAllFiles: () => Object.keys(files),
+    getNodesInFile: () => [],
+    getNodesByName: () => [],
+    getNodesByQualifiedName: () => [],
+    getNodesByLowerName: () => [],
+    fileExists: () => true,
+    getImportMappings: () => [],
+  } as unknown as ResolutionContext;
+
+  const ref = (referenceName: string, line: number, column = 0): UnresolvedRef => ({
+    fromNodeId: 'function:src',
+    referenceName,
+    referenceKind: 'calls',
+    line,
+    column,
+    filePath: 'src/services/nav.ts',
+    language: 'typescript',
+  });
+
+  it('claims router navigation method names through the name pre-filter', () => {
+    expect(expoRouterResolver.claimsReference!('router.push')).toBe(true);
+    expect(expoRouterResolver.claimsReference!('nav.navigate')).toBe(true);
+    expect(expoRouterResolver.claimsReference!('safePush')).toBe(true);
+    expect(expoRouterResolver.claimsReference!('guardedNavigate')).toBe(true);
+    expect(expoRouterResolver.claimsReference!('router.back')).toBe(false);
+    expect(expoRouterResolver.claimsReference!('fetch')).toBe(false);
+    expect(expoRouterResolver.claimsReference!('Push')).toBe(false);
+  });
+
+  it('binds a project wrapper named for the verb, remembering the wrapper', () => {
+    const r = expoRouterResolver.resolve(ref('safePush', 15, 27), context);
+    expect(r?.targetNodeId).toBe(routes[2]!.id);
+    expect(r?.metadata).toEqual({ href: '/capture/', navMethod: 'push', via: 'safePush' });
+  });
+
+  it('binds a multi-line template href to its route as a navigates edge with the href', () => {
+    const r = expoRouterResolver.resolve(ref('router.navigate', 3, 2), context);
+    expect(r).not.toBeNull();
+    expect(r!.targetNodeId).toBe(routes[1]!.id);
+    expect(r!.edgeKind).toBe('navigates');
+    expect(r!.resolvedBy).toBe('framework');
+    expect(r!.metadata).toEqual({ href: '/object-detail?detectionItem=${…}', navMethod: 'navigate' });
+  });
+
+  it('matches an interpolated segment against a [param] route', () => {
+    const r = expoRouterResolver.resolve(ref('router.push', 7, 29), context);
+    expect(r?.targetNodeId).toBe(routes[3]!.id);
+  });
+
+  it('matches a pathname object against a catch-all route', () => {
+    const r = expoRouterResolver.resolve(ref('router.push', 8, 28), context);
+    expect(r?.targetNodeId).toBe(routes[4]!.id);
+  });
+
+  it('normalizes a trailing slash onto an index route', () => {
+    const r = expoRouterResolver.resolve(ref('router.replace', 9, 32), context);
+    expect(r?.targetNodeId).toBe(routes[2]!.id);
+  });
+
+  it('returns null for a path with no screen and for a computed href', () => {
+    expect(expoRouterResolver.resolve(ref('router.push', 10, 28), context)).toBeNull();
+    expect(expoRouterResolver.resolve(ref('router.push', 11, 30), context)).toBeNull();
+  });
+
+  it('gates on the string naming a real screen, not on the receiver being called router', () => {
+    // `const nav = useRouter(); nav.push('/x')` must bind, so the receiver is
+    // not consulted; a non-router `push` of a real screen path binds too.
+    expect(expoRouterResolver.resolve(ref('list.push', 12, 32), context)?.targetNodeId).toBe(routes[2]!.id);
+  });
+
+  it('binds a conditional whose arms name the same screen, and draws BOTH when they fork', () => {
+    const same = expoRouterResolver.resolve(ref('router.push', 14, 33), context);
+    expect(same?.targetNodeId).toBe(routes[2]!.id);
+    expect(same?.alsoTargets).toBeUndefined();
+    // A fork reaches both screens, and each becomes an edge of its own.
+    const forked = expoRouterResolver.resolve(ref('router.push', 13, 27), context);
+    expect(forked).not.toBeNull();
+    expect([forked!.targetNodeId, ...(forked!.alsoTargets ?? []).map((t) => t.targetNodeId)]).toHaveLength(2);
+  });
+
+  it('ignores refs that are not calls or not JS/TS', () => {
+    expect(
+      expoRouterResolver.resolve({ ...ref('router.push', 9, 32), referenceKind: 'references' }, context)
+    ).toBeNull();
+    expect(expoRouterResolver.resolve({ ...ref('router.push', 9, 32), language: 'swift' }, context)).toBeNull();
+  });
+});
+
+// =============================================================================
+// End to end: index a small Expo app and walk tap → screen
+// =============================================================================
+
+describe('expo-router: end-to-end', () => {
+  beforeAll(async () => {
+    await initGrammars();
+    await loadAllGrammars();
+  });
+
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  function write(rel: string, content: string) {
+    const full = path.join(tmpDir!, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  it('connects a component tap to the screen it navigates to', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-router-'));
+    write(
+      'package.json',
+      JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', react: '18' } })
+    );
+    write('src/app/_layout.tsx', "export default function Layout() { return null }\n");
+    write(
+      'src/app/index.tsx',
+      "import { ItemCard } from '../components/item-card'\n" +
+        'export default function Home() {\n' +
+        "  return <ItemCard item={{ id: '1' }} collected />\n" +
+        '}\n'
+    );
+    write(
+      'src/app/object-detail.tsx',
+      "export default function ObjectDetail() {\n  return null\n}\n"
+    );
+    write('src/app/item/[id].tsx', "export default function Item() { return null }\n");
+    write(
+      'src/services/nav.ts',
+      "import { router } from 'expo-router'\n" +
+        'export function openObjectDetail(item: { id: string }) {\n' +
+        '  router.navigate(\n' +
+        '    `/object-detail?detectionItem=${JSON.stringify(item)}` as any\n' +
+        '  )\n' +
+        '}\n' +
+        'export function openItem(id: string) {\n' +
+        "  router.push({ pathname: '/item/[id]', params: { id } })\n" +
+        '}\n'
+    );
+    write(
+      'src/app/welcome.tsx',
+      "export default function Welcome() { return null }\n"
+    );
+    write(
+      'src/services/post-login.ts',
+      // The literal-union return type is the trap: its routes are string
+      // literals too, BEFORE the ternary — the scan must skip the signature
+      // or the annotation's guardless positions win.
+      'export const resolvePostLoginRoute = async (): Promise<\n' +
+        "  '/welcome/' | '/'\n" +
+        '> => {\n' +
+        "  return (await seen()) ? '/' : '/welcome/'\n" +
+        '}\n' +
+        'async function seen() { return true }\n' +
+        "export function apiPath() { return '/api/users' }\n"
+    );
+    write(
+      'src/services/login.ts',
+      "import { router } from 'expo-router'\n" +
+        "import { resolvePostLoginRoute, apiPath } from './post-login'\n" +
+        'export async function finishLogin() {\n' +
+        '  router.replace(await resolvePostLoginRoute())\n' +
+        '}\n' +
+        'export function fetchUsers() { return fetch(apiPath()) }\n'
+    );
+    write(
+      'src/components/item-card.tsx',
+      "import { openObjectDetail } from '../services/nav'\n" +
+        'export function ItemCard(props: { item: { id: string }; collected: boolean }) {\n' +
+        '  const handlePress = () => {\n' +
+        '    if (props.collected) openObjectDetail(props.item)\n' +
+        '  }\n' +
+        '  return handlePress\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const routes = cg.getNodesByKind('route');
+    expect(routes.map((r) => r.name).sort()).toEqual(['/', '/item/[id]', '/object-detail', '/welcome']);
+    const detailRoute = routes.find((r) => r.name === '/object-detail')!;
+
+    // route → its screen component
+    const screen = cg.getNodesByName('ObjectDetail').find((n) => n.kind !== 'route')!;
+    expect(screen).toBeDefined();
+    const toScreen = cg.getOutgoingEdges(detailRoute.id).find((e) => e.target === screen.id);
+    expect(toScreen?.kind).toBe('calls');
+
+    // navigation call → route, as a navigates edge that remembers the href
+    const opener = cg.getNodesByName('openObjectDetail')[0]!;
+    const nav = cg.getOutgoingEdges(opener.id).find((e) => e.target === detailRoute.id);
+    expect(nav?.kind).toBe('navigates');
+    expect(nav?.metadata?.href).toBe('/object-detail?detectionItem=${…}');
+    expect(nav?.metadata?.navMethod).toBe('navigate');
+    expect(nav?.metadata?.refKind).toBe('calls');
+
+    // the pathname-object form binds the dynamic route
+    const itemRoute = routes.find((r) => r.name === '/item/[id]')!;
+    const openItem = cg.getNodesByName('openItem')[0]!;
+    expect(cg.getOutgoingEdges(openItem.id).some((e) => e.target === itemRoute.id && e.kind === 'navigates')).toBe(true);
+
+    // the route's callers are the navigators — what "who opens this screen" asks
+    const callers = cg.getCallers(detailRoute.id);
+    expect(callers.map((c) => c.node.name)).toContain('openObjectDetail');
+
+    // `router.replace(await resolvePostLoginRoute())`: the helper's return
+    // literals become heuristic navigates edges FROM THE HELPER, one per screen,
+    // remembering the push site; the plain `calls` edge from the pusher closes
+    // the chain. A helper nothing navigates with (`apiPath`) is never read.
+    const helper = cg.getNodesByName('resolvePostLoginRoute')[0]!;
+    const fromHelper = cg.getOutgoingEdges(helper.id).filter((e) => e.kind === 'navigates');
+    expect(fromHelper.map((e) => routes.find((r) => r.id === e.target)?.name).sort()).toEqual(['/', '/welcome']);
+    expect(fromHelper.every((e) => e.provenance === 'heuristic')).toBe(true);
+    expect(fromHelper[0]!.metadata?.synthesizedBy).toBe('expo-router-return');
+    expect(fromHelper[0]!.metadata?.registeredAt).toBe('src/services/login.ts:4');
+    // Each return literal carries its own POSITION: the two arms of
+    // `return (await seen()) ? '/' : '/welcome/'` share a line, and only the
+    // column lets the guard reader say which arm an edge is — without it both
+    // navigations read as `always`.
+    const welcomeEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/welcome')!;
+    const rootEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/')!;
+    expect(rootEdge.line).toBe(welcomeEdge.line);
+    expect(typeof rootEdge.column).toBe('number');
+    expect(welcomeEdge.column!).toBeGreaterThan(rootEdge.column!);
+    const finishLogin = cg.getNodesByName('finishLogin')[0]!;
+    expect(cg.getOutgoingEdges(finishLogin.id).some((e) => e.target === helper.id && e.kind === 'calls')).toBe(true);
+    const apiPath = cg.getNodesByName('apiPath')[0]!;
+    expect(cg.getOutgoingEdges(apiPath.id).some((e) => e.kind === 'navigates')).toBe(false);
+
+    // The Screens payload: the tap on ItemCard is attributed back to the Home
+    // screen through the JSX-render hop, with the chain and its condition.
+    const screens = await buildScreens(cg, tmpDir);
+    expect(screens.routed).toBe(true);
+    const home = screens.screens.find((s) => s.path === '/')!;
+    expect(screens.entry).toBe(home.id);
+    const detail = screens.screens.find((s) => s.path === '/object-detail')!;
+    const tap = screens.links.find((l) => l.from === home.id && l.to === detail.id)!;
+    expect(tap).toBeDefined();
+    expect(tap.via.map((v) => v.name)).toEqual(['ItemCard', 'openObjectDetail']);
+    expect(tap.when).toBe('props.collected');
+    expect(tap.sites[0]!.href).toBe('/object-detail?detectionItem=${…}');
+    // Navigation nothing on a screen reaches is an origin, not dropped: the
+    // post-login helper, and `openItem`, which the fixture never calls.
+    const fromOrigins = screens.links.filter((l) => l.fromOrigin);
+    expect(fromOrigins.map((l) => screens.screens.find((s) => s.id === l.to)!.path).sort()).toEqual(['/', '/item/[id]', '/welcome']);
+    expect(screens.origins.map((o) => o.node.name)).toEqual(['openItem', 'resolvePostLoginRoute']);
+    expect(screens.dropped).toBe(0);
+
+    // The steps walk reads each arm's own condition off the literal's column:
+    // where the app goes after login is a fork, not two `always`es.
+    const steps = await buildSteps(cg, tmpDir, new URLSearchParams({ symbol: 'finishLogin' }));
+    const stepByLabel = (label: string) => steps.steps.find((s) => s.label === label)!;
+    const toRoot = steps.links.find((l) => l.to === stepByLabel('/').id)!;
+    const toWelcome = steps.links.find((l) => l.to === stepByLabel('/welcome').id)!;
+    expect(toRoot.when).toMatch(/await seen\(\)/);
+    expect(toRoot.when).not.toMatch(/!/);
+    expect(toWelcome.when).toMatch(/!\s*\(?\s*await seen\(\)/);
+
+    // …and each site names the DECISION its condition belongs to, so the two
+    // arms can be drawn as one choice rather than as two lines that happen to
+    // read as each other's negation. Same branch, opposite arms, one `on`.
+    const rootArm = toRoot.sites[0]!.decision!;
+    const welcomeArm = toWelcome.sites[0]!.decision!;
+    expect(rootArm.branch).toBe(welcomeArm.branch);
+    expect(rootArm.branch).not.toBe('');
+    expect(rootArm.form).toBe('ternary');
+    expect(rootArm.on).toBe(welcomeArm.on);
+    expect(rootArm.on).toMatch(/await seen\(\)/);
+    expect(rootArm.on).not.toMatch(/^!/);
+    expect(rootArm.not).toBeUndefined();
+    expect(welcomeArm.not).toBe(true);
+    expect(rootArm.arm).not.toBe(welcomeArm.arm);
+
+    cg.close();
+  });
+});
+
+// =============================================================================
+// The backward walk must not leave the app's own execution context
+// =============================================================================
+
+/**
+ * A navigation written inside a component the graph can only reach BACKWARDS
+ * through the native bridge belongs to the screen whose file it is written in
+ * — not to whichever screen happened to start the round trip.
+ *
+ * The shape, from a real Expo app: `/capture` renders `ARCapturePage`, which
+ * renders `memo(CaptureComponent)`; the `router.push` lives in an inline
+ * listener inside `CaptureComponent`. Nothing points at `CaptureComponent`
+ * except Swift emitters — the walk skips `file` nodes, and `memo(x)` leaves no
+ * edge from the memo to the function — so before the guard the walk escaped
+ * through `rn-event-channel`, came back down into `ReviewScreen` (which had
+ * called the native module), and filed four of `/capture`'s navigations under
+ * `/capture/review`, whose only remaining feed was itself. It also carried the
+ * Swift guards home as conditions on a JavaScript navigation.
+ */
+describe('expo-router screens: attribution stops at the native bridge', () => {
+  let tmpDir: string | undefined;
+
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  function write(rel: string, content: string) {
+    const full = path.join(tmpDir!, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  it('files the push on the screen whose file holds it, not on the screen that started the round trip', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-bridge-'));
+    write(
+      'package.json',
+      JSON.stringify({
+        name: 'app',
+        dependencies: { expo: '52', 'expo-router': '4', react: '18', 'react-native': '0.76' },
+      })
+    );
+    write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
+    write('src/app/index.tsx', 'export default function Home() { return null }\n');
+
+    // The Swift side: a method the JS calls, which ends in the event emit.
+    write(
+      'ios/CaptureView.swift',
+      `import Foundation
+@objc(CaptureView)
+class CaptureView: NSObject {
+  @objc func startRetake() {
+    CaptureEvents.shared.emitCaptureComplete()
+  }
+}
+`
+    );
+    // The ObjC bridging shim, without which the JS side never reaches Swift.
+    write(
+      'ios/CaptureView.m',
+      `#import <React/RCTBridgeModule.h>
+@interface RCT_EXTERN_MODULE(CaptureView, NSObject)
+RCT_EXTERN_METHOD(startRetake)
+@end
+`
+    );
+    write(
+      'ios/CaptureEvents.swift',
+      `import Foundation
+class CaptureEvents: RCTEventEmitter {
+  func emitCaptureComplete() {
+    guard Thread.isMainThread else { return }
+    sendEvent(withName: "onCaptureComplete", body: nil)
+  }
+}
+`
+    );
+
+    // /capture — the push is written HERE, in an inline listener inside a
+    // sibling of the route's own default export.
+    write(
+      'src/app/capture/index.tsx',
+      `import { memo, useEffect } from 'react'
+import { router } from 'expo-router'
+const MemoizedCaptureComponent = memo(CaptureComponent)
+export default function ARCapturePage() {
+  return <MemoizedCaptureComponent />
+}
+function CaptureComponent() {
+  useEffect(() => {
+    const sub = nativeEmitter.addListener('onCaptureComplete', (data) => {
+      if (!isRetakeBatchActive) {
+        router.push('/capture/review')
+      }
+    })
+    return () => sub.remove()
+  }, [])
+  return null
+}
+`
+    );
+
+    // /capture/review — calls into the native module, which is what makes the
+    // Swift emitter backwards-reachable from this screen.
+    write(
+      'src/app/capture/review/index.tsx',
+      `import { NativeModules } from 'react-native'
+const { CaptureView } = NativeModules
+export default function ReviewScreen() {
+  function handleRetake() {
+    CaptureView.startRetake()
+  }
+  return handleRetake
+}
+`
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    // The escape route the walk used to take really is in the graph.
+    const capture = cg.getNodesByName('CaptureComponent').find((n) => n.kind !== 'route')!;
+    const bridged = cg
+      .getIncomingEdgesTo([capture.id], ['calls'])
+      .filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'rn-event-channel');
+    expect(bridged.length).toBeGreaterThan(0);
+    // …and it really is a route back OUT to the other screen: without the
+    // guard the walk runs handleRetake > startRetake > emitCaptureComplete >
+    // CaptureComponent and lands the push on /capture/review.
+    const startRetake = cg.getNodesByName('startRetake').find((n) => n.language === 'swift')!;
+    expect(cg.getIncomingEdgesTo([startRetake.id], ['calls']).map((e) => cg.getNodesByIds([e.source]).get(e.source)?.name)).toContain(
+      'handleRetake'
+    );
+
+    const screens = await buildScreens(cg, tmpDir);
+    const from = (path: string) => screens.screens.find((s) => s.path === path)!;
+    const review = from('/capture/review');
+    const links = screens.links.filter((l) => l.to === review.id);
+
+    // One transition into /capture/review, and it comes from /capture.
+    expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']);
+    // Written right there: no chain, and no Swift guard smuggled in.
+    expect(links[0]!.via).toEqual([]);
+    expect(links[0]!.when).toBe('!isRetakeBatchActive');
+    expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx');
+    // …and /capture/review is not left feeding only itself.
+    expect(screens.links.some((l) => l.from === review.id && l.to === review.id)).toBe(false);
+
+    cg.close();
+  });
+});

+ 42 - 4
__tests__/frameworks.test.ts

@@ -1424,6 +1424,42 @@ public IActionResult ListUsers()
     expect(nodes[0].name).toBe('GET /users');
     expect(references[0].referenceName).toBe('ListUsers');
   });
+
+  it('extracts the handler-first endpoint-group form under the class, with the optional path', () => {
+    const src = `
+public class TodoItems : IEndpointGroup
+{
+    public static void Map(RouteGroupBuilder groupBuilder)
+    {
+        groupBuilder.RequireAuthorization();
+        groupBuilder.MapPost(CreateTodoItem);
+        groupBuilder.MapPut(UpdateTodoItem, "{id}");
+        groupBuilder.MapDelete(DeleteTodoItem, "{id}");
+    }
+    public static async Task<Created<int>> CreateTodoItem(ISender sender, CreateTodoItemCommand command) { }
+}
+`;
+    const { nodes, references } = aspnetResolver.extract!('Web/Endpoints/TodoItems.cs', src);
+    expect(nodes.map((n) => n.name)).toEqual(['POST /TodoItems', 'PUT /TodoItems/{id}', 'DELETE /TodoItems/{id}']);
+    expect(nodes.map((n) => n.startLine)).toEqual([7, 8, 9]);
+    expect(references.map((r) => r.referenceName)).toEqual(['CreateTodoItem', 'UpdateTodoItem', 'DeleteTodoItem']);
+    expect(nodes[0]!.qualifiedName).toBe('Web/Endpoints/TodoItems.cs::group:TodoItems:POST:');
+  });
+
+  it('a class with its own RoutePrefix literal names its routes under it', () => {
+    const src = `
+public class TodoLists : IEndpointGroup
+{
+    public static string RoutePrefix => "/api/todo-lists";
+    public static void Map(RouteGroupBuilder group)
+    {
+        group.MapGet(GetTodoLists);
+    }
+}
+`;
+    const { nodes } = aspnetResolver.extract!('TodoLists.cs', src);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /api/todo-lists']);
+  });
 });
 
 import { vaporResolver } from '../src/resolution/frameworks/swift';
@@ -1516,6 +1552,7 @@ app.get(
 });
 
 import { reactResolver } from '../src/resolution/frameworks/react';
+import { nextjsResolver } from '../src/resolution/frameworks/nextjs';
 import { svelteResolver } from '../src/resolution/frameworks/svelte';
 import { astroResolver } from '../src/resolution/frameworks/astro';
 
@@ -1555,13 +1592,14 @@ describe('reactResolver.extract — React Router', () => {
   });
 
   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 {}');
+    const cfg = nextjsResolver.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 {}');
+    const vite = nextjsResolver.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}');
+    // a real page still works — and the React resolver leaves it to the Next one
+    const page = nextjsResolver.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']);
+    expect(reactResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}').nodes).toHaveLength(0);
   });
 });
 

+ 20 - 0
__tests__/is-test-file.test.ts

@@ -13,6 +13,26 @@ import { describe, it, expect } from 'vitest';
 import { isTestFile } from '../src/search/query-utils';
 
 describe('isTestFile', () => {
+  it('flags test-support modules and doubles by directory name', () => {
+    expect(isTestFile('core/data-test/src/main/kotlin/com/example/FakeUserDataRepository.kt')).toBe(true);
+    expect(isTestFile('core/datastore-test/src/main/kotlin/com/example/InMemoryDataStore.kt')).toBe(true);
+    expect(isTestFile('core/testing/src/main/kotlin/com/example/TestUserDataRepository.kt')).toBe(true);
+    expect(isTestFile('pkg/testdata/fixture.go')).toBe(true);
+    expect(isTestFile('src/__mocks__/api.ts')).toBe(true);
+    expect(isTestFile('internal/testutil/helpers.go')).toBe(true);
+  });
+
+  it('does NOT flag production code whose package path runs through a samples or examples segment', () => {
+    // Only the project layout above `src/` decides; the package path below it never does.
+    expect(isTestFile('core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/SyncUtilities.kt')).toBe(false);
+    expect(isTestFile('feature/foryou/impl/src/main/kotlin/com/google/samples/apps/ForYouViewModel.kt')).toBe(false);
+    expect(isTestFile('src/samples/demo.ts')).toBe(false);
+    // …while a real examples folder in the layout still counts.
+    expect(isTestFile('examples/basic/src/index.ts')).toBe(true);
+    expect(isTestFile('packages/x/examples/basic.ts')).toBe(true);
+    expect(isTestFile('benchmarks/run.py')).toBe(true);
+  });
+
   it('flags Kotlin test files and source sets', () => {
     expect(isTestFile('okhttp/src/jvmTest/kotlin/okhttp3/CallTest.kt')).toBe(true);
     expect(isTestFile('okhttp/src/commonTest/kotlin/okhttp3/CompressionInterceptorTest.kt')).toBe(true);

+ 78 - 0
__tests__/namespace-object-resolution.test.ts

@@ -0,0 +1,78 @@
+/**
+ * The default-export namespace object — `const UploadApi = { uploadARCapture };
+ * export default UploadApi` — and a call through it from another file. Two
+ * things have to hold for `handleZipComplete → uploadARCapture` to exist:
+ * the default import must find the constant the `export default` statement
+ * names (it is not exported at its declaration), and the member must resolve
+ * to the binding the shorthand property carries, through the object's own
+ * imports.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+describe('namespace object default exports', () => {
+  let dir: string;
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-namespace-object-'));
+  });
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  function write(rel: string, content: string): void {
+    const full = path.join(dir, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  it('resolves Api.member() to the function the shorthand property names', async () => {
+    write('package.json', '{"name":"app"}');
+    write('src/api/frames.ts', 'export async function uploadARCapture(uri: string) {\n  return uri\n}\n');
+    write('src/api/folders.ts', 'export function createFolder(name: string) {\n  return name\n}\n');
+    write(
+      'src/api/index.ts',
+      "import { uploadARCapture } from './frames'\n" +
+        "import { createFolder } from './folders'\n" +
+        'function localHelper() {\n  return 1\n}\n' +
+        'const UploadApi = {\n  uploadARCapture,\n  makeFolder: createFolder,\n  localHelper,\n}\n' +
+        'export default UploadApi\n'
+    );
+    write(
+      'src/hooks.ts',
+      "import UploadApi from './api'\n" +
+        'export function handleZipComplete(uri: string) {\n' +
+        '  UploadApi.makeFolder(uri)\n' +
+        '  UploadApi.localHelper()\n' +
+        '  return UploadApi.uploadARCapture(uri)\n' +
+        '}\n'
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const handler = cg.getNodesByName('handleZipComplete')[0]!;
+    const callees = cg.getCallees(handler.id).map((c) => c.node.name).sort();
+    cg.close();
+    expect(callees).toEqual(['createFolder', 'localHelper', 'uploadARCapture']);
+  });
+
+  it('a default import of a later-exported const finds that const, and a method inside it', async () => {
+    write('package.json', '{"name":"app"}');
+    write(
+      'src/store.ts',
+      'const useStore = {\n  read() {\n    return 1\n  },\n}\nexport function unrelated() {\n  return 2\n}\nexport default useStore\n'
+    );
+    write('src/use.ts', "import store from './store'\nexport function consume() {\n  return store.read()\n}\n");
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const consume = cg.getNodesByName('consume')[0]!;
+    const callees = cg.getCallees(consume.id).map((c) => c.node.name);
+    cg.close();
+    // Without the `export default NAME` binding the default import guessed the
+    // first exported function (`unrelated`); now it is the object, and the
+    // member resolves inside it.
+    expect(callees).toEqual(['read']);
+  });
+});

+ 324 - 0
__tests__/nextjs.test.ts

@@ -0,0 +1,324 @@
+/**
+ * Next.js as a Screens app (`src/resolution/frameworks/nextjs.ts`,
+ * `src/resolution/next-router-synthesizer.ts`): pages from files, route
+ * handlers as endpoints, navigation from `<Link>`, `router.push`, `redirect`
+ * and `NextResponse.redirect`, and the Screens / Steps pictures they make.
+ * Mirrors `expo-router.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildScreens } from '../src/ui-server/api/screens';
+import { buildSteps } from '../src/ui-server/api/steps';
+import { nextjsResolver, nextRouteForFile, nextNavVerb } from '../src/resolution/frameworks/nextjs';
+import type { Node } from '../src/types';
+
+// =============================================================================
+// Route paths from file names
+// =============================================================================
+
+describe('nextjs: nextRouteForFile', () => {
+  it.each([
+    ['app/page.tsx', 'page', '/', ''],
+    ['src/app/users/page.tsx', 'page', '/users', ''],
+    ['apps/web/app/(marketing)/about/page.tsx', 'page', '/about', 'apps/web/'],
+    ['app/blog/[slug]/page.tsx', 'page', '/blog/:slug', ''],
+    ['app/docs/[...all]/page.tsx', 'page', '/docs/:all*', ''],
+    ['app/docs/[[...all]]/page.jsx', 'page', '/docs/:all*', ''],
+    ['app/api/users/route.ts', 'handler', '/api/users', ''],
+    ['app/api/users/[id]/route.ts', 'handler', '/api/users/:id', ''],
+    ['pages/index.tsx', 'page', '/', ''],
+    ['pages/about.tsx', 'page', '/about', ''],
+    ['src/pages/blog/[slug].tsx', 'page', '/blog/:slug', ''],
+    ['pages/api/users.ts', 'api', '/api/users', ''],
+    ['apps/web/pages/api/users/[id].ts', 'api', '/api/users/:id', 'apps/web/'],
+  ])('%s → %s %s (root %s)', (file, kind, route, root) => {
+    expect(nextRouteForFile(file)).toEqual({ kind, path: route, root });
+  });
+
+  it.each([
+    'app/layout.tsx',
+    'app/loading.tsx',
+    'app/users/error.tsx',
+    'app/@modal/photo/page.tsx',
+    'app/(.)photo/[id]/page.tsx',
+    'pages/_app.tsx',
+    'pages/_document.tsx',
+    'src/pages/vite.config.ts',
+    'apps/nextjs-pages/next.config.mjs',
+    'app/users/__tests__/page.tsx',
+    'src/components/button.tsx',
+  ])('%s is not a route', (file) => {
+    expect(nextRouteForFile(file)).toBeNull();
+  });
+});
+
+describe('nextjs: extract', () => {
+  it('a page is a route named by its path, calling its default export', () => {
+    const { nodes, references } = nextjsResolver.extract!('app/users/page.tsx', "export default function UsersPage() {\n  return null\n}\n");
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0]).toMatchObject({ kind: 'route', name: '/users', language: 'tsx' });
+    expect(references).toEqual([expect.objectContaining({ fromNodeId: nodes[0]!.id, referenceName: 'UsersPage', referenceKind: 'calls', line: 1 })]);
+  });
+
+  it('a route handler file is one endpoint per exported method, each naming its function', () => {
+    const src = "import { NextResponse } from 'next/server'\nexport async function GET() {\n  return NextResponse.json([])\n}\nexport const POST = async (req) => {\n  return NextResponse.json({}, { status: 201 })\n}\n";
+    const { nodes, references } = nextjsResolver.extract!('app/api/users/route.ts', src);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /api/users', 'POST /api/users']);
+    expect(nodes.map((n) => n.startLine)).toEqual([2, 5]);
+    expect(references.map((r) => [r.referenceName, r.referenceKind])).toEqual([
+      ['GET', 'references'],
+      ['POST', 'references'],
+    ]);
+  });
+
+  it('a Pages Router API file is ANY on its path, bound to the default export', () => {
+    const { nodes, references } = nextjsResolver.extract!('pages/api/users.ts', 'export default async function handler(req, res) {\n  res.status(200).json([])\n}\n');
+    expect(nodes.map((n) => n.name)).toEqual(['ANY /api/users']);
+    expect(references[0]).toMatchObject({ referenceName: 'handler', referenceKind: 'references' });
+  });
+
+  it('emits nothing for a layout or a component file', () => {
+    expect(nextjsResolver.extract!('app/layout.tsx', 'export default function L() {}').nodes).toHaveLength(0);
+    expect(nextjsResolver.extract!('components/nav.tsx', 'export default function Nav() {}').nodes).toHaveLength(0);
+  });
+
+  it('claims the navigation calls and names their verb', () => {
+    expect(nextNavVerb('router.push')).toBe('push');
+    expect(nextNavVerb('router.replace')).toBe('replace');
+    expect(nextNavVerb('redirect')).toBe('redirect');
+    expect(nextNavVerb('permanentRedirect')).toBe('permanentRedirect');
+    expect(nextNavVerb('NextResponse.redirect')).toBe('response.redirect');
+    expect(nextNavVerb('router.back')).toBeNull();
+    expect(nextNavVerb('fetch')).toBeNull();
+    expect(nextjsResolver.claimsReference!('redirect')).toBe(true);
+    expect(nextjsResolver.claimsReference!('Redirect')).toBe(false);
+  });
+});
+
+// =============================================================================
+// End to end: a small App Router site
+// =============================================================================
+
+describe('nextjs: end to end', () => {
+  let tmpDir: string;
+  let cg: CodeGraph;
+
+  function write(rel: string, content: string): void {
+    const full = path.join(tmpDir, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  beforeAll(async () => {
+    await initGrammars();
+    await loadAllGrammars();
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nextjs-'));
+    write('package.json', JSON.stringify({ name: 'site', dependencies: { next: '15', react: '19', '@prisma/client': '5' } }));
+    write('lib/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
+    write('app/layout.tsx', 'export default function RootLayout({ children }) {\n  return children\n}\n');
+    write(
+      'app/page.tsx',
+      "import Link from 'next/link'\n" +
+        'export default function Home() {\n' +
+        '  return (\n' +
+        '    <main>\n' +
+        '      <Link href="/users">Users</Link>\n' +
+        '      <a href="/login">Log in</a>\n' +
+        '      <a href="https://example.com">Elsewhere</a>\n' +
+        '    </main>\n' +
+        '  )\n' +
+        '}\n'
+    );
+    write('app/login/page.tsx', 'export default function LoginPage() {\n  return null\n}\n');
+    write(
+      'app/users/page.tsx',
+      "import { NewUserForm } from '../../components/new-user-form'\n" +
+        "import { prisma } from '../../lib/db'\n" +
+        'export default async function UsersPage() {\n' +
+        '  const users = await prisma.user.findMany()\n' +
+        '  return <NewUserForm count={users.length} />\n' +
+        '}\n'
+    );
+    write('app/users/[id]/page.tsx', 'export default function UserPage({ params }) {\n  return <a href="/users">Back</a>\n}\n');
+    write(
+      'components/new-user-form.tsx',
+      "'use client'\n" +
+        "import { useCallback, useState } from 'react'\n" +
+        "import { useRouter } from 'next/navigation'\n" +
+        "import { createUserAction } from '../app/actions'\n" +
+        'export function NewUserForm({ count }) {\n' +
+        "  const [email, setEmail] = useState('')\n" +
+        '  const router = useRouter()\n' +
+        '  const handleSubmit = useCallback(async (e) => {\n' +
+        '    e.preventDefault()\n' +
+        '    const user = await createUserAction({ email })\n' +
+        '    if (user.ok) router.push(`/users/${user.id}`)\n' +
+        '  }, [email])\n' +
+        '  return <form onSubmit={handleSubmit}><input value={email} onChange={(e) => setEmail(e.target.value)} /></form>\n' +
+        '}\n'
+    );
+    write(
+      'app/actions.ts',
+      "'use server'\n" +
+        "import { redirect } from 'next/navigation'\n" +
+        "import { prisma } from '../lib/db'\n" +
+        'export async function createUserAction(data) {\n' +
+        '  const user = await prisma.user.create({ data })\n' +
+        "  if (!user.verified) redirect('/users')\n" +
+        '  return { ok: true, id: user.id }\n' +
+        '}\n'
+    );
+    write(
+      'app/api/users/route.ts',
+      "import { NextResponse } from 'next/server'\n" +
+        "import { prisma } from '../../../lib/db'\n" +
+        'export async function GET() {\n' +
+        '  return NextResponse.json(await prisma.user.findMany())\n' +
+        '}\n' +
+        'export async function POST(req) {\n' +
+        '  const data = await req.json()\n' +
+        '  const user = await prisma.user.create({ data })\n' +
+        '  return NextResponse.json(user, { status: 201 })\n' +
+        '}\n'
+    );
+    write(
+      'middleware.ts',
+      "import { NextResponse } from 'next/server'\n" +
+        'export function middleware(req) {\n' +
+        "  if (!req.cookies.get('session')) {\n" +
+        "    return NextResponse.redirect(new URL('/login', req.url))\n" +
+        '  }\n' +
+        '  return NextResponse.next()\n' +
+        '}\n'
+    );
+    cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+  });
+
+  afterAll(() => {
+    cg?.close();
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  const route = (name: string): Node => {
+    const r = cg.getNodesByKind('route').find((r) => r.name === name);
+    if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
+    return r;
+  };
+  const sym = (name: string): Node => {
+    const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
+    if (!n) throw new Error(`no symbol ${name}`);
+    return n;
+  };
+  const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
+
+  it('names every page and endpoint, and binds a page to its component and an endpoint to its function', () => {
+    expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual(['/', '/login', '/users', '/users/:id', 'GET /api/users', 'POST /api/users']);
+    const home = cg.getOutgoingEdges(route('/').id).find((e) => e.kind === 'calls');
+    expect(cg.getNode(home!.target)?.name).toBe('Home');
+    const post = cg.getOutgoingEdges(route('POST /api/users').id).find((e) => e.kind === 'references');
+    expect(cg.getNode(post!.target)).toMatchObject({ name: 'POST', kind: 'function', filePath: 'app/api/users/route.ts' });
+  });
+
+  it('a <Link href> and an internal <a href> navigate from the component that renders them; an external one does not', () => {
+    const fromHome = navs(sym('Home'));
+    const byHref = new Map(fromHome.map((e) => [(e.metadata as Record<string, unknown>).href, e]));
+    expect([...byHref.keys()].sort()).toEqual(['/login', '/users']);
+    const users = byHref.get('/users')!;
+    expect(users.target).toBe(route('/users').id);
+    expect(users.provenance).toBe('heuristic');
+    expect(users.metadata).toEqual({ synthesizedBy: 'next-link', href: '/users', navMethod: 'link', registeredAt: 'app/page.tsx:5' });
+    expect((byHref.get('/login')!.metadata as Record<string, unknown>).navMethod).toBe('a');
+    expect(navs(sym('UserPage')).map((e) => cg.getNode(e.target)?.name)).toEqual(['/users']);
+  });
+
+  it('router.push with a template hole reaches the [id] page; redirect() and NextResponse.redirect(new URL(…)) reach theirs', () => {
+    const push = navs(sym('handleSubmit'));
+    expect(push).toHaveLength(1);
+    expect(push[0]!.target).toBe(route('/users/:id').id);
+    expect(push[0]!.metadata).toMatchObject({ href: '/users/${…}', navMethod: 'push', refKind: 'calls' });
+    const redirect = navs(sym('createUserAction'));
+    expect(redirect).toHaveLength(1);
+    expect(redirect[0]!.target).toBe(route('/users').id);
+    expect(redirect[0]!.metadata).toMatchObject({ href: '/users', navMethod: 'redirect' });
+    const guard = navs(sym('middleware'));
+    expect(guard).toHaveLength(1);
+    expect(guard[0]!.target).toBe(route('/login').id);
+    expect(guard[0]!.metadata).toMatchObject({ href: '/login', navMethod: 'response.redirect' });
+  });
+
+  it('lands on the Screens tab: the entry page, its links, and the form’s push attributed back to its page with the condition', async () => {
+    const screens = await buildScreens(cg, tmpDir);
+    expect(screens.routed).toBe(true);
+    const home = screens.screens.find((s) => s.path === '/')!;
+    expect(screens.entry).toBe(home.id);
+    expect(home.component?.name).toBe('Home');
+    const users = screens.screens.find((s) => s.path === '/users')!;
+    const user = screens.screens.find((s) => s.path === '/users/:id')!;
+    const link = screens.links.find((l) => l.from === home.id && l.to === users.id)!;
+    expect(link.via).toEqual([]);
+    expect(link.synthesized).toBe(true);
+    // Markup, not a return value: the destination is written right there, so
+    // the site keeps its own verb rather than reading as a helper's return.
+    expect(link.sites[0]).toMatchObject({ href: '/users', method: 'link' });
+    const push = screens.links.find((l) => l.from === users.id && l.to === user.id)!;
+    expect(push).toBeDefined();
+    expect(push.via.map((v) => v.name)).toEqual(['NewUserForm', 'handleSubmit']);
+    expect(push.when).toBe('user.ok');
+    expect(push.sites[0]).toMatchObject({ href: '/users/${…}', method: 'push' });
+    // The middleware's redirect starts from no page: an origin.
+    expect(screens.origins.map((o) => o.node.name)).toContain('middleware');
+    expect(screens.dropped).toBe(0);
+  });
+
+  it('an endpoint is not a screen — the Screens tab is pages, Entry points is every route', async () => {
+    const screens = await buildScreens(cg, tmpDir);
+    // `GET /api/users` and `POST /api/users` are routes, and they are on the
+    // Entry points list — but a request is not somewhere a user can be.
+    expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/login', '/users', '/users/:id']);
+    expect(cg.getNodesByKind('route').some((r) => r.name === 'POST /api/users')).toBe(true);
+  });
+
+  it('a page’s Steps picture fires from its load, crosses to the server action, and draws the pages it leads to as boundaries', async () => {
+    const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/users').id }));
+    expect(p.project).toBe('web');
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.kind).toBe('screen');
+    expect(anchor.sub).toBe('UsersPage');
+    expect(anchor.trigger).toEqual({ kind: 'load', name: 'GET', of: '/users', in: 'page.tsx' });
+    const loadRead = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database' && s.effect.by.name === 'UsersPage')!;
+    expect(loadRead.label).toBe('prisma.user.findMany()');
+    const handler = p.steps.find((s) => s.kind === 'trigger' && s.node?.name === 'handleSubmit')!;
+    expect(handler.trigger).toMatchObject({ kind: 'prop', name: 'onSubmit', of: 'form' });
+    const action = p.steps.find((s) => s.node?.name === 'createUserAction')!;
+    expect(action.kind).toBe('bridge');
+    const toAction = p.links.find((l) => l.to === action.id)!;
+    expect(toAction.label).toContain('server action');
+    const write = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database' && s.effect.by.name === 'createUserAction')!;
+    expect(write.label).toBe('prisma.user.create({ data })');
+    const detail = p.steps.find((s) => s.kind === 'screen' && s.screen?.path === '/users/:id')!;
+    expect(detail.cut).toBe('screen');
+    const toDetail = p.links.find((l) => l.to === detail.id)!;
+    expect(toDetail.kind).toBe('navigates');
+    expect(toDetail.when).toBe('user.ok');
+    expect(toDetail.sites[0]!.text).toBe('push /users/${…}');
+    const back = p.links.find((l) => l.from === action.id && l.to === anchor.id)!;
+    expect(back.sites[0]).toMatchObject({ text: 'redirect /users', when: '!user.verified' });
+  });
+
+  it('an endpoint anchors as any server route does', async () => {
+    const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('POST /api/users').id }));
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.sub).toBe('POST');
+    expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/api/users', in: 'route.ts' });
+    const db = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database')!;
+    expect(db.effect).toMatchObject({ model: 'user', access: 'write', by: { name: 'POST' } });
+    const res = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'response')!;
+    expect(res.label).toBe('201');
+  });
+});

+ 136 - 0
__tests__/react-hook-handlers.test.ts

@@ -0,0 +1,136 @@
+/**
+ * React handler hooks name the function they wrap.
+ *
+ * `const handleSubmit = useCallback(() => {…}, [])` is how nearly every
+ * handler in a React / React Native component is written, and the arrow is
+ * anonymous only syntactically — the declarator is the name every
+ * `onPress={handleSubmit}` and `addListener('x', handleSubmit)` uses. Without
+ * a node the handler's calls attribute to the component and the trigger of a
+ * flow (the tap, the native event) has nothing to resolve to.
+ */
+import { describe, it, expect, beforeAll } from 'vitest';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+});
+
+const refsFrom = (result: ReturnType<typeof extractFromSource>, id: string) =>
+  result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => r.referenceName);
+
+describe('useCallback handlers', () => {
+  it('extracts the wrapped arrow as a function named by the declarator, inside the component', () => {
+    const code = `
+      import { useCallback, useMemo, useEffect } from 'react'
+      import { finalize, upload, log } from './api'
+      export default function ReviewScreen() {
+        const handleApprove = useCallback(() => {
+          finalize()
+        }, [])
+        const handleZip = useCallback(async (data: { uri: string }) => {
+          await upload(data.uri)
+        }, [])
+        const total = useMemo(() => 1 + 1, [])
+        useEffect(() => {
+          log('mounted')
+        }, [])
+        return <Button onPress={handleApprove} />
+      }
+    `;
+    const result = extractFromSource('src/app/review.tsx', code);
+    const fns = result.nodes.filter((n) => n.kind === 'function');
+    const names = fns.map((n) => n.name);
+    expect(names).toEqual(expect.arrayContaining(['ReviewScreen', 'handleApprove', 'handleZip']));
+    // A memo is a value and an effect is anonymous: neither becomes a function.
+    expect(names).not.toContain('total');
+    expect(names.filter((n) => n === '<anonymous>')).toEqual([]);
+
+    const screen = fns.find((n) => n.name === 'ReviewScreen')!;
+    const handleZip = fns.find((n) => n.name === 'handleZip')!;
+    expect(handleZip.qualifiedName).toBe('ReviewScreen::handleZip');
+    expect(handleZip.startLine).toBe(8);
+
+    // The handler's calls are its own; the component keeps only what it does itself.
+    expect(refsFrom(result, handleZip.id)).toContain('upload');
+    expect(refsFrom(result, screen.id)).not.toContain('upload');
+    expect(refsFrom(result, screen.id)).toContain('log');
+
+    // Containment: the component contains its handlers.
+    expect(
+      result.edges.some((e) => e.kind === 'contains' && e.source === screen.id && e.target === handleZip.id)
+    ).toBe(true);
+
+    // `onPress={handleApprove}` is a function-as-value site: the tap's handler
+    // is referenced from the component, which is how a Steps picture knows
+    // the handler is a trigger.
+    const handleApprove = fns.find((n) => n.name === 'handleApprove')!;
+    expect(
+      result.unresolvedReferences.some(
+        (r) => r.fromNodeId === screen.id && r.referenceKind === 'function_ref' && r.referenceName === 'handleApprove'
+      )
+    ).toBe(true);
+    expect(handleApprove.startLine).toBe(5);
+  });
+
+  it('accepts React.useCallback, function expressions, and useEffectEvent', () => {
+    const code = `
+      import React from 'react'
+      export function Screen() {
+        const onOpen = React.useCallback(function () { open() }, [])
+        const onLog = useEffectEvent((url: string) => { track(url) })
+        return null
+      }
+    `;
+    const result = extractFromSource('src/screen.tsx', code);
+    const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
+    expect(names).toEqual(expect.arrayContaining(['Screen', 'onOpen', 'onLog']));
+  });
+
+  it('leaves a hook whose first argument is not the bound function alone', () => {
+    const code = `
+      export function Screen() {
+        const value = useState(() => compute())
+        const cb = useCallback(existingHandler, [])
+        const [x] = useReducer((s) => s, 0)
+        return null
+      }
+    `;
+    const result = extractFromSource('src/screen.tsx', code);
+    const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
+    expect(names).toEqual(['Screen']);
+  });
+
+  it('a handler a hook returns in an object is a function-as-value of the hook', () => {
+    const code = `
+      import { useCallback } from 'react'
+      export function useReviewHandlers() {
+        const handleApprove = useCallback(() => { finalize() }, [])
+        const handleRetake = useCallback(() => { retake() }, [])
+        const count = 1
+        return { handleApprove, handleRetake, count, extra: helper }
+      }
+      function helper() {}
+    `;
+    const result = extractFromSource('src/hooks.ts', code);
+    const hook = result.nodes.find((n) => n.name === 'useReviewHandlers')!;
+    const fnRefs = result.unresolvedReferences
+      .filter((r) => r.fromNodeId === hook.id && r.referenceKind === 'function_ref')
+      .map((r) => r.referenceName)
+      .sort();
+    // `count` is a value, not a function defined here: gated out.
+    expect(fnRefs).toEqual(['handleApprove', 'handleRetake', 'helper']);
+  });
+
+  it('does nothing outside the JS family', () => {
+    const code = `
+      func screen() {
+        let handle = useCallback({ () in finalize() }, [])
+      }
+    `;
+    const result = extractFromSource('Screen.swift', code);
+    const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
+    expect(names).toEqual(['screen']);
+  });
+});

+ 127 - 0
__tests__/react-native-bridge.test.ts

@@ -340,3 +340,130 @@ describe('React Native cross-platform pairing — end to end', () => {
     expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions
   });
 });
+
+// =============================================================================
+// Swift modules via RCT_EXTERN_MODULE, and receiver evidence
+// =============================================================================
+
+import { parseObjcRNExterns, collectNativeModuleAliases } from '../src/resolution/frameworks/react-native';
+
+function swiftMethod(name: string, owner: string, filePath: string, startLine: number): Node {
+  return {
+    id: `swift:${filePath}:${name}:${startLine}`,
+    kind: 'method',
+    name,
+    qualifiedName: `${owner}::${name}`,
+    filePath,
+    language: 'swift',
+    startLine,
+    endLine: startLine + 4,
+    startColumn: 0,
+    endColumn: 0,
+    updatedAt: Date.now(),
+  } as Node;
+}
+
+const SHIM = `
+#import <React/RCTBridgeModule.h>
+#import <React/RCTViewManager.h>
+
+@interface RCT_EXTERN_MODULE(CaptureView, RCTViewManager)
+
+RCT_EXTERN_METHOD(syncSettings:(NSDictionary *)settings)
+RCT_EXTERN_METHOD(finalizeCaptureSession)
+RCT_EXTERN_REMAP_METHOD(pause, pauseInferenceNow)
+
+@end
+`;
+
+describe('React Native bridge resolver — RCT_EXTERN (Swift) modules', () => {
+  const finalize = swiftMethod('finalizeCaptureSession', 'CaptureView', 'ios/CaptureView+ReactBridge.swift', 26);
+  const sync = swiftMethod('syncSettings', 'CaptureView', 'ios/CaptureView.swift', 40);
+  const pause = swiftMethod('pauseInferenceNow', 'CaptureView', 'ios/CaptureView.swift', 60);
+  // Same method name on another Swift type — never the bridge target.
+  const decoy = swiftMethod('syncSettings', 'CaptureSettings', 'ios/CaptureSettings.swift', 12);
+
+  const files = {
+    'package.json': '{"name":"app","dependencies":{"react-native":"0.76"}}',
+    'ios/CaptureView.m': SHIM,
+    'src/components/capture/capture-view.tsx':
+      "import { NativeModules, NativeEventEmitter } from 'react-native'\n" +
+      'export const { CaptureEvents } = NativeModules\n' +
+      'export const captureView = NativeModules.CaptureView\n',
+  };
+  const ctx = makeContext([finalize, sync, pause, decoy], files);
+
+  it('parses the shim: module, class, first keyword, remap', () => {
+    expect(parseObjcRNExterns(SHIM).map((e) => [e.moduleName, e.className, e.jsName, e.nativeSelectorFirstKw])).toEqual([
+      ['CaptureView', 'CaptureView', 'syncSettings', 'syncSettings'],
+      ['CaptureView', 'CaptureView', 'finalizeCaptureSession', 'finalizeCaptureSession'],
+      ['CaptureView', 'CaptureView', 'pause', 'pauseInferenceNow'],
+    ]);
+    const remapped = parseObjcRNExterns('@interface RCT_EXTERN_REMAP_MODULE(Camera, CameraModule, NSObject)\nRCT_EXTERN_METHOD(snap)');
+    expect(remapped).toEqual([
+      { moduleName: 'Camera', className: 'CameraModule', jsName: 'snap', nativeSelectorFirstKw: 'snap', line: 2 },
+    ]);
+  });
+
+  it('collects the local names bound to NativeModules', () => {
+    const aliases = new Map<string, string>();
+    const ambiguous = new Set<string>();
+    collectNativeModuleAliases(
+      'const captureView = NativeModules.CaptureView\n' +
+        'export const { CaptureEvents, Geo: geolocation } = NativeModules\n' +
+        'let typed: Spec = NativeModules.Typed\n',
+      aliases,
+      ambiguous
+    );
+    // Direct bindings first (one pass), then the destructured ones.
+    expect([...aliases]).toEqual([
+      ['captureView', 'CaptureView'],
+      ['typed', 'Typed'],
+      ['CaptureEvents', 'CaptureEvents'],
+      ['geolocation', 'Geo'],
+    ]);
+    // The same name bound to two modules is dropped, not guessed.
+    collectNativeModuleAliases('const captureView = NativeModules.Other', aliases, ambiguous);
+    expect(aliases.has('captureView')).toBe(false);
+    expect(ambiguous.has('captureView')).toBe(true);
+  });
+
+  it('detects a project from the RCT_EXTERN_MODULE marker alone', () => {
+    expect(reactNativeBridgeResolver.detect(makeContext([], { 'ios/CaptureView.m': SHIM }))).toBe(true);
+  });
+
+  it('resolves an aliased receiver to the Swift method of the named class at 0.95', () => {
+    const r = reactNativeBridgeResolver.resolve(
+      ref('captureView.finalizeCaptureSession', 'tsx', 'src/hooks/use-review-handlers.ts'),
+      ctx
+    );
+    expect(r?.targetNodeId).toBe(finalize.id);
+    expect(r?.confidence).toBe(0.95);
+    expect(r?.metadata).toEqual({ bridge: 'react-native', module: 'CaptureView' });
+  });
+
+  it('resolves NativeModules.Module.method the same way, class-scoped past a same-named decoy', () => {
+    const r = reactNativeBridgeResolver.resolve(ref('NativeModules.CaptureView.syncSettings', 'tsx', 'src/a.tsx'), ctx);
+    expect(r?.targetNodeId).toBe(sync.id);
+    expect(r?.confidence).toBe(0.95);
+  });
+
+  it('follows RCT_EXTERN_REMAP_METHOD to the Swift implementation under the JS name', () => {
+    const r = reactNativeBridgeResolver.resolve(ref('captureView.pause', 'tsx', 'src/a.tsx'), ctx);
+    expect(r?.targetNodeId).toBe(pause.id);
+  });
+
+  it('keeps a bare method name at the by-name confidence, and refuses a named module that lacks the method', () => {
+    const bare = reactNativeBridgeResolver.resolve(ref('syncSettings', 'tsx', 'src/a.tsx'), ctx);
+    expect(bare?.targetNodeId).toBe(sync.id);
+    expect(bare?.confidence).toBe(0.6);
+    expect(reactNativeBridgeResolver.resolve(ref('captureView.nothingHere', 'tsx', 'src/a.tsx'), ctx)).toBeNull();
+    // A receiver that is NOT a module alias falls back to by-name evidence.
+    const other = reactNativeBridgeResolver.resolve(ref('somethingElse.syncSettings', 'tsx', 'src/a.tsx'), ctx);
+    expect(other?.confidence).toBe(0.6);
+  });
+
+  it('never redirects a native caller', () => {
+    expect(reactNativeBridgeResolver.resolve(ref('captureView.finalizeCaptureSession', 'swift', 'ios/x.swift'), ctx)).toBeNull();
+  });
+});

+ 481 - 0
__tests__/react-router.test.ts

@@ -0,0 +1,481 @@
+/**
+ * React Router as a Screens app (`src/resolution/frameworks/react-router.ts`,
+ * `src/resolution/react-router-synthesizer.ts`): `<Route path>` routes bound
+ * to their screens by `frameworks/react.ts`, and the navigation half — the
+ * `history.push` / `navigate` / `redirect` calls and the `<Link to>` markup
+ * that carry a user from one screen to the next.
+ *
+ * The fixture is proshop's shape on purpose: a `frontend/` workspace whose
+ * routes live in `src/App.js` and whose screens live in `src/screens/`, which
+ * is what the app-root gate has to get right. Mirrors `nextjs.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildScreens } from '../src/ui-server/api/screens';
+import { buildSteps } from '../src/ui-server/api/steps';
+import { reactRouterRoot, reactRouterNavVerb } from '../src/resolution/frameworks/react-router';
+import type { Node } from '../src/types';
+
+// =============================================================================
+// The app root a route file owns
+// =============================================================================
+
+describe('react-router: reactRouterRoot', () => {
+  it.each([
+    ['frontend/src/App.js', 'frontend/'],
+    ['src/App.tsx', ''],
+    ['apps/web/src/routes/index.tsx', 'apps/web/'],
+    ['client/App.jsx', 'client/'],
+    ['App.jsx', ''],
+  ])('%s → %s', (file, root) => {
+    expect(reactRouterRoot(file)).toBe(root);
+  });
+});
+
+describe('react-router: reactRouterNavVerb', () => {
+  it.each([
+    ['history.push', 'push'],
+    ['history.replace', 'replace'],
+    ['navigate', 'navigate'],
+    ['router.navigate', 'navigate'],
+    ['redirect', 'redirect'],
+  ])('%s → %s', (name, verb) => {
+    expect(reactRouterNavVerb(name)).toBe(verb);
+  });
+
+  it.each(['push', 'replace', 'paths.push', 'list.replace', 'items.navigate', 'go', 'goBack'])(
+    '%s is not a navigation — an unqualified push is an array’s',
+    (name) => {
+      expect(reactRouterNavVerb(name)).toBeNull();
+    }
+  );
+});
+
+// =============================================================================
+// The whole picture, indexed
+// =============================================================================
+
+describe('react-router: a routed app end to end', () => {
+  let tmpDir: string;
+  let cg: CodeGraph;
+
+  function write(rel: string, content: string): void {
+    const full = path.join(tmpDir, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  beforeAll(async () => {
+    await initGrammars();
+    await loadAllGrammars();
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-react-router-'));
+    write('package.json', JSON.stringify({ name: 'shop', private: true }));
+    write(
+      'frontend/package.json',
+      JSON.stringify({
+        name: 'frontend',
+        dependencies: { react: '18', 'react-router-dom': '5', 'react-router-bootstrap': '0.26' },
+      })
+    );
+    write(
+      'frontend/src/App.js',
+      "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" +
+        "import LoginScreen from './screens/LoginScreen'\n" +
+        "import ShippingScreen from './screens/ShippingScreen'\n" +
+        "import PaymentScreen from './screens/PaymentScreen'\n" +
+        "import PlaceOrderScreen from './screens/PlaceOrderScreen'\n" +
+        "import ProductScreen from './screens/ProductScreen'\n" +
+        "import CartScreen from './screens/CartScreen'\n" +
+        'const App = () => (\n' +
+        '  <Router>\n' +
+        "    <Route path='/login' component={LoginScreen} />\n" +
+        "    <Route path='/shipping' component={ShippingScreen} />\n" +
+        "    <Route path='/payment' component={PaymentScreen} />\n" +
+        "    <Route path='/placeorder' component={PlaceOrderScreen} />\n" +
+        "    <Route path='/product/:id' component={ProductScreen} />\n" +
+        "    <Route path='/cart/:id?' component={CartScreen} />\n" +
+        '  </Router>\n' +
+        ')\n' +
+        'export default App\n'
+    );
+    // The screen the picture was wrong on: a guarded bounce out, and a push on
+    // submit after the store action. Both are `history.push` with a literal.
+    write(
+      'frontend/src/screens/PaymentScreen.js',
+      "import React, { useState } from 'react'\n" +
+        "import { useDispatch, useSelector } from 'react-redux'\n" +
+        "import CheckoutSteps from '../components/CheckoutSteps'\n" +
+        "import { savePaymentMethod } from '../actions/cartActions'\n" +
+        'const PaymentScreen = ({ history }) => {\n' +
+        '  const cart = useSelector((state) => state.cart)\n' +
+        '  const { shippingAddress } = cart\n' +
+        '  if (!shippingAddress.address) {\n' +
+        "    history.push('/shipping')\n" +
+        '  }\n' +
+        "  const [paymentMethod, setPaymentMethod] = useState('PayPal')\n" +
+        '  const dispatch = useDispatch()\n' +
+        '  const submitHandler = (e) => {\n' +
+        '    e.preventDefault()\n' +
+        '    dispatch(savePaymentMethod(paymentMethod))\n' +
+        "    history.push('/placeorder')\n" +
+        '  }\n' +
+        '  return <form onSubmit={submitHandler}><CheckoutSteps step1 step2 step3 /></form>\n' +
+        '}\n' +
+        'export default PaymentScreen\n'
+    );
+    // A computed destination is not a destination: `redirect` is read off the
+    // query string, so nothing static names a route.
+    write(
+      'frontend/src/screens/LoginScreen.js',
+      "import React, { useEffect } from 'react'\n" +
+        "import { Link } from 'react-router-dom'\n" +
+        'const LoginScreen = ({ location, history, userInfo }) => {\n' +
+        "  const redirect = location.search ? location.search.split('=')[1] : '/'\n" +
+        '  useEffect(() => {\n' +
+        '    if (userInfo) {\n' +
+        '      history.push(redirect)\n' +
+        '    }\n' +
+        '  }, [history, userInfo, redirect])\n' +
+        "  return <Link to='/shipping'>Continue</Link>\n" +
+        '}\n' +
+        'export default LoginScreen\n'
+    );
+    write(
+      'frontend/src/screens/ShippingScreen.js',
+      "import React from 'react'\n" +
+        'const ShippingScreen = ({ history }) => {\n' +
+        '  const submitHandler = () => {\n' +
+        "    history.replace('/payment')\n" +
+        '  }\n' +
+        '  return <form onSubmit={submitHandler} />\n' +
+        '}\n' +
+        'export default ShippingScreen\n'
+    );
+    write(
+      'frontend/src/screens/PlaceOrderScreen.js',
+      "import React from 'react'\nconst PlaceOrderScreen = () => <div>Order</div>\nexport default PlaceOrderScreen\n"
+    );
+    // v6's hook, and a template hole that has to land on the `:id` route.
+    write(
+      'frontend/src/screens/ProductScreen.js',
+      "import React from 'react'\n" +
+        "import { useNavigate } from 'react-router-dom'\n" +
+        'const ProductScreen = ({ match }) => {\n' +
+        '  const navigate = useNavigate()\n' +
+        '  const addToCart = () => {\n' +
+        '    navigate(`/cart/${match.params.id}`)\n' +
+        '  }\n' +
+        '  return <button onClick={addToCart}>Add</button>\n' +
+        '}\n' +
+        'export default ProductScreen\n'
+    );
+    write(
+      'frontend/src/screens/CartScreen.js',
+      "import React from 'react'\nconst CartScreen = () => <div>Cart</div>\nexport default CartScreen\n"
+    );
+    // Navigation written as markup, including react-router-bootstrap's wrapper.
+    write(
+      'frontend/src/components/CheckoutSteps.js',
+      "import React from 'react'\n" +
+        "import { NavLink } from 'react-router-dom'\n" +
+        "import { LinkContainer } from 'react-router-bootstrap'\n" +
+        'const CheckoutSteps = ({ step1, step2 }) => (\n' +
+        '  <nav>\n' +
+        "    <LinkContainer to='/cart'><span>Cart</span></LinkContainer>\n" +
+        "    {step1 ? <LinkContainer to='/login'><span>Sign In</span></LinkContainer> : null}\n" +
+        "    {step2 ? <NavLink to='/placeorder'>Place Order</NavLink> : null}\n" +
+        "    <a href='https://example.com'>Elsewhere</a>\n" +
+        '  </nav>\n' +
+        ')\n' +
+        'export default CheckoutSteps\n'
+    );
+    write(
+      'frontend/src/actions/cartActions.js',
+      'export const savePaymentMethod = (data) => (dispatch) => {\n' +
+        "  dispatch({ type: 'CART_SAVE_PAYMENT_METHOD', payload: data })\n" +
+        "  localStorage.setItem('paymentMethod', JSON.stringify(data))\n" +
+        '}\n'
+    );
+    // The precision floor: an array's `push` with a string that IS a route.
+    write(
+      'frontend/src/utils/breadcrumbs.js',
+      'export const trail = () => {\n' +
+        '  const paths = []\n' +
+        "  paths.push('/placeorder')\n" +
+        '  return paths\n' +
+        '}\n'
+    );
+    cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+  });
+
+  afterAll(() => {
+    cg?.close();
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  const route = (name: string): Node => {
+    const r = cg.getNodesByKind('route').find((r) => r.name === name);
+    if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
+    return r;
+  };
+  const sym = (name: string): Node => {
+    const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
+    if (!n) throw new Error(`no symbol ${name}`);
+    return n;
+  };
+  const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
+  const hrefs = (from: Node) =>
+    navs(from)
+      .map((e) => (e.metadata as Record<string, unknown>).href as string)
+      .sort();
+
+  it('names every route and binds it to its screen', () => {
+    expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
+      '/cart/:id?',
+      '/login',
+      '/payment',
+      '/placeorder',
+      '/product/:id',
+      '/shipping',
+    ]);
+    const bound = cg.getOutgoingEdges(route('/payment').id).find((e) => e.kind === 'references');
+    expect(cg.getNode(bound!.target)?.name).toBe('PaymentScreen');
+  });
+
+  it('the payment screen pushes to both pages it leads to — the bounce out and the one on submit', () => {
+    const payment = sym('PaymentScreen');
+    expect(hrefs(payment)).toEqual(['/placeorder', '/shipping']);
+    const byHref = new Map(navs(payment).map((e) => [(e.metadata as Record<string, unknown>).href, e]));
+    expect(byHref.get('/shipping')!.target).toBe(route('/shipping').id);
+    expect(byHref.get('/placeorder')!.target).toBe(route('/placeorder').id);
+    expect(byHref.get('/placeorder')!.metadata).toMatchObject({ navMethod: 'push' });
+  });
+
+  it('history.replace navigates, and v6’s navigate() with a template hole reaches the :id route', () => {
+    expect(navs(sym('ShippingScreen'))[0]!.target).toBe(route('/payment').id);
+    expect(navs(sym('ShippingScreen'))[0]!.metadata).toMatchObject({ href: '/payment', navMethod: 'replace' });
+    const product = navs(sym('ProductScreen'));
+    expect(product).toHaveLength(1);
+    expect(product[0]!.target).toBe(route('/cart/:id?').id);
+    expect(product[0]!.metadata).toMatchObject({ href: '/cart/${…}', navMethod: 'navigate' });
+  });
+
+  it('a <Link to> / <NavLink to> / <LinkContainer to> navigates from the component that renders it; an external <a> does not', () => {
+    expect(hrefs(sym('LoginScreen'))).toEqual(['/shipping']);
+    const link = navs(sym('LoginScreen'))[0]!;
+    expect(link.provenance).toBe('heuristic');
+    expect(link.metadata).toMatchObject({ synthesizedBy: 'react-router-link', href: '/shipping', navMethod: 'link' });
+    // `/cart` reaches `/cart/:id?` — an optional parameter serves the bare path too.
+    expect(hrefs(sym('CheckoutSteps'))).toEqual(['/cart', '/login', '/placeorder']);
+  });
+
+  it('a computed destination is left unresolved, and an array’s push is never claimed', () => {
+    // `history.push(redirect)` — the path comes off the query string.
+    expect(navs(sym('LoginScreen')).every((e) => (e.metadata as Record<string, unknown>).synthesizedBy === 'react-router-link')).toBe(true);
+    expect(navs(sym('trail'))).toEqual([]);
+  });
+
+  it('lands on the Screens tab as transitions between screens', async () => {
+    const screens = await buildScreens(cg, tmpDir);
+    expect(screens.routed).toBe(true);
+    const at = (p: string) => screens.screens.find((s) => s.path === p)!;
+    const link = screens.links.find((l) => l.from === at('/payment').id && l.to === at('/placeorder').id)!;
+    expect(link).toBeDefined();
+    expect(link.sites[0]).toMatchObject({ href: '/placeorder', method: 'push' });
+    expect(link.via).toEqual([]);
+    expect(screens.links.find((l) => l.from === at('/shipping').id && l.to === at('/payment').id)).toBeDefined();
+    expect(screens.links.find((l) => l.from === at('/product/:id').id && l.to === at('/cart/:id?').id)).toBeDefined();
+  });
+
+  it('the payment screen’s Steps picture draws the pages it leads to, not just its store write', async () => {
+    const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/payment').id }));
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.sub).toBe('PaymentScreen');
+    const store = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'storage')!;
+    expect(store.label).toContain("localStorage.setItem('paymentMethod'");
+    // Its own two pushes, plus the link back to sign-in its checkout nav renders.
+    const to = p.steps.filter((s) => s.kind === 'screen' && !s.anchor).map((s) => s.screen?.path).sort();
+    expect(to).toEqual(['/cart/:id?', '/login', '/placeorder', '/shipping']);
+    const placeorder = p.steps.find((s) => s.screen?.path === '/placeorder')!;
+    expect(placeorder.cut).toBe('screen');
+    const push = p.links.find((l) => l.to === placeorder.id)!;
+    expect(push.kind).toBe('navigates');
+    expect(push.sites.map((site) => site.text)).toContain('push /placeorder');
+    // The bounce out is drawn with the condition that sends the user there.
+    const shipping = p.steps.find((s) => s.screen?.path === '/shipping')!;
+    const bounce = p.links.find((l) => l.to === shipping.id)!;
+    expect(bounce.sites[0]).toMatchObject({ text: 'push /shipping', when: '!shippingAddress.address' });
+  });
+});
+
+// =============================================================================
+// One component at several addresses, and the destinations a login writes
+// =============================================================================
+
+describe('react-router: the shapes proshop is written in', () => {
+  let tmpDir: string;
+  let cg: CodeGraph;
+
+  function write(rel: string, content: string): void {
+    const full = path.join(tmpDir, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  beforeAll(async () => {
+    await initGrammars();
+    await loadAllGrammars();
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-shapes-'));
+    write('package.json', JSON.stringify({ name: 'shop', dependencies: { react: '18', 'react-router-dom': '5' } }));
+    // One component, four addresses — proshop renders HomeScreen at all four.
+    write(
+      'src/App.js',
+      "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" +
+        "import HomeScreen from './screens/HomeScreen'\n" +
+        "import LoginScreen from './screens/LoginScreen'\n" +
+        "import RegisterScreen from './screens/RegisterScreen'\n" +
+        "import ProductScreen from './screens/ProductScreen'\n" +
+        'const App = () => (\n' +
+        '  <Router>\n' +
+        "    <Route path='/search/:keyword' component={HomeScreen} exact />\n" +
+        "    <Route path='/page/:pageNumber' component={HomeScreen} exact />\n" +
+        "    <Route path='/' component={HomeScreen} exact />\n" +
+        "    <Route path='/login' component={LoginScreen} />\n" +
+        "    <Route path='/register' component={RegisterScreen} />\n" +
+        "    <Route path='/product/:id' component={ProductScreen} />\n" +
+        '  </Router>\n' +
+        ')\n' +
+        'export default App\n'
+    );
+    write(
+      'src/screens/HomeScreen.js',
+      "import React from 'react'\n" +
+        "import { Link } from 'react-router-dom'\n" +
+        'const HomeScreen = ({ match }) => {\n' +
+        '  const keyword = match.params.keyword\n' +
+        '  return <Link to={`/product/${keyword}`}>A product</Link>\n' +
+        '}\n' +
+        'export default HomeScreen\n'
+    );
+    // The destination every react-router app writes for "where to after login".
+    write(
+      'src/screens/LoginScreen.js',
+      "import React, { useEffect } from 'react'\n" +
+        "import { Link } from 'react-router-dom'\n" +
+        'const LoginScreen = ({ location, history, userInfo }) => {\n' +
+        "  const redirect = location.search ? location.search.split('=')[1] : '/'\n" +
+        '  useEffect(() => {\n' +
+        '    if (userInfo) {\n' +
+        '      history.push(redirect)\n' +
+        '    }\n' +
+        '  }, [history, userInfo, redirect])\n' +
+        '  return (\n' +
+        '    <Link to={redirect ? `/register?redirect=${redirect}` : \'/register\'}>Register</Link>\n' +
+        '  )\n' +
+        '}\n' +
+        'export default LoginScreen\n'
+    );
+    write(
+      'src/screens/RegisterScreen.js',
+      "import React from 'react'\nconst RegisterScreen = () => <div>Register</div>\nexport default RegisterScreen\n"
+    );
+    // proshop's paginator: one link, three destinations, chosen at runtime.
+    write(
+      'src/components/Paginate.js',
+      "import React from 'react'\n" +
+        "import { Link } from 'react-router-dom'\n" +
+        'const Paginate = ({ isAdmin, keyword, x }) => (\n' +
+        '  <Link\n' +
+        '    to={\n' +
+        '      !isAdmin\n' +
+        '        ? keyword\n' +
+        '          ? `/search/${keyword}`\n' +
+        '          : `/page/${x}`\n' +
+        "        : '/register'\n" +
+        '    }\n' +
+        '  >\n' +
+        '    {x}\n' +
+        '  </Link>\n' +
+        ')\n' +
+        'export default Paginate\n'
+    );
+    write(
+      'src/screens/ProductScreen.js',
+      "import React from 'react'\nconst ProductScreen = () => <div>Product</div>\nexport default ProductScreen\n"
+    );
+    cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+  });
+
+  afterAll(() => {
+    cg?.close();
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  const route = (name: string): Node => {
+    const r = cg.getNodesByKind('route').find((r) => r.name === name);
+    if (!r) throw new Error(`no route ${name}`);
+    return r;
+  };
+  const sym = (name: string): Node => {
+    const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
+    if (!n) throw new Error(`no symbol ${name}`);
+    return n;
+  };
+  const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
+
+  it('a `to={cond ? … : …}` is read, because markup uses the same reader a push does', () => {
+    const toRegister = navs(sym('LoginScreen')).find((e) => e.target === route('/register').id);
+    expect(toRegister).toBeDefined();
+    // Both arms name `/register`; the href shows the one as written.
+    expect(toRegister!.metadata).toMatchObject({ synthesizedBy: 'react-router-link', href: '/register?redirect=${…}' });
+  });
+
+  it('a destination whose other arm is computed still names where it goes', () => {
+    // `const redirect = location.search ? location.search.split('=')[1] : '/'`
+    // then `history.push(redirect)` — `/` is where this lands by default.
+    const home = navs(sym('LoginScreen')).find((e) => e.target === route('/').id);
+    expect(home).toBeDefined();
+    expect(home!.metadata).toMatchObject({ href: '/', navMethod: 'push' });
+  });
+
+  it('a destination written as a three-way choice draws all three, each with the arm it took', () => {
+    const from = navs(sym('Paginate'));
+    const byTarget = new Map(from.map((e) => [e.target, (e.metadata as Record<string, unknown>).href]));
+    expect(byTarget.get(route('/search/:keyword').id)).toBe('/search/${…}');
+    expect(byTarget.get(route('/page/:pageNumber').id)).toBe('/page/${…}');
+    expect(byTarget.get(route('/register').id)).toBe('/register');
+    // Each edge names the path it took, not the first arm's.
+    expect(from).toHaveLength(3);
+  });
+
+  it('a link written under a condition carries that condition, and reads as a link', async () => {
+    const screens = await buildScreens(cg, tmpDir);
+    const at = (p: string) => screens.screens.find((s) => s.path === p)!;
+    // `<Link to={redirect ? … : '/register'}>` is markup: the destination is
+    // written right there, so it is a `link`, not a helper's `return` value.
+    const toRegister = screens.links.find((l) => l.from === at('/login').id && l.to === at('/register').id)!;
+    expect(toRegister.sites[0]!.method).toBe('link');
+  });
+
+  it('a component rendered at several addresses gives its navigation to EVERY one', async () => {
+    const screens = await buildScreens(cg, tmpDir);
+    const at = (p: string) => screens.screens.find((s) => s.path === p)!;
+    // HomeScreen serves three routes; all three lead to the product page.
+    for (const from of ['/', '/search/:keyword', '/page/:pageNumber']) {
+      expect(screens.links.find((l) => l.from === at(from).id && l.to === at('/product/:id').id)).toBeDefined();
+    }
+    // …and none of them is left as a screen you can reach but never leave.
+    for (const s of screens.screens) {
+      if (s.path === '/product/:id' || s.path === '/register') continue;
+      expect(screens.links.some((l) => l.from === s.id)).toBe(true);
+    }
+    expect(screens.dropped).toBe(0);
+  });
+});

+ 68 - 0
__tests__/rn-event-channel.test.ts

@@ -158,3 +158,71 @@ export function onMessage(listener: (m: any) => void) {
     expect(rows[0].target_name).toBe('onBattery');
   });
 });
+
+describe('RN event channel synthesizer — inline listeners', () => {
+  let dir: string;
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-event-inline-'));
+  });
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('attributes an inline arrow listener to the enclosing component, from a Swift sendEvent(withName:)', async () => {
+    fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"x","dependencies":{"react-native":"^0.76"}}');
+    fs.writeFileSync(
+      path.join(dir, 'CaptureEvents.swift'),
+      `import Foundation
+class CaptureEvents: RCTEventEmitter {
+  func emitZipComplete() {
+    sendEvent(withName: "onZipComplete", body: ["ok": true])
+  }
+  func emitProgress() {
+    sendEvent(withName: "onCaptureProgress", body: nil)
+  }
+}
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'App.tsx'),
+      `import { useEffect } from 'react'
+export default function ReviewScreen() {
+  useEffect(() => {
+    const zip = nativeEmitter.addListener('onZipComplete', (data) => {
+      upload(data)
+    })
+    const progress = nativeEmitter.addListener('onCaptureProgress', async function () {
+      await tick()
+    })
+    return () => {
+      zip.remove()
+      progress.remove()
+    }
+  }, [])
+  return null
+}
+function upload(d: unknown) {}
+function tick() {}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const rows = db
+      .prepare(
+        `SELECT s.name source_name, t.name target_name, json_extract(e.metadata,'$.event') event,
+                json_extract(e.metadata,'$.registeredAt') registered_at
+         FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy') = 'rn-event-channel'
+         ORDER BY event`
+      )
+      .all();
+    cg.close?.();
+    expect(rows.map((r: any) => [r.source_name, r.target_name, r.event])).toEqual([
+      ['emitProgress', 'ReviewScreen', 'onCaptureProgress'],
+      ['emitZipComplete', 'ReviewScreen', 'onZipComplete'],
+    ]);
+    expect(rows[1].registered_at).toBe('App.tsx:4');
+  });
+});

+ 67 - 0
__tests__/store-exported-later.test.ts

@@ -0,0 +1,67 @@
+/**
+ * A store exported by a LATER statement — `const useStore = create(…)` then
+ * `export default useStore` — is exported, and its actions are extracted like
+ * an `export const` store's (object-literal-methods.test.ts covers that
+ * form). The scope rule that keeps inline-object noise out still holds: a
+ * store nothing exports stays a constant.
+ */
+import { describe, it, expect, beforeAll } from 'vitest';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+});
+
+const fnNames = (code: string, file = 'store.ts') =>
+  extractFromSource(file, code)
+    .nodes.filter((n) => n.kind === 'function')
+    .map((n) => n.name);
+
+describe('store actions on a later-exported const', () => {
+  it('export default NAME', () => {
+    const code = `
+      import { create } from 'zustand'
+      const useCaptureStorage = create<State>((set, get) => ({
+        object: null,
+        setSettings: (settings: Settings) => {
+          set({ settings })
+        },
+        reset: () => set({ object: null }),
+      }))
+      export default useCaptureStorage
+    `;
+    expect(fnNames(code)).toEqual(expect.arrayContaining(['setSettings', 'reset']));
+  });
+
+  it('export { NAME } and export { NAME as default }', () => {
+    const named = `
+      const useStore = create((set) => ({ bump: () => set({}) }))
+      export { useStore }
+    `;
+    const asDefault = `
+      const useStore = create((set) => ({ bump: () => set({}) }))
+      export { useStore as default }
+    `;
+    expect(fnNames(named)).toContain('bump');
+    expect(fnNames(asDefault)).toContain('bump');
+  });
+
+  it('a const nothing exports keeps its members out of the graph', () => {
+    const code = `
+      const useStore = create((set) => ({ bump: () => set({}) }))
+      export const other = 1
+    `;
+    expect(fnNames(code)).not.toContain('bump');
+  });
+
+  it('is not fooled by a different name in the export', () => {
+    const code = `
+      const useStoreInternal = create((set) => ({ bump: () => set({}) }))
+      const useStore = 1
+      export default useStore
+    `;
+    expect(fnNames(code)).not.toContain('bump');
+  });
+});

+ 220 - 0
__tests__/sveltekit-router.test.ts

@@ -0,0 +1,220 @@
+/**
+ * SvelteKit as a Screens app (`src/resolution/frameworks/sveltekit-router.ts`,
+ * `src/resolution/sveltekit-link-synthesizer.ts`): the `+page.svelte` routes
+ * `frameworks/svelte.ts` names, and the navigation between them — `goto` in
+ * the browser, `redirect(status, path)` from a load or an action, and the
+ * plain `<a href>` that IS a link in a SvelteKit app.
+ *
+ * The fixture is the SvelteKit realworld app's shape. Mirrors
+ * `react-router.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildScreens } from '../src/ui-server/api/screens';
+import { svelteResolver } from '../src/resolution/frameworks/svelte';
+import { svelteKitHrefArgument } from '../src/resolution/frameworks/sveltekit-router';
+import type { Node } from '../src/types';
+
+// =============================================================================
+// Which file is a URL, and which argument is the destination
+// =============================================================================
+
+describe('sveltekit: only a +page.svelte is a route', () => {
+  const routeNames = (filePath: string): string[] =>
+    svelteResolver.extract!(filePath, '').nodes.filter((n) => n.kind === 'route').map((n) => n.name);
+
+  it('a page is its directory', () => {
+    expect(routeNames('src/routes/+page.svelte')).toEqual(['/']);
+    expect(routeNames('src/routes/login/+page.svelte')).toEqual(['/login']);
+    expect(routeNames('src/routes/article/[slug]/+page.svelte')).toEqual(['/article/:slug']);
+  });
+
+  it.each(['src/routes/+layout.svelte', 'src/routes/+error.svelte', 'src/routes/profile/+layout.svelte'])(
+    '%s sits at a page’s address without being one',
+    (file) => {
+      expect(routeNames(file)).toEqual([]);
+    }
+  );
+});
+
+describe('sveltekit: which argument carries the path', () => {
+  it('goto takes it first; redirect takes the status first', () => {
+    expect(svelteKitHrefArgument('goto')).toBe(0);
+    expect(svelteKitHrefArgument('redirect')).toBe(1);
+    expect(svelteKitHrefArgument('push')).toBeNull();
+  });
+});
+
+// =============================================================================
+// The whole picture, indexed
+// =============================================================================
+
+describe('sveltekit: a routed app end to end', () => {
+  let tmpDir: string;
+  let cg: CodeGraph;
+
+  function write(rel: string, content: string): void {
+    const full = path.join(tmpDir, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  beforeAll(async () => {
+    await initGrammars();
+    await loadAllGrammars();
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sveltekit-'));
+    write('package.json', JSON.stringify({ name: 'conduit', devDependencies: { '@sveltejs/kit': '2', svelte: '5' } }));
+    write(
+      'src/routes/+layout.svelte',
+      '<script>\n  export let data\n</script>\n' +
+        '<nav>\n' +
+        '  <a href="/">Home</a>\n' +
+        '  <a href="/login">Sign in</a>\n' +
+        '  <a href="/settings">Settings</a>\n' +
+        '  <a href="https://example.com">Elsewhere</a>\n' +
+        '</nav>\n' +
+        '<slot />\n'
+    );
+    write(
+      'src/routes/+page.svelte',
+      '<script>\n  export let data\n</script>\n<h1>Conduit</h1>\n<a href="/register">Sign up</a>\n'
+    );
+    write('src/routes/login/+page.svelte', '<script>\n  export let form\n</script>\n<a href="/register">Need an account?</a>\n');
+    write(
+      'src/routes/login/+page.server.js',
+      "import { redirect } from '@sveltejs/kit'\n" +
+        'export function load({ locals }) {\n' +
+        "  if (locals.user) redirect(307, '/')\n" +
+        '}\n' +
+        'export const actions = {\n' +
+        '  default: async ({ request, locals }) => {\n' +
+        '    const user = await signIn(request)\n' +
+        "    if (!user) return { errors: ['bad login'] }\n" +
+        "    redirect(307, '/')\n" +
+        '  }\n' +
+        '}\n'
+    );
+    write('src/routes/register/+page.svelte', '<script>\n  export let form\n</script>\n<a href="/login">Have an account?</a>\n');
+    write('src/routes/settings/+page.svelte', '<script>\n  export let data\n</script>\n<h1>Settings</h1>\n');
+    write(
+      'src/routes/settings/+page.server.js',
+      "import { redirect } from '@sveltejs/kit'\n" +
+        'export function load({ locals }) {\n' +
+        "  if (!locals.user) redirect(302, '/login')\n" +
+        '}\n'
+    );
+    write(
+      'src/routes/editor/+page.svelte',
+      '<script>\n' +
+        "  import { goto } from '$app/navigation'\n" +
+        '  async function publish() {\n' +
+        '    const article = await save()\n' +
+        '    goto(`/article/${article.slug}`)\n' +
+        '  }\n' +
+        '</script>\n' +
+        '<button on:click={publish}>Publish</button>\n'
+    );
+    write(
+      'src/routes/article/[slug]/+page.svelte',
+      '<script>\n  export let data\n</script>\n<a href="/editor">Edit</a>\n<a href="/profile/@{data.author}">Author</a>\n'
+    );
+    write('src/routes/profile/@[user]/+page.svelte', '<script>\n  export let data\n</script>\n<h1>Profile</h1>\n');
+    write('src/routes/profile/@[user]/+layout.svelte', '<script>\n  export let data\n</script>\n<slot />\n');
+    // The precision floor: a destination nothing serves, and a computed one.
+    write(
+      'src/routes/nowhere/+page.server.js',
+      "import { redirect } from '@sveltejs/kit'\n" +
+        'export function load({ url }) {\n' +
+        "  redirect(307, '/no-such-page')\n" +
+        '  redirect(307, url.searchParams.get("next"))\n' +
+        '}\n'
+    );
+    cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+  });
+
+  afterAll(() => {
+    cg?.close();
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  const route = (name: string): Node => {
+    const r = cg.getNodesByKind('route').find((r) => r.name === name);
+    if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
+    return r;
+  };
+  const sym = (name: string, file?: string): Node => {
+    const n = cg
+      .getNodesByName(name)
+      .find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import' && (!file || n.filePath.includes(file)));
+    if (!n) throw new Error(`no symbol ${name}${file ? ` in ${file}` : ''}`);
+    return n;
+  };
+  const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
+  const hrefs = (from: Node) =>
+    navs(from)
+      .map((e) => (e.metadata as Record<string, unknown>).href as string)
+      .sort();
+
+  it('names one route per page, and a layout is not a second screen at the same address', () => {
+    expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
+      '/',
+      '/article/:slug',
+      '/editor',
+      '/login',
+      '/profile/@:user',
+      '/register',
+      '/settings',
+    ]);
+  });
+
+  it('redirect takes its path from the SECOND argument, after the status', () => {
+    const guard = navs(sym('load', 'settings'));
+    expect(guard).toHaveLength(1);
+    expect(guard[0]!.target).toBe(route('/login').id);
+    expect(guard[0]!.metadata).toMatchObject({ href: '/login', navMethod: 'redirect' });
+    expect(navs(sym('load', 'login'))[0]!.target).toBe(route('/').id);
+  });
+
+  it('goto with a template hole reaches the [slug] page', () => {
+    const publish = navs(sym('publish'));
+    expect(publish).toHaveLength(1);
+    expect(publish[0]!.target).toBe(route('/article/:slug').id);
+    expect(publish[0]!.metadata).toMatchObject({ href: '/article/${…}', navMethod: 'goto' });
+  });
+
+  it('an internal <a href> navigates from the component that renders it; an external one does not', () => {
+    const article = sym('+page', 'article/[slug]');
+    // `/profile/@{data.author}` is an interpolation, and reaches `/profile/@:user`.
+    expect(hrefs(article)).toEqual(['/editor', '/profile/@${…}']);
+    const link = navs(article).find((e) => (e.metadata as Record<string, unknown>).href === '/editor')!;
+    expect(link.provenance).toBe('heuristic');
+    expect(link.metadata).toMatchObject({ synthesizedBy: 'sveltekit-link', navMethod: 'a' });
+    expect(navs(article).find((e) => e.target === route('/profile/@:user').id)).toBeDefined();
+    // The layout's nav bar links out, and never to the external site.
+    expect(hrefs(sym('+layout', 'routes/+layout'))).toEqual(['/', '/login', '/settings']);
+  });
+
+  it('a path no page serves and a computed one are left unresolved', () => {
+    expect(navs(sym('load', 'nowhere'))).toEqual([]);
+  });
+
+  it('lands on the Screens tab as transitions between screens', async () => {
+    const screens = await buildScreens(cg, tmpDir);
+    expect(screens.routed).toBe(true);
+    const at = (p: string) => screens.screens.find((s) => s.path === p)!;
+    // One screen per address — a layout does not double them.
+    expect(screens.screens.filter((s) => s.path === '/')).toHaveLength(1);
+    expect(screens.links.find((l) => l.from === at('/settings').id && l.to === at('/login').id)).toBeDefined();
+    const publish = screens.links.find((l) => l.from === at('/editor').id && l.to === at('/article/:slug').id)!;
+    expect(publish).toBeDefined();
+    expect(publish.sites[0]).toMatchObject({ href: '/article/${…}', method: 'goto' });
+    expect(screens.links.find((l) => l.from === at('/article/:slug').id && l.to === at('/editor').id)).toBeDefined();
+    expect(screens.dropped).toBe(0);
+  });
+});

+ 355 - 0
__tests__/tanstack-router.test.ts

@@ -0,0 +1,355 @@
+/**
+ * TanStack Router as a Screens app (`src/resolution/frameworks/tanstack-router.ts`,
+ * `src/resolution/tanstack-router-synthesizer.ts`): routes declared file-based
+ * (`createFileRoute('/posts/$postId')`) and code-based (`createRoute({ path,
+ * getParentRoute })`), and the navigation between them — where the destination
+ * is the route PATTERN rather than a filled URL, and rides under a `to` key.
+ *
+ * The fixture is the TanStack kitchen-sink and basic examples' shape. Mirrors
+ * `react-router.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildScreens } from '../src/ui-server/api/screens';
+import {
+  parseTanstackRoutes,
+  tanstackPath,
+  tanstackNavVerb,
+  tanstackDestination,
+} from '../src/resolution/frameworks/tanstack-router';
+import type { Node } from '../src/types';
+
+// =============================================================================
+// Paths
+// =============================================================================
+
+describe('tanstack: tanstackPath', () => {
+  it.each([
+    ['/', '/'],
+    ['/login', '/login'],
+    ['/posts/$postId', '/posts/:postId'],
+    // A pathless layout is not in the URL; nor is a route group.
+    ['/_auth/profile', '/profile'],
+    ['/_pathlessLayout/route-a', '/route-a'],
+    ['/(this-folder-is-not-in-the-url)/route-group', '/route-group'],
+    // An index route's trailing slash is the address of its parent.
+    ['/dashboard/', '/dashboard'],
+    // A trailing `_` un-nests without changing the segment.
+    ['/posts_/$postId/edit', '/posts/:postId/edit'],
+    ['/files/$', '/files/:splat*'],
+  ])('%s → %s', (raw, normalized) => {
+    expect(tanstackPath(raw)).toBe(normalized);
+  });
+
+  it('a path that names no address is nothing', () => {
+    expect(tanstackPath('posts')).toBeNull();
+  });
+});
+
+// =============================================================================
+// Reading the routes
+// =============================================================================
+
+describe('tanstack: parseTanstackRoutes — file-based', () => {
+  it('takes the path from the literal and the component from the options', () => {
+    const src =
+      "import { createFileRoute } from '@tanstack/react-router'\n" +
+      "export const Route = createFileRoute('/dashboard/invoices/$invoiceId')({\n" +
+      '  params: { parse: (p) => ({ invoiceId: Number(p.invoiceId) }) },\n' +
+      '  component: InvoiceComponent,\n' +
+      '})\n';
+    expect(parseTanstackRoutes(src)).toEqual([
+      { path: '/dashboard/invoices/:invoiceId', component: 'InvoiceComponent', index: false, fileBased: true, line: 2 },
+    ]);
+  });
+
+  it('finds a component written on a chained .update()', () => {
+    const src =
+      "export const Route = createFileRoute('/login')({\n" +
+      '  validateSearch: z.object({ redirect: z.string().optional() }),\n' +
+      '}).update({\n' +
+      '  component: LoginComponent,\n' +
+      '})\n';
+    expect(parseTanstackRoutes(src)[0]).toMatchObject({ path: '/login', component: 'LoginComponent' });
+  });
+
+  it('marks an index route, and drops a pathless layout that is no address of its own', () => {
+    expect(parseTanstackRoutes("createFileRoute('/dashboard/')({ component: X })")[0]).toMatchObject({
+      path: '/dashboard',
+      index: true,
+    });
+    expect(parseTanstackRoutes("createFileRoute('/_auth')({ component: X })")).toEqual([]);
+    // …but the index INSIDE a pathless layout is the page at that layout's
+    // address — `_layout/index.tsx` is a project's home page.
+    expect(parseTanstackRoutes("createFileRoute('/_layout/')({ component: Home })")[0]).toMatchObject({
+      path: '/',
+      index: true,
+    });
+  });
+});
+
+describe('tanstack: parseTanstackRoutes — code-based', () => {
+  const src =
+    "import { createRootRoute, createRoute } from '@tanstack/react-router'\n" +
+    'const rootRoute = createRootRoute({ component: RootComponent })\n' +
+    'const indexRoute = createRoute({\n' +
+    '  getParentRoute: () => rootRoute,\n' +
+    "  path: '/',\n" +
+    '  component: IndexComponent,\n' +
+    '})\n' +
+    'const postsLayoutRoute = createRoute({\n' +
+    '  getParentRoute: () => rootRoute,\n' +
+    "  path: 'posts',\n" +
+    '  component: PostsLayoutComponent,\n' +
+    '})\n' +
+    'const postsIndexRoute = createRoute({\n' +
+    '  getParentRoute: () => postsLayoutRoute,\n' +
+    "  path: '/',\n" +
+    '  component: PostsIndexComponent,\n' +
+    '})\n' +
+    'const postRoute = createRoute({\n' +
+    '  getParentRoute: () => postsLayoutRoute,\n' +
+    "  path: '$postId',\n" +
+    '  component: PostComponent,\n' +
+    '})\n' +
+    'const pathlessRoute = createRoute({\n' +
+    '  getParentRoute: () => rootRoute,\n' +
+    "  id: 'pathless',\n" +
+    '  component: PathlessComponent,\n' +
+    '})\n' +
+    'const routeARoute = createRoute({\n' +
+    '  getParentRoute: () => pathlessRoute,\n' +
+    "  path: '/route-a',\n" +
+    '  component: RouteAComponent,\n' +
+    '})\n';
+
+  it('composes a path through getParentRoute, and a pathless layout adds nothing to it', () => {
+    expect(parseTanstackRoutes(src).map((r) => [r.path, r.component])).toEqual([
+      ['/', 'IndexComponent'],
+      ['/posts', 'PostsIndexComponent'],
+      ['/posts/:postId', 'PostComponent'],
+      ['/route-a', 'RouteAComponent'],
+    ]);
+  });
+
+  it('a layout with children is not itself a page at that address', () => {
+    // `postsLayoutRoute` sits at `/posts` and wraps the index that renders there.
+    const posts = parseTanstackRoutes(src).filter((r) => r.path === '/posts');
+    expect(posts).toHaveLength(1);
+    expect(posts[0]!.component).toBe('PostsIndexComponent');
+  });
+});
+
+// =============================================================================
+// Destinations
+// =============================================================================
+
+describe('tanstack: destinations', () => {
+  it.each([
+    ['navigate', 'navigate'],
+    ['redirect', 'redirect'],
+    ['router.navigate', 'navigate'],
+  ])('%s is a navigation', (name, verb) => {
+    expect(tanstackNavVerb(name)).toBe(verb);
+  });
+
+  it.each(['push', 'replace', 'paths.push', 'goto'])('%s is not', (name) => {
+    expect(tanstackNavVerb(name)).toBeNull();
+  });
+
+  it('reads the `to` key, and normalises the pattern the way a route name is', () => {
+    expect(tanstackDestination("{ to: '/posts/$postId' }")?.path).toBe('/posts/:postId');
+    expect(tanstackDestination("{ to: '/login', search: { redirect } }")?.path).toBe('/login');
+    expect(tanstackDestination("'/posts/$postId'")?.path).toBe('/posts/:postId');
+  });
+
+  it('a navigation with no destination changes the search on the page it is on', () => {
+    expect(tanstackDestination('{ search: (old) => ({ ...old, page: 2 }) }')).toBeNull();
+  });
+});
+
+// =============================================================================
+// The whole picture, indexed
+// =============================================================================
+
+describe('tanstack: a routed app end to end', () => {
+  let tmpDir: string;
+  let cg: CodeGraph;
+
+  function write(rel: string, content: string): void {
+    const full = path.join(tmpDir, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  beforeAll(async () => {
+    await initGrammars();
+    await loadAllGrammars();
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-tanstack-'));
+    write('package.json', JSON.stringify({ name: 'app', dependencies: { react: '19', '@tanstack/react-router': '1' } }));
+    write(
+      'src/routes/index.tsx',
+      "import { createFileRoute, Link } from '@tanstack/react-router'\n" +
+        "export const Route = createFileRoute('/')({ component: IndexComponent })\n" +
+        'function IndexComponent() {\n' +
+        '  return (\n' +
+        '    <div>\n' +
+        '      <Link\n' +
+        '        to="/posts/$postId"\n' +
+        '        params={{ postId: 3 }}\n' +
+        '      >\n' +
+        '        A post\n' +
+        '      </Link>\n' +
+        '      <Link to="/login">Sign in</Link>\n' +
+        '    </div>\n' +
+        '  )\n' +
+        '}\n'
+    );
+    write(
+      'src/routes/posts.route.tsx',
+      "import { createFileRoute, Outlet } from '@tanstack/react-router'\n" +
+        "export const Route = createFileRoute('/posts')({ component: PostsLayout })\n" +
+        'function PostsLayout() {\n  return <Outlet />\n}\n'
+    );
+    write(
+      'src/routes/posts.index.tsx',
+      "import { createFileRoute } from '@tanstack/react-router'\n" +
+        "export const Route = createFileRoute('/posts/')({ component: PostsIndexComponent })\n" +
+        'function PostsIndexComponent() {\n  return <div>Posts</div>\n}\n'
+    );
+    write(
+      'src/routes/posts.$postId.tsx',
+      "import { createFileRoute } from '@tanstack/react-router'\n" +
+        "export const Route = createFileRoute('/posts/$postId')({ component: PostComponent })\n" +
+        'function PostComponent() {\n  return <div>Post</div>\n}\n'
+    );
+    write(
+      'src/routes/login.tsx',
+      "import { createFileRoute, useNavigate } from '@tanstack/react-router'\n" +
+        "export const Route = createFileRoute('/login')({ component: LoginComponent })\n" +
+        'function LoginComponent() {\n' +
+        '  const navigate = useNavigate()\n' +
+        '  async function submit(creds) {\n' +
+        '    const ok = await signIn(creds)\n' +
+        "    if (ok) navigate({ to: '/dashboard' })\n" +
+        '  }\n' +
+        '  return <form onSubmit={submit} />\n' +
+        '}\n'
+    );
+    write(
+      'src/routes/_auth.tsx',
+      "import { createFileRoute, redirect } from '@tanstack/react-router'\n" +
+        "export const Route = createFileRoute('/_auth')({\n" +
+        '  beforeLoad: ({ context }) => {\n' +
+        "    if (context.auth.status === 'loggedOut') {\n" +
+        "      throw redirect({ to: '/login' })\n" +
+        '    }\n' +
+        '  },\n' +
+        '})\n'
+    );
+    write(
+      'src/routes/_auth.dashboard.tsx',
+      "import { createFileRoute, Link } from '@tanstack/react-router'\n" +
+        "export const Route = createFileRoute('/_auth/dashboard')({ component: DashboardComponent })\n" +
+        'function DashboardComponent() {\n' +
+        '  return <Link to="/posts">All posts</Link>\n' +
+        '}\n'
+    );
+    // The precision floor: a pattern nothing serves, and a search-only navigation.
+    write(
+      'src/routes/settings.tsx',
+      "import { createFileRoute, useNavigate } from '@tanstack/react-router'\n" +
+        "export const Route = createFileRoute('/settings')({ component: SettingsComponent })\n" +
+        'function SettingsComponent() {\n' +
+        '  const navigate = useNavigate()\n' +
+        '  function nowhere() {\n' +
+        "    navigate({ to: '/no-such-route' })\n" +
+        '  }\n' +
+        '  function filter() {\n' +
+        '    navigate({ search: (old) => ({ ...old, page: 2 }) })\n' +
+        '  }\n' +
+        '  return <button onClick={nowhere} />\n' +
+        '}\n'
+    );
+    cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+  });
+
+  afterAll(() => {
+    cg?.close();
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  const route = (name: string): Node => {
+    const r = cg.getNodesByKind('route').find((r) => r.name === name);
+    if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
+    return r;
+  };
+  const sym = (name: string): Node => {
+    const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
+    if (!n) throw new Error(`no symbol ${name}`);
+    return n;
+  };
+  const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
+  const hrefs = (from: Node) =>
+    navs(from)
+      .map((e) => (e.metadata as Record<string, unknown>).href as string)
+      .sort();
+
+  it('names one route per address: the pathless layout is stripped, the index wins over the layout', () => {
+    expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
+      '/',
+      '/dashboard',
+      '/login',
+      '/posts',
+      '/posts/:postId',
+      '/settings',
+    ]);
+    // `/posts` is the index page, not the `posts.route.tsx` layout beside it.
+    const bound = cg.getOutgoingEdges(route('/posts').id).find((e) => e.kind === 'calls');
+    expect(cg.getNode(bound!.target)?.name).toBe('PostsIndexComponent');
+    // `_auth.dashboard.tsx` is the page at `/dashboard`.
+    expect(route('/dashboard').filePath).toBe('src/routes/_auth.dashboard.tsx');
+  });
+
+  it('navigate({ to }) reaches the route the pattern names', () => {
+    const submit = navs(sym('submit'));
+    expect(submit).toHaveLength(1);
+    expect(submit[0]!.target).toBe(route('/dashboard').id);
+    expect(submit[0]!.metadata).toMatchObject({ href: '/dashboard', navMethod: 'navigate' });
+  });
+
+  it('a <Link to> names the route PATTERN, with its params beside it', () => {
+    // `to="/posts/$postId"` is the route, not a filled URL.
+    expect(hrefs(sym('IndexComponent'))).toEqual(['/login', '/posts/:postId']);
+    const link = navs(sym('IndexComponent')).find((e) => e.target === route('/posts/:postId').id)!;
+    expect(link.provenance).toBe('heuristic');
+    expect(link.metadata).toMatchObject({ synthesizedBy: 'tanstack-link', href: '/posts/:postId', navMethod: 'link' });
+    expect(hrefs(sym('DashboardComponent'))).toEqual(['/posts']);
+  });
+
+  it('a pattern nothing serves, and a navigation that only changes the search, are left unresolved', () => {
+    expect(navs(sym('nowhere'))).toEqual([]);
+    expect(navs(sym('filter'))).toEqual([]);
+  });
+
+  it('lands on the Screens tab as transitions between screens', async () => {
+    const screens = await buildScreens(cg, tmpDir);
+    expect(screens.routed).toBe(true);
+    const at = (p: string) => screens.screens.find((s) => s.path === p)!;
+    expect(at('/posts').component?.name).toBe('PostsIndexComponent');
+    const toPost = screens.links.find((l) => l.from === at('/').id && l.to === at('/posts/:postId').id)!;
+    expect(toPost).toBeDefined();
+    expect(toPost.sites[0]).toMatchObject({ href: '/posts/:postId' });
+    const signIn = screens.links.find((l) => l.from === at('/login').id && l.to === at('/dashboard').id)!;
+    expect(signIn).toBeDefined();
+    expect(signIn.via.map((v) => v.name)).toEqual(['submit']);
+    expect(signIn.when).toBe('ok');
+    expect(screens.dropped).toBe(0);
+  });
+});

+ 622 - 0
__tests__/type-hierarchy.test.ts

@@ -0,0 +1,622 @@
+/**
+ * The type hierarchy (CG-58) — the walk, the fan, and the tree the viewer draws.
+ *
+ * The walk half runs against a real indexed fixture rather than a stubbed
+ * `CodeGraph`: the properties worth pinning are ones only a real index has —
+ * that a Go struct satisfies an interface through a SYNTHESIZED `implements`
+ * edge with no textual link between the two files, that a self-referential
+ * `extends` in generated code does not loop, that the breadth-first order puts
+ * every direct subtype ahead of any indirect one.
+ *
+ * The layout half is pure arithmetic over a payload, so it is asserted
+ * directly. Everything the block does that could be WRONG rather than merely
+ * ugly lives there: which row a connector attaches to, what folds, and which
+ * noun the fold uses.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import type { Node } from '../src/types';
+import {
+  buildTypeHierarchy,
+  canHaveHierarchy,
+  countImplementers,
+  DISPATCH_MIN_IMPLEMENTERS,
+  MAX_DESCENDANTS,
+} from '../src/graph/type-hierarchy';
+import { buildHierarchy } from '../src/ui-server/api/hierarchy';
+import {
+  buildHierarchyModel,
+  connectorPath,
+  visibleHierarchy,
+  HIER_FOLD_AT,
+  HIER_GLYPH_X,
+  HIER_INDENT,
+  HIER_PORT_X,
+  HIER_ROW_H,
+} from '../ui/src/lib/hierarchy-model';
+import type {
+  WireHierarchy,
+  WireHierarchyNode,
+  WireNodeDetail,
+} from '../ui/src/lib/wire';
+
+// =============================================================================
+// A real index
+// =============================================================================
+
+let tempDir: string;
+let projectRoot: string;
+let cg: CodeGraph;
+
+/** The one node with this name and kind, or a failure that says which was missing. */
+function nodeNamed(name: string, kind?: string): Node {
+  const hits = cg
+    .searchNodes(name, { limit: 40 })
+    .map((r: any) => (r.node ?? r) as Node)
+    .filter((n) => n.name === name && (!kind || n.kind === kind));
+  expect(hits.length, `no ${kind ?? 'node'} named ${name}`).toBeGreaterThan(0);
+  return hits[0]!;
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-hierarchy-'));
+  projectRoot = path.join(tempDir, 'project');
+  const src = path.join(projectRoot, 'src');
+  fs.mkdirSync(src, { recursive: true });
+
+  // A three-level TypeScript chain with a real override, plus an interface with
+  // enough implementations to be a dispatch fan.
+  fs.writeFileSync(
+    path.join(src, 'shapes.ts'),
+    `export interface Drawable {
+  draw(): string;
+}
+
+export abstract class Shape implements Drawable {
+  draw(): string {
+    return 'shape';
+  }
+  area(): number {
+    return 0;
+  }
+}
+
+export class Square extends Shape {
+  draw(): string {
+    return 'square';
+  }
+}
+
+export class Tile extends Square {
+  label = 'tile';
+}
+`
+  );
+
+  // Nine implementations, so the fan clears DISPATCH_MIN_IMPLEMENTERS.
+  const targets = [
+    'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel', 'India',
+  ];
+  fs.writeFileSync(
+    path.join(src, 'plugins.ts'),
+    `export interface Plugin {
+  run(): void;
+}
+
+${targets
+  .map((name) => `export class ${name}Plugin implements Plugin {\n  run(): void {}\n}`)
+  .join('\n\n')}
+`
+  );
+
+  // Go: `System` satisfies `Clock` without either file naming the other. The
+  // `implements` edge here is synthesized, which is the case the viewer draws
+  // differently — the fixture mirrors `__tests__/fixtures/payroll-go`.
+  fs.writeFileSync(path.join(projectRoot, 'go.mod'), 'module fixture\n\ngo 1.22\n');
+  fs.writeFileSync(
+    path.join(src, 'clock.go'),
+    `package clock
+
+import "time"
+
+// Clock is the time seam.
+type Clock interface {
+	Now() time.Time
+}
+
+// System is the production clock.
+type System struct{}
+
+func (System) Now() time.Time { return time.Now().UTC() }
+
+// Fixed is a frozen clock.
+type Fixed struct{ At time.Time }
+
+func (f Fixed) Now() time.Time { return f.At }
+`
+  );
+
+  cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', 'src/**/*.go'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+}, 120_000);
+
+afterAll(() => {
+  cg?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('canHaveHierarchy', () => {
+  it('is false for a function, so the walk never runs for one', () => {
+    expect(canHaveHierarchy({ kind: 'function' } as Node)).toBe(false);
+    expect(canHaveHierarchy({ kind: 'method' } as Node)).toBe(false);
+    expect(canHaveHierarchy({ kind: 'class' } as Node)).toBe(true);
+    expect(canHaveHierarchy({ kind: 'interface' } as Node)).toBe(true);
+    expect(canHaveHierarchy({ kind: 'struct' } as Node)).toBe(true);
+    expect(canHaveHierarchy({ kind: 'trait' } as Node)).toBe(true);
+  });
+});
+
+describe('buildTypeHierarchy — upward', () => {
+  it('walks past the direct parent to the whole chain', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'));
+    expect(hierarchy).not.toBeNull();
+    const byName = new Map(hierarchy!.ancestors.map((a) => [a.node.name, a]));
+    expect(byName.get('Square')?.depth).toBe(1);
+    expect(byName.get('Shape')?.depth).toBe(2);
+    // `Shape implements Drawable`, so the interface is three steps up from Tile.
+    expect(byName.get('Drawable')?.depth).toBe(3);
+    expect(byName.get('Square')?.relation).toBe('extends');
+    expect(byName.get('Drawable')?.relation).toBe('implements');
+  });
+
+  it('nearest ancestors come first', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'))!;
+    const depths = hierarchy.ancestors.map((a) => a.depth);
+    expect(depths).toEqual([...depths].sort((a, b) => a - b));
+  });
+});
+
+describe('buildTypeHierarchy — the fan', () => {
+  it('returns every direct subtype before any indirect one', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Shape', 'class'))!;
+    const depths = hierarchy.descendants.map((d) => d.depth);
+    expect(depths).toEqual([...depths].sort((a, b) => a - b));
+    expect(hierarchy.descendants.map((d) => d.node.name)).toContain('Square');
+    expect(hierarchy.descendants.map((d) => d.node.name)).toContain('Tile');
+    expect(hierarchy.directSubtypes).toBe(1);
+  });
+
+  it('hangs an indirect subtype off its own parent, not off the focus', () => {
+    const focus = nodeNamed('Shape', 'class');
+    const hierarchy = buildTypeHierarchy(cg, focus)!;
+    const square = hierarchy.descendants.find((d) => d.node.name === 'Square')!;
+    const tile = hierarchy.descendants.find((d) => d.node.name === 'Tile')!;
+    expect(square.parentId).toBe(focus.id);
+    expect(tile.parentId).toBe(square.node.id);
+  });
+
+  it('calls a nine-implementation interface polymorphic', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Plugin', 'interface'))!;
+    expect(hierarchy.directImplementers).toBeGreaterThanOrEqual(DISPATCH_MIN_IMPLEMENTERS);
+    expect(hierarchy.polymorphic).toBe(true);
+    expect(hierarchy.directSubtypes).toBe(hierarchy.descendants.filter((d) => d.depth === 1).length);
+  });
+
+  it('does not call a two-implementation interface polymorphic', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
+    expect(hierarchy.directSubtypes).toBe(2);
+    expect(hierarchy.polymorphic).toBe(false);
+  });
+});
+
+describe('buildTypeHierarchy — Go implicit satisfaction', () => {
+  it('finds the implementations of an interface no file names', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
+    const names = hierarchy.descendants.map((d) => d.node.name).sort();
+    expect(names).toEqual(['Fixed', 'System']);
+    expect(hierarchy.descendants.every((d) => d.relation === 'implements')).toBe(true);
+  });
+
+  it('marks the synthesized edge, and keeps where it was wired', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
+    const system = hierarchy.descendants.find((d) => d.node.name === 'System')!;
+    expect(system.synthesized).toBe(true);
+    const meta = (system.edge.metadata ?? {}) as Record<string, unknown>;
+    expect(meta.synthesizedBy).toBe('go-implements');
+    expect(String(meta.registeredAt)).toContain('clock.go');
+  });
+});
+
+describe('buildTypeHierarchy — overrides', () => {
+  it('marks a member that redeclares an ancestor s, and names the ancestor', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Square', 'class'))!;
+    const matches = [...hierarchy.overrides.values()];
+    const draw = matches.find((m) => m.baseTypeName === 'Shape');
+    expect(draw, 'Square.draw should be matched against Shape.draw').toBeTruthy();
+    expect(draw!.relation).toBe('extends');
+  });
+
+  it('leaves a member that declares something new unmarked', () => {
+    const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'))!;
+    // `label` exists on nothing above Tile.
+    const named = [...hierarchy.overrides.values()].map((m) => m.memberId);
+    const label = cg
+      .getOutgoingEdges(nodeNamed('Tile', 'class').id)
+      .filter((e) => e.kind === 'contains')
+      .map((e) => cg.getNode(e.target))
+      .find((n) => n?.name === 'label');
+    if (label) expect(named).not.toContain(label.id);
+  });
+
+  it('can be switched off without changing the tree', () => {
+    const focus = nodeNamed('Square', 'class');
+    const withOverrides = buildTypeHierarchy(cg, focus)!;
+    const without = buildTypeHierarchy(cg, focus, { overrides: false })!;
+    expect(without.overrides.size).toBe(0);
+    expect(without.descendants.length).toBe(withOverrides.descendants.length);
+    expect(without.ancestors.length).toBe(withOverrides.ancestors.length);
+  });
+});
+
+describe('countImplementers', () => {
+  it('counts distinct types, and agrees with the fan it sits beside', () => {
+    const plugin = nodeNamed('Plugin', 'interface');
+    const hierarchy = buildTypeHierarchy(cg, plugin)!;
+    expect(countImplementers(cg, plugin.id)).toBe(hierarchy.directSubtypes);
+  });
+
+  it('is zero for a type nothing extends', () => {
+    expect(countImplementers(cg, nodeNamed('Tile', 'class').id)).toBe(0);
+  });
+});
+
+describe('the /api/node block', () => {
+  it('is null for a function', () => {
+    const fn = cg
+      .searchNodes('run', { limit: 40 })
+      .map((r: any) => (r.node ?? r) as Node)
+      .find((n) => n.kind === 'method');
+    if (fn) expect(buildHierarchy(cg, fn)).toBeNull();
+  });
+
+  it('is null for a type with no hierarchy at all', () => {
+    const orphan = { id: 'x', kind: 'class', name: 'Nope' } as Node;
+    expect(buildHierarchy(cg, orphan)).toBeNull();
+  });
+
+  it('carries a total that equals the list beneath it', () => {
+    const built = buildHierarchy(cg, nodeNamed('Plugin', 'interface'))!;
+    expect(built.wire.descendants.items.length).toBe(built.wire.descendants.shown);
+    expect(built.wire.descendants.total).toBe(built.wire.descendants.items.length);
+    expect(built.wire.descendants.truncated).toBe(false);
+    expect(built.wire.direct).toBe(built.wire.descendants.total);
+  });
+
+  it('lifts the synthesized edge s wiring onto the row', () => {
+    const built = buildHierarchy(cg, nodeNamed('Clock', 'interface'))!;
+    const system = built.wire.descendants.items.find((d) => d.name === 'System')!;
+    expect(system.synthesized).toBe(true);
+    expect(system.via).toBe('go-implements');
+    expect(system.registeredAt).toContain('clock.go');
+  });
+
+  it('hands the outline its override marks', () => {
+    const built = buildHierarchy(cg, nodeNamed('Square', 'class'))!;
+    expect([...built.overrides.values()].some((o) => o.baseTypeName === 'Shape')).toBe(true);
+  });
+});
+
+// =============================================================================
+// The bounds, against a synthetic graph
+// =============================================================================
+
+/**
+ * A `CodeGraph` stub holding only what the walk reads.
+ *
+ * A fan wide enough to hit {@link MAX_DESCENDANTS} would be thousands of files
+ * to index for one assertion, and the property being pinned is arithmetic
+ * rather than extraction: that the cap stops materialising rows, keeps counting
+ * the direct ones, and says it was bounded.
+ */
+function stubGraph(childCount: number): any {
+  const type = (id: string, name: string): Node =>
+    ({
+      id,
+      kind: 'class',
+      name,
+      qualifiedName: name,
+      filePath: `src/${name}.ts`,
+      startLine: 1,
+      endLine: 2,
+      startColumn: 0,
+      endColumn: 0,
+      language: 'typescript',
+    }) as Node;
+
+  const root = type('root', 'Root');
+  const children = Array.from({ length: childCount }, (_, i) => type(`c${i}`, `Child${i}`));
+  const all = new Map<string, Node>([[root.id, root], ...children.map((c) => [c.id, c] as const)]);
+
+  return {
+    getIncomingEdgesTo: (ids: string[]) =>
+      ids.includes('root')
+        ? children.map((c) => ({ source: c.id, target: 'root', kind: 'extends' }))
+        : [],
+    getOutgoingEdgesFrom: () => [],
+    getNodesByIds: (ids: string[]) =>
+      new Map(ids.map((id) => [id, all.get(id)!]).filter(([, n]) => !!n) as Array<[string, Node]>),
+    root,
+  };
+}
+
+describe('the descendant bound', () => {
+  it('stays unbounded under the cap', () => {
+    const cgStub = stubGraph(10);
+    const hierarchy = buildTypeHierarchy(cgStub, cgStub.root)!;
+    expect(hierarchy.descendants.length).toBe(10);
+    expect(hierarchy.directSubtypes).toBe(10);
+    expect(hierarchy.bounded).toBe(false);
+  });
+
+  it('stops materialising rows past the cap but keeps the direct count true', () => {
+    const cgStub = stubGraph(MAX_DESCENDANTS + 37);
+    const hierarchy = buildTypeHierarchy(cgStub, cgStub.root)!;
+    expect(hierarchy.descendants.length).toBe(MAX_DESCENDANTS);
+    // The number of subtypes is not the number of rows, and says so.
+    expect(hierarchy.directSubtypes).toBe(MAX_DESCENDANTS + 37);
+    expect(hierarchy.bounded).toBe(true);
+  });
+
+  it('reports the cap through the wire block as a truncated list', () => {
+    const cgStub = stubGraph(MAX_DESCENDANTS + 37);
+    const built = buildHierarchy(cgStub, cgStub.root)!;
+    expect(built.wire.descendants.truncated).toBe(true);
+    expect(built.wire.descendants.items.length).toBeLessThan(built.wire.descendants.total);
+    expect(built.wire.bounded).toBe(true);
+    expect(built.wire.direct).toBe(MAX_DESCENDANTS + 37);
+  });
+});
+
+// =============================================================================
+// The tree the viewer draws
+// =============================================================================
+
+const FOCUS: WireNodeDetail = {
+  id: 'focus',
+  kind: 'interface',
+  name: 'Clock',
+  qualifiedName: 'Clock',
+  file: 'src/clock.ts',
+  line: 1,
+  endLine: 3,
+  language: 'typescript' as WireNodeDetail['language'],
+  test: false,
+  startColumn: 0,
+  endColumn: 0,
+  lines: 3,
+};
+
+function entry(
+  name: string,
+  depth: number,
+  parentId: string,
+  relation: 'extends' | 'implements' = 'implements'
+): WireHierarchyNode {
+  return {
+    id: name,
+    kind: 'class',
+    name,
+    qualifiedName: name,
+    file: `src/${name}.ts`,
+    line: 1,
+    endLine: 2,
+    language: 'typescript' as WireNodeDetail['language'],
+    test: false,
+    depth,
+    parentId,
+    relation,
+    synthesized: false,
+    hiddenSubtypes: 0,
+  };
+}
+
+function hierarchyOf(
+  ancestors: WireHierarchyNode[],
+  descendants: WireHierarchyNode[],
+  extra: Partial<WireHierarchy> = {}
+): WireHierarchy {
+  return {
+    ancestors: {
+      total: ancestors.length,
+      shown: ancestors.length,
+      truncated: false,
+      items: ancestors,
+    },
+    descendants: {
+      total: descendants.length,
+      shown: descendants.length,
+      truncated: false,
+      items: descendants,
+    },
+    direct: descendants.filter((d) => d.depth === 1).length,
+    implementers: descendants.filter((d) => d.depth === 1 && d.relation === 'implements').length,
+    bounded: false,
+    polymorphic: false,
+    ...extra,
+  };
+}
+
+describe('buildHierarchyModel', () => {
+  it('puts the focus between the two halves, farthest ancestor at the top', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf(
+        [entry('Base', 2, 'Mid', 'extends'), entry('Mid', 1, 'focus', 'extends')],
+        [entry('Sub', 1, 'focus', 'extends')]
+      ),
+      FOCUS
+    );
+    expect(model.rows.map((r) => r.node.name)).toEqual(['Base', 'Mid', 'Clock', 'Sub']);
+    expect(model.focusIndex).toBe(2);
+    expect(model.rows[2]!.side).toBe('focus');
+  });
+
+  it('indents each descendant level and leaves ancestors at zero', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf([entry('Base', 1, 'focus', 'extends')], [
+        entry('Sub', 1, 'focus', 'extends'),
+        entry('SubSub', 2, 'Sub', 'extends'),
+      ]),
+      FOCUS
+    );
+    const indents = Object.fromEntries(model.rows.map((r) => [r.node.name, r.indent]));
+    expect(indents.Base).toBe(0);
+    expect(indents.Clock).toBe(0);
+    expect(indents.Sub).toBe(HIER_INDENT);
+    expect(indents.SubSub).toBe(HIER_INDENT * 2);
+  });
+
+  it('draws a descendant connector from its own parent row, not from the focus', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf([], [entry('Sub', 1, 'focus', 'extends'), entry('SubSub', 2, 'Sub', 'extends')]),
+      FOCUS
+    );
+    const rowOf = (name: string) => model.rows.findIndex((r) => r.node.name === name);
+    const deep = model.connectors.find((c) => c.toIndex === rowOf('SubSub'))!;
+    expect(deep.fromIndex).toBe(rowOf('Sub'));
+    // Leaves the parent's glyph centre, meets the child's glyph.
+    expect(deep.x).toBe(HIER_INDENT + HIER_PORT_X);
+    expect(deep.toX).toBe(HIER_INDENT * 2 + HIER_GLYPH_X - 2);
+  });
+
+  it('never hangs a descendant off an ancestor row that shares its name', () => {
+    // A cycle in generated code: `Loop` is both above and below the focus.
+    const model = buildHierarchyModel(
+      hierarchyOf([entry('Loop', 1, 'focus', 'extends')], [entry('Loop', 1, 'focus', 'extends')]),
+      FOCUS
+    );
+    const descendantRow = model.rows.findIndex((r) => r.side === 'descendant');
+    const connector = model.connectors.find((c) => c.toIndex === descendantRow)!;
+    expect(connector.fromIndex).toBe(model.focusIndex);
+  });
+
+  it('carries the relation into the connector so implements can be dashed', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf([], [entry('Impl', 1, 'focus', 'implements')]),
+      FOCUS
+    );
+    expect(model.connectors[0]!.relation).toBe('implements');
+  });
+
+  it('claims a dispatch only when the payload says the type is polymorphic', () => {
+    const plain = buildHierarchyModel(hierarchyOf([], [entry('A', 1, 'focus')]), FOCUS);
+    expect(plain.headline).toBe('');
+
+    const fan = buildHierarchyModel(
+      hierarchyOf([], [entry('A', 1, 'focus')], { polymorphic: true, implementers: 9 }),
+      FOCUS
+    );
+    expect(fan.headline).toContain('9 implementations');
+    expect(fan.headline).toContain('Clock');
+  });
+});
+
+describe('the fold', () => {
+  const fan = (n: number, relation: 'extends' | 'implements' = 'implements') =>
+    hierarchyOf(
+      [],
+      Array.from({ length: n }, (_, i) => entry(`Impl${i}`, 1, 'focus', relation))
+    );
+
+  it('does not fold a fan of exactly the threshold — a "+0 more" is not a fold', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT), FOCUS);
+    expect(model.foldFrom).toBeNull();
+    expect(model.foldCount).toBe(0);
+  });
+
+  it('folds the tail past the threshold and counts what it hid', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
+    expect(model.foldCount).toBe(5);
+    expect(model.foldNoun).toBe('implementations');
+    const folded = visibleHierarchy(model, false);
+    expect(folded.rows.length).toBe(model.focusIndex + 1 + HIER_FOLD_AT);
+    expect(visibleHierarchy(model, true).rows.length).toBe(model.rows.length);
+  });
+
+  it('never leaves a connector running into the fold', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
+    const folded = visibleHierarchy(model, false);
+    for (const connector of folded.connectors) {
+      expect(connector.toIndex).toBeLessThan(folded.rows.length);
+      expect(connector.fromIndex).toBeLessThan(folded.rows.length);
+    }
+  });
+
+  it('calls a family of subclasses subclasses, not implementations', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT + 2, 'extends'), FOCUS);
+    expect(model.foldNoun).toBe('subclasses');
+  });
+
+  it('heights are the row count times the row height, with nothing measured', () => {
+    const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
+    expect(visibleHierarchy(model, false).height).toBe(
+      (model.focusIndex + 1 + HIER_FOLD_AT) * HIER_ROW_H
+    );
+    expect(visibleHierarchy(model, true).height).toBe(model.rows.length * HIER_ROW_H);
+  });
+});
+
+describe('connectorPath', () => {
+  it('is two straight runs and a corner, never a curve', () => {
+    const path = connectorPath({
+      fromIndex: 0,
+      toIndex: 1,
+      x: 26,
+      toX: 38,
+      relation: 'extends',
+      synthesized: false,
+    });
+    expect(path).toBe(`M 26 ${HIER_ROW_H / 2} L 26 ${HIER_ROW_H + HIER_ROW_H / 2} L 38 ${HIER_ROW_H + HIER_ROW_H / 2}`);
+    expect(path).not.toContain('C');
+  });
+
+  it('drops the horizontal run when the two rows share an indent', () => {
+    const path = connectorPath({
+      fromIndex: 0,
+      toIndex: 1,
+      x: 26,
+      toX: 26,
+      relation: 'implements',
+      synthesized: false,
+    });
+    expect(path.match(/L/g)).toHaveLength(1);
+  });
+});
+
+describe('the note under the tree', () => {
+  it('says how much of the fan is on screen when it was capped', () => {
+    const payload = hierarchyOf([], [entry('A', 1, 'focus')]);
+    payload.descendants.total = 900;
+    payload.descendants.truncated = true;
+    const model = buildHierarchyModel(payload, FOCUS);
+    expect(model.note).toContain('900');
+  });
+
+  it('says deeper subtypes exist when the walk stopped rather than the list', () => {
+    const model = buildHierarchyModel(
+      hierarchyOf([], [entry('A', 1, 'focus')], { bounded: true }),
+      FOCUS
+    );
+    expect(model.note).toContain('Deeper subtypes');
+  });
+
+  it('is empty when the payload is the whole truth', () => {
+    expect(buildHierarchyModel(hierarchyOf([], [entry('A', 1, 'focus')]), FOCUS).note).toBe('');
+  });
+});

+ 69 - 0
__tests__/ui-conditions.test.ts

@@ -0,0 +1,69 @@
+/**
+ * Conditions as a reader says them: the joins we add (`&&` between guards,
+ * `||` between a link's scenarios, `!(…)` around a negated guard) become
+ * and / or / not, the code inside a guard stays code, and a link with several
+ * call sites is several scenarios with their shared clauses said once.
+ */
+import { describe, it, expect } from 'vitest';
+import { clauseWords, clauses, restWords, scenarios, splitTop, whenWords } from '../ui/src/lib/conditions';
+
+describe('conditions', () => {
+  it('splits at the top level only, respecting brackets and strings', () => {
+    expect(splitTop('a && (b || c) && "x && y" && d', ' && ')).toEqual(['a', '(b || c)', '"x && y"', 'd']);
+    expect(splitTop('a && b || c && d', ' || ')).toEqual(['a && b', 'c && d']);
+    expect(clauses('!busy && isCollected')).toEqual(['!busy', 'isCollected']);
+    // A merged condition has no single innermost clause: it comes back whole.
+    expect(clauses('a && b || c')).toEqual(['a && b || c']);
+  });
+
+  it('says NOT for our negations and leaves the code inside alone', () => {
+    expect(clauseWords('!busy')).toBe('NOT busy');
+    expect(clauseWords('!user?.organization_id')).toBe('NOT user?.organization_id');
+    expect(clauseWords('!(isUploadInProgress || elapsed < 5000)')).toBe('NOT (isUploadInProgress || elapsed < 5000)');
+    // `!(a) || b` is not a negated whole: untouched.
+    expect(clauseWords('!(a) || b')).toBe('!(a) || b');
+    expect(clauseWords('(!object?.id || !object?.name)')).toBe('(!object?.id || !object?.name)');
+    expect(clauseWords('selectedDetectionItems.length === 1')).toBe('selectedDetectionItems.length === 1');
+  });
+
+  it('words a whole condition: AND within a scenario, OR between scenarios', () => {
+    expect(whenWords('!(busy || late) && user?.organization_id && !object?.id')).toBe(
+      'NOT (busy || late) AND user?.organization_id AND NOT object?.id'
+    );
+    expect(whenWords('!x && y || !x && !y')).toBe('NOT x AND y OR NOT x AND NOT y');
+    // The same guard met twice along a chain is said once.
+    expect(whenWords('ctl && !(!ctl || done) && !(!ctl || done) && ready')).toBe('ctl AND NOT (!ctl || done) AND ready');
+    expect(scenarios([{ when: 'a && a && b' }]).common).toEqual(['a', 'b']);
+    expect(whenWords('')).toBe('');
+  });
+
+  it('factors the clauses every scenario shares, and keeps each row’s own tail', () => {
+    const sites = [
+      { line: 248, when: '!(busy || late) && !user?.organization_id' },
+      { line: 257, when: '!(busy || late) && user?.organization_id && (!object?.id || !object?.name)' },
+      { line: 292, when: '!(busy || late) && user?.organization_id && !(!object?.id || !object?.name) && items.length === 1' },
+      { line: 306, when: '!(busy || late) && user?.organization_id && !(!object?.id || !object?.name) && !items.length' },
+    ];
+    const sc = scenarios(sites);
+    expect(sc.common).toEqual(['!(busy || late)']);
+    expect(sc.rows.map((r) => r.rest)).toEqual([
+      ['!user?.organization_id'],
+      ['user?.organization_id', '(!object?.id || !object?.name)'],
+      ['user?.organization_id', '!(!object?.id || !object?.name)', 'items.length === 1'],
+      ['user?.organization_id', '!(!object?.id || !object?.name)', '!items.length'],
+    ]);
+    expect(restWords(sc.rows[0]!.rest, true)).toBe('AND NOT user?.organization_id');
+    expect(restWords(sc.rows[2]!.rest, true)).toBe(
+      'AND user?.organization_id AND NOT (!object?.id || !object?.name) AND items.length === 1'
+    );
+  });
+
+  it('one site is one scenario with nothing left to say; no shared prefix says when', () => {
+    expect(scenarios([{ when: 'a && b' }])).toEqual({ common: ['a', 'b'], rows: [{ site: { when: 'a && b' }, rest: [] }] });
+    const sc = scenarios([{ when: 'a' }, { when: 'b' }, { when: '' }]);
+    expect(sc.common).toEqual([]);
+    expect(restWords(sc.rows[0]!.rest, false)).toBe('WHEN a');
+    expect(restWords(sc.rows[2]!.rest, false)).toBe('always');
+    expect(scenarios([])).toEqual({ common: [], rows: [] });
+  });
+});

+ 268 - 0
__tests__/ui-effects.test.ts

@@ -0,0 +1,268 @@
+/**
+ * The effects table (`src/ui-server/api/effects.ts`): what a call is when it
+ * leaves the index, by the call as written, per language; the model and the
+ * access a database call names; the status a response site sends.
+ */
+import { describe, it, expect } from 'vitest';
+import { classifyEffect, implicitResponseStatus, responseStatus } from '../src/ui-server/api/effects';
+
+const c = (text: string, language?: string, extra: Partial<Parameters<typeof classifyEffect>[0]> = {}) =>
+  classifyEffect({ text, kind: 'calls', language: language as never, project: 'api', ...extra });
+const n = (text: string, language?: string, extra: Partial<Parameters<typeof classifyEffect>[0]> = {}) =>
+  classifyEffect({ text, kind: 'instantiates', language: language as never, project: 'api', ...extra });
+
+describe('classifyEffect', () => {
+  it('keeps the mobile app’s categories, with or without a language', () => {
+    expect(c('client.post')?.category).toBe('network');
+    expect(c('fetch', 'tsx')?.category).toBe('network');
+    expect(c('AsyncStorage.setItem', 'tsx')?.category).toBe('storage');
+    expect(c('Linking.openURL', 'tsx')?.category).toBe('device');
+    expect(c('DdRum.addAction', 'tsx')?.category).toBe('telemetry');
+    expect(c('Math.max', 'tsx')).toBeNull();
+    expect(c('i18n.t', 'tsx')).toBeNull();
+    expect(c('Object.create', 'typescript')).toBeNull();
+  });
+
+  it('TypeScript servers: the database by the chain, the model and the access', () => {
+    expect(c('prisma.article.findFirst', 'typescript')).toEqual({ category: 'database', model: 'article', access: 'read' });
+    expect(c('this.prisma.user.create', 'typescript')).toEqual({ category: 'database', model: 'user', access: 'write' });
+    expect(c('this.usersRepository.save', 'typescript')).toEqual({ category: 'database', model: 'users', access: 'write' });
+    expect(c('this.catModel.find', 'typescript')).toEqual({ category: 'database', model: 'cat', access: 'read' });
+    expect(c('db.insert', 'typescript')).toEqual({ category: 'database', access: 'write' });
+    expect(c('knex', 'typescript', { args: "'users'" })).toBeNull();
+    expect(c('User.findOne', 'typescript')).toEqual({ category: 'database', model: 'User', access: 'read' });
+    expect(c('Promise.all', 'typescript')).toBeNull();
+  });
+
+  it('TypeScript servers: responses, queues, email, payments, cache, auth', () => {
+    expect(c('res.status(404).json', 'typescript')?.category).toBe('response');
+    expect(c('res.json', 'typescript')?.category).toBe('response');
+    expect(c('reply.code(201).send', 'typescript')?.category).toBe('response');
+    expect(c('c.json', 'typescript')?.category).toBe('response');
+    expect(n('NotFoundException', 'typescript')?.category).toBe('response');
+    expect(n('UnprocessableEntityException', 'typescript')?.category).toBe('response');
+    expect(n('HttpException', 'typescript')?.category).toBe('response');
+    expect(n('Error', 'typescript')).toBeNull();
+    expect(n('TypeError', 'typescript')).toBeNull();
+    // In an app, an exception is an error, not a reply.
+    expect(classifyEffect({ text: 'ValidationException', kind: 'instantiates', language: 'typescript', project: 'app' })).toBeNull();
+    expect(c('this.emailQueue.add', 'typescript')?.category).toBe('queue');
+    expect(c('queue.add', 'typescript')?.category).toBe('queue');
+    expect(c('this.mailerService.sendMail', 'typescript')?.category).toBe('email');
+    expect(c('resend.emails.send', 'typescript')?.category).toBe('email');
+    expect(c('stripe.checkout.sessions.create', 'typescript')?.category).toBe('payments');
+    expect(c('this.cacheManager.get', 'typescript')?.category).toBe('cache');
+    expect(c('redis.setex', 'typescript')?.category).toBe('cache');
+    expect(c('this.jwtService.signAsync', 'typescript')?.category).toBe('auth');
+    expect(c('bcrypt.compare', 'typescript')?.category).toBe('auth');
+    expect(c('jwt.verify', 'typescript')?.category).toBe('auth');
+    expect(c('crypto.createHmac', 'typescript')?.category).toBe('auth');
+    expect(c('crypto.createHash', 'typescript')).toBeNull();
+    expect(c('crypto.randomBytes', 'typescript')).toBeNull();
+    expect(c('s3.putObject', 'typescript')?.category).toBe('storage');
+    expect(c('fs.writeFile', 'typescript')?.category).toBe('storage');
+    expect(c('spawn', 'typescript')?.category).toBe('process');
+    expect(c('process.exit', 'typescript')?.category).toBe('process');
+  });
+
+  it('Python: SQLAlchemy / Django, FastAPI / Flask / Django responses, celery, files, processes', () => {
+    expect(c('session.exec', 'python')).toEqual({ category: 'database', access: 'read' });
+    expect(c('session.add', 'python')).toEqual({ category: 'database', access: 'write' });
+    expect(c('session.commit', 'python')).toEqual({ category: 'database', access: 'write' });
+    expect(c('User.objects.filter', 'python')).toEqual({ category: 'database', model: 'User', access: 'read' });
+    expect(c('db.session.add', 'python')?.category).toBe('database');
+    expect(c('HTTPException', 'python')?.category).toBe('response');
+    expect(c('JSONResponse', 'python')?.category).toBe('response');
+    expect(c('jsonify', 'python')?.category).toBe('response');
+    expect(c('abort', 'python')?.category).toBe('response');
+    expect(c('render', 'python')?.category).toBe('response');
+    expect(c('send_email.delay', 'python')?.category).toBe('queue');
+    expect(c('send_mail', 'python')?.category).toBe('email');
+    expect(c('requests.post', 'python')?.category).toBe('network');
+    expect(c('httpx.AsyncClient', 'python')?.category).toBe('network');
+    expect(c('open', 'python')?.category).toBe('storage');
+    expect(c('s3.upload_file', 'python')?.category).toBe('storage');
+    expect(c('subprocess.run', 'python')?.category).toBe('process');
+    expect(c('jwt.encode', 'python')?.category).toBe('auth');
+    expect(c('pwd_context.verify', 'python')?.category).toBe('auth');
+    expect(c('print', 'python')).toBeNull();
+    expect(c('len', 'python')).toBeNull();
+    expect(c('item.model_dump', 'python')).toBeNull();
+  });
+
+  it('Java / Kotlin: repositories by name and by declared type, Spring responses, templates', () => {
+    expect(c('owners.save', 'java', { receiverType: 'OwnerRepository' })).toEqual({ category: 'database', model: 'Owner', access: 'write' });
+    expect(c('owners.findById', 'kotlin', { receiverType: 'OwnerRepository' })).toEqual({ category: 'database', model: 'Owner', access: 'read' });
+    expect(c('this.ownerRepository.findAll', 'java')).toEqual({ category: 'database', model: 'owner', access: 'read' });
+    expect(c('jdbcTemplate.update', 'java')?.category).toBe('database');
+    expect(c('entityManager.persist', 'java')?.category).toBe('database');
+    expect(c('ResponseEntity.ok', 'java')?.category).toBe('response');
+    expect(c('ResponseEntity.status(HttpStatus.NOT_FOUND).body', 'java')?.category).toBe('response');
+    expect(n('ResponseStatusException', 'java')?.category).toBe('response');
+    expect(n('IllegalArgumentException', 'java')).toBeNull();
+    expect(n('ResourceNotFoundException', 'java')?.category).toBe('response');
+    expect(c('rabbitTemplate.convertAndSend', 'java')?.category).toBe('queue');
+    expect(c('kafkaTemplate.send', 'java')?.category).toBe('queue');
+    expect(c('applicationEventPublisher.publishEvent', 'java')?.category).toBe('queue');
+    expect(c('mailSender.send', 'java')?.category).toBe('email');
+    expect(c('restTemplate.getForObject', 'java')?.category).toBe('network');
+    expect(c('webClient.get', 'java')?.category).toBe('network');
+    expect(c('passwordEncoder.encode', 'java')?.category).toBe('auth');
+    expect(c('redisTemplate.opsForValue', 'java')?.category).toBe('cache');
+    expect(c('Files.write', 'java')?.category).toBe('storage');
+    // Android: DataStore, SharedPreferences, Room DAOs, WorkManager.
+    expect(c('userPreferences.updateData', 'kotlin')?.category).toBe('storage');
+    expect(c('sharedPreferences.edit', 'kotlin')?.category).toBe('storage');
+    expect(c('topicDao.upsertTopics', 'kotlin')).toEqual({ category: 'database', model: 'topic', access: 'write' });
+    expect(c('workManager.enqueueUniqueWork', 'kotlin')?.category).toBe('queue');
+    expect(c('viewModelScope.launch', 'kotlin')).toBeNull();
+    expect(c('model.addAttribute', 'java')).toBeNull();
+    expect(c('result.hasErrors', 'java')).toBeNull();
+    expect(c('Objects.equals', 'java')).toBeNull();
+  });
+
+  it('C#: EF Core / repositories, controller responses, MassTransit, Identity', () => {
+    expect(c('_context.TodoItems.Add', 'csharp')).toEqual({ category: 'database', model: 'TodoItems', access: 'write' });
+    expect(c('_context.SaveChangesAsync', 'csharp')).toEqual({ category: 'database', access: 'write' });
+    expect(c('_orderRepository.AddAsync', 'csharp')).toEqual({ category: 'database', model: 'order', access: 'write' });
+    expect(c('_basketRepository.FirstOrDefaultAsync', 'csharp')).toEqual({ category: 'database', model: 'basket', access: 'read' });
+    expect(c('NotFound', 'csharp')?.category).toBe('response');
+    expect(c('Ok', 'csharp')?.category).toBe('response');
+    expect(c('TypedResults.NoContent', 'csharp')?.category).toBe('response');
+    expect(c('Results.Created', 'csharp')?.category).toBe('response');
+    expect(n('NotFoundException', 'csharp')?.category).toBe('response');
+    expect(n('ArgumentNullException', 'csharp')).toBeNull();
+    expect(c('_bus.Publish', 'csharp')?.category).toBe('queue');
+    expect(c('_publishEndpoint.Publish', 'csharp')?.category).toBe('queue');
+    expect(c('BackgroundJob.Enqueue', 'csharp')?.category).toBe('queue');
+    expect(c('_emailSender.SendEmailAsync', 'csharp')?.category).toBe('email');
+    expect(c('_httpClient.GetAsync', 'csharp')?.category).toBe('network');
+    expect(c('_userManager.CreateAsync', 'csharp')?.category).toBe('auth');
+    expect(c('_signInManager.PasswordSignInAsync', 'csharp')?.category).toBe('auth');
+    expect(c('_cache.GetOrCreateAsync', 'csharp')?.category).toBe('cache');
+    expect(c('File.ReadAllText', 'csharp')?.category).toBe('storage');
+    expect(c('Guard.Against.Null', 'csharp')).toBeNull();
+    expect(c('nameof', 'csharp')).toBeNull();
+    expect(c('sender.Send', 'csharp')).toBeNull();
+  });
+
+  it('Go: database/sql, gorm, gin responses, net/http, os', () => {
+    expect(c('db.QueryRow', 'go')).toEqual({ category: 'database', access: 'read' });
+    expect(c('db.Exec', 'go')).toEqual({ category: 'database', access: 'write' });
+    expect(c('db.Create', 'go')?.category).toBe('database');
+    expect(c('c.JSON', 'go')?.category).toBe('response');
+    expect(c('c.AbortWithStatus', 'go')?.category).toBe('response');
+    expect(c('http.Error', 'go')?.category).toBe('response');
+    expect(c('w.WriteHeader', 'go')?.category).toBe('response');
+    expect(c('http.Get', 'go')?.category).toBe('network');
+    expect(c('client.Do', 'go')?.category).toBe('network');
+    expect(c('os.ReadFile', 'go')?.category).toBe('storage');
+    expect(c('exec.Command', 'go')?.category).toBe('process');
+    expect(c('producer.Produce', 'go')?.category).toBe('queue');
+    expect(c('jwt.NewWithClaims', 'go')?.category).toBe('auth');
+    expect(c('fmt.Sprintf', 'go')).toBeNull();
+    expect(c('errors.New', 'go')).toBeNull();
+  });
+
+  it('C: files, sockets, processes', () => {
+    expect(c('fopen', 'c')?.category).toBe('storage');
+    expect(c('fprintf', 'c')?.category).toBe('storage');
+    expect(c('write', 'c')?.category).toBe('storage');
+    expect(c('socket', 'c')?.category).toBe('network');
+    expect(c('connect', 'c')?.category).toBe('network');
+    expect(c('curl_easy_perform', 'c')?.category).toBe('network');
+    expect(c('fork', 'c')?.category).toBe('process');
+    expect(c('exit', 'c')?.category).toBe('process');
+    expect(c('pthread_create', 'c')?.category).toBe('process');
+    expect(c('strlen', 'c')).toBeNull();
+    expect(c('malloc', 'c')).toBeNull();
+    expect(c('memcpy', 'c')).toBeNull();
+    expect(c('serverLog', 'c')).toBeNull();
+  });
+
+  it('Swift (Vapor), Ruby (Rails), PHP (Laravel)', () => {
+    expect(c('Abort', 'swift')?.category).toBe('response');
+    expect(c('Todo.query', 'swift')).toEqual({ category: 'database', model: 'Todo', access: 'read' });
+    expect(c('todo.save', 'swift')?.category).toBe('database');
+    expect(c('URLSession.shared.dataTask', 'swift')?.category).toBe('network');
+    expect(c('render', 'ruby')?.category).toBe('response');
+    expect(c('redirect_to', 'ruby')?.category).toBe('response');
+    expect(c('User.find_by', 'ruby')).toEqual({ category: 'database', model: 'User', access: 'read' });
+    expect(c('@user.save', 'ruby')?.category).toBe('database');
+    expect(c('UserMailer.welcome', 'ruby')?.category).toBe('email');
+    expect(c('HardJob.perform_later', 'ruby')?.category).toBe('queue');
+    expect(c('User::find', 'php')).toEqual({ category: 'database', model: 'User', access: 'read' });
+    expect(c('DB::table', 'php')?.category).toBe('database');
+    expect(c('abort', 'php')?.category).toBe('response');
+    expect(c('Mail::to', 'php')?.category).toBe('email');
+  });
+
+  it('a language without rows for a family stays quiet', () => {
+    expect(c('foo.bar', 'ruby')).toBeNull();
+    expect(c('save', 'python')).toBeNull();
+    expect(c('render', 'java')).toBeNull();
+  });
+});
+
+describe('responseStatus', () => {
+  it('reads the literal code out of the chain, the arguments, or the name', () => {
+    expect(responseStatus('res.status(404).json', '{ error }')).toBe(404);
+    expect(responseStatus('res.status', '404')).toBe(404);
+    expect(responseStatus('res.sendStatus', '204')).toBe(204);
+    expect(responseStatus('res.json', '{ user }')).toBeNull();
+    expect(responseStatus('reply.code(201).send', 'user')).toBe(201);
+    expect(responseStatus('res.redirect', "'/login'")).toBe(302);
+    expect(responseStatus('NotFoundException', "'no such user'", 'instantiates')).toBe(404);
+    expect(responseStatus('UnprocessableEntityException', '{ errors }', 'instantiates')).toBe(422);
+    expect(responseStatus('HttpException', "'x', HttpStatus.FORBIDDEN", 'instantiates')).toBe(403);
+    expect(responseStatus('HttpException', "'x', 418", 'instantiates')).toBe(418);
+    expect(responseStatus('HTTPException', 'status_code=404, detail="no title"')).toBe(404);
+    expect(responseStatus('abort', '404')).toBe(404);
+    expect(responseStatus('JsonResponse', '{ "error" }, status=400')).toBe(400);
+    expect(responseStatus('Http404', '')).toBe(404);
+    expect(responseStatus('ResponseEntity.ok', 'body')).toBe(200);
+    expect(responseStatus('ResponseEntity.notFound().build', '')).toBe(404);
+    expect(responseStatus('ResponseEntity.status(HttpStatus.CREATED).body', 'saved')).toBe(201);
+    expect(responseStatus('ResponseStatusException', 'HttpStatus.NOT_FOUND, "x"', 'instantiates')).toBe(404);
+    expect(responseStatus('ResponseEntity', 'body, HttpStatus.CREATED', 'instantiates')).toBe(201);
+    expect(responseStatus('NotFound', '')).toBe(404);
+    expect(responseStatus('Ok', 'item')).toBe(200);
+    expect(responseStatus('CreatedAtAction', 'nameof(Get), item')).toBe(201);
+    expect(responseStatus('TypedResults.NoContent', '')).toBe(204);
+    expect(responseStatus('StatusCode', '500')).toBe(500);
+    expect(responseStatus('Results.Problem', '')).toBe(500);
+    expect(responseStatus('c.JSON', 'http.StatusCreated, u')).toBe(201);
+    expect(responseStatus('c.String', '200, "ok"')).toBe(200);
+    expect(responseStatus('http.Error', 'w, msg, http.StatusInternalServerError')).toBe(500);
+    expect(responseStatus('w.WriteHeader', 'http.StatusNotFound')).toBe(404);
+    expect(responseStatus('c.AbortWithStatus', '404')).toBe(404);
+    expect(responseStatus('Abort', '.notFound')).toBe(404);
+    expect(responseStatus('Abort', '.badRequest, reason: "x"')).toBe(400);
+    expect(responseStatus('redirect_to', 'root_path')).toBe(302);
+    expect(responseStatus('render', 'json: user, status: :created')).toBeNull();
+    expect(responseStatus('res.status', 'code')).toBeNull();
+    expect(responseStatus('res.json', '')).toBeNull();
+  });
+});
+
+describe('implicitResponseStatus', () => {
+  it('a body-sending reply that sets no status is a 200', () => {
+    expect(implicitResponseStatus('res.json')).toBe(200);
+    expect(implicitResponseStatus('res.send')).toBe(200);
+    expect(implicitResponseStatus('res.render')).toBe(200);
+    expect(implicitResponseStatus('reply.send')).toBe(200);
+    expect(implicitResponseStatus('c.json')).toBe(200);
+    expect(implicitResponseStatus('NextResponse.json')).toBe(200);
+    expect(implicitResponseStatus('JSONResponse')).toBe(200);
+    expect(implicitResponseStatus('jsonify')).toBe(200);
+  });
+  it('is null when the chain sets a status — literal or not — or ends without a body', () => {
+    expect(implicitResponseStatus('res.status(404).json')).toBeNull();
+    expect(implicitResponseStatus('res.status(code).json')).toBeNull();
+    expect(implicitResponseStatus('res.sendStatus(204)')).toBeNull();
+    expect(implicitResponseStatus('res.end')).toBeNull();
+    expect(implicitResponseStatus('res.redirect')).toBeNull();
+    expect(implicitResponseStatus('NotFoundException')).toBeNull();
+    expect(implicitResponseStatus('prisma.user.create')).toBeNull();
+  });
+});

+ 342 - 0
__tests__/ui-entry-model.test.ts

@@ -0,0 +1,342 @@
+/**
+ * The entry-points panel's grouping, without a browser (CG-54).
+ *
+ * The half of `ui-entrypoints-api.test.ts` that needs no index: given a
+ * payload, which rows exist, what they say, where they group, and which of them
+ * can be clicked or turned into a flow. The rules worth pinning are the ones a
+ * refactor would quietly break:
+ *
+ * - `panel.rows` is exactly the sections' rows in draw order (the same identity
+ *   the search palette rests its keyboard on).
+ * - A route with no resolved handler still appears, but carries no target — a
+ *   row that looks clickable and is not is worse than a row that says so.
+ * - Only a row that names a callable symbol offers a flow.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildEntryPanel,
+  directoryOf,
+  flowPair,
+  frameworkPhrase,
+  groupRows,
+  matchEntries,
+  originLabel,
+  routeRow,
+  type EntryRow,
+} from '../ui/src/lib/entry-model';
+import type {
+  WireEntryFile,
+  WireEntryHub,
+  WireEntryPoints,
+  WireEntryRoute,
+  WireEntryTest,
+  WireNodeRef,
+} from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function ref(over: Partial<WireNodeRef> = {}): WireNodeRef {
+  return {
+    id: 'function:x',
+    name: 'x',
+    kind: 'function',
+    qualifiedName: 'x',
+    file: 'src/x.ts',
+    line: 1,
+    endLine: 2,
+    language: 'typescript',
+    signature: null,
+    exported: true,
+    generated: false,
+    test: false,
+    ...over,
+  } as WireNodeRef;
+}
+
+function route(over: Partial<WireEntryRoute> = {}): WireEntryRoute {
+  return {
+    url: 'POST /v1/payroll/cycles/{cycleID}/run',
+    method: 'POST',
+    path: '/v1/payroll/cycles/{cycleID}/run',
+    handler: 'RunCycle',
+    handlerKind: 'method',
+    file: 'internal/transport/httpapi/payroll_handler.go',
+    line: 34,
+    handlerId: 'method:RunCycle',
+    routeFile: 'internal/transport/httpapi/router.go',
+    routeLine: 9,
+    routeId: 'route:router.go:9:POST:/v1/payroll/cycles/{cycleID}/run',
+    ...over,
+  };
+}
+
+function file(over: Partial<WireEntryFile> = {}): WireEntryFile {
+  return {
+    ...ref({ id: 'file:src/bin/cli.ts', kind: 'file', name: 'cli.ts', file: 'src/bin/cli.ts' }),
+    calls: 9,
+    reaches: 37,
+    dependents: 3,
+    ...over,
+  } as WireEntryFile;
+}
+
+function test(over: Partial<WireEntryTest> = {}): WireEntryTest {
+  return {
+    ...ref({
+      id: 'file:__tests__/a.test.ts',
+      kind: 'file',
+      name: 'a.test.ts',
+      file: '__tests__/a.test.ts',
+    }),
+    reaches: 12,
+    refs: 40,
+    ...over,
+  } as WireEntryTest;
+}
+
+function hub(over: Partial<WireEntryHub> = {}): WireEntryHub {
+  return {
+    ...ref({ id: 'interface:Node', name: 'Node', kind: 'interface', file: 'src/types.ts', line: 42 }),
+    dependents: 264,
+    ...over,
+  } as WireEntryHub;
+}
+
+function payload(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
+  return {
+    frameworks: ['go'],
+    routes: {
+      routed: true,
+      routeCount: 4,
+      items: { total: 2, shown: 2, truncated: false, items: [route(), route({
+        url: 'GET /healthz',
+        method: 'GET',
+        path: '/healthz',
+        handler: 'health',
+        handlerKind: 'function',
+        file: 'internal/transport/httpapi/router.go',
+        line: 16,
+        handlerId: 'function:health',
+        routeLine: 12,
+        routeId: 'route:router.go:12:GET:/healthz',
+      })] },
+    },
+    files: { total: 92, shown: 1, truncated: true, items: [file()] },
+    tests: { total: 1, shown: 1, truncated: false, items: [test()] },
+    hubs: { total: 351, shown: 1, truncated: true, items: [hub()] },
+    index: { lastIndexedAt: 1, files: 20 },
+    timing: { elapsedMs: 3, cached: false },
+    ...over,
+  } as WireEntryPoints;
+}
+
+/* ---------------------------------------------------------------- panel -- */
+
+describe('the entry-points panel', () => {
+  it('draws every section it has data for, in reading order', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.sections.map((s) => s.id)).toEqual(['routes', 'files', 'tests', 'hubs']);
+    expect(panel.sections.map((s) => s.title)).toEqual([
+      'Routes',
+      'Top-level files with calls',
+      'Tests',
+      'Most depended on',
+    ]);
+  });
+
+  it('keeps `rows` exactly the sections it draws', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.rows).toEqual(panel.sections.flatMap((s) => s.groups.flatMap((g) => g.rows)));
+    expect(panel.rows).toHaveLength(5);
+  });
+
+  it('names the framework beside the route count', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.sections[0]?.meta).toBe('2 · go');
+  });
+
+  it('groups routes by where they are REGISTERED, not where they are served', () => {
+    const panel = buildEntryPanel(payload());
+    const routes = panel.sections[0];
+    // Two routes served from two different files, one router.
+    expect(routes?.groups).toHaveLength(1);
+    expect(routes?.groups[0]?.path).toBe('internal/transport/httpapi/router.go');
+    expect(routes?.groups[0]?.file).toBe('internal/transport/httpapi/router.go');
+  });
+
+  it('says a list was cut, and whether the total is a floor', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.sections.find((s) => s.id === 'files')?.meta).toBe('1 of at least 92');
+    expect(panel.sections.find((s) => s.id === 'tests')?.meta).toBe('1');
+    expect(panel.sections.find((s) => s.id === 'hubs')?.floor).toBe(true);
+    expect(panel.sections.find((s) => s.id === 'tests')?.floor).toBe(false);
+  });
+
+  it('draws no Routes heading when the project is not a routed app', () => {
+    const panel = buildEntryPanel(
+      payload({
+        routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
+      })
+    );
+    // The fallback is the point: an empty box under a heading reads as a
+    // failure, and a library legitimately has no routes.
+    expect(panel.sections.map((s) => s.id)).toEqual(['files', 'tests', 'hubs']);
+    expect(panel.empty).toBeNull();
+  });
+
+  it('says what is missing when there is nothing at all', () => {
+    const panel = buildEntryPanel(
+      payload({
+        routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
+        files: { total: 0, shown: 0, truncated: false, items: [] },
+        tests: { total: 0, shown: 0, truncated: false, items: [] },
+        hubs: { total: 0, shown: 0, truncated: false, items: [] },
+      })
+    );
+    expect(panel.sections).toEqual([]);
+    expect(panel.empty).toMatch(/no routes/);
+  });
+
+  it('draws nothing at all before the answer arrives', () => {
+    const panel = buildEntryPanel(null);
+    expect(panel.sections).toEqual([]);
+    // Not an "empty" message: nothing is known yet, and saying "this index has
+    // nothing" while the request is in flight would be a claim, not a state.
+    expect(panel.empty).toBeNull();
+  });
+});
+
+/* ------------------------------------------------------------------ rows -- */
+
+describe('an entry-point row', () => {
+  it('leads a route with its verb and names the handler in the meta', () => {
+    const row = routeRow(route());
+    expect(row.method).toBe('POST');
+    expect(row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
+    expect(row.meta).toBe('RunCycle · payroll_handler.go:34');
+    expect(row.title).toContain('registered at internal/transport/httpapi/router.go:9');
+  });
+
+  it('keeps an unplaceable route but does not pretend it opens', () => {
+    const row = routeRow(route({ handlerId: null }));
+    expect(row.target).toBeNull();
+    expect(row.flowFrom).toBeNull();
+    expect(row.meta).toBe('RunCycle · not in the index');
+  });
+
+  it('offers a flow only from a row that names a callable symbol', () => {
+    const panel = buildEntryPanel(payload());
+    const byId = (id: string) => panel.sections.find((s) => s.id === id);
+    expect(byId('routes')?.groups[0]?.rows[0]?.flowFrom).toBe('RunCycle');
+    expect(byId('hubs')?.groups[0]?.rows[0]?.flowFrom).toBe('Node');
+    // A file has no name `/api/flow` can look up; a chip here would always fail.
+    expect(byId('files')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
+    expect(byId('tests')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
+  });
+
+  it('sends a file row to the File view and a symbol row to the symbol', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.target).toEqual({
+      type: 'file',
+      path: 'src/bin/cli.ts',
+    });
+    expect(panel.sections.find((s) => s.id === 'hubs')?.groups[0]?.rows[0]?.target).toEqual({
+      type: 'symbol',
+      id: 'interface:Node',
+      name: 'Node',
+      kind: 'interface',
+    });
+  });
+
+  it('says when nothing imports an executable file', () => {
+    const panel = buildEntryPanel(
+      payload({ files: { total: 1, shown: 1, truncated: false, items: [file({ dependents: 0 })] } })
+    );
+    expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.meta).toBe(
+      '9 calls at module level · reaches 37 files · nothing imports it'
+    );
+  });
+});
+
+/* -------------------------------------------------------------- grouping -- */
+
+describe('grouping', () => {
+  it('folds by path in first-seen order, so the ranking stays visible', () => {
+    const row = (id: string): EntryRow => ({
+      id,
+      name: id,
+      method: null,
+      meta: '',
+      kind: 'file',
+      target: null,
+      flowFrom: null,
+      title: id,
+    });
+    const groups = groupRows([
+      { row: row('b1'), path: 'b', file: null },
+      { row: row('a1'), path: 'a', file: null },
+      { row: row('b2'), path: 'b', file: null },
+    ]);
+    expect(groups.map((g) => g.path)).toEqual(['b', 'a']);
+    expect(groups[0]?.rows.map((r) => r.id)).toEqual(['b1', 'b2']);
+  });
+
+  it('names the directory, or the project root', () => {
+    expect(directoryOf('src/bin/cli.ts')).toBe('src/bin');
+    expect(directoryOf('package.json')).toBe('project root');
+  });
+});
+
+/* --------------------------------------------------------------- palette -- */
+
+describe('entry points under a typed query', () => {
+  it('matches on anything the row draws, including the handler', () => {
+    const matches = matchEntries(payload(), 'runcycle', 6);
+    expect(matches).toHaveLength(1);
+    expect(matches[0]?.origin).toBe('route');
+    expect(matches[0]?.row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
+  });
+
+  it('matches a URL a search for the path would find, and a verb one would not', () => {
+    expect(matchEntries(payload(), 'healthz', 6)).toHaveLength(1);
+    expect(matchEntries(payload(), 'post ', 6)).toHaveLength(1);
+  });
+
+  it('honours the cap and answers nothing for an empty query', () => {
+    expect(matchEntries(payload(), '', 6)).toEqual([]);
+    expect(matchEntries(null, 'x', 6)).toEqual([]);
+    expect(matchEntries(payload(), '.', 1)).toHaveLength(1);
+  });
+
+  it('says where each match came from', () => {
+    expect(originLabel('route')).toBe('route');
+    expect(originLabel('file')).toBe('runs at module level');
+    expect(originLabel('test')).toBe('test');
+    expect(originLabel('hub')).toBe('depended on');
+  });
+});
+
+/* ------------------------------------------------------------------ flow -- */
+
+describe('starting a flow from a row', () => {
+  it('refuses a pair that is not a question', () => {
+    expect(flowPair('RunCycle', '')).toBeNull();
+    expect(flowPair('', 'Upsert')).toBeNull();
+    // `/api/flow` refuses this with a 400; disabling the button is kinder.
+    expect(flowPair('Upsert', 'upsert')).toBeNull();
+  });
+
+  it('trims what was typed', () => {
+    expect(flowPair('  RunCycle ', ' Upsert ')).toEqual({ from: 'RunCycle', to: 'Upsert' });
+  });
+});
+
+describe('naming the frameworks', () => {
+  it('reads as a sentence, however many there are', () => {
+    expect(frameworkPhrase([])).toBe('');
+    expect(frameworkPhrase(['gin'])).toBe('gin');
+    expect(frameworkPhrase(['gin', 'spring'])).toBe('gin and spring');
+    expect(frameworkPhrase(['gin', 'spring', 'rails'])).toBe('gin, spring and rails');
+  });
+});

+ 393 - 0
__tests__/ui-entrypoints-api.test.ts

@@ -0,0 +1,393 @@
+/**
+ * `GET /api/entrypoints` and the panel it draws (CG-54).
+ *
+ * Two indexed projects over two real loopback servers, because the two answers
+ * this endpoint has to get right are opposites:
+ *
+ * - **A routed service.** `__tests__/fixtures/payroll-go` is a Go HTTP service
+ *   whose four routes are registered in one router file and served from
+ *   another, which is exactly the shape that makes "group routes by file"
+ *   ambiguous — and the reason the payload carries the registration site as
+ *   well as the handler. It is also the issue's acceptance case: the routes
+ *   appear with their handlers, and the route's own handler reaches the store
+ *   as a flow.
+ * - **A library.** A TypeScript project with no routes at all, where the panel
+ *   must fall back to the files that run something and the tests that exercise
+ *   them, and must NOT draw an empty Routes box: "this isn't a web app" is an
+ *   answer, not a failure.
+ *
+ * The grouping itself is pure and lives in `ui/src/lib/entry-model.ts`; it is
+ * driven here from the real payload so a wire change that the pure tests would
+ * happily keep passing still fails somewhere.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import { resetEntryPointsCache } from '../src/ui-server/api/entrypoints';
+import { splitRouteName } from '../src/ui-server/api/routes';
+import { isTestFile, isTestPath } from '../src/search/query-utils';
+import { buildEntryPanel, frameworkPhrase } from '../ui/src/lib/entry-model';
+import type { WireEntryPoints } from '../ui/src/lib/api';
+
+const FIXTURE_GO = path.join(__dirname, 'fixtures', 'payroll-go');
+
+interface Instance {
+  dir: string;
+  root: string;
+  cg: CodeGraph;
+  api: GraphApi;
+  server: UiServerHandle;
+}
+
+function request(port: number, requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+async function getJson(instance: Instance, requestPath: string, expected = 200): Promise<any> {
+  const res = await request(instance.server.port, requestPath);
+  expect(res.type).toBe('application/json; charset=utf-8');
+  expect(res.status).toBe(expected);
+  return JSON.parse(res.body);
+}
+
+async function serve(root: string, dir: string, cg: CodeGraph): Promise<Instance> {
+  const api = createGraphApi({ projectRoot: root });
+  const server = await startUiServer({ projectRoot: root, port: 0, api: api.handler });
+  return { dir, root, cg, api, server };
+}
+
+function write(root: string, rel: string, body: string): void {
+  const full = path.join(root, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, body);
+}
+
+async function stop(instance: Instance | undefined): Promise<void> {
+  if (!instance) return;
+  await instance.server.close();
+  instance.api.close();
+  instance.cg.destroy();
+  fs.rmSync(instance.dir, { recursive: true, force: true });
+}
+
+/* ======================================================================== */
+/* A routed Go service — the issue's acceptance case                        */
+/* ======================================================================== */
+
+describe('entry points on a routed service', () => {
+  let go: Instance;
+  let payload: WireEntryPoints;
+
+  beforeAll(async () => {
+    resetEntryPointsCache();
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-entry-go-'));
+    fs.cpSync(FIXTURE_GO, dir, { recursive: true });
+    // A stray index in the checked-in tree would be copied in and reused.
+    fs.rmSync(path.join(dir, '.codegraph'), { recursive: true, force: true });
+
+    const cg = CodeGraph.initSync(dir);
+    await cg.indexAll();
+    go = await serve(dir, dir, cg);
+    payload = (await getJson(go, '/api/entrypoints')) as WireEntryPoints;
+  }, 120_000);
+
+  afterAll(async () => {
+    await stop(go);
+  });
+
+  it('names the framework the route list came from', () => {
+    expect(payload.frameworks).toContain('go');
+    expect(frameworkPhrase(payload.frameworks)).toContain('go');
+  });
+
+  it('lists every route with the symbol that serves it', () => {
+    expect(payload.routes.routed).toBe(true);
+    expect(payload.routes.routeCount).toBe(4);
+
+    const rows = payload.routes.items.items;
+    expect(rows).toHaveLength(4);
+    expect(rows.map((r) => r.url)).toEqual(
+      expect.arrayContaining([
+        'POST /v1/payroll/cycles/{cycleID}/run',
+        'GET /v1/payroll/cycles/{cycleID}',
+        'GET /v1/payroll/cycles/{cycleID}/payslips',
+        'GET /healthz',
+      ])
+    );
+
+    const run = rows.find((r) => r.url.startsWith('POST '));
+    expect(run).toBeDefined();
+    expect(run?.method).toBe('POST');
+    expect(run?.path).toBe('/v1/payroll/cycles/{cycleID}/run');
+    expect(run?.handler).toBe('RunCycle');
+    expect(run?.file).toBe('internal/transport/httpapi/payroll_handler.go');
+    // A row has to be navigable, or it is a label.
+    expect(run?.handlerId).toBeTruthy();
+    expect(rows.every((r) => r.handlerId)).toBe(true);
+  });
+
+  it('carries where each URL is registered, which is not where it is served', () => {
+    const rows = payload.routes.items.items;
+    // Every route is registered by NewRouter; three of the four are served
+    // from a different file. Without the registration site there is nothing
+    // to group four routes under.
+    expect(new Set(rows.map((r) => r.routeFile))).toEqual(
+      new Set(['internal/transport/httpapi/router.go'])
+    );
+    expect(new Set(rows.map((r) => r.file)).size).toBe(2);
+    expect(rows.every((r) => r.routeLine > 0)).toBe(true);
+  });
+
+  it('groups the panel by the router file, with the handler in the meta line', () => {
+    const panel = buildEntryPanel(payload);
+    const routes = panel.sections.find((s) => s.id === 'routes');
+    expect(routes).toBeDefined();
+    expect(routes?.groups).toHaveLength(1);
+    expect(routes?.groups[0]?.path).toBe('internal/transport/httpapi/router.go');
+    expect(routes?.groups[0]?.rows).toHaveLength(4);
+    // The framework rides in the section header, beside the count.
+    expect(routes?.meta).toContain('go');
+
+    const run = routes?.groups[0]?.rows.find((r) => r.method === 'POST');
+    expect(run?.name).toBe('/v1/payroll/cycles/{cycleID}/run');
+    expect(run?.meta).toBe('RunCycle · payroll_handler.go:34');
+    expect(run?.target).toEqual({
+      type: 'symbol',
+      id: expect.any(String),
+      name: 'RunCycle',
+      kind: 'method',
+    });
+    // A route names a callable symbol, so it can start a flow.
+    expect(run?.flowFrom).toBe('RunCycle');
+  });
+
+  it('draws the flow from a route handler down to the store', async () => {
+    // The issue's "route -> insertNode-style flow": the POST handler reaching
+    // the row that lands in the database.
+    const flow = await getJson(go, '/api/flow?from=RunCycle&to=Upsert');
+    expect(flow.flows.length).toBeGreaterThan(0);
+    const hops = flow.flows[0].hops.map((h: any) => h.node.name);
+    expect(hops[0]).toBe('RunCycle');
+    expect(hops[hops.length - 1]).toBe('Upsert');
+    expect(hops).toContain('runPayrollCycleAll');
+    // Every hop after the first carries the edge that got there.
+    expect(flow.flows[0].hops.slice(1).every((h: any) => h.edge)).toBe(true);
+  });
+
+  it('answers a second time from the cache', async () => {
+    const again = await getJson(go, '/api/entrypoints');
+    expect(again.timing.cached).toBe(true);
+    expect(again.routes.items.items).toEqual(payload.routes.items.items);
+  });
+
+  it('refuses a route window it cannot answer truthfully', async () => {
+    // Under three rows the engine's own "is this routed" test cannot run, so
+    // the parameter is floored rather than silently answering "not routed".
+    const body = await getJson(go, '/api/entrypoints?routes=2', 400);
+    expect(body.error).toMatch(/routes/);
+  });
+});
+
+/* ======================================================================== */
+/* A library — no routes, and no empty Routes box                           */
+/* ======================================================================== */
+
+describe('entry points on a project with no routes', () => {
+  let lib: Instance;
+  let payload: WireEntryPoints;
+
+  beforeAll(async () => {
+    resetEntryPointsCache();
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-entry-lib-'));
+    const root = path.join(dir, 'project');
+    fs.mkdirSync(root, { recursive: true });
+
+    write(
+      root,
+      'src/store.ts',
+      `export function insertNode(name: string): string {
+  return name.trim();
+}
+
+export function readNode(name: string): string {
+  return insertNode(name);
+}
+`
+    );
+    // Module-level statements: the only reason an executable root is visible.
+    write(
+      root,
+      'src/main.ts',
+      `import { insertNode, readNode } from './store';
+
+const first = insertNode('boot');
+const second = readNode('warm');
+
+export const started = [first, second];
+`
+    );
+    write(
+      root,
+      '__tests__/store.test.ts',
+      `import { insertNode } from '../src/store';
+
+export function exercisesTheStore(): string {
+  return insertNode('x');
+}
+
+exercisesTheStore();
+`
+    );
+    // A fixture is not a test, even though the ranking treats it as one.
+    write(root, '__tests__/fixtures/sample.ts', `export const sample = 1;\n`);
+
+    const cg = CodeGraph.initSync(root, {
+      config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
+    });
+    await cg.indexAll();
+    cg.resolveReferences();
+    lib = await serve(root, dir, cg);
+    payload = (await getJson(lib, '/api/entrypoints')) as WireEntryPoints;
+  }, 120_000);
+
+  afterAll(async () => {
+    await stop(lib);
+  });
+
+  it('says it is not a routed app instead of drawing an empty list', () => {
+    expect(payload.routes.routed).toBe(false);
+    expect(payload.routes.items.items).toEqual([]);
+    expect(payload.routes.items.total).toBe(0);
+
+    const panel = buildEntryPanel(payload);
+    // No Routes heading at all — an empty box under a heading reads as a
+    // failure, and this is the ordinary shape of a library.
+    expect(panel.sections.map((s) => s.id)).not.toContain('routes');
+    // …and the panel is not empty: it fell back to what does exist.
+    expect(panel.empty).toBeNull();
+    expect(panel.sections.length).toBeGreaterThan(0);
+  });
+
+  it('falls back to the file that runs something at module level', () => {
+    const files = payload.files.items.map((f) => f.file);
+    expect(files).toContain('src/main.ts');
+    expect(files).not.toContain('__tests__/store.test.ts');
+
+    const main = payload.files.items.find((f) => f.file === 'src/main.ts');
+    expect(main?.calls).toBeGreaterThan(0);
+    expect(main?.reaches).toBeGreaterThan(0);
+
+    const panel = buildEntryPanel(payload);
+    const section = panel.sections.find((s) => s.id === 'files');
+    expect(section?.title).toBe('Top-level files with calls');
+    expect(section?.groups[0]?.path).toBe('src');
+    // A file has no name the path finder can look up, so no flow chip.
+    expect(section?.groups[0]?.rows.every((r) => r.flowFrom === null)).toBe(true);
+    expect(section?.groups[0]?.rows[0]?.target).toEqual({ type: 'file', path: 'src/main.ts' });
+  });
+
+  it('lists the tests by what they exercise', () => {
+    const tests = payload.tests.items.map((t) => t.file);
+    expect(tests).toContain('__tests__/store.test.ts');
+    // A fixture reaches nothing and is not a test; either reason keeps it out.
+    expect(tests).not.toContain('__tests__/fixtures/sample.ts');
+
+    const suite = payload.tests.items.find((t) => t.file === '__tests__/store.test.ts');
+    expect(suite?.reaches).toBeGreaterThan(0);
+    expect(suite?.refs).toBeGreaterThanOrEqual(suite?.reaches ?? 0);
+
+    const panel = buildEntryPanel(payload);
+    const section = panel.sections.find((s) => s.id === 'tests');
+    expect(section?.title).toBe('Tests');
+    expect(section?.groups[0]?.rows[0]?.meta).toMatch(/^exercises \d+ files? · \d+ references?$/);
+  });
+
+  it('counts the tests exactly, and the derived lists as a floor', () => {
+    // Every count equals a list in the same payload, or is labelled a floor.
+    expect(payload.tests.total).toBe(payload.tests.items.length);
+    expect(payload.files.total).toBeGreaterThanOrEqual(payload.files.items.length);
+    expect(payload.hubs.total).toBeGreaterThanOrEqual(payload.hubs.items.length);
+
+    const panel = buildEntryPanel(payload);
+    expect(panel.sections.find((s) => s.id === 'tests')?.floor).toBe(false);
+    expect(panel.sections.find((s) => s.id === 'files')?.floor).toBe(true);
+  });
+});
+
+/* ======================================================================== */
+/* The narrow test predicate                                                */
+/* ======================================================================== */
+
+describe('what counts as a test', () => {
+  it('keeps the suites and drops the examples', () => {
+    for (const suite of [
+      'foo_test.go',
+      'src/foo.test.ts',
+      'src/__tests__/foo.ts',
+      'test/foo.rb',
+      'src/FooTest.java',
+      'app/src/jvmTest/Bar.kt',
+    ]) {
+      expect(isTestPath(suite), suite).toBe(true);
+      expect(isTestFile(suite), suite).toBe(true);
+    }
+
+    // Examples, benchmarks and fixtures are still off-target for RANKING —
+    // nothing about this change moves that — but they are not tests, and a
+    // heading that says "Tests" must not gather them.
+    for (const other of ['examples/demo.ts', 'benchmarks/run.ts', 'fixtures/a.ts']) {
+      expect(isTestFile(other), other).toBe(true);
+      expect(isTestPath(other), other).toBe(false);
+    }
+  });
+});
+
+/* ======================================================================== */
+/* Route names                                                              */
+/* ======================================================================== */
+
+describe('splitting a route name', () => {
+  it('takes the verb off when there is one', () => {
+    expect(splitRouteName('POST /v1/users')).toEqual({ method: 'POST', path: '/v1/users' });
+    expect(splitRouteName('ANY /healthz')).toEqual({ method: 'ANY', path: '/healthz' });
+  });
+
+  it('leaves a file-routed page whole', () => {
+    // A verb column invented out of the first path segment would be a lie, and
+    // the URL would lose its head.
+    expect(splitRouteName('/blog/[slug]')).toEqual({ method: null, path: '/blog/[slug]' });
+    expect(splitRouteName('user.created handler')).toEqual({
+      method: null,
+      path: 'user.created handler',
+    });
+  });
+});

+ 479 - 0
__tests__/ui-events-api.test.ts

@@ -0,0 +1,479 @@
+/**
+ * The viewer's live channel and its drift parity (CG-53).
+ *
+ * Two things are proved here that a unit test could not:
+ *
+ * - `GET /api/events` is a real SSE stream over the real loopback server, and
+ *   it says something the moment a source file changes and again when the index
+ *   moves underneath it. Both watchers are edge-triggered, so a test that
+ *   passed by polling would be testing the wrong thing entirely.
+ * - `/api/source?ondrift=current` serves a drifted file's CURRENT bytes rather
+ *   than nothing, flagged `showing: 'current'` — the parity with
+ *   `codegraph_node`'s behaviour on a file that changed after its last sync.
+ *
+ * Every test that rewrites a fixture file restores it, because the fixture is
+ * indexed once for the whole suite.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import { HEARTBEAT_MS, MAX_EVENT_FILES } from '../src/ui-server/api/events';
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+
+const ORIGINAL = `export function greet(name: string): string {
+  return 'hello ' + name;
+}
+
+export function shout(name: string): string {
+  return greet(name).toUpperCase();
+}
+`;
+
+function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port: server.port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${server.port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+interface SseEvent {
+  event: string;
+  data: any;
+}
+
+/**
+ * One open SSE connection, with the frames it has received so far.
+ *
+ * The parser is the whole SSE grammar this server uses: `retry:`, `event:`,
+ * `data:` and a blank line. Comment frames (`: ping`) are counted separately —
+ * they are the heartbeat, and a client must never see them as events.
+ */
+class Stream {
+  readonly events: SseEvent[] = [];
+  comments = 0;
+  status = 0;
+  contentType: string | undefined;
+  private buffer = '';
+  private req: http.ClientRequest | null = null;
+  private res: http.IncomingMessage | null = null;
+
+  open(requestPath = '/api/events'): Promise<void> {
+    return new Promise((resolve, reject) => {
+      const req = http.request(
+        {
+          host: '127.0.0.1',
+          port: server.port,
+          path: requestPath,
+          method: 'GET',
+          headers: { Host: `127.0.0.1:${server.port}`, Accept: 'text/event-stream' },
+          setHost: false,
+        },
+        (res) => {
+          this.res = res;
+          this.status = res.statusCode ?? 0;
+          this.contentType = res.headers['content-type'];
+          res.setEncoding('utf-8');
+          res.on('data', (chunk: string) => this.ingest(chunk));
+          resolve();
+        }
+      );
+      this.req = req;
+      req.on('error', reject);
+      req.end();
+    });
+  }
+
+  private ingest(chunk: string): void {
+    this.buffer += chunk;
+    let split = this.buffer.indexOf('\n\n');
+    while (split !== -1) {
+      const frame = this.buffer.slice(0, split);
+      this.buffer = this.buffer.slice(split + 2);
+      this.parse(frame);
+      split = this.buffer.indexOf('\n\n');
+    }
+    // A heartbeat is its own frame and ends the same way, but node may deliver
+    // it alone; the loop above already handled it.
+  }
+
+  private parse(frame: string): void {
+    let name = 'message';
+    let data = '';
+    for (const line of frame.split('\n')) {
+      if (line.startsWith(':')) {
+        this.comments += 1;
+        continue;
+      }
+      if (line.startsWith('event: ')) name = line.slice(7);
+      else if (line.startsWith('data: ')) data += line.slice(6);
+    }
+    if (data === '') return;
+    try {
+      this.events.push({ event: name, data: JSON.parse(data) });
+    } catch {
+      this.events.push({ event: name, data });
+    }
+  }
+
+  /** Wait for an event of `type`, or give up. Never polls the server. */
+  async waitFor(type: string, timeoutMs = 12_000): Promise<SseEvent> {
+    const deadline = Date.now() + timeoutMs;
+    for (;;) {
+      const hit = this.events.find((e) => e.event === type);
+      if (hit) return hit;
+      if (Date.now() > deadline) {
+        throw new Error(
+          `No "${type}" event within ${timeoutMs}ms. Saw: ${this.events.map((e) => e.event).join(', ') || '(nothing)'}`
+        );
+      }
+      await new Promise((r) => setTimeout(r, 25));
+    }
+  }
+
+  close(): void {
+    this.res?.destroy();
+    this.req?.destroy();
+  }
+}
+
+function fixture(rel: string): string {
+  return path.join(projectRoot, rel);
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-events-'));
+  projectRoot = path.join(tempDir, 'project');
+  fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
+  fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+  fs.writeFileSync(
+    fixture('src/other.ts'),
+    `import { greet } from './greet';\n\nexport const hi = greet('there');\n`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  cg.close();
+
+  const viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  await server?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('GET /api/events', () => {
+  it('is listed by the API index', async () => {
+    const index = JSON.parse((await request('/api')).body);
+    const paths = index.endpoints.map((e: any) => e.path);
+    expect(paths).toContain('/api/events');
+  });
+
+  it('answers as an event stream and opens with the index revision', async () => {
+    const stream = new Stream();
+    await stream.open();
+    try {
+      const hello = await stream.waitFor('hello');
+      expect(stream.status).toBe(200);
+      expect(stream.contentType).toBe('text/event-stream; charset=utf-8');
+      expect(hello.data.type).toBe('hello');
+      // The revision the client is synchronised against — the same numbers
+      // /api/stats reports.
+      expect(hello.data.index.files).toBe(2);
+      expect(typeof hello.data.index.lastIndexedAt).toBe('number');
+      expect(hello.data.heartbeatMs).toBe(HEARTBEAT_MS);
+      // Whether each observer came up is stated, never implied.
+      expect(typeof hello.data.watching.source).toBe('boolean');
+      expect(typeof hello.data.watching.index).toBe('boolean');
+      expect(hello.data.degraded).toBeNull();
+    } finally {
+      stream.close();
+    }
+  });
+
+  it('never sends a heartbeat as an event', async () => {
+    const stream = new Stream();
+    await stream.open();
+    try {
+      await stream.waitFor('hello');
+      // The heartbeat is a comment frame; if it ever became an event, every
+      // client would refetch every 25 seconds forever.
+      expect(stream.events.every((e) => e.event !== 'ping' && e.event !== 'message')).toBe(true);
+    } finally {
+      stream.close();
+    }
+  });
+
+  it('answers HEAD with the stream headers and no body', async () => {
+    const res = await new Promise<{ status: number; type?: string; body: string }>((resolve, reject) => {
+      const req = http.request(
+        {
+          host: '127.0.0.1',
+          port: server.port,
+          path: '/api/events',
+          method: 'HEAD',
+          headers: { Host: `127.0.0.1:${server.port}` },
+          setHost: false,
+        },
+        (r) => {
+          const chunks: Buffer[] = [];
+          r.on('data', (c: Buffer) => chunks.push(c));
+          r.on('end', () =>
+            resolve({
+              status: r.statusCode ?? 0,
+              type: r.headers['content-type'],
+              body: Buffer.concat(chunks).toString('utf-8'),
+            })
+          );
+        }
+      );
+      req.on('error', reject);
+      req.end();
+    });
+    expect(res.status).toBe(200);
+    expect(res.type).toBe('text/event-stream; charset=utf-8');
+    expect(res.body).toBe('');
+  });
+
+  it('announces a source file that changed on disk, before any sync', async () => {
+    const stream = new Stream();
+    await stream.open();
+    try {
+      await stream.waitFor('hello');
+      // Give the watcher a moment to install its watch before the write; an
+      // event that predates the watch is not a bug, just an untestable one.
+      await new Promise((r) => setTimeout(r, 300));
+      fs.writeFileSync(fixture('src/greet.ts'), `${ORIGINAL}\nexport const EXTRA = 1;\n`);
+
+      const changed = await stream.waitFor('changed');
+      expect(changed.data.type).toBe('changed');
+      expect(changed.data.scan === true || changed.data.files.includes('src/greet.ts')).toBe(true);
+      // A count always equals a list, or says it was cut.
+      expect(changed.data.total).toBeGreaterThanOrEqual(changed.data.files.length);
+      expect(changed.data.files.length).toBeLessThanOrEqual(MAX_EVENT_FILES);
+
+      // ...and the index has NOT moved: this server watches, it never syncs.
+      const source = JSON.parse((await request('/api/source?file=src/greet.ts')).body);
+      expect(source.drift).toBe(true);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+      stream.close();
+    }
+  });
+
+  it('announces the index moving, and names what the sync picked up', async () => {
+    const stream = new Stream();
+    await stream.open();
+    try {
+      await stream.waitFor('hello');
+      await new Promise((r) => setTimeout(r, 300));
+
+      // Another process re-indexes — exactly what a daemon's watcher or a
+      // `codegraph sync` does while the viewer is open.
+      fs.writeFileSync(fixture('src/greet.ts'), `${ORIGINAL}\nexport const SYNCED = 2;\n`);
+      const writer = CodeGraph.openSync(projectRoot);
+      await writer.sync();
+      writer.close();
+
+      const moved = await stream.waitFor('index');
+      expect(moved.data.type).toBe('index');
+      expect(moved.data.index.files).toBe(2);
+      expect(moved.data.files).toContain('src/greet.ts');
+      expect(moved.data.total).toBeGreaterThanOrEqual(moved.data.files.length);
+
+      // And the graph really did move: the new symbol is there.
+      const search = JSON.parse((await request('/api/search?q=SYNCED')).body);
+      expect(search.results.items.some((r: any) => r.name === 'SYNCED')).toBe(true);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+      const writer = CodeGraph.openSync(projectRoot);
+      await writer.sync();
+      writer.close();
+      stream.close();
+    }
+  }, 60_000);
+
+  it('stops serving a symbol a sync in another process deleted', async () => {
+    // A node's id contains its start line, so pushing two lines in above
+    // `shout` gives it a different id. The old one must go — the query layer
+    // keeps an LRU of nodes by id that only its OWN writes invalidate, so
+    // without `GraphSession` dropping it this endpoint would keep answering
+    // 200 with a row that is no longer in the database, while `/api/search`
+    // beside it correctly says the symbol moved.
+    const before = JSON.parse((await request('/api/search?q=shout')).body);
+    const oldId = before.results.items[0].id as string;
+    expect((await request(`/api/node/${encodeURIComponent(oldId)}`)).status).toBe(200);
+
+    fs.writeFileSync(fixture('src/greet.ts'), `// one
+// two
+${ORIGINAL}`);
+    const writer = CodeGraph.openSync(projectRoot);
+    await writer.sync();
+    writer.close();
+
+    try {
+      expect((await request(`/api/node/${encodeURIComponent(oldId)}`)).status).toBe(404);
+      const after = JSON.parse((await request('/api/search?q=shout')).body);
+      const newId = after.results.items[0].id as string;
+      expect(newId).not.toBe(oldId);
+      const moved = JSON.parse((await request(`/api/node/${encodeURIComponent(newId)}`)).body);
+      expect(moved.node.line).toBe(7);
+      // ...and its rails came back with it, rather than an empty shell — the
+      // exact symptom of a cached row whose edges were re-keyed around it.
+      expect(moved.counts.callees).toBeGreaterThan(0);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+      const restore = CodeGraph.openSync(projectRoot);
+      await restore.sync();
+      restore.close();
+    }
+  }, 60_000);
+
+  it('closes every stream when the API is closed', async () => {
+    const own = createGraphApi({ projectRoot });
+    const handle = await startUiServer({
+      projectRoot,
+      viewerDir: path.join(tempDir, 'viewer'),
+      port: 0,
+      api: own.handler,
+    });
+    const ended = new Promise<void>((resolve, reject) => {
+      const req = http.request(
+        {
+          host: '127.0.0.1',
+          port: handle.port,
+          path: '/api/events',
+          method: 'GET',
+          headers: { Host: `127.0.0.1:${handle.port}` },
+          setHost: false,
+        },
+        (res) => {
+          res.resume();
+          res.on('end', () => resolve());
+        }
+      );
+      req.on('error', reject);
+      req.end();
+    });
+    // Let the subscription land before pulling the rug.
+    await new Promise((r) => setTimeout(r, 200));
+    own.close();
+    await ended;
+    await handle.close();
+  });
+});
+
+describe('GET /api/source?ondrift=', () => {
+  it('omits the slice by default when the file drifted', async () => {
+    fs.writeFileSync(fixture('src/greet.ts'), `// a new first line\n${ORIGINAL}`);
+    try {
+      const body = JSON.parse((await request('/api/source?file=src/greet.ts&from=1&to=3')).body);
+      expect(body.drift).toBe(true);
+      expect(body.showing).toBe('none');
+      expect(body.lines).toBeUndefined();
+      expect(body.highlight).toBeUndefined();
+      expect(body.reason).toMatch(/changed on disk/);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+    }
+  });
+
+  it('serves the CURRENT bytes when asked, flagged as current', async () => {
+    const rewritten = `// a new first line\n${ORIGINAL}`;
+    fs.writeFileSync(fixture('src/greet.ts'), rewritten);
+    try {
+      const body = JSON.parse(
+        (await request('/api/source?file=src/greet.ts&from=1&ondrift=current')).body
+      );
+      expect(body.drift).toBe(true);
+      expect(body.showing).toBe('current');
+      // The bytes on disk right now, not the ones that were indexed.
+      expect(body.lines[0]).toBe('// a new first line');
+      expect(body.totalLines).toBe(rewritten.replace(/\n$/, '').split('\n').length);
+      // Highlighting rides with them, or the code block paints plain text and
+      // then reflows.
+      expect(body.highlight).toBeTruthy();
+      expect(body.highlight.lines.length).toBe(body.lines.length);
+      expect(body.reason).toMatch(/current lines/);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+    }
+  });
+
+  it('says showing: indexed when there is no drift, with or without the flag', async () => {
+    const plain = JSON.parse((await request('/api/source?file=src/greet.ts&from=1&to=2')).body);
+    expect(plain.drift).toBe(false);
+    expect(plain.showing).toBe('indexed');
+    const asked = JSON.parse(
+      (await request('/api/source?file=src/greet.ts&from=1&to=2&ondrift=current')).body
+    );
+    expect(asked.showing).toBe('indexed');
+    expect(asked.lines).toEqual(plain.lines);
+  });
+
+  it('rejects an ondrift value it does not implement', async () => {
+    const res = await request('/api/source?file=src/greet.ts&ondrift=guess');
+    expect(res.status).toBe(400);
+    expect(res.type).toBe('application/json; charset=utf-8');
+    expect(JSON.parse(res.body).code).toBe('bad-request');
+  });
+
+  it('answers an empty slice rather than a 400 when a drifted file shrank', async () => {
+    fs.writeFileSync(fixture('src/greet.ts'), 'export const only = 1;\n');
+    try {
+      const res = await request('/api/source?file=src/greet.ts&from=5&to=9&ondrift=current');
+      expect(res.status).toBe(200);
+      const body = JSON.parse(res.body);
+      expect(body.showing).toBe('current');
+      expect(body.lines).toEqual([]);
+      expect(body.totalLines).toBe(1);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+    }
+  });
+
+  it('still refuses a path outside the project, ondrift or not', async () => {
+    const res = await request('/api/source?file=/etc/passwd&ondrift=current');
+    expect(res.status).toBe(403);
+    expect(JSON.parse(res.body).code).toBe('refused');
+  });
+});

+ 531 - 0
__tests__/ui-export-svg.test.ts

@@ -0,0 +1,531 @@
+/**
+ * The SVG exporter (CG-55) — `ui/src/lib/export-svg.ts`.
+ *
+ * The export exists to leave the app, so the properties worth pinning are the
+ * ones a reader on the other side depends on:
+ *
+ * - it is **well-formed XML**, or GitHub's sanitiser drops it and the reader
+ *   sees a broken-image icon with no explanation;
+ * - it carries the **light** tokens whatever the viewer was set to, because a
+ *   dark image on a white comment background reads as a mistake;
+ * - it says the **same thing the screen does** — same cards, same hops, same
+ *   dashed hops, same hidden thin links — because the whole point of exporting
+ *   from the layout object rather than the DOM is that the two cannot diverge;
+ * - it fits the drawing, with nothing running off the edge of the canvas.
+ *
+ * Everything here is pure. The raster step needs a browser and is verified
+ * over CDP against a live `codegraph ui`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  EXPORT_COLORS,
+  EXPORT_PADDING,
+  MARK_TEXT,
+  capRows,
+  esc,
+  exportFilename,
+  flowSvg,
+  mapSvg,
+  truncate,
+  wrapText,
+} from '../ui/src/lib/export-svg';
+import { buildFlowLayout } from '../ui/src/lib/flow-model';
+import { buildMapLayout } from '../ui/src/lib/map-model';
+import type {
+  WireFlow,
+  WireFlowBoundary,
+  WireFlowEdge,
+  WireFlowHop,
+  WireMapLink,
+  WireMapModule,
+  WireNodeRef,
+} from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- builders -- */
+
+function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
+  return {
+    kind: 'calls',
+    label: 'calls',
+    upward: false,
+    uncertain: false,
+    synthesized: false,
+    line: 42,
+    ...over,
+  };
+}
+
+function ref(name: string): WireNodeRef {
+  return {
+    id: `method:${name}`,
+    kind: 'method',
+    name,
+    qualifiedName: name,
+    file: `src/deep/${name}.ts`,
+    line: 10,
+    endLine: 40,
+    language: 'typescript',
+    test: false,
+  };
+}
+
+function hop(
+  name: string,
+  opts: { lines?: string[]; edge?: WireFlowEdge | null; callLine?: number } = {}
+): WireFlowHop {
+  const lines = opts.lines ?? ['  const a = 1;', '  return other(a);'];
+  return {
+    node: ref(name),
+    edge: opts.edge === undefined ? edge() : opts.edge,
+    callRef:
+      opts.callLine === undefined
+        ? null
+        : { line: opts.callLine, col: 9, name: 'other', targetId: 'method:other', backwards: false },
+    source: {
+      file: `src/deep/${name}.ts`,
+      language: 'typescript',
+      from: 7,
+      to: 6 + lines.length,
+      lines,
+      drift: false,
+    },
+  };
+}
+
+function flow(id: string, names: string[], over: Partial<WireFlow> = {}): WireFlow {
+  return {
+    id,
+    label: `${names[0]} → ${names[names.length - 1]}`,
+    hops: names.map((name, i) =>
+      hop(name, { edge: i === 0 ? null : edge(), callLine: i === 0 ? 8 : undefined })
+    ),
+    boundary: null,
+    partial: false,
+    ...over,
+  };
+}
+
+function boundary(over: Partial<WireFlowBoundary> = {}): WireFlowBoundary {
+  return {
+    node: ref('routeAny'),
+    sites: [
+      {
+        form: 'computed-call',
+        label: 'computed member call',
+        snippet: 'return table[name](payload);',
+        line: 61,
+        key: 'save',
+        keyIsType: false,
+        moreSites: 0,
+        candidates: [{ node: ref('onSave'), display: 'onSave', named: true }],
+        candidateNote: null,
+      },
+    ],
+    uncertain: { total: 0, shown: 0, truncated: false, items: [] },
+    further: { total: 0, shown: 0, truncated: false, items: [] },
+    missed: [],
+    ...over,
+  };
+}
+
+function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
+  return {
+    id,
+    label: id.slice(id.lastIndexOf('/') + 1) || id,
+    files: over.files ?? 3,
+    symbols: over.symbols ?? 30,
+    languages: over.languages ?? [{ language: 'typescript', files: 3 }],
+    test: over.test ?? false,
+    facade: over.facade ?? false,
+    fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
+  };
+}
+
+function link(source: string, target: string, count: number, declared = count): WireMapLink {
+  return { source, target, count, declared, byKind: [{ kind: 'calls', count }], topPairs: [] };
+}
+
+/* ------------------------------------------------------------ utilities -- */
+
+/**
+ * Parse the SVG the way a consumer does.
+ *
+ * `DOMParser` is not in Node, so this is a hand-rolled well-formedness check:
+ * every tag balanced, every attribute quoted, no stray `<` or `&` in text. That
+ * is exactly the class of bug an un-escaped symbol name (`Map<K,V>`, `a && b`)
+ * would introduce, and it is the one that makes GitHub refuse the file.
+ */
+function assertWellFormed(svg: string): void {
+  const stack: string[] = [];
+  const tag = /<(\/?)([a-zA-Z:]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
+  let at = 0;
+  let match: RegExpExecArray | null;
+  while ((match = tag.exec(svg)) !== null) {
+    const between = svg.slice(at, match.index);
+    expect(between, `unescaped < or & in text: ${JSON.stringify(between)}`).not.toMatch(
+      /[<]|&(?!(amp|lt|gt|quot|apos|#\d+);)/
+    );
+    at = match.index + match[0].length;
+    const [, closing, name, attrs, selfClosing] = match;
+    // Every attribute is name="value" with a balanced pair of quotes.
+    const quotes = (attrs as string).split('"').length - 1;
+    expect(quotes % 2, `unbalanced quotes in <${name} ${attrs}>`).toBe(0);
+    if (closing === '/') {
+      expect(stack.pop(), 'closing tag with no opener').toBe(name);
+    } else if (selfClosing !== '/') {
+      stack.push(name as string);
+    }
+  }
+  expect(stack, 'unclosed tags').toEqual([]);
+}
+
+function viewBox(svg: string): { width: number; height: number } {
+  const box = /viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"/.exec(svg);
+  expect(box, 'no viewBox').toBeTruthy();
+  return { width: Number(box![1]), height: Number(box![2]) };
+}
+
+function rootSize(svg: string): { width: number; height: number } {
+  const w = /<svg[^>]*\bwidth="(\d+)"/.exec(svg);
+  const h = /<svg[^>]*\bheight="(\d+)"/.exec(svg);
+  return { width: Number(w![1]), height: Number(h![1]) };
+}
+
+/** Every x/y coordinate that appears on a drawn element, for a bounds check. */
+function coords(svg: string): Array<{ x: number; y: number }> {
+  const out: Array<{ x: number; y: number }> = [];
+  const re = /x="(-?\d+(?:\.\d+)?)"\s+y="(-?\d+(?:\.\d+)?)"/g;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(svg)) !== null) out.push({ x: Number(m[1]), y: Number(m[2]) });
+  return out;
+}
+
+/* ------------------------------------------------------------ primitives -- */
+
+describe('esc', () => {
+  it('escapes everything XML would choke on', () => {
+    expect(esc('Map<K, V> & "co"')).toBe('Map&lt;K, V&gt; &amp; &quot;co&quot;');
+  });
+});
+
+describe('truncate', () => {
+  it('leaves a string that fits alone, and ellipses one that does not', () => {
+    expect(truncate('short', 400, 12)).toBe('short');
+    // 12px mono advances at 7.2px, so 36px holds five characters.
+    expect(truncate('abcdefgh', 36, 12)).toBe('abcd…');
+  });
+
+  it('does not emit a lone ellipsis when there is no room at all', () => {
+    expect(truncate('abcdefgh', 7, 12)).toBe('');
+  });
+});
+
+describe('wrapText', () => {
+  it('breaks on words, never mid-word', () => {
+    expect(wrapText('the quick brown fox jumps over', 12)).toEqual([
+      'the quick',
+      'brown fox',
+      'jumps over',
+    ]);
+  });
+
+  it('keeps an over-long word on its own line rather than losing it', () => {
+    expect(wrapText('aa supercalifragilistic bb', 8)).toEqual(['aa', 'supercalifragilistic', 'bb']);
+  });
+});
+
+describe('exportFilename', () => {
+  it('slugs a flow label into something a filesystem accepts', () => {
+    expect(exportFilename('flow', 'execute → getFile')).toBe('codegraph-flow-execute-getfile');
+    expect(exportFilename('map', 'src/')).toBe('codegraph-map-src');
+    expect(exportFilename('map', '')).toBe('codegraph-map');
+  });
+});
+
+/* ------------------------------------------------------------ flow strip -- */
+
+describe('flowSvg', () => {
+  const layout = buildFlowLayout([flow('f1', ['execute', 'openFile', 'rowToFileRecord'])], 'f1');
+
+  it('is well-formed XML with a viewBox and the mark', () => {
+    const svg = flowSvg(layout);
+    assertWellFormed(svg);
+    expect(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg"')).toBe(true);
+    expect(svg.trimEnd().endsWith('</svg>')).toBe(true);
+    expect(svg).toContain(`>${MARK_TEXT}</text>`);
+  });
+
+  it('paints the light paper whatever the viewer was set to', () => {
+    const svg = flowSvg(layout);
+    expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
+    expect(svg).toContain(EXPORT_COLORS.ink);
+    // No token from the dark set appears anywhere in the file: dark paper,
+    // dark ink, dark accent. An export follows the reader's page, not ours.
+    for (const dark of ['#1c1a14', '#f3f1ea', '#d48b96', '#34322a']) {
+      expect(svg, dark).not.toContain(dark);
+    }
+  });
+
+  it('names every hop on the strip, once each', () => {
+    const svg = flowSvg(layout);
+    for (const name of ['execute', 'openFile', 'rowToFileRecord']) {
+      expect(svg.split(`>${name}<`).length - 1, name).toBe(1);
+    }
+  });
+
+  it('keeps fonts as stacks and embeds nothing', () => {
+    const svg = flowSvg(layout);
+    expect(svg).toContain("'IBM Plex Mono'");
+    expect(svg).not.toContain('@font-face');
+    expect(svg).not.toContain('base64');
+  });
+
+  it('scales only the root size — the geometry is identical', () => {
+    const one = flowSvg(layout, { scale: 1 });
+    const two = flowSvg(layout, { scale: 2 });
+    expect(viewBox(two)).toEqual(viewBox(one));
+    expect(rootSize(two).width).toBe(rootSize(one).width * 2);
+    expect(rootSize(two).height).toBe(rootSize(one).height * 2);
+    // Same drawing, two envelopes: everything between the root tags matches.
+    expect(two.slice(two.indexOf('\n'))).toBe(one.slice(one.indexOf('\n')));
+  });
+
+  it('fits the drawing inside the canvas with the padding on every side', () => {
+    const svg = flowSvg(layout);
+    const box = viewBox(svg);
+    const cards = layout.cards;
+    const spanX = Math.max(...cards.map((c) => c.x + c.width)) - Math.min(...cards.map((c) => c.x));
+    expect(box.width).toBeGreaterThanOrEqual(spanX + EXPORT_PADDING * 2);
+    for (const { x, y } of coords(svg)) {
+      expect(x).toBeGreaterThanOrEqual(-1);
+      expect(y).toBeGreaterThanOrEqual(-1);
+    }
+  });
+
+  it('carries the edge label and the line the call was recorded at', () => {
+    const svg = flowSvg(layout);
+    expect(svg).toContain('>calls</text>');
+    expect(svg).toContain('>line 42</text>');
+  });
+
+  it('dashes a synthesized hop exactly as the strip does', () => {
+    const synthesized = flow('f2', ['a', 'b']);
+    synthesized.hops[1]!.edge = edge({
+      synthesized: true,
+      label: 'via callback · registered at src/wire.ts:88',
+    });
+    const svg = flowSvg(buildFlowLayout([synthesized], 'f2'));
+    expect(svg).toContain('stroke-dasharray="5 3"');
+    // The wiring site is the evidence for a hop nobody can see in the source.
+    expect(svg).toContain('wire.ts:88');
+  });
+
+  it('tints the call line and underlines the identifier the graph resolved', () => {
+    const one = flow('f3', ['execute', 'other']);
+    one.hops[0]!.callRef = {
+      line: 8,
+      col: 9,
+      name: 'other',
+      targetId: 'method:other',
+      backwards: false,
+    };
+    const svg = flowSvg(buildFlowLayout([one], 'f3'));
+    expect(svg).toContain(`fill="${EXPORT_COLORS.accentSoft}"`);
+    expect(svg).toContain(`<tspan fill="${EXPORT_COLORS.accent}">other</tspan>`);
+    expect(svg).toContain(`stroke="${EXPORT_COLORS.accentLine}"`);
+  });
+
+  it('preserves the indentation of every source line', () => {
+    const svg = flowSvg(layout);
+    expect(svg).toContain('xml:space="preserve"');
+    expect(svg).toContain('<tspan>  </tspan>');
+  });
+
+  it('escapes source that would otherwise break the document', () => {
+    const nasty = flow('f4', ['render']);
+    nasty.hops[0]!.source!.lines = ['const x = a < b && c > d;', 'type T = Map<K, "v">;'];
+    const svg = flowSvg(buildFlowLayout([nasty], 'f4'));
+    assertWellFormed(svg);
+    expect(svg).toContain('&lt;');
+    expect(svg).toContain('&amp;&amp;');
+  });
+
+  it('draws the end cap dashed, with the site, the key and the candidate', () => {
+    const capped = flow('f5', ['dispatch'], { boundary: null });
+    capped.boundary = boundary({ node: capped.hops[0]!.node });
+    const svg = flowSvg(buildFlowLayout([capped], 'f5'));
+    expect(svg).toContain('Where the graph stops.');
+    expect(svg).toContain('computed member call at line 61');
+    expect(svg).toContain('>key save</text>');
+    expect(svg).toContain('1 candidate target');
+    // The dotted link into a cap, and the cap's own dashed border.
+    expect(svg).toContain('stroke-dasharray="2 4"');
+    expect(svg).toContain('>end of</text>');
+    // …and no arrowhead on it: the absence of a continuation is the finding.
+    expect(svg.match(/<polygon/g)).toBeNull();
+  });
+
+  it('gives the cap room for the lines it really wraps to', () => {
+    const long = boundary({
+      sites: [
+        {
+          form: 'computed-call',
+          label: 'reflective invoke through a registry of handlers',
+          snippet: 'x',
+          line: 61,
+          key: null,
+          keyIsType: false,
+          moreSites: 3,
+          candidates: [],
+          candidateNote: 'the key is too generic to shortlist against',
+        },
+      ],
+    });
+    const rows = capRows({
+      id: 'cap:x',
+      anchorId: 'x',
+      boundary: long,
+      x: 0,
+      y: 0,
+      width: 240,
+      height: 10,
+      flows: ['f'],
+    });
+    // Every row is inside the cap's own text column…
+    for (const row of rows.rows) expect(row.text.length).toBeLessThanOrEqual(32);
+    // …and the height accounts for all of them.
+    expect(rows.height).toBeGreaterThan(rows.rows.length * 15);
+  });
+
+  it('dims the paths that are not the picked one when several are drawn', () => {
+    const both = [flow('a', ['start', 'left', 'end']), flow('b', ['start', 'right', 'end'])];
+    const svg = flowSvg(buildFlowLayout(both, 'a'), { activeFlowId: 'a', showAll: true });
+    expect(svg).toContain('opacity="0.4"');
+    // The picked path keeps the accent border; the other does not.
+    expect(svg).toContain(`stroke="${EXPORT_COLORS.accent}"`);
+    expect(svg).toContain('>right</text>');
+  });
+
+  it('writes the caption next to the mark', () => {
+    const svg = flowSvg(layout, { caption: 'execute → rowToFileRecord · 3 hops' });
+    expect(svg).toContain('execute → rowToFileRecord · 3 hops');
+    assertWellFormed(svg);
+  });
+});
+
+/* -------------------------------------------------------------------- map -- */
+
+describe('mapSvg', () => {
+  const payload = {
+    modules: [
+      mod('src/bin'),
+      mod('src/mcp'),
+      mod('src/db', { symbols: 1218, files: 54 }),
+      mod('__tests__', { test: true }),
+    ],
+    links: [
+      link('src/bin', 'src/mcp', 30),
+      link('src/mcp', 'src/db', 22),
+      link('src/bin', 'src/db', 2),
+      link('__tests__', 'src/db', 40),
+    ],
+  };
+  const layout = buildMapLayout(payload, { includeTests: false });
+
+  it('is well-formed, light, and marked', () => {
+    const svg = mapSvg(layout);
+    assertWellFormed(svg);
+    expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
+    expect(svg).toContain(`>${MARK_TEXT}</text>`);
+  });
+
+  it('draws every module box with its name and its counts', () => {
+    const svg = mapSvg(layout);
+    expect(svg).toContain('>src/bin</text>');
+    expect(svg).toContain('>src/db</text>');
+    expect(svg).toContain('>1218 symbols · 54 files</text>');
+    // Tests were filtered out of the layout, so they are not in the image.
+    expect(svg).not.toContain('>__tests__</text>');
+  });
+
+  it('names the top and bottom bands', () => {
+    const svg = mapSvg(layout);
+    expect(svg).toContain('>entry points</text>');
+    expect(svg).toContain('>foundations — depend on nothing below</text>');
+  });
+
+  it('hides the same thin links the canvas hides', () => {
+    const svg = mapSvg(layout);
+    // src/bin → src/db carries 2, under MIN_WEIGHT: one path per visible link
+    // plus one per layer rule is not a count worth asserting, so check the
+    // stroke widths instead — a hidden link contributes none.
+    const drawn = svg.match(/<path /g)?.length ?? 0;
+    expect(drawn).toBe(layout.edges.filter((e) => !e.thin && !e.back).length);
+  });
+
+  it('brings a selected module’s thin links out, as the canvas does', () => {
+    const svg = mapSvg(layout, { selected: 'src/bin' });
+    const drawn = svg.match(/<path /g)?.length ?? 0;
+    expect(drawn).toBe(
+      layout.edges.filter((e) => e.source === 'src/bin' || e.target === 'src/bin').length
+    );
+  });
+
+  it('dims a module the selection does not touch, and only that one', () => {
+    // src/bin reaches both other modules, so a fixture needs a fourth module
+    // standing apart before dimming has anything to say.
+    const apart = buildMapLayout(
+      { modules: [...payload.modules, mod('site')], links: payload.links },
+      { includeTests: false }
+    );
+    const svg = mapSvg(apart, { selected: 'src/bin' });
+    // Exactly one box goes grey: its rule and its two lines of text.
+    expect(svg.split(`stroke="${EXPORT_COLORS.ink4}"`).length - 1).toBe(1);
+    expect(svg.split(`fill="${EXPORT_COLORS.ink4}"`).length - 1).toBe(2);
+  });
+
+  it('scales the root only', () => {
+    const one = mapSvg(layout, { scale: 1 });
+    const two = mapSvg(layout, { scale: 2 });
+    expect(viewBox(two)).toEqual(viewBox(one));
+    expect(rootSize(two).width).toBe(rootSize(one).width * 2);
+  });
+
+  it('keeps every drawn coordinate inside the canvas', () => {
+    const svg = mapSvg(layout);
+    const box = viewBox(svg);
+    for (const { x, y } of coords(svg)) {
+      expect(x).toBeGreaterThanOrEqual(-1);
+      expect(y).toBeGreaterThanOrEqual(-1);
+      expect(x).toBeLessThanOrEqual(box.width + 1);
+      expect(y).toBeLessThanOrEqual(box.height + 1);
+    }
+    // Layer rules are the one thing that spans the whole picture, and the one
+    // that used to run off the right-hand edge: they follow the boxes, not the
+    // canvas' own padded width.
+    const rules = [...svg.matchAll(/x1="(-?[\d.]+)"[^>]*x2="(-?[\d.]+)"/g)];
+    expect(rules.length).toBeGreaterThan(0);
+    for (const [, x1, x2] of rules) {
+      expect(Number(x1)).toBeGreaterThanOrEqual(0);
+      expect(Number(x2)).toBeLessThanOrEqual(box.width);
+    }
+  });
+
+  it('marks a test module dashed when it is included', () => {
+    const withTests = buildMapLayout(payload, { includeTests: true });
+    const svg = mapSvg(withTests);
+    expect(svg).toContain('>__tests__</text>');
+    expect(svg).toContain('stroke-dasharray="4 3"');
+  });
+
+  it('survives a module id that needs escaping', () => {
+    const odd = buildMapLayout(
+      { modules: [mod('src/<odd> & co'), mod('src/db')], links: [link('src/<odd> & co', 'src/db', 9)] },
+      { includeTests: false }
+    );
+    const svg = mapSvg(odd);
+    assertWellFormed(svg);
+    expect(svg).toContain('&lt;odd&gt; &amp; co');
+  });
+});

+ 304 - 0
__tests__/ui-file-model.test.ts

@@ -0,0 +1,304 @@
+/**
+ * The File view's models, without a browser (CG-46).
+ *
+ * The decision under test throughout is the rails' source of truth: they are
+ * built from `dependencies` / `dependents` — the engine's own
+ * `getFileDependencies` / `getFileDependents` — and merely *decorated* with
+ * the `imports` rows. Getting that backwards is not a cosmetic bug: it silently
+ * understates what a change to the file would reach, which is the only reason
+ * the screen exists.
+ *
+ * The geometry-free sibling of `ui-symbol-model.test.ts` and
+ * `ui-search-model.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildFileOutline,
+  buildFileRail,
+  fileMetaLine,
+  formatBytes,
+  looksLikeTest,
+  OUTLINE_ROW_HEIGHT,
+  OUTLINE_VIRTUAL_THRESHOLD,
+} from '../ui/src/lib/file-model';
+import type { WireFilePayload, WireImportRow, WireOutlineEntry } from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function importRow(over: Partial<WireImportRow> = {}): WireImportRow {
+  const symbols = over.symbols ?? [
+    { id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 },
+  ];
+  return {
+    file: over.file ?? 'src/db/queries.ts',
+    test: over.test ?? false,
+    symbols,
+    symbolCount: over.symbolCount ?? symbols.length,
+  };
+}
+
+function entry(over: Partial<WireOutlineEntry> = {}): WireOutlineEntry {
+  return {
+    id: over.id ?? 'method:x',
+    kind: 'method',
+    name: 'traverseBFS',
+    qualifiedName: 'GraphTraverser.traverseBFS',
+    file: 'src/graph/traversal.ts',
+    line: 48,
+    endLine: 150,
+    language: 'typescript',
+    test: false,
+    parentId: 'class:GraphTraverser',
+    depth: 1,
+    fanIn: 3,
+    fanOut: 7,
+    ...over,
+  } as WireOutlineEntry;
+}
+
+function payload(over: Partial<WireFilePayload> = {}): WireFilePayload {
+  return {
+    file: {
+      path: 'src/graph/traversal.ts',
+      language: 'typescript',
+      size: 24216,
+      modifiedAt: 1,
+      indexedAt: 2,
+      contentHash: 'abc',
+      nodeCount: 26,
+      generated: false,
+      test: false,
+      errors: [],
+      id: 'file:src/graph/traversal.ts',
+    },
+    topLevel: { calls: 0 },
+    drift: false,
+    outline: { total: 0, shown: 0, truncated: false, items: [] },
+    imports: { total: 0, shown: 0, truncated: false, items: [] },
+    importedBy: { total: 0, shown: 0, truncated: false, items: [] },
+    unresolvedImports: [],
+    dependencies: [],
+    dependents: [],
+    ...over,
+  } as WireFilePayload;
+}
+
+/* ----------------------------------------------------------------- rail -- */
+
+describe('the import rails', () => {
+  it('counts every dependency, not just the ones an import statement named', () => {
+    // The real shape on this repo: traversal.ts imports two files and depends
+    // on four — it reaches the LRU cache through a call with no import.
+    const rail = buildFileRail(
+      [
+        'src/db/queries.ts',
+        'src/resolution/lru-cache.ts',
+        'src/types.ts',
+        'scripts/agent-eval/probe.mjs',
+      ],
+      [importRow({ file: 'src/db/queries.ts' }), importRow({ file: 'src/types.ts' })]
+    );
+
+    expect(rail.total).toBe(4);
+    expect(rail.rows).toHaveLength(4);
+    expect(rail.rows.filter((r) => r.imported).map((r) => r.path)).toEqual([
+      'src/db/queries.ts',
+      'src/types.ts',
+    ]);
+    expect(rail.rows.find((r) => r.path === 'src/resolution/lru-cache.ts')?.imported).toBe(false);
+  });
+
+  it('names the symbols an import row carries, on the row for that file', () => {
+    const rail = buildFileRail(
+      ['src/db/queries.ts'],
+      [
+        importRow({
+          symbols: [
+            { id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 },
+            { id: 'iface:R', name: 'Row', kind: 'interface', line: 12 },
+          ],
+        }),
+      ]
+    );
+    expect(rail.rows[0]?.symbols.map((s) => s.name)).toEqual(['QueryBuilder', 'Row']);
+    expect(rail.rows[0]?.symbolCount).toBe(2);
+  });
+
+  it('does not count a file node as a named symbol', () => {
+    // An `importedBy` edge's far end is the importing file's own file node, so
+    // its "symbols" repeat the path already in the row. A `1` there would be a
+    // count of nothing.
+    const rail = buildFileRail(
+      ['src/index.ts'],
+      [
+        importRow({
+          file: 'src/index.ts',
+          symbols: [{ id: 'file:src/index.ts', name: 'index.ts', kind: 'file', line: 1 }],
+        }),
+      ]
+    );
+    expect(rail.rows[0]?.symbolCount).toBe(0);
+    expect(rail.rows[0]?.imported).toBe(true);
+  });
+
+  it('sorts production files before tests, each alphabetically', () => {
+    const rail = buildFileRail(
+      ['src/z.ts', '__tests__/graph.test.ts', 'src/a.ts', '__tests__/a.test.ts'],
+      []
+    );
+    expect(rail.rows.map((r) => r.path)).toEqual([
+      'src/a.ts',
+      'src/z.ts',
+      '__tests__/a.test.ts',
+      '__tests__/graph.test.ts',
+    ]);
+    expect(rail.testCount).toBe(2);
+  });
+
+  it('trusts the server about what is a test, and falls back to the path', () => {
+    const rail = buildFileRail(
+      ['src/looks-normal.ts', 'src/other.ts'],
+      // The server can see more than a path; a row it marks wins.
+      [importRow({ file: 'src/looks-normal.ts', test: true })]
+    );
+    expect(rail.rows[0]?.path).toBe('src/other.ts');
+    expect(rail.rows[1]?.test).toBe(true);
+  });
+
+  it('de-duplicates a file the engine listed twice', () => {
+    const rail = buildFileRail(['src/a.ts', 'src/a.ts'], []);
+    expect(rail.rows).toHaveLength(1);
+    expect(rail.total).toBe(1);
+  });
+
+  it('folds unresolved imports by name, keeping every line', () => {
+    const rail = buildFileRail(
+      [],
+      [],
+      [
+        { name: 'node:fs', line: 12 },
+        { name: 'react', line: 3 },
+        { name: 'node:fs', line: 4 },
+      ]
+    );
+    expect(rail.outside).toEqual([
+      { name: 'node:fs', lines: [4, 12] },
+      { name: 'react', lines: [3] },
+    ]);
+    // Outside-index rows never inflate the dependency count.
+    expect(rail.total).toBe(0);
+  });
+});
+
+describe('looksLikeTest', () => {
+  it('recognises the shapes an unnamed dependency can arrive in', () => {
+    expect(looksLikeTest('__tests__/graph.test.ts')).toBe(true);
+    expect(looksLikeTest('src/service.spec.ts')).toBe(true);
+    expect(looksLikeTest('test/helper.go')).toBe(true);
+    expect(looksLikeTest('__tests__/fixtures/app/main.ts')).toBe(true);
+  });
+
+  it('errs towards production — misfiling a real file is the worse mistake', () => {
+    expect(looksLikeTest('src/latest.ts')).toBe(false);
+    expect(looksLikeTest('src/protest/index.ts')).toBe(false);
+    expect(looksLikeTest('src/testing-library.ts')).toBe(false);
+  });
+});
+
+/* -------------------------------------------------------------- outline -- */
+
+describe('the file outline', () => {
+  it('keeps the server order and indents by depth', () => {
+    const rows = buildFileOutline(
+      payload({
+        outline: {
+          total: 3,
+          shown: 3,
+          truncated: false,
+          items: [
+            entry({ id: 'class:C', kind: 'class', name: 'GraphTraverser', depth: 0, line: 34 }),
+            entry({ id: 'method:m', depth: 1, line: 48 }),
+            entry({ id: 'prop:p', kind: 'property', name: 'queries', depth: 1, line: 35 }),
+          ],
+        },
+      })
+    );
+    expect(rows.map((r) => r.entry.id)).toEqual(['class:C', 'method:m', 'prop:p']);
+    expect(rows.map((r) => r.indent)).toEqual([0, 1, 1]);
+  });
+
+  it('dims data rather than behaviour', () => {
+    const rows = buildFileOutline(
+      payload({
+        outline: {
+          total: 4,
+          shown: 4,
+          truncated: false,
+          items: [
+            entry({ id: 'a', kind: 'property' }),
+            entry({ id: 'b', kind: 'enum_member' }),
+            entry({ id: 'c', kind: 'method' }),
+            entry({ id: 'd', kind: 'class' }),
+          ],
+        },
+      })
+    );
+    expect(rows.map((r) => r.dimmed)).toEqual([true, true, false, false]);
+  });
+
+  it('clamps the indent so a deeply nested closure stays in its column', () => {
+    const rows = buildFileOutline(
+      payload({
+        outline: {
+          total: 1,
+          shown: 1,
+          truncated: false,
+          items: [entry({ depth: 9 })],
+        },
+      })
+    );
+    expect(rows[0]?.indent).toBe(3);
+  });
+
+  it('windows past a threshold that leaves ordinary files alone', () => {
+    // 135 symbols in this repo's biggest hand-written file (src/mcp/tools.ts);
+    // 1,681 in the generated fixture that motivated the window.
+    expect(OUTLINE_VIRTUAL_THRESHOLD).toBeGreaterThan(135);
+    expect(OUTLINE_ROW_HEIGHT).toBeGreaterThan(0);
+  });
+});
+
+/* --------------------------------------------------------------- header -- */
+
+describe('the header line', () => {
+  it('counts the outline, not the file record', () => {
+    // nodeCount includes the file node and its import declarations; neither is
+    // a row, and a header disagreeing with the list under it is unresolvable.
+    const line = fileMetaLine(
+      payload({
+        file: { ...payload().file, nodeCount: 26 },
+        outline: { total: 23, shown: 23, truncated: false, items: [] },
+      })
+    );
+    expect(line).toBe('typescript · 23.6 KB · 23 symbols');
+  });
+
+  it('tags a generated file and a test file', () => {
+    const line = fileMetaLine(
+      payload({
+        file: { ...payload().file, generated: true, test: true, size: 1024 },
+        outline: { total: 1, shown: 1, truncated: false, items: [] },
+      })
+    );
+    expect(line).toBe('typescript · 1.0 KB · 1 symbol · generated · test');
+  });
+
+  it('formats sizes for scale, never for accounting', () => {
+    expect(formatBytes(0)).toBe('0 B');
+    expect(formatBytes(999)).toBe('999 B');
+    expect(formatBytes(24216)).toBe('23.6 KB');
+    expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
+    expect(formatBytes(Number.NaN)).toBe('—');
+  });
+});

+ 303 - 0
__tests__/ui-filecode-api.test.ts

@@ -0,0 +1,303 @@
+/**
+ * `GET /api/filecode` — everything the whole-file view draws (CG-52).
+ *
+ * Against a real indexed fixture over a real loopback server, like the rest of
+ * the viewer's API suite. The fixture is shaped around the four claims this
+ * endpoint makes that a hand-written payload could not prove:
+ *
+ * - a call group is one (CALLER, CALLEE) pair, not one per callee — the same
+ *   helper reached from two functions has to come back as two rows, because a
+ *   row is anchored to a line and there is no line that is both,
+ * - `intraFileCalls` counts exactly the arcs the viewer can draw from `calls`,
+ *   so the header and the picture under it cannot disagree,
+ * - top-level code has an owner (the file node), which is the only way a
+ *   statement outside every definition gets a port at all,
+ * - a reference that resolves to nothing still comes back, so a line calling a
+ *   runtime builtin shows a hollow port instead of an empty gutter.
+ *
+ * The pure geometry is tested without a server in `ui-filecode-model.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import { MAX_FILE_CALL_GROUPS, MAX_FILE_OUTSIDE_REFS } from '../src/ui-server/api/filecode';
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+
+function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port: server.port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${server.port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+async function getCode(file: string, expected = 200): Promise<any> {
+  const res = await request(`/api/filecode/${file}`);
+  expect(res.type).toBe('application/json; charset=utf-8');
+  expect(res.status).toBe(expected);
+  return JSON.parse(res.body);
+}
+
+function write(root: string, rel: string, body: string): void {
+  const full = path.join(root, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, body);
+}
+
+/** Rows as `caller -> callee`, which is how the rail reads. */
+function pairs(payload: any): string[] {
+  const names = new Map<string, string>(
+    payload.outline.items.map((e: any) => [e.id, e.name] as [string, string])
+  );
+  return payload.calls.items.map(
+    (c: any) => `${names.get(c.ownerId) ?? 'file'} -> ${c.relation.node.name}`
+  );
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-filecode-'));
+  projectRoot = path.join(tempDir, 'project');
+
+  // `format` is called by TWO functions in this file and by one in another, and
+  // `render` calls it twice from two different lines — every grouping case in
+  // one file.
+  write(
+    projectRoot,
+    'src/report.ts',
+    `import { widen } from './widen';
+
+export function format(value: string): string {
+  return value.trim();
+}
+
+export function render(a: string, b: string): string {
+  const left = format(a);
+  const right = format(b);
+  return left + right;
+}
+
+export function summarise(rows: string[]): string {
+  const head = format(rows[0] ?? '');
+  console.log(head);
+  return widen(head);
+}
+
+render('a', 'b');
+`
+  );
+  write(
+    projectRoot,
+    'src/widen.ts',
+    `export function widen(text: string): string {
+  return text + '  ';
+}
+`
+  );
+  // Nothing in it reaches anything: the empty-rail, no-arc case.
+  write(projectRoot, 'src/quiet.ts', `export const NAME = 'quiet';\n`);
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  cg.close();
+
+  const viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  await server?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('GET /api/filecode', () => {
+  it('describes the file and its length, which is the view\'s layout', async () => {
+    const payload = await getCode('src/report.ts');
+    expect(payload.file.path).toBe('src/report.ts');
+    expect(payload.file.language).toBe('typescript');
+    expect(payload.file.id).toBe('file:src/report.ts');
+    expect(payload.drift).toBe(false);
+    // The count comes from disk, not from the index: it is the height of the
+    // scrolling document, and the source itself is paged in separately.
+    const onDisk = fs.readFileSync(path.join(projectRoot, 'src/report.ts'), 'utf-8');
+    expect(payload.file.totalLines).toBe(onDisk.replace(/\n$/, '').split('\n').length);
+  });
+
+  it('returns the same outline rows the File view draws', async () => {
+    const code = await getCode('src/report.ts');
+    const file = JSON.parse((await request('/api/file/src/report.ts')).body);
+    expect(code.outline.total).toBe(file.outline.total);
+    expect(code.outline.items.map((e: any) => e.name)).toEqual(
+      file.outline.items.map((e: any) => e.name)
+    );
+    // A rail that disagreed with the source beside it would be worse than none.
+    for (const entry of code.outline.items) {
+      expect(entry.line).toBeGreaterThan(0);
+      expect(entry.endLine).toBeGreaterThanOrEqual(entry.line);
+    }
+  });
+
+  it('groups by the PAIR, so one callee reached from two functions is two rows', async () => {
+    const payload = await getCode('src/report.ts');
+    const rows = pairs(payload);
+    expect(rows).toContain('render -> format');
+    expect(rows).toContain('summarise -> format');
+
+    // …and the two lines `render` calls it from stay ONE row, with both lines.
+    const renderRow = payload.calls.items.find(
+      (c: any) =>
+        c.relation.node.name === 'format' &&
+        payload.outline.items.find((e: any) => e.id === c.ownerId)?.name === 'render'
+    );
+    expect(renderRow.relation.lines.length).toBe(2);
+    expect(renderRow.relation.lines[0]).toBeLessThan(renderRow.relation.lines[1]);
+  });
+
+  it('rows are in call-site order — the only ordering the screen has', async () => {
+    const payload = await getCode('src/report.ts');
+    const firstLines = payload.calls.items.map((c: any) => c.relation.lines[0] ?? Infinity);
+    const sorted = [...firstLines].sort((a: number, b: number) => a - b);
+    expect(firstLines).toEqual(sorted);
+  });
+
+  it('gives top-level code an owner, so a statement outside every definition has a port', async () => {
+    const payload = await getCode('src/report.ts');
+    const topLevel = payload.calls.items.filter((c: any) => c.ownerId === payload.file.id);
+    // `render('a', 'b')` at the bottom of the file belongs to no symbol.
+    expect(topLevel.map((c: any) => c.relation.node.name)).toContain('render');
+  });
+
+  it('counts exactly the arcs the payload can draw', async () => {
+    const payload = await getCode('src/report.ts');
+    // Recompute the arc list the way the viewer does, from `calls` alone.
+    let arcs = 0;
+    for (const call of payload.calls.items) {
+      if (call.relation.node.file !== payload.file.path) continue;
+      for (const line of call.relation.lines) {
+        if (line !== call.relation.node.line) arcs++;
+      }
+    }
+    expect(payload.intraFileCalls).toBe(arcs);
+    // render x2, summarise x1, top-level render x1 — every call that stays home.
+    expect(payload.intraFileCalls).toBeGreaterThanOrEqual(4);
+  });
+
+  it('does not count a cross-file call as an arc', async () => {
+    const payload = await getCode('src/report.ts');
+    const widen = payload.calls.items.find((c: any) => c.relation.node.name === 'widen');
+    expect(widen).toBeDefined();
+    expect(widen.relation.node.file).toBe('src/widen.ts');
+  });
+
+  it('returns references that resolved to nothing, with a line and a plain name', async () => {
+    const payload = await getCode('src/report.ts');
+    const names = payload.outside.items.map((r: any) => r.name);
+    // `console.log` reaches a runtime builtin; the gutter must still show it.
+    expect(names).toContain('log');
+    for (const ref of payload.outside.items) {
+      expect(ref.line).toBeGreaterThan(0);
+      expect(ref.name).toMatch(/^[A-Za-z_$][\w$]*$/);
+    }
+    expect(payload.outside.total).toBe(payload.outside.items.length);
+    expect(payload.outside.shown).toBeLessThanOrEqual(MAX_FILE_OUTSIDE_REFS);
+  });
+
+  it('answers for a file that reaches nothing without inventing rows', async () => {
+    const payload = await getCode('src/quiet.ts');
+    expect(payload.calls.total).toBe(0);
+    expect(payload.calls.items).toEqual([]);
+    expect(payload.intraFileCalls).toBe(0);
+    expect(payload.file.totalLines).toBe(1);
+  });
+
+  it('every capped list still reports its real total', async () => {
+    const payload = await getCode('src/report.ts');
+    for (const list of [payload.outline, payload.calls, payload.outside]) {
+      expect(list.shown).toBe(list.items.length);
+      expect(list.total).toBeGreaterThanOrEqual(list.shown);
+      expect(list.truncated).toBe(list.shown < list.total);
+    }
+    expect(payload.calls.shown).toBeLessThanOrEqual(MAX_FILE_CALL_GROUPS);
+  });
+
+  it('refuses a path outside the project before it looks in the index', async () => {
+    // The chokepoint answers "outside the project", not "not indexed" — the
+    // order is what makes that true by construction. See `resolveRequestedFile`.
+    const res = await request('/api/filecode//etc/passwd');
+    expect(res.status).toBe(403);
+    expect(JSON.parse(res.body).code).toBe('refused');
+  });
+
+  it('answers 404 for a file that is fine but not indexed', async () => {
+    const payload = await getCode('src/nope.ts', 404);
+    expect(payload.code).toBe('not-found');
+    expect(payload.error).toMatch(/not in this CodeGraph index/);
+  });
+
+  it('says what the endpoint wants when given no path', async () => {
+    const res = await request('/api/filecode');
+    expect(res.status).toBe(400);
+    expect(JSON.parse(res.body).error).toMatch(/\/api\/filecode\/<path>/);
+  });
+
+  it('is listed on the API index', async () => {
+    const body = JSON.parse((await request('/api')).body);
+    expect(body.endpoints.find((e: any) => e.path === '/api/filecode/<path>')).toBeDefined();
+    // The shorter route must still resolve to the File view's own endpoint.
+    expect(body.endpoints.find((e: any) => e.path === '/api/file/<path>')).toBeDefined();
+  });
+});
+
+describe('drift', () => {
+  it('flags a file that changed on disk and withholds its length', async () => {
+    const file = path.join(projectRoot, 'src/widen.ts');
+    const original = fs.readFileSync(file, 'utf-8');
+    try {
+      fs.writeFileSync(file, `// a new first line\n${original}`);
+      const payload = await getCode('src/widen.ts');
+      expect(payload.drift).toBe(true);
+      expect(payload.reason).toMatch(/changed on disk/);
+      // The rows are still true about the graph; only the line numbers are not,
+      // which is exactly why the view draws the banner instead of the source.
+      expect(payload.outline.total).toBeGreaterThan(0);
+    } finally {
+      fs.writeFileSync(file, original);
+    }
+  });
+});

+ 403 - 0
__tests__/ui-filecode-model.test.ts

@@ -0,0 +1,403 @@
+/**
+ * The whole-file view's geometry (CG-52), tested without a browser.
+ *
+ * Everything on that screen — where a line sits, which lines are rendered,
+ * which page has to be fetched, where a rail row lands, what an arc's path is —
+ * is arithmetic over line numbers, and that is deliberate: measuring six
+ * thousand laid-out lines is neither 60 fps nor possible. So the arithmetic is
+ * the thing worth pinning, and it can be pinned here.
+ *
+ * The API side is `ui-filecode-api.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  ARC_COLUMN,
+  ARC_CROWD_LIMIT,
+  CODE_LINE_HEIGHT,
+  CODE_TOP_PAD,
+  PAGE_LEAD_IN,
+  PAGE_LINES,
+  ROW_HEIGHT,
+  arcPath,
+  arcSummary,
+  arcsInRange,
+  buildFileArcs,
+  buildFileCallRows,
+  buildFileRefs,
+  documentHeight,
+  lineAtOffset,
+  lineCentre,
+  lineTop,
+  ownerAt,
+  pageFor,
+  pageOf,
+  pagesForRange,
+  railHeight,
+  rowsInRange,
+  visibleArcs,
+  visibleLines,
+} from '../ui/src/lib/filecode-model';
+import type {
+  WireFileCall,
+  WireFileCodePayload,
+  WireNodeRef,
+  WireOutlineEntry,
+  WireRelation,
+} from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function node(over: Partial<WireNodeRef> & { id: string; name: string }): WireNodeRef {
+  return {
+    kind: 'function',
+    qualifiedName: over.name,
+    file: 'src/a.ts',
+    line: 1,
+    endLine: 1,
+    language: 'typescript',
+    test: false,
+    ...over,
+  } as WireNodeRef;
+}
+
+function relation(target: WireNodeRef, lines: number[], over: Partial<WireRelation> = {}): WireRelation {
+  return {
+    node: target,
+    edgeKinds: ['calls'],
+    edges: lines.map((line) => ({ kind: 'calls', line, col: 4 })),
+    edgeCount: lines.length,
+    lines,
+    confidence: null,
+    uncertain: false,
+    synthesized: false,
+    ...over,
+  } as WireRelation;
+}
+
+function call(ownerId: string, ownerLine: number, rel: WireRelation): WireFileCall {
+  return { ownerId, ownerLine, relation: rel };
+}
+
+function entry(over: Partial<WireOutlineEntry> & { id: string; name: string }): WireOutlineEntry {
+  return {
+    kind: 'function',
+    qualifiedName: over.name,
+    file: 'src/a.ts',
+    line: 1,
+    endLine: 1,
+    language: 'typescript',
+    test: false,
+    parentId: null,
+    depth: 0,
+    fanIn: 0,
+    fanOut: 0,
+    ...over,
+  } as WireOutlineEntry;
+}
+
+function payloadWith(calls: WireFileCall[], outline: WireOutlineEntry[] = []): WireFileCodePayload {
+  return {
+    file: {
+      path: 'src/a.ts',
+      language: 'typescript',
+      size: 100,
+      indexedAt: 0,
+      contentHash: 'h',
+      generated: false,
+      test: false,
+      errors: [],
+      id: 'file:src/a.ts',
+      totalLines: 500,
+    },
+    drift: false,
+    outline: { total: outline.length, shown: outline.length, truncated: false, items: outline },
+    calls: { total: calls.length, shown: calls.length, truncated: false, items: calls },
+    outside: { total: 0, shown: 0, truncated: false, items: [] },
+    intraFileCalls: 0,
+    timing: { elapsedMs: 0 },
+  };
+}
+
+/* ---------------------------------------------------------------- pixels -- */
+
+describe('line arithmetic', () => {
+  it('places line 1 at the top pad and every line a fixed step below', () => {
+    expect(lineTop(1)).toBe(CODE_TOP_PAD);
+    expect(lineTop(2)).toBe(CODE_TOP_PAD + CODE_LINE_HEIGHT);
+    expect(lineCentre(1)).toBe(CODE_TOP_PAD + CODE_LINE_HEIGHT / 2);
+  });
+
+  it('round-trips an offset back to its line', () => {
+    for (const line of [1, 2, 17, 400, 6820]) {
+      expect(lineAtOffset(lineTop(line), 6820)).toBe(line);
+      expect(lineAtOffset(lineCentre(line), 6820)).toBe(line);
+    }
+    // The pads above and below read as the line they are adjacent to.
+    expect(lineAtOffset(0, 100)).toBe(1);
+    expect(lineAtOffset(999_999, 100)).toBe(100);
+  });
+
+  it('sizes the document from the line count alone', () => {
+    expect(documentHeight(6820)).toBe(CODE_TOP_PAD + 6820 * CODE_LINE_HEIGHT + 120);
+    expect(documentHeight(0)).toBe(CODE_TOP_PAD + 120);
+  });
+});
+
+describe('visibleLines', () => {
+  it('renders a viewport plus overscan, never the whole file', () => {
+    const { first, last } = visibleLines(60_000, 900, 6820);
+    expect(first).toBeLessThan(lineAtOffset(60_000, 6820));
+    expect(last - first).toBeLessThan(150);
+    // The viewport itself is covered.
+    expect(first).toBeLessThanOrEqual(lineAtOffset(60_000, 6820));
+    expect(last).toBeGreaterThanOrEqual(lineAtOffset(60_900, 6820));
+  });
+
+  it('clamps at both ends', () => {
+    expect(visibleLines(0, 900, 6820).first).toBe(1);
+    expect(visibleLines(10_000_000, 900, 6820).last).toBe(6820);
+    expect(visibleLines(0, 900, 0)).toEqual({ first: 1, last: 0 });
+  });
+});
+
+describe('paging', () => {
+  it('asks for a lead-in it then throws away', () => {
+    const page = pageFor(3, 6820);
+    expect(page.from).toBe(3 * PAGE_LINES + 1);
+    expect(page.to).toBe(4 * PAGE_LINES);
+    expect(page.requestFrom).toBe(page.from - PAGE_LEAD_IN);
+  });
+
+  it('never reaches before line 1, and never past the end', () => {
+    expect(pageFor(0, 6820).requestFrom).toBe(1);
+    expect(pageFor(8, 6820).to).toBe(6820);
+  });
+
+  it('stays inside the source endpoint\'s per-request line cap', () => {
+    // MAX_SOURCE_LINES is 4000; a page plus its lead-in must fit, or the last
+    // lines of a page would silently arrive truncated.
+    const page = pageFor(5, 100_000);
+    expect(page.to - page.requestFrom + 1).toBeLessThanOrEqual(4000);
+  });
+
+  it('names every page a rendered range touches', () => {
+    expect(pagesForRange(1, 40, 6820)).toEqual([0]);
+    expect(pagesForRange(PAGE_LINES - 2, PAGE_LINES + 2, 6820)).toEqual([0, 1]);
+    expect(pagesForRange(1, 0, 0)).toEqual([]);
+    expect(pageOf(1)).toBe(0);
+    expect(pageOf(PAGE_LINES)).toBe(0);
+    expect(pageOf(PAGE_LINES + 1)).toBe(1);
+  });
+});
+
+/* ------------------------------------------------------------- ownership -- */
+
+describe('ownerAt', () => {
+  const outline = [
+    entry({ id: 'class', name: 'Service', kind: 'class', line: 10, endLine: 90 }),
+    entry({ id: 'm1', name: 'run', kind: 'method', line: 20, endLine: 40, depth: 1 }),
+    entry({ id: 'm2', name: 'stop', kind: 'method', line: 50, endLine: 60, depth: 1 }),
+  ];
+
+  it('answers with the DEEPEST symbol holding the line', () => {
+    // Not the class: it holds every line equally, so hovering anywhere inside
+    // it would light every arc in it.
+    expect(ownerAt(outline, 25)).toBe('m1');
+    expect(ownerAt(outline, 55)).toBe('m2');
+    expect(ownerAt(outline, 45)).toBe('class');
+  });
+
+  it('answers null outside every symbol', () => {
+    expect(ownerAt(outline, 5)).toBeNull();
+    expect(ownerAt(outline, 200)).toBeNull();
+  });
+});
+
+/* ---------------------------------------------------------------- ports -- */
+
+describe('buildFileRefs', () => {
+  it('marks every recorded call site with its column', () => {
+    const target = node({ id: 't', name: 'format', line: 3 });
+    const refs = buildFileRefs(payloadWith([call('o', 1, relation(target, [8, 9]))]));
+    expect([...refs.keys()].sort((a, b) => a - b)).toEqual([8, 9]);
+    expect(refs.get(8)![0]).toMatchObject({ ident: 'format', col: 4, targetId: 't', outside: false });
+  });
+
+  it('still marks a call site the capped edge list left out', () => {
+    // A relation caps its EDGES but never its `lines`; without the fallback the
+    // overflow call sites would silently lose their ports.
+    const target = node({ id: 't', name: 'format', line: 3 });
+    const rel = relation(target, [8, 9, 10]);
+    rel.edges = rel.edges.slice(0, 1);
+    const refs = buildFileRefs(payloadWith([call('o', 1, rel)]));
+    expect(refs.get(10)).toHaveLength(1);
+    expect(refs.get(10)![0]!.col).toBeNull();
+  });
+
+  it('carries unresolved references, which have no destination', () => {
+    const payload = payloadWith([]);
+    payload.outside = {
+      total: 1,
+      shown: 1,
+      truncated: false,
+      items: [{ line: 12, col: 6, name: 'log', kind: 'calls' }],
+    };
+    const ref = buildFileRefs(payload).get(12)![0]!;
+    expect(ref).toMatchObject({ ident: 'log', targetId: null, outside: true });
+  });
+});
+
+/* ----------------------------------------------------------------- rail -- */
+
+describe('buildFileCallRows', () => {
+  it('puts a row at the centre of its first call site', () => {
+    const rows = buildFileCallRows(
+      payloadWith([call('o', 1, relation(node({ id: 't', name: 'format' }), [100]))])
+    );
+    expect(rows[0]!.top).toBe(lineCentre(100) - ROW_HEIGHT / 2);
+  });
+
+  it('pushes rows apart rather than letting them overlap, keeping source order', () => {
+    const rows = buildFileCallRows(
+      payloadWith([
+        call('o', 1, relation(node({ id: 'a', name: 'a' }), [10])),
+        call('o', 1, relation(node({ id: 'b', name: 'b' }), [11])),
+        call('o', 1, relation(node({ id: 'c', name: 'c' }), [12])),
+      ])
+    );
+    expect(rows.map((r) => r.call.relation.node.name)).toEqual(['a', 'b', 'c']);
+    for (let i = 1; i < rows.length; i++) {
+      expect(rows[i]!.top - rows[i - 1]!.top).toBeGreaterThanOrEqual(ROW_HEIGHT);
+    }
+    // The first one still gets exactly the place it wanted.
+    expect(rows[0]!.top).toBe(lineCentre(10) - ROW_HEIGHT / 2);
+  });
+
+  it('keys a row by the PAIR, so one callee from two callers is two rows', () => {
+    const target = node({ id: 't', name: 'format' });
+    const rows = buildFileCallRows(
+      payloadWith([
+        call('render', 5, relation(target, [8])),
+        call('summarise', 20, relation(target, [22])),
+      ])
+    );
+    expect(rows).toHaveLength(2);
+    expect(new Set(rows.map((r) => r.key)).size).toBe(2);
+  });
+
+  it('sends a row with no recorded call site to the end, where a cap trims it', () => {
+    const rows = buildFileCallRows(
+      payloadWith([
+        call('o', 1, relation(node({ id: 'nolines', name: 'z' }), [])),
+        call('o', 1, relation(node({ id: 'lined', name: 'a' }), [400])),
+      ])
+    );
+    expect(rows.map((r) => r.call.relation.node.id)).toEqual(['lined', 'nolines']);
+  });
+
+  it('windows by pixel range and reports the height it needs', () => {
+    const rows = buildFileCallRows(
+      payloadWith(
+        [10, 200, 4000].map((line, i) =>
+          call('o', 1, relation(node({ id: `t${i}`, name: `t${i}` }), [line]))
+        )
+      )
+    );
+    expect(rowsInRange(rows, 0, 600).map((r) => r.call.relation.node.id)).toEqual(['t0']);
+    expect(rowsInRange(rows, 3900, 4100).map((r) => r.call.relation.node.id)).toEqual(['t1']);
+    // A stretch of file with no calls in it draws no rows at all.
+    expect(rowsInRange(rows, 5000, 10_000)).toEqual([]);
+    expect(railHeight(rows)).toBeGreaterThan(lineCentre(4000));
+    expect(railHeight([])).toBe(0);
+  });
+});
+
+/* ----------------------------------------------------------------- arcs -- */
+
+describe('buildFileArcs', () => {
+  const local = (id: string, name: string, line: number): WireNodeRef =>
+    node({ id, name, line, endLine: line + 5, file: 'src/a.ts' });
+
+  it('draws one arc per call site whose callee is defined in the same file', () => {
+    const payload = payloadWith([
+      call('r', 30, relation(local('fmt', 'format', 3), [31, 32])),
+      call('r', 30, relation(node({ id: 'far', name: 'widen', file: 'src/b.ts', line: 1 }), [33])),
+    ]);
+    const arcs = buildFileArcs(payload, buildFileCallRows(payload));
+    expect(arcs).toHaveLength(2);
+    expect(arcs.map((a) => a.fromLine).sort()).toEqual([31, 32]);
+    expect(arcs.every((a) => a.toLine === 3)).toBe(true);
+  });
+
+  it('skips a call sitting on its own callee\'s definition line', () => {
+    const payload = payloadWith([call('r', 10, relation(local('r', 'recurse', 10), [10, 14]))]);
+    const arcs = buildFileArcs(payload, buildFileCallRows(payload));
+    expect(arcs.map((a) => a.fromLine)).toEqual([14]);
+  });
+
+  it('sits short arcs innermost, by their own span rather than by rank', () => {
+    const payload = payloadWith([
+      call('r', 100, relation(local('near', 'near', 98), [100])),
+      call('r', 100, relation(local('far', 'far', 2), [101])),
+    ]);
+    const arcs = buildFileArcs(payload, buildFileCallRows(payload));
+    const depth = (key: string): number =>
+      Number(/A([\d.]+),/.exec(arcs.find((a) => a.targetId === key)!.d)![1]);
+    expect(depth('near')).toBeLessThan(depth('far'));
+    expect(depth('near')).toBeGreaterThan(0);
+    expect(depth('far')).toBeLessThanOrEqual(ARC_COLUMN);
+
+    // Filtering to one symbol must not move the survivors sideways, which is
+    // exactly what a rank-based depth would do.
+    const filtered = buildFileArcs(
+      payloadWith([call('r', 100, relation(local('near', 'near', 98), [100]))]),
+      buildFileCallRows(payloadWith([call('r', 100, relation(local('near', 'near', 98), [100]))]))
+    );
+    expect(Number(/A([\d.]+),/.exec(filtered[0]!.d)![1])).toBeGreaterThan(0);
+  });
+
+  it('bulges LEFT in both directions', () => {
+    // Both ends sit on the column's right edge; the sweep flag is what keeps a
+    // downward arc and an upward one on the same side of the gutter.
+    expect(arcPath(10, 40, 30)).toMatch(/^M56,\d+(\.\d+)? A30\.0,\d+(\.\d+)? 0 0 0 56,/);
+    expect(arcPath(40, 10, 30)).toMatch(/ 0 0 1 56,/);
+  });
+});
+
+describe('visibleArcs', () => {
+  const arcs = [
+    { key: 'a', ownerId: 'x', targetId: 'y', minLine: 1, maxLine: 10 },
+    { key: 'b', ownerId: 'z', targetId: 'w', minLine: 50, maxLine: 60 },
+  ] as any[];
+
+  it('shows everything while there are few enough to read', () => {
+    expect(visibleArcs(arcs, null, false)).toHaveLength(2);
+  });
+
+  it('shows only the focused symbol\'s once the file is crowded — both directions', () => {
+    expect(visibleArcs(arcs, 'x', true).map((a) => a.key)).toEqual(['a']);
+    // A reader hovering a symbol is asking about its neighbourhood, so the
+    // calls INTO it count too.
+    expect(visibleArcs(arcs, 'y', true).map((a) => a.key)).toEqual(['a']);
+    expect(visibleArcs(arcs, null, true)).toEqual([]);
+  });
+
+  it('windows by line range', () => {
+    expect(arcsInRange(arcs, 1, 20).map((a) => a.key)).toEqual(['a']);
+    expect(arcsInRange(arcs, 5, 55).map((a) => a.key)).toEqual(['a', 'b']);
+    expect(arcsInRange(arcs, 20, 40)).toEqual([]);
+  });
+
+  it('the crowd limit is the spec\'s', () => {
+    expect(ARC_CROWD_LIMIT).toBe(40);
+  });
+});
+
+describe('arcSummary', () => {
+  it('says nothing rather than "0 calls"', () => {
+    expect(arcSummary(0)).toMatch(/No calls/);
+    expect(arcSummary(1)).toBe('1 call stays within this file');
+    expect(arcSummary(209)).toBe('209 calls stay within this file');
+  });
+});

+ 618 - 0
__tests__/ui-flow-api.test.ts

@@ -0,0 +1,618 @@
+/**
+ * `GET /api/flow` — the call path behind the Flow strip (CG-50).
+ *
+ * Against a real indexed fixture over a real loopback server, like the rest of
+ * the viewer's API suite. The fixture is shaped to produce the four things this
+ * endpoint has to get right and that a synthetic payload cannot prove:
+ *
+ * - a real five-hop chain of calls, so the hops, their edges, and the line each
+ *   card is opened at all come out of the graph rather than out of a fixture
+ *   object,
+ * - two definitions of the same name, one of them in a test file, so the
+ *   directed search's overload handling and the `ambiguous` report can be
+ *   checked (this is the shape that broke `main` on the engine's own index —
+ *   the right definition sorted seventh),
+ * - a symbol nothing reaches, so "no path" is exercised as the ordinary answer
+ *   it is rather than as an error,
+ * - a Go interface with one implementation, so a SYNTHESIZED hop — the thing
+ *   the strip draws dashed and labels with its wiring site — is a real edge
+ *   from the resolver rather than a hand-written metadata blob.
+ *
+ * The pure geometry is tested without a server in `ui-flow-model.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
+import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
+import { ToolHandler } from '../src/mcp/tools';
+import { continuationsFrom } from '../src/graph/dynamic-boundary-report';
+import type { Edge } from '../src/types';
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+
+function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port: server.port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${server.port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+async function getFlow(query: string, expected = 200): Promise<any> {
+  const res = await request(`/api/flow${query}`);
+  expect(res.type).toBe('application/json; charset=utf-8');
+  expect(res.status).toBe(expected);
+  return JSON.parse(res.body);
+}
+
+function write(root: string, rel: string, body: string): void {
+  const full = path.join(root, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, body);
+}
+
+/** `name` at each hop, so an assertion reads like the strip does. */
+function names(flow: any): string[] {
+  return flow.hops.map((h: any) => h.node.name);
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-flow-'));
+  projectRoot = path.join(tempDir, 'project');
+
+  // A five-hop chain: bootstrap -> handleRequest -> loadRow -> readRow -> toRow.
+  write(
+    projectRoot,
+    'src/main.ts',
+    `import { handleRequest } from './server/handler';
+
+export function bootstrap(): string {
+  const banner = 'ready';
+  return handleRequest(banner);
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/server/handler.ts',
+    `import { loadRow } from '../db/rows';
+
+export function handleRequest(id: string): string {
+  const trimmed = id.trim();
+  return loadRow(trimmed);
+}
+
+/** Nothing on the chain calls this — it is the "no path" endpoint. */
+export function orphanHandler(): string {
+  return 'nobody calls me';
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/db/rows.ts',
+    `export function loadRow(id: string): string {
+  return readRow(id);
+}
+
+function readRow(id: string): string {
+  return toRow(id);
+}
+
+function toRow(id: string): string {
+  return id.toUpperCase();
+}
+`
+  );
+  // Two `describe` definitions, one of them in a test file: the ambiguity the
+  // directed search has to walk past rather than truncate away.
+  write(
+    projectRoot,
+    'src/db/describe.ts',
+    `import { loadRow } from './rows';
+
+export function describeRow(id: string): string {
+  return loadRow(id);
+}
+`
+  );
+  write(
+    projectRoot,
+    '__tests__/rows.test.ts',
+    `export function describeRow(id: string): string {
+  return id;
+}
+`
+  );
+
+  // A registry whose call target is a string key (CG-51): one site whose key is
+  // a literal — so a candidate shortlist is possible — and one whose key is a
+  // runtime value, where claiming a candidate would be a guess.
+  write(
+    projectRoot,
+    'src/router/table.ts',
+    `type Handler = (payload: string) => string;
+
+const routerTable: Record<string, Handler> = {};
+
+export function register(key: string, fn: Handler): void {
+  routerTable[key] = fn;
+}
+
+export function routeSave(payload: string): string {
+  return routerTable['save'](payload);
+}
+
+export function routeAny(name: string, payload: string): string {
+  return routerTable[name](payload);
+}
+
+export function beginWork(name: string, payload: string): string {
+  return routeAny(name, payload);
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/router/handlers.ts',
+    `import { register } from './table';
+
+export function onSave(payload: string): string {
+  return payload;
+}
+
+register('save', onSave);
+`
+  );
+
+  // A Go interface with one implementation: the resolver synthesizes an
+  // interface-impl `calls` edge across it, which is what the strip draws dashed.
+  write(
+    projectRoot,
+    'go/clock.go',
+    `package clock
+
+type Clock interface {
+	Now() string
+}
+
+type SystemClock struct{}
+
+func (SystemClock) Now() string {
+	return stamp()
+}
+
+func stamp() string {
+	return "now"
+}
+
+func Tick(c Clock) string {
+	return c.Now()
+}
+`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', '__tests__/**/*.ts', 'go/**/*.go'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  cg.close();
+
+  const viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  await server?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('parseFlowQuery', () => {
+  it('reads the three shapes and refuses the empty one', () => {
+    expect(parseFlowQuery(new URLSearchParams('from=a&to=b'))).toEqual({
+      kind: 'directed',
+      from: 'a',
+      to: 'b',
+    });
+    expect(parseFlowQuery(new URLSearchParams('symbols=a,b,c'))).toEqual({
+      kind: 'symbols',
+      text: 'a,b,c',
+    });
+    expect(parseFlowQuery(new URLSearchParams('hop=sx&hop=dy&hop=uz'))).toEqual({
+      kind: 'trail',
+      hops: [
+        { id: 'x', dir: 'start' },
+        { id: 'y', dir: 'down' },
+        { id: 'z', dir: 'up' },
+      ],
+    });
+    expect(() => parseFlowQuery(new URLSearchParams(''))).toThrow(/No flow was asked for/);
+  });
+
+  it('refuses a pair that names the same symbol twice', () => {
+    expect(() => parseFlowQuery(new URLSearchParams('from=run&to=run'))).toThrow(/same symbol/);
+  });
+
+  it('takes a trail over a from/to pair, and refuses a one-hop trail', () => {
+    // A hop parameter is only ever sent by "Read as flow", which is a complete
+    // question on its own; a stray `from` alongside it must not be searched.
+    const parsed = parseFlowQuery(new URLSearchParams('from=a&to=b&hop=sx&hop=dy'));
+    expect(parsed.kind).toBe('trail');
+    expect(() => parseFlowQuery(new URLSearchParams('hop=sx'))).toThrow(/at least two hops/);
+  });
+});
+
+describe('flowEdgeLabel', () => {
+  const edge = (metadata: Record<string, unknown>, provenance = 'heuristic'): Edge =>
+    ({ kind: 'calls', source: 'a', target: 'b', provenance, metadata }) as unknown as Edge;
+
+  it('names the mechanism and the wiring site for a synthesized hop', () => {
+    expect(
+      flowEdgeLabel(edge({ synthesizedBy: 'callback', registeredAt: 'src/a.ts:12' }), false)
+    ).toBe('via callback · registered at src/a.ts:12');
+  });
+
+  it('never lets a synthesized hop read as a plain call', () => {
+    expect(flowEdgeLabel(edge({ synthesizedBy: 'react-render' }), false)).toBe('via react render');
+  });
+
+  it('says "called by" when the reader walked the edge backwards', () => {
+    expect(flowEdgeLabel(edge({}, 'resolved'), true)).toBe('called by');
+    expect(flowEdgeLabel(edge({}, 'resolved'), false)).toBe('calls');
+  });
+});
+
+describe('GET /api/flow — a directed question', () => {
+  it('returns the whole chain, one hop per card', async () => {
+    const payload = await getFlow('?from=bootstrap&to=toRow');
+    expect(payload.query).toMatchObject({ kind: 'directed', from: 'bootstrap', to: 'toRow' });
+    expect(payload.reason).toBeNull();
+    expect(payload.flows).toHaveLength(1);
+    expect(names(payload.flows[0])).toEqual([
+      'bootstrap',
+      'handleRequest',
+      'loadRow',
+      'readRow',
+      'toRow',
+    ]);
+    expect(payload.flows[0].label).toBe('bootstrap → toRow');
+  });
+
+  it('opens each card at the line that calls the next one', async () => {
+    const { flows } = await getFlow('?from=bootstrap&to=toRow');
+    const hops = flows[0].hops;
+    for (let i = 0; i < hops.length - 1; i++) {
+      const ref = hops[i].callRef;
+      expect(ref, `hop ${i} has a call site`).not.toBeNull();
+      expect(ref.name).toBe(hops[i + 1].node.name);
+      expect(ref.targetId).toBe(hops[i + 1].node.id);
+      expect(ref.backwards).toBe(false);
+      // The window is centred on it, and the source really contains it.
+      expect(ref.line).toBeGreaterThanOrEqual(hops[i].source.from);
+      expect(ref.line).toBeLessThanOrEqual(hops[i].source.to);
+      const offset = ref.line - hops[i].source.from;
+      expect(hops[i].source.lines[offset]).toContain(hops[i + 1].node.name);
+    }
+    // The last card has nothing to call, so it opens at its own definition.
+    const last = hops[hops.length - 1];
+    expect(last.callRef).toBeNull();
+    expect(last.source.from).toBeLessThanOrEqual(last.node.line);
+    expect(last.source.to).toBeGreaterThanOrEqual(last.node.line);
+  });
+
+  it('carries the edge on every hop but the first, with its line', async () => {
+    const { flows } = await getFlow('?from=bootstrap&to=toRow');
+    const hops = flows[0].hops;
+    expect(hops[0].edge).toBeNull();
+    for (let i = 1; i < hops.length; i++) {
+      expect(hops[i].edge.kind).toBe('calls');
+      expect(hops[i].edge.label).toBe('calls');
+      expect(hops[i].edge.upward).toBe(false);
+      expect(hops[i].edge.synthesized).toBe(false);
+      // The edge's line is the previous card's call site — the two agree, and
+      // the strip prints both, so a disagreement would be visible.
+      expect(hops[i].edge.line).toBe(hops[i - 1].callRef.line);
+    }
+  });
+
+  it('highlights each card with real source, never a drifted slice', async () => {
+    const { flows } = await getFlow('?from=bootstrap&to=toRow');
+    for (const hop of flows[0].hops) {
+      expect(hop.source.drift).toBe(false);
+      expect(hop.source.lines.length).toBeGreaterThan(0);
+      expect(hop.source.lines.length).toBe(hop.source.to - hop.source.from + 1);
+      // Highlight rides with the slice and is line-for-line with it (CG-43).
+      expect(hop.source.highlight.lines).toHaveLength(hop.source.lines.length);
+    }
+  });
+
+  it('answers "not connected" as an ordinary answer, with a reason', async () => {
+    const payload = await getFlow('?from=bootstrap&to=orphanHandler');
+    expect(payload.flows).toEqual([]);
+    expect(payload.reason).toMatch(/No chain of calls reaches orphanHandler/);
+    expect(payload.reason).toMatch(/dynamic dispatch/);
+    expect(payload.unresolved).toEqual([]);
+  });
+
+  it('says which names matched nothing rather than blaming the path', async () => {
+    const payload = await getFlow('?from=bootstrap&to=thisNameIsNotHere');
+    expect(payload.unresolved).toEqual(['thisNameIsNotHere']);
+    expect(payload.reason).toMatch(/thisNameIsNotHere names nothing/);
+  });
+
+  it('walks past an overload in a test file and reports the ambiguity', async () => {
+    const payload = await getFlow('?from=describeRow&to=toRow');
+    expect(names(payload.flows[0])).toEqual(['describeRow', 'loadRow', 'readRow', 'toRow']);
+    const ambiguity = payload.ambiguous.find((a: any) => a.token === 'describeRow');
+    expect(ambiguity).toBeDefined();
+    expect(ambiguity.chosen.file).toBe('src/db/describe.ts');
+    expect(ambiguity.others.map((o: any) => o.file)).toContain('__tests__/rows.test.ts');
+  });
+});
+
+describe('GET /api/flow — where the graph stops', () => {
+  it('caps a keyed dispatch with its form, its key and a candidate target', async () => {
+    const payload = await getFlow('?from=routeSave&to=onSave');
+    // No static edge crosses `routerTable['save']`, so this is not a path — it
+    // is the one card where the looking stopped, plus the cap.
+    expect(payload.reason).toMatch(/No chain of calls reaches onSave/);
+    const flow = payload.flows[0];
+    expect(flow.partial).toBe(true);
+    expect(names(flow)).toEqual(['routeSave']);
+
+    const boundary = flow.boundary;
+    expect(boundary.node.name).toBe('routeSave');
+    const site = boundary.sites[0];
+    expect(site.form).toBe('computed-call');
+    expect(site.label).toBe('computed member call');
+    expect(site.key).toBe('save');
+    expect(site.line).toBeGreaterThan(boundary.node.line);
+    expect(site.candidates.map((c: any) => c.display)).toContain('onSave');
+    // The reader named it, so the cap says so rather than presenting it as new.
+    expect(site.candidates.find((c: any) => c.display === 'onSave').named).toBe(true);
+    expect(boundary.missed.map((m: any) => m.name)).toContain('onSave');
+  });
+
+  it('opens the card at the dispatch line, with real source around it', async () => {
+    const payload = await getFlow('?from=routeSave&to=onSave');
+    const flow = payload.flows[0];
+    const site = flow.boundary.sites[0];
+    const source = flow.hops[0].source;
+    expect(source.drift).toBe(false);
+    expect(source.from).toBeLessThanOrEqual(site.line);
+    expect(source.to).toBeGreaterThanOrEqual(site.line);
+    expect(source.lines.join('\n')).toContain("routerTable['save']");
+  });
+
+  it('claims no candidates when the key is a runtime value', async () => {
+    const payload = await getFlow('?from=routeAny&to=onSave');
+    const site = payload.flows[0].boundary.sites[0];
+    expect(site.form).toBe('computed-call');
+    expect(site.key).toBeNull();
+    expect(site.candidates).toEqual([]);
+    expect(site.candidateNote).toBeNull();
+  });
+
+  it('caps a chain that connects but never reaches everything it was asked about', async () => {
+    const payload = await getFlow('?symbols=beginWork,routeAny,onSave');
+    const flow = payload.flows[0];
+    expect(flow.partial).toBe(false);
+    expect(names(flow)).toEqual(['beginWork', 'routeAny']);
+    // The cap hangs off the dead end, not off the symbol that was named last.
+    expect(flow.boundary.node.name).toBe('routeAny');
+    expect(flow.boundary.sites[0].form).toBe('computed-call');
+    expect(flow.boundary.missed.map((m: any) => m.name)).toEqual(['onSave']);
+    // The last card opens at the dispatch line the cap beside it describes.
+    const last = flow.hops[flow.hops.length - 1].source;
+    const stop = flow.boundary.sites[0].line;
+    expect(last.from).toBeLessThanOrEqual(stop);
+    expect(last.to).toBeGreaterThanOrEqual(stop);
+  });
+
+  it('never caps a flow that reaches what it was asked for', async () => {
+    const payload = await getFlow('?from=bootstrap&to=toRow');
+    expect(payload.flows[0].boundary).toBeNull();
+    expect(payload.flows[0].partial).toBe(false);
+  });
+
+  it('stays silent when nothing connects and no dispatch site explains it', async () => {
+    // `bootstrap` and `orphanHandler` are both ordinary code. Inventing a
+    // stopping point here would be a claim, not a finding.
+    const payload = await getFlow('?from=bootstrap&to=orphanHandler');
+    expect(payload.flows).toEqual([]);
+  });
+
+  it('counts the calls the path did not need and lists them', async () => {
+    const payload = await getFlow('?symbols=beginWork,routeAny,onSave');
+    const { further, uncertain } = payload.flows[0].boundary;
+    // The count and the list are the same fact — the rule every payload keeps.
+    expect(further.shown).toBe(further.items.length);
+    expect(further.total).toBeGreaterThanOrEqual(further.shown);
+    expect(uncertain.shown).toBe(uncertain.items.length);
+  });
+});
+
+describe('the end cap and codegraph_explore agree', () => {
+  it('names the same site, the same key and the same candidate', async () => {
+    const payload = await getFlow('?from=routeSave&to=onSave');
+    const site = payload.flows[0].boundary.sites[0];
+
+    const cg = CodeGraph.openSync(projectRoot);
+    try {
+      const res = await new ToolHandler(cg).execute('codegraph_explore', {
+        query: 'routeSave onSave',
+      });
+      const text = res.content[0].text as string;
+      // Both renderings come from `findDynamicBoundaries`; if they ever drift
+      // apart, a reader with the strip and the MCP answer side by side has no
+      // way to tell which one is lying.
+      expect(text).toContain('**Dynamic boundaries');
+      expect(text).toContain(site.label);
+      expect(text).toContain(`src/router/table.ts:${site.line}`);
+      expect(text).toContain(`candidates for key \`${site.key}\``);
+      for (const candidate of site.candidates) expect(text).toContain(candidate.display);
+    } finally {
+      cg.close();
+    }
+  });
+
+  it('splits a symbol\'s outgoing calls into the sure and the unfollowed', () => {
+    const cg = CodeGraph.openSync(projectRoot);
+    try {
+      const node = cg.getNodesByName('handleRequest')[0]!;
+      const all = continuationsFrom(cg, node);
+      expect(all.resolved.map((c) => c.node.name)).toContain('loadRow');
+      expect(all.uncertain.every((c) => (c.confidence ?? 1) < 0.6)).toBe(true);
+      // Excluding what is already on the path is what keeps the cap from
+      // listing the hop the reader just walked as an unexplored exit.
+      const target = all.resolved[0]!.node.id;
+      const rest = continuationsFrom(cg, node, new Set([target]));
+      expect(rest.resolved.map((c) => c.node.id)).not.toContain(target);
+    } finally {
+      cg.close();
+    }
+  });
+});
+
+describe('GET /api/flow — a synthesized hop', () => {
+  it('draws the interface bridge as a dashed hop that names its mechanism', async () => {
+    const payload = await getFlow('?from=Tick&to=stamp');
+    expect(payload.flows.length).toBeGreaterThan(0);
+    const hops = payload.flows[0].hops;
+    expect(names(payload.flows[0])[0]).toBe('Tick');
+    expect(names(payload.flows[0]).at(-1)).toBe('stamp');
+    const synthesized = hops.filter((h: any) => h.edge?.synthesized);
+    expect(synthesized.length).toBeGreaterThan(0);
+    for (const hop of synthesized) {
+      expect(hop.edge.provenance).toBe('heuristic');
+      expect(hop.edge.label).toMatch(/^via /);
+      expect(hop.edge.label).not.toBe('calls');
+    }
+  });
+});
+
+describe('GET /api/flow — explore parity', () => {
+  it('answers a ?symbols= question with the chain the explore search finds', async () => {
+    const payload = await getFlow('?symbols=bootstrap,loadRow,toRow');
+    expect(payload.query.kind).toBe('symbols');
+    expect(payload.flows.length).toBeGreaterThan(0);
+
+    // The endpoint must not have its own path finder. Run the engine's directly
+    // and require the same hops, in the same order.
+    const cg = CodeGraph.openSync(projectRoot);
+    try {
+      const flow = resolveNamedSymbolFlow(cg, 'bootstrap,loadRow,toRow');
+      expect(flow.chains[0]?.steps.map((s) => s.node.id)).toEqual(
+        payload.flows[0].hops.map((h: any) => h.node.id)
+      );
+    } finally {
+      cg.close();
+    }
+  });
+});
+
+describe('GET /api/flow — a trail read as a flow', () => {
+  it('draws the hops it was given, finding the edge that already joins them', async () => {
+    const forward = await getFlow('?from=bootstrap&to=toRow');
+    const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id);
+    const query = ids
+      .map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'd'}${id}`)}`)
+      .join('&');
+
+    const payload = await getFlow(`?${query}`);
+    expect(payload.query.kind).toBe('trail');
+    expect(payload.flows[0].hops.map((h: any) => h.node.id)).toEqual(ids);
+    expect(payload.flows[0].hops[1].edge.kind).toBe('calls');
+    expect(payload.flows[0].hops[1].edge.upward).toBe(false);
+  });
+
+  it('reads a trail walked BACKWARDS as caller hops, opened at the calling line', async () => {
+    const forward = await getFlow('?from=bootstrap&to=toRow');
+    const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id).reverse();
+    const query = ids
+      .map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'u'}${id}`)}`)
+      .join('&');
+
+    const payload = await getFlow(`?${query}`);
+    const hops = payload.flows[0].hops;
+    expect(hops.map((h: any) => h.node.id)).toEqual(ids);
+    // Every hop after the first is the caller of the one before it, so its own
+    // body holds the call — and the card opens there, pointing BACK.
+    for (let i = 1; i < hops.length; i++) {
+      expect(hops[i].edge.upward).toBe(true);
+      expect(hops[i].edge.label).toBe('called by');
+      expect(hops[i].callRef.backwards).toBe(true);
+      expect(hops[i].callRef.name).toBe(hops[i - 1].node.name);
+      expect(hops[i].callRef.line).toBe(hops[i].edge.line);
+    }
+    // The first card is the callee: nothing in it calls anything on this trail.
+    expect(hops[0].callRef).toBeNull();
+  });
+
+  it('says so when the ids on a trail are no longer in the index', async () => {
+    const payload = await getFlow('?hop=smethod%3Agone&hop=dmethod%3Aalso-gone');
+    expect(payload.flows).toEqual([]);
+    expect(payload.unresolved).toEqual(['method:gone', 'method:also-gone']);
+    expect(payload.reason).toMatch(/still in the index/);
+  });
+});
+
+describe('GET /api/flow — refusals', () => {
+  it('answers JSON, not text, when the question is malformed', async () => {
+    const payload = await getFlow('', 400);
+    expect(payload.code).toBe('bad-request');
+    expect(payload.error).toMatch(/No flow was asked for/);
+    expect(payload.hint).toMatch(/\?from=/);
+  });
+
+  it('caps the number of trail hops it will read', async () => {
+    const query = Array.from({ length: 40 }, (_, i) => `hop=s${i}xx`).join('&');
+    const payload = await getFlow(`?${query}`, 400);
+    expect(payload.code).toBe('bad-request');
+    expect(payload.error).toMatch(/longer than this endpoint reads/);
+  });
+
+  it('is listed on the API index', async () => {
+    const res = await request('/api');
+    const body = JSON.parse(res.body);
+    const entry = body.endpoints.find((e: any) => e.path === '/api/flow');
+    expect(entry).toBeDefined();
+    expect(entry.params).toContain('from');
+    expect(entry.params).toContain('hop');
+  });
+});

+ 489 - 0
__tests__/ui-flow-model.test.ts

@@ -0,0 +1,489 @@
+/**
+ * The Flow strip's geometry (CG-50) — `ui/src/lib/flow-model.ts`.
+ *
+ * Pure functions, no browser: this is where the strip's two load-bearing claims
+ * are checked. That a card's height is ARITHMETIC (the CSS pins the same
+ * number, so an arrow lands where the layout said it would), and that a column
+ * is a card's LONGEST distance from a start (so two routes that rejoin do so in
+ * the same column, and nothing is ever drawn left of something that calls it).
+ *
+ * The endpoint that feeds it is tested against a real index in
+ * `ui-flow-api.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildFlowLayout,
+  cardHeight,
+  dashFor,
+  labelLinesFor,
+  lineLabelFor,
+  CARD_WIDTH,
+  CODE_LINE_HEIGHT,
+  CODE_PADDING,
+  COLUMN_PITCH,
+  HEADER_HEIGHT,
+  LABEL_MAX_CHARS,
+  LINK_WIDTH,
+  NO_SOURCE_HEIGHT,
+  PADDING,
+  ROW_GAP,
+  capId,
+  endCapHeight,
+  endCapText,
+  END_CAP_DASH,
+  END_CAP_WIDTH,
+} from '../ui/src/lib/flow-model';
+import type {
+  WireFlow,
+  WireFlowBoundary,
+  WireFlowEdge,
+  WireFlowHop,
+  WireNodeRef,
+} from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- builders -- */
+
+function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
+  return {
+    kind: 'calls',
+    label: 'calls',
+    upward: false,
+    uncertain: false,
+    synthesized: false,
+    ...over,
+  };
+}
+
+function hop(name: string, opts: { lines?: number; edge?: WireFlowEdge | null } = {}): WireFlowHop {
+  const lines = opts.lines ?? 7;
+  return {
+    node: {
+      id: `method:${name}`,
+      kind: 'method',
+      name,
+      qualifiedName: name,
+      file: `src/${name}.ts`,
+      line: 10,
+      endLine: 40,
+      language: 'typescript',
+      test: false,
+    },
+    edge: opts.edge === undefined ? edge() : opts.edge,
+    callRef: null,
+    source:
+      lines === 0
+        ? null
+        : {
+            file: `src/${name}.ts`,
+            language: 'typescript',
+            from: 7,
+            to: 6 + lines,
+            lines: Array.from({ length: lines }, (_, i) => `line ${i}`),
+            drift: false,
+          },
+  };
+}
+
+function flow(
+  id: string,
+  names: string[],
+  extra: { boundary?: WireFlowBoundary | null; partial?: boolean } = {}
+): WireFlow {
+  return {
+    id,
+    label: `${names[0]} → ${names[names.length - 1]}`,
+    hops: names.map((name, i) => hop(name, { edge: i === 0 ? null : edge() })),
+    boundary: extra.boundary ?? null,
+    partial: extra.partial === true,
+  };
+}
+
+function ref(name: string): WireNodeRef {
+  return {
+    id: `method:${name}`,
+    kind: 'method',
+    name,
+    qualifiedName: name,
+    file: `src/${name}.ts`,
+    line: 10,
+    endLine: 40,
+    language: 'typescript',
+    test: false,
+  };
+}
+
+function boundary(over: Partial<WireFlowBoundary> = {}): WireFlowBoundary {
+  return {
+    node: ref('routeAny'),
+    sites: [
+      {
+        form: 'computed-call',
+        label: 'computed member call',
+        snippet: "return table[name](payload);",
+        line: 61,
+        key: 'save',
+        keyIsType: false,
+        moreSites: 0,
+        candidates: [{ node: ref('onSave'), display: 'onSave', named: true }],
+        candidateNote: null,
+      },
+    ],
+    uncertain: { total: 0, shown: 0, truncated: false, items: [] },
+    further: { total: 0, shown: 0, truncated: false, items: [] },
+    missed: [ref('onSave')],
+    ...over,
+  };
+}
+
+/* ---------------------------------------------------------------- tests -- */
+
+describe('cardHeight', () => {
+  it('is the header plus one row per source line', () => {
+    expect(cardHeight(hop('a', { lines: 7 }))).toBe(HEADER_HEIGHT + 7 * CODE_LINE_HEIGHT + CODE_PADDING);
+    expect(cardHeight(hop('a', { lines: 1 }))).toBe(HEADER_HEIGHT + CODE_LINE_HEIGHT + CODE_PADDING);
+  });
+
+  it('gives a card with no source the height of the sentence that replaces it', () => {
+    expect(cardHeight(hop('a', { lines: 0 }))).toBe(HEADER_HEIGHT + NO_SOURCE_HEIGHT);
+  });
+});
+
+describe('dashFor', () => {
+  it('marks a synthesized hop `5 3` and an uncertain one `2 3`', () => {
+    expect(dashFor(edge({ synthesized: true }))).toBe('5 3');
+    expect(dashFor(edge({ uncertain: true }))).toBe('2 3');
+    expect(dashFor(edge())).toBeNull();
+  });
+
+  it('lets the synthesized pattern win, because it is the stronger claim', () => {
+    // A dynamic-dispatch bridge that also scored low confidence is still first
+    // and foremost a bridge: "we inferred this hop" is what a reader has to see.
+    expect(dashFor(edge({ synthesized: true, uncertain: true }))).toBe('5 3');
+  });
+});
+
+describe('labelLinesFor', () => {
+  it('leaves an ordinary call as one word', () => {
+    expect(labelLinesFor(edge())).toEqual(['calls']);
+  });
+
+  it('stacks a synthesized label and shortens the wiring site to a basename', () => {
+    expect(
+      labelLinesFor(
+        edge({ synthesized: true, label: 'via callback · registered at src/deep/nested/wire.ts:88' })
+      )
+    ).toEqual(['via callback', 'registered at wire.ts:88']);
+  });
+
+  it('cuts anything still too wide for an 86px connector', () => {
+    const lines = labelLinesFor(edge({ label: 'via an extraordinarily long mechanism name' }));
+    expect(lines).toHaveLength(1);
+    expect(lines[0]!.length).toBe(LABEL_MAX_CHARS);
+    expect(lines[0]!.endsWith('…')).toBe(true);
+  });
+});
+
+describe('lineLabelFor', () => {
+  it('prints the recorded line, and nothing when there is none', () => {
+    expect(lineLabelFor(edge({ line: 2029 }))).toBe('line 2029');
+    expect(lineLabelFor(edge())).toBeNull();
+    expect(lineLabelFor(edge({ line: 0 }))).toBeNull();
+  });
+});
+
+describe('buildFlowLayout — one path', () => {
+  const single = flow('f1', ['a', 'b', 'c']);
+
+  it('puts one card per column, left to right, at the spec pitch', () => {
+    const layout = buildFlowLayout([single], 'f1');
+    expect(layout.cards.map((c) => c.hop.node.name)).toEqual(['a', 'b', 'c']);
+    expect(layout.cards.map((c) => c.column)).toEqual([0, 1, 2]);
+    expect(layout.cards.map((c) => c.x)).toEqual([PADDING, PADDING + COLUMN_PITCH, PADDING + 2 * COLUMN_PITCH]);
+    expect(COLUMN_PITCH).toBe(CARD_WIDTH + LINK_WIDTH);
+  });
+
+  it('places every card on one row and numbers its step on the active flow', () => {
+    const layout = buildFlowLayout([single], 'f1');
+    expect(new Set(layout.cards.map((c) => c.y)).size).toBe(1);
+    expect(layout.cards.map((c) => c.step)).toEqual([0, 1, 2]);
+  });
+
+  it('links consecutive cards and nothing else', () => {
+    const layout = buildFlowLayout([single], 'f1');
+    expect(layout.links.map((l) => [l.source, l.target])).toEqual([
+      ['method:a', 'method:b'],
+      ['method:b', 'method:c'],
+    ]);
+  });
+
+  it('sizes the canvas to the cards it drew', () => {
+    const layout = buildFlowLayout([single], 'f1');
+    expect(layout.columns).toBe(3);
+    expect(layout.gaps).toEqual([LINK_WIDTH, LINK_WIDTH]);
+    expect(layout.width).toBe(PADDING * 2 + 3 * CARD_WIDTH + 2 * LINK_WIDTH);
+    expect(layout.height).toBe(PADDING * 2 + cardHeight(single.hops[0] as WireFlowHop));
+  });
+
+  it('widens the gap a long synthesized label has to fit into', () => {
+    // 86px holds `calls`; it does not hold `registered at App.tsx:3764`, which
+    // at a fixed pitch ran under the source of the card it was explaining.
+    const wired: WireFlow = {
+      id: 'f1',
+      label: 'a → b',
+      hops: [
+        hop('a', { edge: null }),
+        hop('b', {
+          edge: edge({
+            synthesized: true,
+            line: 5337,
+            label: 'via callback · onUpdate · registered at src/app/App.tsx:3764',
+          }),
+        }),
+      ],
+    };
+    const layout = buildFlowLayout([wired], 'f1');
+    expect(layout.gaps[0]).toBeGreaterThan(LINK_WIDTH);
+    // Wide enough for the widest line it has to hold.
+    const widest = Math.max(...(layout.links[0]?.labelLines ?? []).map((l) => l.length));
+    expect(layout.gaps[0]).toBeGreaterThanOrEqual(widest * 6.65);
+    // …and the second card starts past it, so nothing is drawn over the label.
+    expect(layout.cards[1]?.x).toBe(PADDING + CARD_WIDTH + (layout.gaps[0] as number));
+  });
+
+  it('answers an empty picture for no flows at all', () => {
+    expect(buildFlowLayout([], null)).toEqual({
+      cards: [],
+      endCaps: [],
+      links: [],
+      width: 0,
+      height: 0,
+      columns: 0,
+      gaps: [],
+    });
+  });
+});
+
+describe('buildFlowLayout — two paths that merge', () => {
+  // a → b → d and a → c → d: the same start, the same end, different middles.
+  const left = flow('f1', ['a', 'b', 'd']);
+  const right = flow('f2', ['a', 'c', 'd']);
+
+  it('draws one DAG, not two strips', () => {
+    const layout = buildFlowLayout([left, right], 'f1');
+    expect(layout.cards).toHaveLength(4);
+    expect(layout.links).toHaveLength(4);
+    expect(layout.columns).toBe(3);
+  });
+
+  it('rejoins the shared cards in one column and stacks the branch', () => {
+    const layout = buildFlowLayout([left, right], 'f1');
+    const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
+    expect(at('a').column).toBe(0);
+    expect(at('d').column).toBe(2);
+    expect(at('b').column).toBe(1);
+    expect(at('c').column).toBe(1);
+    // Same column, different rows, exactly one gap apart.
+    expect(at('c').y - at('b').y).toBe(at('b').height + ROW_GAP);
+  });
+
+  it('records which paths a shared card and a branch link belong to', () => {
+    const layout = buildFlowLayout([left, right], 'f1');
+    const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
+    expect(at('a').flows).toEqual(['f1', 'f2']);
+    expect(at('b').flows).toEqual(['f1']);
+    expect(at('c').flows).toEqual(['f2']);
+    expect(layout.links.find((l) => l.target === 'method:c')!.flows).toEqual(['f2']);
+  });
+
+  it('marks the picked path, and only the picked path, with a step', () => {
+    const picked = buildFlowLayout([left, right], 'f2');
+    const at = (name: string) => picked.cards.find((c) => c.hop.node.name === name)!;
+    expect(at('c').step).toBe(1);
+    expect(at('b').step).toBe(-1);
+    // …and the picked path is the one drawn along the top of its columns.
+    expect(at('c').y).toBeLessThan(at('b').y);
+  });
+});
+
+describe('endCapText', () => {
+  it('names the form, keeps the key and counts the candidates', () => {
+    const text = endCapText(boundary());
+    expect(text.intro).toContain('routeAny');
+    expect(text.sites[0].headline).toBe('computed member call at line 61');
+    expect(text.sites[0].key).toBe('save');
+    expect(text.sites[0].candidateHeading).toBe('1 candidate target \u203a');
+    expect(text.quiet).toBeNull();
+    expect(text.missed).toContain('onSave');
+  });
+
+  it('says the key is a runtime value rather than leaving the line blank', () => {
+    const b = boundary();
+    b.sites[0]!.key = null;
+    b.sites[0]!.candidates = [];
+    const text = endCapText(b);
+    expect(text.sites[0].key).toBeNull();
+    expect(text.sites[0].notes).toContain('the key is a runtime value');
+    expect(text.sites[0].candidateHeading).toBeNull();
+  });
+
+  it('admits when the detector found nothing rather than implying a cause', () => {
+    const text = endCapText(boundary({ sites: [] }));
+    expect(text.quiet).toMatch(/No dynamic-dispatch site/);
+    expect(text.sites).toEqual([]);
+  });
+
+  it('leads with the unfollowed name-only matches and their confidence', () => {
+    const text = endCapText(
+      boundary({
+        uncertain: {
+          total: 3,
+          shown: 2,
+          truncated: true,
+          items: [
+            { node: ref('save'), line: 61, confidence: 0.4 },
+            { node: ref('store'), line: 62, confidence: 0.35 },
+          ],
+        },
+      })
+    );
+    // The count is the TRUE total, not the length of the visible list.
+    expect(text.uncertainHeading).toBe('3 name-only matches not followed (confidence < 0.6)');
+    expect(text.uncertain).toHaveLength(2);
+  });
+
+  it('counts further resolved calls in the plural the number actually needs', () => {
+    const one = endCapText(
+      boundary({ further: { total: 1, shown: 1, truncated: false, items: [] } })
+    );
+    expect(one.further).toContain('1 further resolved call ');
+    const many = endCapText(
+      boundary({ further: { total: 4, shown: 0, truncated: true, items: [] } })
+    );
+    expect(many.further).toContain('4 further resolved calls ');
+  });
+});
+
+describe('endCapHeight', () => {
+  it('grows with what the cap has to say', () => {
+    const bare = endCapHeight(boundary({ sites: [], missed: [] }));
+    const full = endCapHeight(
+      boundary({
+        uncertain: {
+          total: 2,
+          shown: 2,
+          truncated: false,
+          items: [
+            { node: ref('save'), line: 61, confidence: 0.4 },
+            { node: ref('store'), line: 62, confidence: 0.3 },
+          ],
+        },
+        further: { total: 5, shown: 0, truncated: true, items: [] },
+      })
+    );
+    expect(full).toBeGreaterThan(bare);
+  });
+
+  it('is a whole number, because it is a pixel', () => {
+    expect(Number.isInteger(endCapHeight(boundary()))).toBe(true);
+  });
+});
+
+describe('buildFlowLayout — the end cap', () => {
+  it('places the cap one column past the symbol the path stopped at', () => {
+    const f = flow('f1', ['alpha', 'routeAny'], { boundary: boundary() });
+    const layout = buildFlowLayout([f], 'f1');
+    expect(layout.endCaps).toHaveLength(1);
+    const cap = layout.endCaps[0]!;
+    expect(cap.id).toBe(capId('method:routeAny'));
+    expect(cap.anchorId).toBe('method:routeAny');
+    expect(cap.column).toBe(1 + 1);
+    expect(cap.width).toBe(END_CAP_WIDTH);
+    expect(layout.columns).toBe(3);
+    // The card the cap hangs off is tinted at the dispatch line.
+    expect(layout.cards.find((c) => c.id === 'method:routeAny')!.stopLine).toBe(61);
+    expect(layout.cards.find((c) => c.id === 'method:alpha')!.stopLine).toBeNull();
+  });
+
+  it('joins it with a dotted link that carries no arrow and no edge', () => {
+    const layout = buildFlowLayout([flow('f1', ['alpha', 'routeAny'], { boundary: boundary() })], 'f1');
+    const link = layout.links.find((l) => l.cap);
+    expect(link).toBeDefined();
+    expect(link!.edge).toBeNull();
+    expect(link!.dash).toBe(END_CAP_DASH);
+    expect(link!.label).toBe('end of static path');
+    expect(link!.labelLines.join(' ')).toBe('end of static path');
+    expect(link!.lineLabel).toBeNull();
+  });
+
+  it('draws no cap for a flow that reached what it was asked for', () => {
+    const layout = buildFlowLayout([flow('f1', ['alpha', 'beta'])], 'f1');
+    expect(layout.endCaps).toEqual([]);
+    expect(layout.links.every((l) => !l.cap)).toBe(true);
+  });
+
+  it('draws ONE cap when two paths run out at the same symbol', () => {
+    const a = flow('a', ['alpha', 'routeAny'], { boundary: boundary() });
+    const b = flow('b', ['gamma', 'routeAny'], { boundary: boundary() });
+    const layout = buildFlowLayout([a, b], 'a');
+    expect(layout.endCaps).toHaveLength(1);
+    expect(layout.endCaps[0]!.flows.sort()).toEqual(['a', 'b']);
+  });
+
+  it('leaves room for a cap wider or narrower than a card', () => {
+    const layout = buildFlowLayout([flow('f1', ['alpha', 'routeAny'], { boundary: boundary() })], 'f1');
+    const cap = layout.endCaps[0]!;
+    // The canvas is wide enough to hold the cap, not just the cards.
+    expect(layout.width).toBe(cap.x + cap.width + PADDING);
+    // And the cap starts one gap past the card it hangs off.
+    const anchor = layout.cards.find((c) => c.id === 'method:routeAny')!;
+    expect(cap.x).toBe(anchor.x + CARD_WIDTH + LINK_WIDTH);
+  });
+
+  it('ignores a boundary whose symbol is not on screen', () => {
+    const orphan = boundary({ node: ref('nowhere') });
+    const layout = buildFlowLayout([flow('f1', ['alpha', 'beta'], { boundary: orphan })], 'f1');
+    expect(layout.endCaps).toEqual([]);
+  });
+});
+
+describe('buildFlowLayout — awkward shapes', () => {
+  it('never draws a card left of something that calls it, on a long merge', () => {
+    // a → b → c → d and a → d: `d`'s column must come from the LONGEST route,
+    // or the short path would drag it back on top of `b`.
+    const long = flow('f1', ['a', 'b', 'c', 'd']);
+    const short = flow('f2', ['a', 'd']);
+    const layout = buildFlowLayout([long, short], 'f1');
+    const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
+    expect(at('d').column).toBe(3);
+    for (const link of layout.links) {
+      const from = layout.cards.find((c) => c.id === link.source)!;
+      const to = layout.cards.find((c) => c.id === link.target)!;
+      expect(to.column).toBeGreaterThan(from.column);
+    }
+  });
+
+  it('still draws every card when a flow calls back into itself', () => {
+    // a → b → a: a real shape (recursion through a helper) and one with no
+    // topological order. Nothing may vanish.
+    const cyclic: WireFlow = {
+      id: 'f1',
+      label: 'a → a',
+      hops: [hop('a', { edge: null }), hop('b'), { ...hop('a'), edge: edge() }],
+    };
+    const layout = buildFlowLayout([cyclic], 'f1');
+    expect(layout.cards.map((c) => c.hop.node.name).sort()).toEqual(['a', 'b']);
+    expect(layout.links).toHaveLength(2);
+    expect(layout.cards.every((c) => Number.isFinite(c.x) && Number.isFinite(c.y))).toBe(true);
+  });
+
+  it('centres a short column against a tall one', () => {
+    const tall = flow('f1', ['a', 'b', 'd']);
+    const alt = flow('f2', ['a', 'c', 'd']);
+    const layout = buildFlowLayout([tall, alt], 'f1');
+    const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
+    const columnMiddle = (name: string) => at(name).y + at(name).height / 2;
+    // `a` is alone in its column; `b`/`c` share the next one. Their midpoints line up.
+    expect(columnMiddle('a')).toBeCloseTo((at('b').y + at('c').y + at('c').height) / 2, 5);
+  });
+});

+ 458 - 0
__tests__/ui-highlight.test.ts

@@ -0,0 +1,458 @@
+/**
+ * The viewer's server-side syntax classification (CG-43, rebuilt on the
+ * engine's own tree-sitter parse in CG-57).
+ *
+ * Two things are worth pinning here and they are not the colours. The first is
+ * that a call-site link lands on the callee's own name — the accent underline
+ * is the only colour in the code block, and putting it on the receiver or on a
+ * word inside a comment is worse than not drawing it. The second is that
+ * highlighting never becomes a way for a source request to fail: a language
+ * with no grammar, an oversized slice, a minified line all have to answer with
+ * the source and an honest `engine: 'plain'`.
+ *
+ * The end-to-end shape is deliberate: the server's tokens are fed straight
+ * through the viewer's own `decodeLine` and `assignRefs`, because the seam
+ * between "how a grammar chose to cut a line" and "which token the overlay
+ * claims" is exactly where this breaks.
+ *
+ * These run against the real grammars, which live in `src/extraction/wasm/`
+ * and `tree-sitter-wasms` — the same ones indexing uses — so unlike the Shiki
+ * era there is nothing to build first and nothing to skip.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+  clearHighlightCache,
+  grammarFor,
+  highlightCacheStats,
+  highlightLines,
+  isHighlightable,
+  MAX_HIGHLIGHT_CHARS,
+  SLICE_CACHE_LINES,
+  TOKEN_CLASSES,
+  type HighlightResult,
+} from '../src/ui-server/highlight';
+import { classifyTree, syntaxRegionsFor } from '../src/extraction/syntax-tokens';
+import { getParser, initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
+import { LANGUAGES } from '../src/types';
+import { decodeLine, type Token } from '../ui/src/lib/highlight';
+import { assignRefs, type LineRef } from '../ui/src/lib/symbol-model';
+
+function tokensOf(result: HighlightResult, line: number): Token[] {
+  return decodeLine(result.lines[line] ?? [], result.classes);
+}
+
+/** What the code block would render for one line: `class:text` per token. */
+function shape(result: HighlightResult, line: number): string[] {
+  return tokensOf(result, line).map((t) => `${t.cls}:${t.text}`);
+}
+
+function lineRef(over: Partial<LineRef>): LineRef {
+  return {
+    ident: 'x',
+    col: null,
+    targetId: 'method:x',
+    uncertain: false,
+    outside: false,
+    title: '',
+    ...over,
+  };
+}
+
+/** Which token an overlay ref claims — the whole point of the atomisation. */
+function claimedText(result: HighlightResult, line: number, ref: LineRef): string | undefined {
+  const tokens = tokensOf(result, line);
+  const claimed = assignRefs(tokens, [ref]);
+  const [index] = [...claimed.keys()];
+  return index === undefined ? undefined : tokens[index]?.text;
+}
+
+describe('which languages classify', () => {
+  it('answers for every language the engine indexes, without throwing', () => {
+    for (const language of LANGUAGES) {
+      expect(() => grammarFor(language)).not.toThrow();
+    }
+    // The ones the classification is measured on all have a grammar.
+    for (const language of ['typescript', 'go', 'python', 'rust', 'swift', 'csharp', 'ruby', 'php']) {
+      expect(isHighlightable(language)).toBe(true);
+    }
+  });
+
+  it('answers null rather than throwing for a language this build never heard of', () => {
+    expect(grammarFor('some-future-language')).toBeNull();
+    expect(grammarFor(undefined)).toBeNull();
+    expect(grammarFor('')).toBeNull();
+  });
+
+  it('reads a single-file component through its script block', () => {
+    // A .svelte file has no grammar of its own; its symbols live in <script>
+    // and the extractor hands those to TypeScript. The classifier follows.
+    expect(grammarFor('svelte')).toBe('typescript');
+    const regions = syntaxRegionsFor('<p>{x}</p>\n<script lang="ts">\nlet x = 1;\n</script>\n', 'svelte');
+    expect(regions).toHaveLength(1);
+    expect(regions?.[0]?.language).toBe('typescript');
+  });
+
+  it('has no grammar for the formats that only have file-level extraction', () => {
+    for (const language of ['yaml', 'xml', 'properties', 'twig', 'unknown']) {
+      expect(grammarFor(language)).toBeNull();
+    }
+  });
+});
+
+describe('classification', () => {
+  beforeAll(() => clearHighlightCache());
+
+  it('reads TypeScript with the classes the theme paints', async () => {
+    const result = await highlightLines(['const answer = 42; // note'], {
+      language: 'typescript',
+    });
+    expect(result.engine).toBe('tree-sitter');
+    expect(result.grammar).toBe('typescript');
+    expect(result.classes).toEqual([...TOKEN_CLASSES]);
+    const rendered = shape(result, 0);
+    expect(rendered).toContain('keyword:const');
+    expect(rendered).toContain('ident:answer');
+    expect(rendered).toContain('number:42');
+    expect(rendered).toContain('comment:// note');
+  });
+
+  it('reads a # comment as a comment in Python and as code in TypeScript', async () => {
+    const python = await highlightLines(['x = 1  # note'], { language: 'python' });
+    expect(shape(python, 0).at(-1)).toBe('comment:# note');
+
+    const ts = await highlightLines(['x = 1  # note'], { language: 'typescript' });
+    expect(shape(ts, 0).at(-1)).not.toBe('comment:# note');
+  });
+
+  it('carries a block comment across lines within one slice', async () => {
+    const result = await highlightLines(['/* open', 'still comment', 'done */ const x = 1;'], {
+      language: 'typescript',
+    });
+    expect(shape(result, 1)).toEqual(['comment:still comment']);
+    expect(shape(result, 2)[0]).toBe('comment:done */');
+    expect(shape(result, 2)).toContain('keyword:const');
+  });
+
+  it('reads Go, which has its own idea of what a keyword is', async () => {
+    const result = await highlightLines(['func Greet(name string) string {'], { language: 'go' });
+    expect(shape(result, 0)).toContain('keyword:func');
+    expect(shape(result, 0)).toContain('def:Greet');
+  });
+
+  it('reads ArkTS with its own grammar, not TypeScript’s', async () => {
+    const result = await highlightLines(['@Entry struct Index { build() {} }'], {
+      language: 'arkts',
+    });
+    expect(result.engine).toBe('tree-sitter');
+    expect(result.grammar).toBe('arkts');
+  });
+
+  it('does not read a type annotation’s `string` as a string literal', async () => {
+    // An anonymous tree-sitter node's type IS its text, so `string` in a
+    // signature arrives as a node literally typed `string`. Reading that as a
+    // string literal greys out half of every signature in TypeScript and PHP.
+    for (const [language, line] of [
+      ['typescript', 'function put(key: string): void {}'],
+      ['php', '<?php function put(string $key): void {}'],
+    ] as const) {
+      const result = await highlightLines([line], { language });
+      expect(shape(result, 0)).toContain('type:string');
+      expect(shape(result, 0)).not.toContain('string:string');
+    }
+  });
+
+  it('paints a built-in type the same way in every language', async () => {
+    // The grammars disagree: `string` is a `type_identifier` in Go and an
+    // anonymous token inside a `predefined_type` in TypeScript. Left alone that
+    // is one word painting two ways on the same screen.
+    for (const [language, line] of [
+      ['typescript', 'let a: string;'],
+      ['go', 'var a string'],
+      ['csharp', 'string a;'],
+      ['rust', 'let a: u32 = 1;'],
+    ] as const) {
+      const rendered = shape(await highlightLines([line], { language }), 0);
+      expect(rendered.some((t) => t.startsWith('type:'))).toBe(true);
+      expect(rendered.some((t) => t === 'keyword:string' || t === 'keyword:u32')).toBe(false);
+    }
+  });
+
+  it('keeps a template literal’s interpolated call as code, so it can link', async () => {
+    const line = 'const s = `n=${store.size()} done`;';
+    const result = await highlightLines([line], { language: 'typescript' });
+    expect(shape(result, 0)).toContain('ident:size');
+    expect(claimedText(result, 0, lineRef({ ident: 'size' }))).toBe('size');
+  });
+
+  it('marks a definition’s own name, from the extractor’s tables', async () => {
+    const cases: [string, string, string][] = [
+      ['typescript', 'export class Store {}', 'Store'],
+      ['python', 'def put(self):', 'put'],
+      ['rust', 'pub fn put(&self) {}', 'put'],
+      ['ruby', 'class Store', 'Store'],
+      ['csharp', 'public class Store {}', 'Store'],
+      ['swift', 'final class Store {}', 'Store'],
+    ];
+    for (const [language, line, name] of cases) {
+      const result = await highlightLines([line], { language });
+      expect(shape(result, 0)).toContain(`def:${name}`);
+    }
+  });
+
+  it('emits one entry per source line, always', async () => {
+    const lines = ['a();', '', 'b();', ''];
+    const result = await highlightLines(lines, { language: 'typescript' });
+    // The code block indexes rows positionally: one short answer and every
+    // line below it renders the wrong source.
+    expect(result.lines).toHaveLength(lines.length);
+    expect(result.lines[1]).toEqual([]);
+  });
+
+  it('reproduces every line of a real file exactly', async () => {
+    // The code block renders these tokens and nothing else, so a dropped or
+    // duplicated character is a corrupted file on screen — silently.
+    const file = path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts');
+    const lines = fs.readFileSync(file, 'utf-8').split('\n');
+    const result = await highlightLines(lines, { language: 'typescript' });
+    expect(result.engine).toBe('tree-sitter');
+    result.lines.forEach((row, i) => {
+      expect(row.map(([, text]) => text).join('')).toBe(lines[i]);
+    });
+  });
+
+  it('classifies a component’s script and leaves its markup plain', async () => {
+    const lines = [
+      '<script lang="ts">',
+      '  let count = 0;',
+      '</script>',
+      '',
+      '<button onclick={bump}>{count}</button>',
+    ];
+    const result = await highlightLines(lines, { language: 'svelte' });
+    expect(result.engine).toBe('tree-sitter');
+    expect(shape(result, 1)).toContain('keyword:let');
+    // The markup still splits into identifiers, so a call site in it links.
+    expect(claimedText(result, 4, lineRef({ ident: 'bump' }))).toBe('bump');
+    expect(result.lines.map((row) => row.map(([, t]) => t).join(''))).toEqual(lines);
+  });
+});
+
+describe('the plain fallback', () => {
+  beforeAll(() => clearHighlightCache());
+
+  it('answers plain, with a reason, for a language no grammar covers', async () => {
+    const result = await highlightLines(['whatever this is'], { language: 'unknown' });
+    expect(result.engine).toBe('plain');
+    expect(result.grammar).toBeNull();
+    expect(result.reason).toBeTruthy();
+    expect(result.lines).toHaveLength(1);
+  });
+
+  it('still splits identifiers when it cannot highlight, so the links land', async () => {
+    const result = await highlightLines(['  return this.mutex.withLock();'], {
+      language: 'unknown',
+    });
+    expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: 9 }))).toBe('withLock');
+  });
+
+  it('refuses to classify a minified line rather than wedging on it', async () => {
+    const enormous = 'a'.repeat(MAX_HIGHLIGHT_CHARS + 1);
+    const result = await highlightLines([enormous], { language: 'javascript' });
+    expect(result.engine).toBe('plain');
+    expect(result.reason).toMatch(/minified/);
+    // The source still comes back whole — that is the part that matters.
+    expect(result.lines[0]?.map(([, text]) => text).join('')).toHaveLength(enormous.length);
+  });
+
+  it('answers plain for a component whose script block is empty', async () => {
+    const result = await highlightLines(['<p>hello</p>'], { language: 'svelte' });
+    expect(result.engine).toBe('plain');
+    expect(result.lines[0]?.map(([, text]) => text).join('')).toBe('<p>hello</p>');
+  });
+});
+
+describe('graph links land on the right token', () => {
+  beforeAll(() => clearHighlightCache());
+
+  it('marks the callee, not the receiver the recorded column points at', async () => {
+    // The recorded column is the start of the calling EXPRESSION — `this` —
+    // and the underline has to end up on `withLock`.
+    const line = '    return this.indexMutex.withLock(async () => {';
+    const result = await highlightLines([line], { language: 'typescript' });
+    expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: line.indexOf('this') }))).toBe(
+      'withLock'
+    );
+  });
+
+  it('lands on a real call site in the engine’s own src/index.ts', async () => {
+    const file = path.join(__dirname, '..', 'src', 'index.ts');
+    const source = fs.readFileSync(file, 'utf-8').split('\n');
+    // A line the engine actually contains, found rather than hard-coded, so a
+    // refactor of index.ts retires this test instead of silently passing.
+    const index = source.findIndex((l) => /^\s*(?:return |const \w+ = )?this\.\w+\.\w+\(/.test(l));
+    expect(index).toBeGreaterThanOrEqual(0);
+    const line = source[index] as string;
+    const match = /this\.(\w+)\.(\w+)\(/.exec(line) as RegExpExecArray;
+    const callee = match[2] as string;
+
+    const result = await highlightLines([line], { language: 'typescript' });
+    expect(claimedText(result, 0, lineRef({ ident: callee, col: line.indexOf('this') }))).toBe(
+      callee
+    );
+  });
+
+  it('lands on a Go method call', async () => {
+    const line = '\tresult := s.repo.FindByID(ctx, id)';
+    const result = await highlightLines([line], { language: 'go' });
+    expect(claimedText(result, 0, lineRef({ ident: 'FindByID', col: line.indexOf('s.repo') }))).toBe(
+      'FindByID'
+    );
+  });
+
+  it('lands on a Python method call, not on the receiver of the same name', async () => {
+    const line = '    return self.store.join(self.store.path)';
+    const result = await highlightLines([line], { language: 'python' });
+    expect(claimedText(result, 0, lineRef({ ident: 'join', col: line.indexOf('self') }))).toBe(
+      'join'
+    );
+  });
+
+  it('leaves a word inside a comment or a string alone', async () => {
+    const result = await highlightLines(
+      ['  // call render here', '  const s = "render";'],
+      { language: 'typescript' }
+    );
+    expect(claimedText(result, 0, lineRef({ ident: 'render' }))).toBeUndefined();
+    expect(claimedText(result, 1, lineRef({ ident: 'render' }))).toBeUndefined();
+  });
+
+  it('keeps every identifier separately claimable', async () => {
+    const result = await highlightLines(['render(); render();'], { language: 'typescript' });
+    const tokens = tokensOf(result, 0);
+    const claimed = assignRefs(tokens, [
+      lineRef({ ident: 'render', targetId: 'a' }),
+      lineRef({ ident: 'render', targetId: 'b' }),
+    ]);
+    expect(claimed.size).toBe(2);
+  });
+
+  it('keeps a type name claimable — it is a distinct class, not an excluded one', async () => {
+    const result = await highlightLines(['let store: Store = make();'], { language: 'typescript' });
+    expect(shape(result, 0)).toContain('type:Store');
+    expect(claimedText(result, 0, lineRef({ ident: 'Store' }))).toBe('Store');
+  });
+
+  it('reproduces the line exactly — the code block renders these tokens', async () => {
+    const line = '  const s = `a ${b.c()} d`; // 1 + 2';
+    const result = await highlightLines([line], { language: 'typescript' });
+    expect(
+      tokensOf(result, 0)
+        .map((t) => t.text)
+        .join('')
+    ).toBe(line);
+  });
+});
+
+describe('cost', () => {
+  it('classifies three thousand lines of TypeScript well inside the budget', async () => {
+    clearHighlightCache();
+    const lines = fs
+      .readFileSync(path.join(__dirname, '..', 'src', 'extraction', 'tree-sitter.ts'), 'utf-8')
+      .split('\n')
+      .slice(0, 3000);
+    // Warm the grammar load, which is a one-off per language per process.
+    await highlightLines(lines.slice(0, 5), { language: 'typescript' });
+    clearHighlightCache();
+
+    const started = Date.now();
+    const result = await highlightLines(lines, { language: 'typescript' });
+    const elapsed = Date.now() - started;
+
+    expect(result.engine).toBe('tree-sitter');
+    // The whole point of CG-57's swap: the TextMate grammar took ~700 ms here.
+    // Generous against a loaded CI box; the dev Mac measures 24–41 ms.
+    expect(elapsed).toBeLessThan(400);
+  });
+
+  it('answers a cached slice without re-classifying it', async () => {
+    clearHighlightCache();
+    const lines = fs
+      .readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts'), 'utf-8')
+      .split('\n');
+
+    const cold = Date.now();
+    await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' });
+    const coldMs = Date.now() - cold;
+
+    const warm = Date.now();
+    const second = await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' });
+    const warmMs = Date.now() - warm;
+
+    expect(second.engine).toBe('tree-sitter');
+    // The cache is what makes a re-render free: every resize, theme flip and
+    // step back through the trail re-asks for the same slice.
+    expect(warmMs).toBeLessThan(Math.max(20, coldMs / 4));
+  });
+
+  it('bounds the cache by total lines, not just by entry count', async () => {
+    clearHighlightCache();
+    const big = new Array(Math.ceil(SLICE_CACHE_LINES / 2) + 10).fill('x');
+    // The entry count alone would let a reader left open on a big repo grow
+    // without limit: three of these is well inside SLICE_CACHE_LIMIT and well
+    // over the line budget.
+    for (const key of ['one', 'two', 'three']) {
+      await highlightLines(big, { language: 'unknown', cacheKey: key });
+    }
+    const stats = highlightCacheStats();
+    expect(stats.entries).toBeLessThan(3);
+    expect(stats.lines).toBeLessThanOrEqual(SLICE_CACHE_LINES);
+  });
+
+  it('keys the cache on the content, so an edited file re-classifies', async () => {
+    clearHighlightCache();
+    const first = await highlightLines(['const a = 1;'], {
+      language: 'typescript',
+      cacheKey: 'hash-one:1:1',
+    });
+    const second = await highlightLines(['const bbb = 2;'], {
+      language: 'typescript',
+      cacheKey: 'hash-two:1:1',
+    });
+    expect(first.lines[0]?.map(([, t]) => t).join('')).toBe('const a = 1;');
+    expect(second.lines[0]?.map(([, t]) => t).join('')).toBe('const bbb = 2;');
+  });
+});
+
+describe('the classifier itself', () => {
+  it('covers the source with ordered, non-overlapping spans', async () => {
+    const source = fs
+      .readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'flow.ts'), 'utf-8')
+      .slice(0, 40_000);
+    await initGrammars();
+    await loadGrammarsForLanguages(['typescript']);
+    const parser = getParser('typescript');
+    expect(parser).not.toBeNull();
+    const tree = (parser as NonNullable<typeof parser>).parse(source);
+    const spans = classifyTree((tree as NonNullable<typeof tree>).rootNode, source, 'typescript');
+
+    expect(spans.length).toBeGreaterThan(1000);
+    let previous = 0;
+    for (const span of spans) {
+      expect(span.start).toBeGreaterThanOrEqual(previous);
+      expect(span.end).toBeGreaterThan(span.start);
+      previous = span.end;
+    }
+    expect(previous).toBeLessThanOrEqual(source.length);
+    // Everything the walk did not claim is whitespace the caller fills in.
+    const uncovered: string[] = [];
+    let at = 0;
+    for (const span of spans) {
+      if (span.start > at) uncovered.push(source.slice(at, span.start));
+      at = span.end;
+    }
+    expect(uncovered.every((gap) => gap.trim() === '')).toBe(true);
+  });
+});

+ 446 - 0
__tests__/ui-map-api.test.ts

@@ -0,0 +1,446 @@
+/**
+ * `GET /api/map` — the module aggregation behind the Map (CG-49).
+ *
+ * Against a real indexed fixture over a real loopback server, like the rest of
+ * the viewer's API suite. The fixture is shaped to produce exactly the things
+ * the endpoint has to get right and that a synthetic payload cannot prove:
+ *
+ * - a façade (`src/index.ts`) that must stay its own box rather than being
+ *   folded in with the loose type declarations beside it,
+ * - real `imports` edges, so the `declared` subset is not always equal to the
+ *   raw count and the layering has something trustworthy to rest on,
+ * - a two-file import cycle, so the file-level cycle report has a component to
+ *   find,
+ * - a test directory, so the `test` flag and the root default can be checked.
+ *
+ * The pure layout — layering, cycle-breaking, ports — is tested without a
+ * server in `ui-map-model.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import { moduleIdFor, normalizeRoot, pickDefaultRoot, resetMapCache } from '../src/ui-server/api/map';
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+
+function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port: server.port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${server.port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+async function getMap(query = ''): Promise<any> {
+  const res = await request(`/api/map${query}`);
+  expect(res.type).toBe('application/json; charset=utf-8');
+  expect(res.status).toBe(200);
+  return JSON.parse(res.body);
+}
+
+function write(root: string, rel: string, body: string): void {
+  const full = path.join(root, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, body);
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-map-'));
+  projectRoot = path.join(tempDir, 'project');
+
+  write(projectRoot, 'src/types.ts', `export interface Row {\n  id: string;\n}\n`);
+
+  write(
+    projectRoot,
+    'src/db/schema.ts',
+    `export const TABLES = ['rows'];\n`
+  );
+  // db -> core, the LIGHT direction of the mutual pair below.
+  write(
+    projectRoot,
+    'src/db/store.ts',
+    `import { Row } from '../types';
+import { normalise } from '../core/util';
+
+export class Store {
+  rows: Row[] = [];
+  put(row: Row): void {
+    this.rows.push(normalise(row));
+  }
+}
+`
+  );
+
+  // util <-> store is a deliberate two-file import cycle: it gives the file
+  // cycle report a component to find and the module graph a mutual pair.
+  write(
+    projectRoot,
+    'src/core/util.ts',
+    `import { Row } from '../types';
+import { Store } from '../db/store';
+
+export function normalise(row: Row): Row {
+  return { id: row.id.trim() };
+}
+
+export function count(store: Store): number {
+  return store.rows.length;
+}
+`
+  );
+
+  // Two directory levels under `src`, so depth=2 has something real to split.
+  write(
+    projectRoot,
+    'src/core/passes/trim.ts',
+    `import { Row } from '../../types';
+
+export function trim(row: Row): Row {
+  return { id: row.id.slice(0, 8) };
+}
+`
+  );
+  // core -> db, several times over: the HEAVY direction.
+  write(
+    projectRoot,
+    'src/core/engine.ts',
+    `import { Store } from '../db/store';
+import { TABLES } from '../db/schema';
+import { trim } from './passes/trim';
+import { Row } from '../types';
+
+export class Engine {
+  store = new Store();
+  boot(): string[] {
+    return TABLES;
+  }
+  add(row: Row): void {
+    this.store.put(trim(row));
+    this.store.put(row);
+  }
+}
+`
+  );
+
+  write(
+    projectRoot,
+    'src/api/handler.ts',
+    `import { Engine } from '../core/engine';
+import { Row } from '../types';
+
+export function handle(engine: Engine, row: Row): void {
+  engine.add(row);
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/api/routes.ts',
+    `import { Engine } from '../core/engine';
+import { handle } from './handler';
+
+export function route(engine: Engine): void {
+  handle(engine, { id: 'x' });
+}
+`
+  );
+
+  write(
+    projectRoot,
+    'src/index.ts',
+    `import { Engine } from './core/engine';
+import { route } from './api/routes';
+
+export function start(): void {
+  route(new Engine());
+}
+`
+  );
+
+  write(
+    projectRoot,
+    '__tests__/engine.test.ts',
+    `import { Engine } from '../src/core/engine';
+
+export function testBoot(): string[] {
+  return new Engine().boot();
+}
+`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  cg.close();
+
+  const viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  resetMapCache();
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  await server?.close();
+  resetMapCache();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('moduleIdFor', () => {
+  it('names a module after the first `depth` segments under the root', () => {
+    expect(moduleIdFor('src/core/engine.ts', 'src', 1)).toEqual({ id: 'src/core', facade: false });
+    expect(moduleIdFor('src/a/b/c.ts', 'src', 2)).toEqual({ id: 'src/a/b', facade: false });
+    expect(moduleIdFor('a/b/c.ts', '', 1)).toEqual({ id: 'a', facade: false });
+  });
+
+  it('keeps a façade as its own box and buckets the other loose files', () => {
+    expect(moduleIdFor('src/index.ts', 'src', 1)).toEqual({ id: 'src/index.ts', facade: true });
+    expect(moduleIdFor('src/lib.rs', 'src', 1)?.facade).toBe(true);
+    expect(moduleIdFor('pkg/__init__.py', 'pkg', 1)?.facade).toBe(true);
+    expect(moduleIdFor('src/types.ts', 'src', 1)).toEqual({
+      id: 'src/(root files)',
+      facade: false,
+    });
+    expect(moduleIdFor('types.ts', '', 1)).toEqual({ id: '(root files)', facade: false });
+  });
+
+  it('buckets a loose file into the directory it is actually in, not the top one', () => {
+    // Two segments at depth 2 is a loose file inside `src/a`, so it belongs to
+    // that directory's bucket. Folding it into `src/(root files)` would claim a
+    // file lives somewhere it does not.
+    expect(moduleIdFor('src/a/loose.ts', 'src', 2)).toEqual({
+      id: 'src/a/(root files)',
+      facade: false,
+    });
+  });
+
+  it('returns null for a file outside the root', () => {
+    expect(moduleIdFor('__tests__/x.test.ts', 'src', 1)).toBeNull();
+    // A sibling whose name merely starts with the root is not under it.
+    expect(moduleIdFor('srcx/y.ts', 'src', 1)).toBeNull();
+  });
+});
+
+describe('normalizeRoot', () => {
+  it('treats `src`, `src/` and `./src` as one root', () => {
+    expect(normalizeRoot('src')).toBe('src');
+    expect(normalizeRoot('src/')).toBe('src');
+    expect(normalizeRoot('./src')).toBe('src');
+    expect(normalizeRoot('src\\')).toBe('src');
+  });
+
+  it('treats the repository root as the empty string however it is written', () => {
+    expect(normalizeRoot('')).toBe('');
+    expect(normalizeRoot('.')).toBe('');
+    expect(normalizeRoot('/')).toBe('');
+    expect(normalizeRoot(undefined)).toBe('');
+  });
+});
+
+describe('pickDefaultRoot', () => {
+  it('picks the directory holding a clear majority of the non-test symbols', () => {
+    expect(
+      pickDefaultRoot([
+        { path: 'src/a.ts', symbols: 80, test: false },
+        { path: 'scripts/b.ts', symbols: 5, test: false },
+        { path: '__tests__/c.ts', symbols: 900, test: true },
+      ])
+    ).toBe('src');
+  });
+
+  it('falls back to the repository root when no directory dominates', () => {
+    expect(
+      pickDefaultRoot([
+        { path: 'a/one.ts', symbols: 10, test: false },
+        { path: 'b/two.ts', symbols: 10, test: false },
+        { path: 'c/three.ts', symbols: 10, test: false },
+      ])
+    ).toBe('');
+    expect(pickDefaultRoot([{ path: 'flat.ts', symbols: 4, test: false }])).toBe('');
+  });
+});
+
+describe('GET /api/map', () => {
+  it('is listed by the API index', async () => {
+    const res = await request('/api');
+    const body = JSON.parse(res.body);
+    expect(body.endpoints.map((e: any) => e.path)).toContain('/api/map');
+  });
+
+  it('opens on the source directory and keeps the façade its own box', async () => {
+    const map = await getMap();
+    expect(map.root).toBe('src');
+    expect(map.depth).toBe(1);
+
+    const ids = map.modules.map((m: any) => m.id);
+    expect(ids).toEqual(['src/(root files)', 'src/api', 'src/core', 'src/db', 'src/index.ts']);
+    expect(map.modules.find((m: any) => m.id === 'src/core').files).toBe(3);
+
+    const facade = map.modules.find((m: any) => m.id === 'src/index.ts');
+    expect(facade.facade).toBe(true);
+    expect(facade.files).toBe(1);
+    expect(facade.symbols).toBeGreaterThan(0);
+    // Nothing under `src` is a test, so the default root already excludes them.
+    expect(map.modules.every((m: any) => m.test === false)).toBe(true);
+  });
+
+  it('offers every top-level directory as a root, plus the repository itself', async () => {
+    const map = await getMap();
+    expect(map.roots[0]).toEqual({ root: '', label: 'whole repository', files: map.index.files });
+    expect(map.roots.map((r: any) => r.root)).toEqual(
+      expect.arrayContaining(['', 'src', '__tests__'])
+    );
+  });
+
+  it('counts cross-module edges only, with a declared subset and named pairs', async () => {
+    const map = await getMap();
+    const link = map.links.find((l: any) => l.source === 'src/api' && l.target === 'src/core');
+    expect(link).toBeTruthy();
+    expect(link.count).toBeGreaterThan(0);
+    // Every kind's count has to add up to the link's own count, or the tooltip
+    // and the stroke width are describing two different things.
+    expect(link.byKind.reduce((sum: number, k: any) => sum + k.count, 0)).toBe(link.count);
+    // `import { Engine }` is a declared dependency; it must survive as one.
+    expect(link.declared).toBeGreaterThan(0);
+    expect(link.declared).toBeLessThanOrEqual(link.count);
+    expect(link.topPairs.length).toBeGreaterThan(0);
+    expect(link.topPairs.length).toBeLessThanOrEqual(4);
+    expect(link.topPairs.every((p: any) => p.declared <= p.count)).toBe(true);
+
+    // No module ever links to itself: same-module edges are not dependencies.
+    expect(map.links.every((l: any) => l.source !== l.target)).toBe(true);
+  });
+
+  it('keeps the heavier direction of a mutual pair heavier', async () => {
+    const map = await getMap();
+    const coreToDb = map.links.find((l: any) => l.source === 'src/core' && l.target === 'src/db');
+    const dbToCore = map.links.find((l: any) => l.source === 'src/db' && l.target === 'src/core');
+    expect(coreToDb).toBeTruthy();
+    expect(dbToCore).toBeTruthy();
+    expect(coreToDb.count).toBeGreaterThan(dbToCore.count);
+  });
+
+  it('reports the file-level cycle the fixture contains', async () => {
+    const map = await getMap();
+    expect(map.cycles.total).toBeGreaterThanOrEqual(1);
+    const knot = map.cycles.items.find((c: any) =>
+      c.files.includes('src/core/util.ts') && c.files.includes('src/db/store.ts')
+    );
+    expect(knot, JSON.stringify(map.cycles)).toBeTruthy();
+    expect(knot.size).toBe(knot.files.length);
+    expect(knot.modules).toEqual(expect.arrayContaining(['src/core', 'src/db']));
+    expect(map.cycles.shown).toBe(map.cycles.items.length);
+  });
+
+  it('lists each module\'s files, capped, with the true total beside them', async () => {
+    const map = await getMap();
+    for (const module of map.modules) {
+      expect(module.fileList.total).toBe(module.files);
+      expect(module.fileList.shown).toBe(module.fileList.items.length);
+      expect(module.fileList.truncated).toBe(module.fileList.shown < module.fileList.total);
+      expect(module.fileList.items).toEqual([...module.fileList.items].sort());
+    }
+    // A module's files are everything BELOW it, not just the files directly in
+    // it: `src/core` at depth 1 owns `src/core/passes/trim.ts` too, and the
+    // panel's list has to match the count on the box.
+    const core = map.modules.find((m: any) => m.id === 'src/core');
+    expect(core.fileList.items).toEqual([
+      'src/core/engine.ts',
+      'src/core/passes/trim.ts',
+      'src/core/util.ts',
+    ]);
+  });
+
+  it('says how many references the confidence floor excluded', async () => {
+    const map = await getMap();
+    expect(map.excluded.confidenceBelow).toBe(0.6);
+    expect(map.excluded.uncertainEdges).toBeGreaterThanOrEqual(0);
+  });
+
+  it('answers the whole repository, where the tests are a test module', async () => {
+    const map = await getMap('?root=&depth=1');
+    expect(map.root).toBe('');
+    const ids = map.modules.map((m: any) => m.id);
+    expect(ids).toEqual(expect.arrayContaining(['src', '__tests__']));
+    expect(map.modules.find((m: any) => m.id === '__tests__').test).toBe(true);
+    expect(map.modules.find((m: any) => m.id === 'src').test).toBe(false);
+    expect(map.links.some((l: any) => l.source === '__tests__' && l.target === 'src')).toBe(true);
+  });
+
+  it('splits deeper when asked, and `src/` is the same root as `src`', async () => {
+    const deep = await getMap('?root=src&depth=2');
+    const ids = deep.modules.map((m: any) => m.id);
+    // A directory two levels down becomes its own box; a file loose one level
+    // down joins that level's bucket rather than being promoted to a module.
+    expect(ids).toContain('src/core/passes');
+    expect(ids).toContain('src/core/(root files)');
+    expect(ids).toContain('src/api/(root files)');
+    expect(ids).not.toContain('src/core');
+
+    const slashed = await getMap('?root=src%2F&depth=2');
+    expect(slashed.modules).toEqual(deep.modules);
+  });
+
+  it('rejects an out-of-range depth as JSON, not as a crash', async () => {
+    const res = await request('/api/map?depth=9');
+    expect(res.status).toBe(400);
+    expect(res.type).toBe('application/json; charset=utf-8');
+    const body = JSON.parse(res.body);
+    expect(body.code).toBe('bad-request');
+    expect(body.error).toContain('depth');
+  });
+
+  it('serves the second identical request from the cache, byte for byte', async () => {
+    // Other cases in this file have already warmed `src` at depth 1; the point
+    // here is the first-then-second transition, so start from a cold cache.
+    resetMapCache();
+    const first = await getMap('?root=src&depth=1');
+    const second = await getMap('?root=src&depth=1');
+    expect(first.timing.cached).toBe(false);
+    expect(second.timing.cached).toBe(true);
+    // Everything except the timing stamp must be identical — a map that is not
+    // reproducible between two reloads is not a map of anything.
+    const strip = (m: any) => JSON.stringify({ ...m, timing: undefined });
+    expect(strip(second)).toBe(strip(first));
+  });
+
+  it('does not let one root\'s answer be served for another', async () => {
+    const src = await getMap('?root=src&depth=1');
+    const all = await getMap('?root=&depth=1');
+    expect(all.root).toBe('');
+    expect(all.modules.map((m: any) => m.id)).not.toEqual(src.modules.map((m: any) => m.id));
+  });
+});

+ 475 - 0
__tests__/ui-map-model.test.ts

@@ -0,0 +1,475 @@
+/**
+ * The Map's layout, without a browser (CG-49).
+ *
+ * The properties under test are the ones that make the picture mean something.
+ * A map is only worth reading if the vertical position of a box is a claim
+ * about the code — so the tests here are mostly about *why* a module ends up
+ * where it does:
+ *
+ * - the layering rests on `declared` weight, not raw counts, because bare name
+ *   matching invents cross-module links out of shared method names;
+ * - a two-cycle keeps its heavier direction and the lighter one is reported,
+ *   never quietly dropped;
+ * - the same payload always produces the same picture, because a diagram you
+ *   cannot recognise between two visits is not a map of anything.
+ *
+ * The endpoint that feeds it is tested against a real index in
+ * `ui-map-api.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildMapLayout,
+  isEdgeVisible,
+  linkId,
+  moduleMetaLabel,
+  nodeWidth,
+  portPoint,
+  strokeWidthFor,
+  LAYER_GAP,
+  MIN_WEIGHT,
+  MIN_WEIGHT_WITH_TESTS,
+  NODE_HEIGHT,
+  type MapLayout,
+} from '../ui/src/lib/map-model';
+import type { WireMapLink, WireMapModule } from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
+  return {
+    id,
+    label: id.slice(id.lastIndexOf('/') + 1) || id,
+    files: over.files ?? 3,
+    symbols: over.symbols ?? 30,
+    languages: over.languages ?? [{ language: 'typescript', files: over.files ?? 3 }],
+    test: over.test ?? false,
+    facade: over.facade ?? false,
+    fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
+  };
+}
+
+function link(
+  source: string,
+  target: string,
+  count: number,
+  declared = count
+): WireMapLink {
+  return {
+    source,
+    target,
+    count,
+    declared,
+    byKind: [{ kind: 'calls', count }],
+    topPairs: [],
+  };
+}
+
+function layerOf(layout: MapLayout, id: string): number {
+  const node = layout.nodes.find((n) => n.id === id);
+  expect(node, `no node ${id}`).toBeTruthy();
+  return node!.layer;
+}
+
+const OPTS = { includeTests: false };
+
+/* ---------------------------------------------------------------- specs -- */
+
+describe('nodeWidth', () => {
+  it('fits the wider of the two lines and never goes under the floor', () => {
+    expect(nodeWidth('ui')).toBe(110);
+    // A long id outgrows the floor; a long meta line outgrows a short id.
+    expect(nodeWidth('src/resolution/(root files)')).toBeGreaterThan(200);
+    expect(nodeWidth('src/db', '1218 symbols · 54 files')).toBeGreaterThan(nodeWidth('src/db'));
+  });
+});
+
+describe('moduleMetaLabel', () => {
+  it('says the counts in singular when there is one of them', () => {
+    expect(moduleMetaLabel(mod('src/x', { symbols: 1, files: 1 }))).toBe('1 symbol · 1 file');
+    expect(moduleMetaLabel(mod('src/x', { symbols: 9, files: 2 }))).toBe('9 symbols · 2 files');
+  });
+});
+
+describe('strokeWidthFor', () => {
+  it('grows with the logarithm of the count and stops at 6', () => {
+    expect(strokeWidthFor(1)).toBe(1);
+    expect(strokeWidthFor(700)).toBeLessThanOrEqual(6);
+    expect(strokeWidthFor(1_000_000)).toBe(6);
+    expect(strokeWidthFor(64)).toBeGreaterThan(strokeWidthFor(8));
+    // A count of zero must not produce -Infinity.
+    expect(Number.isFinite(strokeWidthFor(0))).toBe(true);
+  });
+});
+
+describe('layering', () => {
+  const modules = [mod('src/bin'), mod('src/core'), mod('src/db')];
+
+  it('puts a module one layer above everything it depends on', () => {
+    const layout = buildMapLayout(
+      { modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
+      OPTS
+    );
+    expect(layerOf(layout, 'src/db')).toBe(0);
+    expect(layerOf(layout, 'src/core')).toBe(1);
+    expect(layerOf(layout, 'src/bin')).toBe(2);
+    // Layer 0 is the foundations, and it is drawn at the BOTTOM.
+    const bin = layout.nodes.find((n) => n.id === 'src/bin')!;
+    const db = layout.nodes.find((n) => n.id === 'src/db')!;
+    expect(bin.y).toBeLessThan(db.y);
+    expect(db.y - bin.y).toBe(2 * (NODE_HEIGHT + LAYER_GAP));
+  });
+
+  it('names only the top and bottom layers', () => {
+    const layout = buildMapLayout(
+      { modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
+      OPTS
+    );
+    expect(layout.layers.map((l) => l.label)).toEqual([
+      'foundations — depend on nothing below',
+      null,
+      'entry points',
+    ]);
+  });
+
+  it('ignores a link with nothing declared behind it', () => {
+    // `src/db -> src/bin` is 40 name-only matches (`run`, `push`, `finish`) and
+    // would otherwise lift the storage layer above the CLI. It is still drawn —
+    // as a back-edge — but it must not decide the vertical order.
+    const layout = buildMapLayout(
+      {
+        modules,
+        links: [
+          link('src/bin', 'src/core', 10, 10),
+          link('src/core', 'src/db', 10, 10),
+          link('src/db', 'src/bin', 40, 0),
+        ],
+      },
+      OPTS
+    );
+    expect(layout.basis.kind).toBe('declared');
+    expect(layerOf(layout, 'src/db')).toBe(0);
+    expect(layerOf(layout, 'src/bin')).toBe(2);
+    const noisy = layout.edges.find((e) => e.source === 'src/db' && e.target === 'src/bin')!;
+    expect(noisy).toBeTruthy();
+    expect(noisy.back).toBe(true);
+  });
+
+  it('falls back to raw counts, and says so, when almost nothing is declared', () => {
+    const layout = buildMapLayout(
+      {
+        modules,
+        links: [
+          link('src/bin', 'src/core', 10, 0),
+          link('src/core', 'src/db', 10, 0),
+          link('src/db', 'src/core', 2, 1),
+        ],
+      },
+      OPTS
+    );
+    expect(layout.basis.kind).toBe('all');
+    expect(layout.basis.declaredLinks).toBe(1);
+    expect(layout.basis.totalLinks).toBe(3);
+    expect(layout.basis.declaredLinks / layout.basis.totalLinks).toBeLessThan(0.4);
+    // With raw counts the chain is still a chain, and the light back-reference
+    // becomes the mutual one.
+    expect(layerOf(layout, 'src/db')).toBe(0);
+    expect(layerOf(layout, 'src/bin')).toBe(2);
+    expect(layout.mutual.map((m) => m.back.source)).toEqual(['src/db']);
+  });
+
+  it('survives a three-module loop instead of recursing forever', () => {
+    const layout = buildMapLayout(
+      {
+        modules,
+        links: [
+          link('src/bin', 'src/core', 5),
+          link('src/core', 'src/db', 5),
+          link('src/db', 'src/bin', 5),
+        ],
+      },
+      OPTS
+    );
+    expect(layout.nodes).toHaveLength(3);
+    expect(layout.moduleCycles).toEqual([['src/bin', 'src/core', 'src/db']]);
+    // Every module still got a finite layer.
+    expect(layout.nodes.every((n) => Number.isInteger(n.layer))).toBe(true);
+  });
+});
+
+describe('two-cycles', () => {
+  const modules = [mod('src/a'), mod('src/b')];
+
+  it('keeps the heavier direction and reports the lighter as mutual', () => {
+    const layout = buildMapLayout(
+      { modules, links: [link('src/a', 'src/b', 20), link('src/b', 'src/a', 3)] },
+      OPTS
+    );
+    expect(layerOf(layout, 'src/a')).toBe(1);
+    expect(layerOf(layout, 'src/b')).toBe(0);
+    expect(layout.mutual).toHaveLength(1);
+    expect(layout.mutual[0]!.forward.source).toBe('src/a');
+    expect(layout.mutual[0]!.back.source).toBe('src/b');
+    // Both directions are still on the canvas; the lighter one points up.
+    expect(layout.edges).toHaveLength(2);
+    expect(layout.edges.find((e) => e.source === 'src/b')!.back).toBe(true);
+    expect(layout.edges.find((e) => e.source === 'src/a')!.back).toBe(false);
+  });
+
+  it('breaks an exact tie the same way every time', () => {
+    const one = buildMapLayout(
+      { modules, links: [link('src/a', 'src/b', 7), link('src/b', 'src/a', 7)] },
+      OPTS
+    );
+    const two = buildMapLayout(
+      { modules, links: [link('src/b', 'src/a', 7), link('src/a', 'src/b', 7)] },
+      OPTS
+    );
+    expect(one.mutual[0]!.back.source).toBe('src/b');
+    expect(two.mutual[0]!.back.source).toBe('src/b');
+    expect(layerOf(one, 'src/a')).toBe(layerOf(two, 'src/a'));
+  });
+});
+
+describe('tests and thresholds', () => {
+  const modules = [mod('src/core'), mod('__tests__', { test: true })];
+  const links = [link('__tests__', 'src/core', 30), link('src/core', '__tests__', 2)];
+
+  it('leaves test modules out until they are asked for, and their links with them', () => {
+    const off = buildMapLayout({ modules, links }, { includeTests: false });
+    expect(off.nodes.map((n) => n.id)).toEqual(['src/core']);
+    expect(off.edges).toHaveLength(0);
+    expect(off.minWeight).toBe(MIN_WEIGHT);
+
+    const on = buildMapLayout({ modules, links }, { includeTests: true });
+    expect(on.nodes).toHaveLength(2);
+    expect(on.edges).toHaveLength(2);
+    // A test module touches everything, so the bar for a visible link is higher.
+    expect(on.minWeight).toBe(MIN_WEIGHT_WITH_TESTS);
+  });
+
+  it('marks a link under the threshold thin rather than deleting it', () => {
+    const layout = buildMapLayout(
+      {
+        modules: [mod('src/a'), mod('src/b'), mod('src/c')],
+        links: [link('src/a', 'src/b', 12), link('src/a', 'src/c', 2)],
+      },
+      OPTS
+    );
+    const thin = layout.edges.find((e) => e.target === 'src/c')!;
+    expect(thin.thin).toBe(true);
+    expect(isEdgeVisible(thin, null)).toBe(false);
+    // Selecting either end brings it back — that is the whole point of hiding
+    // it rather than dropping it.
+    expect(isEdgeVisible(thin, 'src/a')).toBe(true);
+    expect(isEdgeVisible(thin, 'src/c')).toBe(true);
+    expect(isEdgeVisible(thin, 'src/b')).toBe(false);
+
+    const fat = layout.edges.find((e) => e.target === 'src/b')!;
+    expect(isEdgeVisible(fat, null)).toBe(true);
+    expect(isEdgeVisible(fat, 'src/c')).toBe(false);
+  });
+});
+
+describe('ports', () => {
+  it('gives every link its own port, ordered by where the other end sits', () => {
+    const layout = buildMapLayout(
+      {
+        modules: [mod('src/top'), mod('src/left'), mod('src/mid'), mod('src/right')],
+        links: [
+          link('src/top', 'src/left', 9),
+          link('src/top', 'src/mid', 9),
+          link('src/top', 'src/right', 9),
+        ],
+      },
+      OPTS
+    );
+    const top = layout.nodes.find((n) => n.id === 'src/top')!;
+    expect(top.sourceHandles).toHaveLength(3);
+    expect(new Set(top.sourceHandles).size).toBe(3);
+
+    // The handle order must follow the targets' left-to-right order, or the
+    // three edges cross each other inside the gap for no reason.
+    const xOf = (id: string) => {
+      const n = layout.nodes.find((m) => m.id === id)!;
+      return n.x + n.width / 2;
+    };
+    const targets = top.sourceHandles.map(
+      (id) => layout.edges.find((e) => e.id === id)!.target
+    );
+    const xs = targets.map(xOf);
+    expect(xs).toEqual([...xs].sort((a, b) => a - b));
+
+    // Each target's single incoming link is its only target handle.
+    for (const id of ['src/left', 'src/mid', 'src/right']) {
+      expect(layout.nodes.find((n) => n.id === id)!.targetHandles).toHaveLength(1);
+    }
+  });
+
+  it('names an edge by its endpoints, so two runs key the same', () => {
+    expect(linkId({ source: 'a', target: 'b' })).toBe(linkId({ source: 'a', target: 'b' }));
+    expect(linkId({ source: 'a', target: 'b' })).not.toBe(linkId({ source: 'b', target: 'a' }));
+  });
+});
+
+describe('determinism', () => {
+  const modules = [
+    mod('src/alpha'),
+    mod('src/beta'),
+    mod('src/gamma'),
+    mod('src/delta'),
+    mod('src/epsilon'),
+  ];
+  const links = [
+    link('src/alpha', 'src/beta', 12),
+    link('src/alpha', 'src/gamma', 8),
+    link('src/beta', 'src/delta', 15),
+    link('src/gamma', 'src/delta', 6),
+    link('src/delta', 'src/epsilon', 20),
+    link('src/beta', 'src/epsilon', 5),
+  ];
+
+  it('produces an identical layout from an identical payload', () => {
+    const a = buildMapLayout({ modules, links }, OPTS);
+    const b = buildMapLayout({ modules, links }, OPTS);
+    expect(JSON.stringify(b)).toBe(JSON.stringify(a));
+  });
+
+  it('does not depend on the order the payload happened to arrive in', () => {
+    const a = buildMapLayout({ modules, links }, OPTS);
+    const b = buildMapLayout(
+      { modules: [...modules].reverse(), links: [...links].reverse() },
+      OPTS
+    );
+    const positions = (l: MapLayout) =>
+      l.nodes
+        .map((n) => `${n.id}@${n.layer}:${Math.round(n.x)},${Math.round(n.y)}`)
+        .sort()
+        .join('|');
+    expect(positions(b)).toBe(positions(a));
+  });
+
+  it('places an unconnected module without stretching the canvas around it', () => {
+    const withIsland = buildMapLayout(
+      { modules: [...modules, mod('src/island')], links },
+      OPTS
+    );
+    const island = withIsland.nodes.find((n) => n.id === 'src/island')!;
+    expect(island).toBeTruthy();
+    expect(island.layer).toBe(0);
+    // Parked at the right-hand end of its layer, not interleaved through the
+    // modules that actually connect.
+    const sameLayer = withIsland.nodes.filter((n) => n.layer === 0);
+    expect(Math.max(...sameLayer.map((n) => n.x))).toBe(island.x);
+    // And the canvas is no wider than the boxes standing shoulder to shoulder.
+    const widest = Math.max(
+      ...[0, 1, 2, 3].map((layer) =>
+        withIsland.nodes
+          .filter((n) => n.layer === layer)
+          .reduce((sum, n) => sum + n.width, 0)
+      )
+    );
+    expect(withIsland.width).toBeLessThan(widest + 6 * 34 + 200);
+  });
+});
+
+describe('empty and degenerate inputs', () => {
+  it('answers an empty payload without throwing', () => {
+    const layout = buildMapLayout({ modules: [], links: [] }, OPTS);
+    expect(layout.nodes).toHaveLength(0);
+    expect(layout.edges).toHaveLength(0);
+    expect(layout.basis.kind).toBe('all');
+    expect(Number.isFinite(layout.width)).toBe(true);
+    expect(Number.isFinite(layout.height)).toBe(true);
+  });
+
+  it('drops a link whose other end was filtered out', () => {
+    const layout = buildMapLayout(
+      {
+        modules: [mod('src/a'), mod('__tests__', { test: true })],
+        links: [link('src/a', '__tests__', 9), link('src/a', 'src/ghost', 9)],
+      },
+      OPTS
+    );
+    expect(layout.edges).toHaveLength(0);
+  });
+
+  it('leaves a single layer unlabelled', () => {
+    const layout = buildMapLayout({ modules: [mod('src/only')], links: [] }, OPTS);
+    expect(layout.layers).toHaveLength(1);
+    expect(layout.layers[0]!.label).toBeNull();
+  });
+});
+
+describe('directional ports and room', () => {
+  const modules = [mod('src/a'), mod('src/b')];
+
+  it('draws the Map exactly as before: bottoms leave, tops arrive, every link runs down', () => {
+    const layout = buildMapLayout(
+      { modules: [mod('src/bin'), mod('src/core'), mod('src/db')], links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
+      OPTS
+    );
+    for (const node of layout.nodes) {
+      expect(node.ports.bottom.map((p) => p.id)).toEqual(node.sourceHandles);
+      expect(node.ports.top.map((p) => p.id)).toEqual(node.targetHandles);
+      expect(node.ports.bottom.every((p) => p.type === 'source')).toBe(true);
+      expect(node.ports.top.every((p) => p.type === 'target')).toBe(true);
+    }
+    expect(layout.edges.every((e) => e.route === 'down')).toBe(true);
+  });
+
+  it('keeps a back-edge bottom-to-top under layered ports, and top-to-bottom under directional ones', () => {
+    const payload = { modules, links: [link('src/a', 'src/b', 10), link('src/b', 'src/a', 2)] };
+    const layered = buildMapLayout(payload, OPTS);
+    const directional = buildMapLayout(payload, { ...OPTS, ports: 'directional' });
+    for (const layout of [layered, directional]) {
+      const back = layout.edges.find((e) => e.source === 'src/b')!;
+      expect(back.route).toBe('up');
+      expect(back.back).toBe(true);
+    }
+    const a = (l: MapLayout) => l.nodes.find((n) => n.id === 'src/a')!;
+    const b = (l: MapLayout) => l.nodes.find((n) => n.id === 'src/b')!;
+    const id = linkId({ source: 'src/b', target: 'src/a' });
+    // The Map: leaves b's bottom, arrives at a's top — through both boxes, as it always has.
+    expect(portPoint(b(layered), id, 'source').y).toBe(b(layered).y + NODE_HEIGHT);
+    expect(portPoint(a(layered), id, 'target').y).toBe(a(layered).y);
+    // Directional: leaves b's top, arrives at a's bottom — around them.
+    expect(portPoint(b(directional), id, 'source').y).toBe(b(directional).y);
+    expect(portPoint(a(directional), id, 'target').y).toBe(a(directional).y + NODE_HEIGHT);
+  });
+
+  it('joins two modules on one layer over the top, under directional ports', () => {
+    const layout = buildMapLayout(
+      { modules, links: [link('src/a', 'src/b', 10)] },
+      { ...OPTS, ports: 'directional', layering: (ids) => new Map(ids.map((id) => [id, 0])) }
+    );
+    const edge = layout.edges[0]!;
+    expect(edge.route).toBe('level');
+    const a = layout.nodes.find((n) => n.id === 'src/a')!;
+    const b = layout.nodes.find((n) => n.id === 'src/b')!;
+    expect(portPoint(a, edge.id, 'source').y).toBe(a.y);
+    expect(portPoint(b, edge.id, 'target').y).toBe(b.y);
+  });
+
+  it('widens a box to keep its ports apart, and only then', () => {
+    const leaves = Array.from({ length: 20 }, (_, i) => mod(`src/leaf${i}`));
+    const payload = { modules: [mod('src/hub'), ...leaves], links: leaves.map((l) => link('src/hub', l.id, 5)) };
+    const plain = buildMapLayout(payload, OPTS).nodes.find((n) => n.id === 'src/hub')!;
+    const pitched = buildMapLayout(payload, { ...OPTS, portPitch: 12 }).nodes.find((n) => n.id === 'src/hub')!;
+    // Nothing depends on the hub, so its second line is the island's.
+    expect(plain.width).toBe(nodeWidth('src/hub', moduleMetaLabel(mod('src/hub'), true)));
+    expect(pitched.width).toBe(Math.max(plain.width, 21 * 12));
+    expect(pitched.width).toBeGreaterThan(plain.width);
+  });
+
+  it('spaces layers by the gap a view asks for', () => {
+    const payload = { modules, links: [link('src/a', 'src/b', 10)] };
+    const wide = buildMapLayout(payload, { ...OPTS, layerGap: 116 });
+    const a = wide.nodes.find((n) => n.id === 'src/a')!;
+    const b = wide.nodes.find((n) => n.id === 'src/b')!;
+    expect(b.y - a.y).toBe(NODE_HEIGHT + 116);
+    expect(wide.height).toBe(buildMapLayout(payload, OPTS).height + (116 - LAYER_GAP));
+    // Layer 0 is the bottom, so it has the larger y.
+    expect(wide.layers[0]!.y - wide.layers[1]!.y).toBe(NODE_HEIGHT + 116);
+  });
+});

+ 746 - 0
__tests__/ui-package.test.ts

@@ -0,0 +1,746 @@
+/**
+ * `@colbymchenry/codegraph-ui` — the package's own test (task CG-61).
+ *
+ * A minimal Svelte host mounts the three headline components from the package
+ * entry against a MOCK adapter and asserts what lands in the document. That is
+ * the whole promise of the package in one file: CodeGraph Pro renders these
+ * same components over its own in-process engine reads, so if a screen can be
+ * drawn from an object literal here, it can be drawn from a graph there.
+ *
+ * The import is `ui/src/index.ts` — the package entry itself, not the
+ * components one by one — so a name dropped from the public surface fails here
+ * rather than in the Pro app.
+ *
+ * Everything below is deliberately about the SEAM, not about the screens:
+ * layout, geometry and the rails have their own suites (`ui-symbol-model`,
+ * `ui-flow-model`, `ui-map-model`). What is being proved here is that no
+ * component reaches past the adapter for anything.
+ */
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { flushSync, mount, unmount } from 'svelte';
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+import {
+  ArchitectureMap,
+  CodegraphUi,
+  FlowStrip,
+  SearchPalette,
+  SymbolView,
+  SavedTrails,
+  TrailBar,
+  TypeHierarchy,
+  createHttpAdapter,
+  fileHref,
+  flowHref,
+  getGraphAdapter,
+  hashNavigation,
+  live,
+  mapHref,
+  setGraphAdapter,
+  setNavigationDriver,
+  symbolHref,
+  trail,
+  type GraphAdapter,
+  type NavigationDriver,
+  type WireFlowPayload,
+  type WireMapPayload,
+  type WireNodeRef,
+  type WireSource,
+  type WireStats,
+  type WireHierarchy,
+  type WireSymbolPayload,
+} from '../ui/src/index';
+
+/* ---------------------------------------------------------------- fixtures */
+
+const ROOT = join(import.meta.dirname, '..');
+
+function nodeRef(overrides: Partial<WireNodeRef> = {}): WireNodeRef {
+  return {
+    id: 'function:parseToken@src/auth/token.ts:12',
+    kind: 'function',
+    name: 'parseToken',
+    qualifiedName: 'parseToken',
+    file: 'src/auth/token.ts',
+    line: 12,
+    endLine: 18,
+    language: 'typescript',
+    test: false,
+    ...overrides,
+  };
+}
+
+const CALLER = nodeRef({
+  id: 'function:handleCallback@src/auth/callback.ts:40',
+  name: 'handleCallback',
+  qualifiedName: 'handleCallback',
+  file: 'src/auth/callback.ts',
+  line: 40,
+  endLine: 60,
+});
+
+const CALLEE = nodeRef({
+  id: 'function:decodeJwt@src/auth/jwt.ts:3',
+  name: 'decodeJwt',
+  qualifiedName: 'decodeJwt',
+  file: 'src/auth/jwt.ts',
+  line: 3,
+  endLine: 9,
+});
+
+const SYMBOL: WireSymbolPayload = {
+  node: {
+    ...nodeRef(),
+    startColumn: 0,
+    endColumn: 1,
+    lines: 7,
+    exported: true,
+  },
+  ancestors: [nodeRef({ id: 'file:src/auth/token.ts', kind: 'file', name: 'token.ts' })],
+  members: { total: 0, shown: 0, truncated: false, items: [] },
+  incoming: {
+    total: 1,
+    shown: 1,
+    truncated: false,
+    items: [
+      {
+        node: CALLER,
+        edgeKinds: ['calls'],
+        edges: [{ kind: 'calls', line: 44, col: 6, confidence: 1 }],
+        edgeCount: 1,
+        lines: [44],
+        confidence: 1,
+        uncertain: false,
+        synthesized: false,
+      },
+    ],
+  },
+  outgoing: {
+    total: 1,
+    shown: 1,
+    truncated: false,
+    items: [
+      {
+        node: CALLEE,
+        edgeKinds: ['calls'],
+        edges: [{ kind: 'calls', line: 14, col: 10, confidence: 1 }],
+        edgeCount: 1,
+        lines: [14],
+        confidence: 1,
+        uncertain: false,
+        synthesized: false,
+      },
+    ],
+  },
+  typesUsed: [],
+  hierarchy: null,
+  counts: { callers: 1, callees: 1, typesUsed: 0, fanIn: 1, fanOut: 1, members: 0, hub: false },
+  tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 },
+  outsideIndex: { total: 0, byKind: {}, samples: [] },
+  blast: {
+    direct: 1,
+    withinHops: 2,
+    hops: 3,
+    files: 2,
+    testFiles: 0,
+    routes: 0,
+    topFiles: [{ file: 'src/auth/callback.ts', symbols: 1, test: false }],
+  },
+  drift: false,
+};
+
+const SOURCE_LINES = [
+  'export function parseToken(raw: string): Token {',
+  '  // Normalize expiry before anything else reads it.',
+  '  const claims = decodeJwt(raw);',
+  '  return { ...claims, expiresAt: claims.exp * 1000 };',
+  '}',
+];
+
+const SOURCE: WireSource = {
+  file: 'src/auth/token.ts',
+  language: 'typescript',
+  drift: false,
+  showing: 'indexed',
+  contentHash: 'abc123',
+  indexedAt: 1_700_000_000_000,
+  generated: false,
+  totalLines: 40,
+  from: 12,
+  to: 18,
+  lines: SOURCE_LINES,
+};
+
+const FLOW: WireFlowPayload = {
+  query: { kind: 'directed', from: 'handleCallback', to: 'decodeJwt', symbols: [] },
+  flows: [
+    {
+      id: 'flow-1',
+      label: 'handleCallback → decodeJwt',
+      partial: false,
+      boundary: null,
+      hops: [
+        {
+          node: CALLER,
+          edge: null,
+          callRef: { line: 44, col: 6, name: 'parseToken', targetId: SYMBOL.node.id, backwards: false },
+          source: {
+            file: 'src/auth/callback.ts',
+            language: 'typescript',
+            from: 44,
+            to: 46,
+            lines: ['  const token = parseToken(raw);'],
+            drift: false,
+          },
+        },
+        {
+          node: nodeRef(),
+          edge: {
+            kind: 'calls',
+            line: 44,
+            label: 'calls',
+            upward: false,
+            uncertain: false,
+            synthesized: false,
+          },
+          callRef: null,
+          source: {
+            file: 'src/auth/token.ts',
+            language: 'typescript',
+            from: 12,
+            to: 14,
+            lines: SOURCE_LINES.slice(0, 3),
+            drift: false,
+          },
+        },
+      ],
+    },
+  ],
+  ambiguous: [],
+  unresolved: [],
+  reason: null,
+  index: { lastIndexedAt: 1_700_000_000_000, edges: 4, files: 3 },
+  timing: { elapsedMs: 2 },
+};
+
+const MAP: WireMapPayload = {
+  root: 'src',
+  depth: 1,
+  roots: [{ root: 'src', label: 'src', files: 3 }],
+  modules: [
+    {
+      id: 'src/auth',
+      label: 'auth',
+      files: 2,
+      symbols: 6,
+      languages: [{ language: 'typescript', files: 2 }],
+      test: false,
+      facade: false,
+      fileList: { total: 2, shown: 2, truncated: false, items: ['src/auth/token.ts', 'src/auth/callback.ts'] },
+    },
+    {
+      id: 'src/http',
+      label: 'http',
+      files: 1,
+      symbols: 3,
+      languages: [{ language: 'typescript', files: 1 }],
+      test: false,
+      facade: false,
+      fileList: { total: 1, shown: 1, truncated: false, items: ['src/http/server.ts'] },
+    },
+  ],
+  links: [
+    {
+      source: 'src/http',
+      target: 'src/auth',
+      count: 9,
+      declared: 7,
+      byKind: [{ kind: 'calls', count: 9 }],
+      topPairs: [{ from: 'src/http/server.ts', to: 'src/auth/token.ts', count: 9, declared: 7 }],
+    },
+  ],
+  cycles: { total: 0, shown: 0, truncated: false, items: [] },
+  excluded: { uncertainEdges: 0, confidenceBelow: 0.6 },
+  index: { lastIndexedAt: 1_700_000_000_000, edges: 9, files: 3 },
+  timing: { elapsedMs: 1, cached: false },
+};
+
+const STATS: WireStats = {
+  project: { root: '/tmp/demo', name: 'demo' },
+  index: {
+    state: 'ready',
+    lastIndexedAt: 1_700_000_000_000,
+    stale: false,
+    version: '1.0.0',
+    extractionVersion: 1,
+    backend: 'node-sqlite',
+    journalMode: 'wal',
+    pendingReferences: 0,
+    generatedFiles: 0,
+    watching: false,
+    watcherDegraded: false,
+  },
+  graph: {
+    nodes: 9,
+    edges: 9,
+    files: 3,
+    nodesByKind: { function: 9 },
+    edgesByKind: { calls: 9 },
+    filesByLanguage: { typescript: 3 },
+    dbSizeBytes: 1024,
+    walSizeBytes: 0,
+  },
+  frameworks: [],
+  thresholds: { hub: 40, uncertainBelow: 0.6 },
+  blastScale: { maxDirect: 20, maxWithinHops: 60, hops: 3, sampled: 24, estimated: true },
+};
+
+/* ------------------------------------------------------------ mock adapter */
+
+/** Every method the components can reach, and a record of which ones they did. */
+function mockAdapter(): { adapter: GraphAdapter; calls: string[] } {
+  const calls: string[] = [];
+  const seen = <T>(name: string, value: T): Promise<T> => {
+    calls.push(name);
+    return Promise.resolve(value);
+  };
+  const adapter: GraphAdapter = {
+    stats: () => seen('stats', STATS),
+    search: () =>
+      seen('search', {
+        query: '',
+        text: '',
+        filters: { kinds: [], languages: [], paths: [], names: [] },
+        results: { total: 0, shown: 0, truncated: false, items: [] },
+        groups: [],
+      }),
+    node: (id) => {
+      calls.push(`node:${id}`);
+      return Promise.resolve(SYMBOL);
+    },
+    nodes: () => seen('nodes', { items: [], missing: [] }),
+    source: (request) => {
+      calls.push(`source:${request.file}`);
+      return Promise.resolve(SOURCE);
+    },
+    file: () =>
+      seen('file', {
+        file: {
+          path: 'src/auth/token.ts',
+          language: 'typescript',
+          size: 900,
+          modifiedAt: 0,
+          indexedAt: 0,
+          contentHash: 'abc123',
+          nodeCount: 3,
+          generated: false,
+          test: false,
+          errors: [],
+          id: 'file:src/auth/token.ts',
+        },
+        topLevel: { calls: 0 },
+        drift: false,
+        outline: { total: 0, shown: 0, truncated: false, items: [] },
+        imports: { total: 0, shown: 0, truncated: false, items: [] },
+        importedBy: { total: 0, shown: 0, truncated: false, items: [] },
+        unresolvedImports: [],
+        dependencies: [],
+        dependents: [],
+      }),
+    fileCode: () =>
+      seen('fileCode', {
+        file: {
+          path: 'src/auth/token.ts',
+          language: 'typescript',
+          size: 900,
+          indexedAt: 0,
+          contentHash: 'abc123',
+          generated: false,
+          test: false,
+          errors: [],
+          id: 'file:src/auth/token.ts',
+          totalLines: 40,
+        },
+        drift: false,
+        outline: { total: 0, shown: 0, truncated: false, items: [] },
+        calls: { total: 0, shown: 0, truncated: false, items: [] },
+        outside: { total: 0, shown: 0, truncated: false, items: [] },
+        intraFileCalls: 0,
+        timing: { elapsedMs: 1 },
+      }),
+    flow: () => seen('flow', FLOW),
+    map: () => seen('map', MAP),
+    routes: () =>
+      seen('routes', {
+        routed: false,
+        routeCount: 0,
+        shown: 0,
+        truncated: false,
+        topHandlerFile: null,
+        topHandlerFileCount: 0,
+        entries: [],
+      }),
+    entryPoints: () =>
+      seen('entryPoints', {
+        frameworks: [],
+        routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
+        files: { total: 0, shown: 0, truncated: false, items: [] },
+        tests: { total: 0, shown: 0, truncated: false, items: [] },
+        hubs: { total: 0, shown: 0, truncated: false, items: [] },
+        index: { lastIndexedAt: null, files: 3 },
+        timing: { elapsedMs: 1, cached: false },
+      }),
+    deadCode: () =>
+      seen('deadCode', {
+        rows: { total: 0, shown: 0, truncated: false, items: [] },
+        groups: [],
+        candidates: 0,
+        excluded: [],
+        excludedTotal: 0,
+        kinds: ['function'],
+        includeExported: false,
+        includeTests: false,
+        includeGenerated: false,
+        bounded: false,
+        corroborated: true,
+        timing: { elapsedMs: 1 },
+      }),
+    trails: () =>
+      seen('trails', {
+        trails: [],
+        // A host with nowhere to keep trails still ANSWERS the question — it
+        // says it is read-only rather than omitting the method, so the screens
+        // show the section explained instead of showing a Save that does
+        // nothing.
+        readOnly: true,
+        readOnlyReason: 'This host does not store trails.',
+        directory: '.codegraph/ui/trails',
+        skipped: 0,
+        bounded: false,
+      }),
+    // Deliberately no `events`, `saveTrail` or `deleteTrail`: a host without a
+    // live channel and without anywhere to write is the normal case, and
+    // nothing may poll or offer to save in their absence.
+  };
+  return { adapter, calls };
+}
+
+/* ----------------------------------------------------------------- harness */
+
+let host: HTMLDivElement;
+let mounted: Record<string, unknown> | null = null;
+
+/** jsdom has none of the observers a canvas library expects. */
+beforeAll(() => {
+  class NoopObserver {
+    observe(): void {}
+    unobserve(): void {}
+    disconnect(): void {}
+  }
+  const globals = globalThis as Record<string, unknown>;
+  globals.ResizeObserver ??= NoopObserver;
+  globals.IntersectionObserver ??= NoopObserver;
+  globals.MutationObserver ??= NoopObserver;
+  globals.requestAnimationFrame ??= (fn: FrameRequestCallback) =>
+    setTimeout(() => fn(0), 0) as unknown as number;
+  globals.cancelAnimationFrame ??= (handle: number) => clearTimeout(handle);
+  // jsdom's own `matchMedia` is a stub that is not callable here, and Svelte's
+  // `MediaQuery` (which `@xyflow/svelte`'s store constructs eagerly) calls it
+  // the moment a canvas mounts. Replace it outright rather than guarding.
+  const media = (query: string) => ({
+    media: query,
+    matches: false,
+    onchange: null,
+    addEventListener() {},
+    removeEventListener() {},
+    addListener() {},
+    removeListener() {},
+    dispatchEvent: () => false,
+  });
+  Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: media });
+  globals.matchMedia = media;
+  if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};
+});
+
+beforeEach(() => {
+  host = document.createElement('div');
+  document.body.appendChild(host);
+  trail.clear();
+});
+
+afterEach(() => {
+  if (mounted) {
+    void unmount(mounted);
+    mounted = null;
+  }
+  host.remove();
+  setGraphAdapter(null);
+  setNavigationDriver(null);
+});
+
+/**
+ * Mount a component and let its data effects settle.
+ *
+ * Every screen fetches inside an `$effect`, so a render is not finished until
+ * the promise the adapter returned has resolved and the follow-up render has
+ * flushed. Two macrotask turns cover the deepest chain any of them has (the
+ * Symbol view: node, then its source).
+ */
+async function render(
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  component: any,
+  props: Record<string, unknown>
+): Promise<void> {
+  mounted = mount(component, { target: host, props }) as Record<string, unknown>;
+  for (let turn = 0; turn < 4; turn += 1) {
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    flushSync();
+  }
+}
+
+describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
+  it('SymbolView draws callers, source and the callee rail from a mock adapter', async () => {
+    const { adapter, calls } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    await render(SymbolView, { id: SYMBOL.node.id, line: null });
+
+    // It asked the adapter, by id, and it asked for the symbol's own slice.
+    expect(calls).toContain(`node:${SYMBOL.node.id}`);
+    expect(calls).toContain('source:src/auth/token.ts');
+
+    const text = host.textContent ?? '';
+    expect(text).toContain('parseToken');
+    // The caller rail (left) and the callee rail (right) are both drawn.
+    expect(text).toContain('handleCallback');
+    expect(text).toContain('decodeJwt');
+    // The verbatim source, not a summary of it.
+    expect(text).toContain('expiresAt');
+    // The honesty badge: nothing in the fixture's graph tests this symbol.
+    expect(text.toLowerCase()).toContain('test');
+  });
+
+  it('TypeHierarchy draws the fan, its wiring and its fold from a payload alone', async () => {
+    const implementers = Array.from({ length: 14 }, (_, i) => ({
+      id: `impl-${i}`,
+      kind: 'class' as const,
+      name: `Target${i}`,
+      qualifiedName: `Target${i}`,
+      file: `src/targets/target-${i}.ts`,
+      line: 1,
+      endLine: 9,
+      language: 'typescript' as const,
+      test: false,
+      depth: 1,
+      parentId: SYMBOL.node.id,
+      relation: 'implements' as const,
+      // The first one arrived through a resolver rather than a parse, which is
+      // the case the block has to draw differently.
+      synthesized: i === 0,
+      ...(i === 0 ? { via: 'go-implements', registeredAt: 'src/clock.go:11' } : {}),
+      hiddenSubtypes: 0,
+    }));
+    const hierarchy: WireHierarchy = {
+      ancestors: { total: 0, shown: 0, truncated: false, items: [] },
+      descendants: {
+        total: implementers.length,
+        shown: implementers.length,
+        truncated: false,
+        items: implementers,
+      },
+      direct: implementers.length,
+      implementers: implementers.length,
+      bounded: false,
+      polymorphic: true,
+    };
+
+    await render(TypeHierarchy, { hierarchy, focus: SYMBOL.node, onopen: () => {} });
+
+    const text = host.textContent ?? '';
+    // The claim a reader cannot get by counting rows.
+    expect(text).toContain('14 implementations');
+    // The wiring site of the synthesized edge.
+    expect(text).toContain('go-implements');
+    // Twelve rows, then the fold — never a silent truncation.
+    expect(text).toContain('+2 more implementations');
+    expect(text).toContain('Target0');
+    expect(text).not.toContain('Target13');
+    // It draws no network of its own: this component was handed a payload.
+    expect(host.querySelectorAll('path').length).toBe(12);
+  });
+
+  it('FlowStrip draws one card per hop from a mock adapter', async () => {
+    const { adapter, calls } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    await render(FlowStrip, {
+      from: 'handleCallback',
+      to: 'decodeJwt',
+      symbols: null,
+      trailParam: null,
+    });
+
+    expect(calls).toContain('flow');
+    const text = host.textContent ?? '';
+    expect(text).toContain('handleCallback');
+    expect(text).toContain('parseToken');
+  });
+
+  it('ArchitectureMap draws modules and their dependency from a mock adapter', async () => {
+    const { adapter, calls } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    await render(ArchitectureMap, { root: 'src', depth: 1, tests: false });
+
+    expect(calls).toContain('map');
+    const text = host.textContent ?? '';
+    expect(text).toContain('auth');
+    expect(text).toContain('http');
+  });
+
+  it('TrailBar and SearchPalette mount and read through the same adapter', async () => {
+    const { adapter } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
+    await render(TrailBar, {});
+    expect(host.textContent ?? '').toContain('parseToken');
+
+    void unmount(mounted as Record<string, unknown>);
+    mounted = null;
+    host.innerHTML = '';
+
+    await render(SearchPalette, {});
+    expect(host.querySelector('input[role="combobox"]')).not.toBeNull();
+  });
+
+  it('offers no Save when the adapter cannot write, and says why in the list', async () => {
+    const { adapter } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
+    await render(TrailBar, {});
+    // The one screen affordance that must never appear against a read-only
+    // host: an adapter with no `saveTrail` has no button, not a button that
+    // fails.
+    expect(host.textContent ?? '').not.toContain('Save trail');
+
+    void unmount(mounted as Record<string, unknown>);
+    mounted = null;
+    host.innerHTML = '';
+
+    await render(SavedTrails, { hideWhenEmpty: false });
+    const text = host.textContent ?? '';
+    expect(text).toContain('Saved trails');
+    expect(text).toContain('This host does not store trails.');
+  });
+
+  it('CodegraphUi installs the adapter before its children ask for data', async () => {
+    const { adapter, calls } = mockAdapter();
+    // NOT installed by hand — the provider is the only thing that installs it.
+    expect(getGraphAdapter()).not.toBe(adapter);
+
+    mounted = mount(CodegraphUi, { target: host, props: { adapter } }) as Record<string, unknown>;
+    flushSync();
+    expect(getGraphAdapter()).toBe(adapter);
+    expect(calls).toEqual([]);
+  });
+});
+
+describe('@colbymchenry/codegraph-ui — the seams', () => {
+  it('a host navigation driver replaces every href the components build', () => {
+    const seen: string[] = [];
+    const driver: NavigationDriver = {
+      symbolHref: (id) => `/review/42/symbol/${encodeURIComponent(id)}`,
+      fileHref: (path) => `/review/42/file/${path}`,
+      mapHref: () => '/review/42/map',
+      flowHref: () => '/review/42/flow',
+      entryHref: () => '/review/42',
+      navigate: (href) => seen.push(href),
+      back: () => seen.push('back'),
+    };
+    setNavigationDriver(driver);
+
+    expect(symbolHref('function:x')).toBe('/review/42/symbol/function%3Ax');
+    expect(fileHref('src/a.ts')).toBe('/review/42/file/src/a.ts');
+    expect(mapHref()).toBe('/review/42/map');
+    expect(flowHref()).toBe('/review/42/flow');
+
+    setNavigationDriver(null);
+    // Back to the viewer's own address space, unchanged.
+    expect(symbolHref('function:x')).toBe(hashNavigation.symbolHref('function:x'));
+    expect(symbolHref('function:x')).toBe('#/s/function%3Ax');
+  });
+
+  it('the default adapter is the loopback JSON API and asks for `api/...`', async () => {
+    const asked: string[] = [];
+    const adapter = createHttpAdapter({
+      fetch: async (input) => {
+        asked.push(String(input));
+        return new Response(JSON.stringify(STATS), {
+          status: 200,
+          headers: { 'content-type': 'application/json' },
+        });
+      },
+    });
+    await adapter.stats();
+    await adapter.node('function:parse@a.ts:1');
+    await adapter.source({ file: 'src/a.ts', from: 1, to: 4 });
+    await adapter.nodes(['a', 'b']);
+
+    expect(asked[0]).toBe('api/stats');
+    // Ids are encoded per slash-separated segment, so ':' survives and '/' is
+    // still a path separator.
+    expect(asked[1]).toBe('api/node/function%3Aparse%40a.ts%3A1');
+    expect(asked[2]).toBe('api/source?file=src%2Fa.ts&from=1&to=4');
+    // Repeated `id` params, never a comma-joined list.
+    expect(asked[3]).toBe('api/nodes?id=a&id=b');
+  });
+
+  it('an adapter with no live channel never connects and never polls', () => {
+    const { adapter } = mockAdapter();
+    setGraphAdapter(adapter);
+    expect(adapter.events).toBeUndefined();
+    // `live.start()` is a no-op in a jsdom test that never called it; what is
+    // asserted here is the counters a host can still drive by hand.
+    const before = live.indexTick;
+    live.signal('index', { index: { lastIndexedAt: 1, files: 3 } });
+    expect(live.indexTick).toBe(before + 1);
+  });
+});
+
+describe('@colbymchenry/codegraph-ui — the published shape', () => {
+  const manifest = JSON.parse(
+    readFileSync(join(ROOT, 'ui', 'package.json'), 'utf8')
+  ) as Record<string, any>;
+
+  it('is versioned with the engine', () => {
+    const engine = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as {
+      version: string;
+    };
+    expect(manifest.version).toBe(engine.version);
+  });
+
+  it('is named, scoped and not publishable by accident', () => {
+    expect(manifest.name).toBe('@colbymchenry/codegraph-ui');
+    // The package is PREPARED, not published (CG-61). `private` is the guard:
+    // npm refuses to publish it until the maintainer deliberately removes this.
+    expect(manifest.private).toBe(true);
+  });
+
+  it('exports the entry, the theme and nothing else', () => {
+    expect(Object.keys(manifest.exports).sort()).toEqual(['.', './package.json', './theme.css']);
+    expect(manifest.exports['.'].svelte).toBe('./dist/index.js');
+    expect(manifest.exports['.'].types).toBe('./dist/index.d.ts');
+  });
+
+  it('takes svelte as a peer, so a host never gets a second copy', () => {
+    expect(manifest.peerDependencies.svelte).toBeDefined();
+    expect(manifest.dependencies?.svelte).toBeUndefined();
+    // The canvas library is a real dependency: the Map and the Flow strip are
+    // unusable without it and a host must not have to know its version.
+    expect(manifest.dependencies['@xyflow/svelte']).toBeDefined();
+  });
+});

+ 330 - 0
__tests__/ui-program-model.test.ts

@@ -0,0 +1,330 @@
+/**
+ * The Steps picture in the code's order: the graph of what happens next.
+ *
+ * The server folds the walk into blocks and forks (`api/program.ts`); this
+ * turns that into the canvas's graph — one edge per "and then", carrying the
+ * condition where the code branched, and a row per step counted by how much
+ * has to happen before it. What is pinned here is exactly that: the shape of
+ * the picture, which is the thing a reader looks at.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { buildOrderModel, lineWords, orderGraph, runWords } from '../ui/src/lib/program-model';
+import { selectionReach, stepEdgeVisible } from '../ui/src/lib/steps-model';
+import { placeLabels } from '../ui/src/lib/screens-model';
+import type { WireArm, WireBlock, WireItem, WireProgram, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
+
+/* ------------------------------------------------------------ material -- */
+
+const step = (id: string, over: Partial<WireStep> = {}): WireStep => ({
+  id,
+  kind: 'effect',
+  anchor: false,
+  node: null,
+  label: id,
+  sub: 'response · handler',
+  depth: 1,
+  cut: null,
+  ...over,
+});
+
+const arm = (when: string, body: WireBlock, over: Partial<WireArm> = {}): WireArm => ({ when, ends: null, body, ...over });
+
+function payload(steps: WireStep[], root: WireBlock): WireStepsPayload {
+  return {
+    anchor: { id: 'anchor', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false },
+    ambiguous: [],
+    project: 'api',
+    steps: [step('anchor', { kind: 'anchor', anchor: true, label: 'POST /login' }), ...steps],
+    links: [],
+    program: { root, truncated: 0 },
+    defaultView: 'order',
+    depth: 8,
+    limit: 120,
+    through: false,
+    truncated: { steps: 0, hubs: 0, chrome: 0 },
+    index: { lastIndexedAt: null, edges: 0, files: 0 },
+    timing: { elapsedMs: 1 },
+  };
+}
+
+/** The graph as `from → to` lines, each with what has to hold. */
+function shape(root: WireBlock): string[] {
+  const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+  return g.edges.map((e) => `${e.from} → ${e.to}${e.when ? ` · ${lineWords(e)}` : ''}${e.runs.length ? ` [${e.runs.join(', ')}]` : ''}`);
+}
+
+function rowsOf(root: WireBlock): Record<string, number> {
+  const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+  return Object.fromEntries(g.depth);
+}
+
+/* --------------------------------------------------------------- tests -- */
+
+describe('the picture in the code’s order', () => {
+  it('puts one step after the next', () => {
+    expect(shape([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual(['anchor → a', 'a → b']);
+    expect(rowsOf([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual({ anchor: 0, a: 1, b: 2 });
+  });
+
+  it('diverges both arms from a point that asks the condition once', () => {
+    // proshop's login: look the user up, then sign+answer 200, else answer 401.
+    // The decision is ONE choice, so it draws once — a point the arms leave,
+    // each line saying only which arm it is — not two lines that each carry
+    // the whole predicate, one of them negated.
+    const on = 'user && (await user.matchPassword(password))';
+    const root: WireBlock = [
+      { kind: 'step', step: 'findOne' },
+      {
+        kind: 'fork',
+        form: 'if',
+        on,
+        arms: [
+          arm(on, [{ kind: 'block', block: 'inline', body: [{ kind: 'step', step: 'sign' }] }, { kind: 'step', step: '200' }], { ends: 'reply' }),
+          arm(`!(${on})`, [{ kind: 'step', step: '401' }], { not: true, ends: 'reply' }),
+        ],
+      },
+    ];
+    const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+    expect(g.forks).toEqual([{ id: 'fork:0', on, form: 'if' }]);
+    expect(shape(root)).toEqual([
+      'anchor → findOne',
+      'findOne → fork:0',
+      'fork:0 → sign · yes [via a helper]',
+      'sign → 200',
+      'fork:0 → 401 · no',
+    ]);
+    // The arm's own condition still rides the line, for the hover.
+    expect(g.edges.find((e) => e.to === '401')!.when).toBe(`!(${on})`);
+    // The 200 sits a row BELOW the signing, which is the whole point; the
+    // decision takes a row of its own between the lookup and the arms.
+    expect(rowsOf(root)).toEqual({ anchor: 0, findOne: 1, 'fork:0': 2, sign: 3, '200': 4, '401': 3 });
+  });
+
+  it('rejoins after an arm that runs on, and stops at one that ends', () => {
+    const root: WireBlock = [
+      { kind: 'step', step: 'lookup' },
+      {
+        kind: 'fork',
+        form: 'if',
+        on: 'ready',
+        arms: [arm('ready', [{ kind: 'step', step: 'inside' }]), arm('!ready', [{ kind: 'step', step: 'bail' }], { not: true, ends: 'return' })],
+      },
+      { kind: 'step', step: 'after' },
+    ];
+    expect(shape(root)).toEqual([
+      'anchor → lookup',
+      'lookup → fork:0',
+      'fork:0 → inside · yes',
+      'fork:0 → bail · no',
+      'inside → after',
+    ]);
+  });
+
+  it('labels a switch’s arms with their own values, and its default with else', () => {
+    const root: WireBlock = [
+      { kind: 'step', step: 'load' },
+      {
+        kind: 'fork',
+        form: 'switch',
+        on: 'status',
+        arms: [
+          arm("status === 'expired'", [{ kind: 'step', step: 'refresh' }]),
+          arm("status === 'active'", [{ kind: 'step', step: 'serve' }]),
+          arm("!(status === 'expired' || status === 'active')", [{ kind: 'step', step: 'reject' }], { not: true, ends: 'reply' }),
+        ],
+      },
+    ];
+    expect(shape(root)).toEqual([
+      'anchor → load',
+      'load → fork:0',
+      "fork:0 → refresh · 'expired'",
+      "fork:0 → serve · 'active'",
+      'fork:0 → reject · else',
+    ]);
+  });
+
+  it('keeps a lone guard on the line — an early exit is not a point', () => {
+    // `if (!product) throw` — the exit arm is empty; only one arm draws, so
+    // the condition rides the line exactly as before.
+    const root: WireBlock = [
+      { kind: 'step', step: 'lookup' },
+      {
+        kind: 'fork',
+        form: 'if',
+        on: 'product',
+        arms: [arm('product', [], { ends: 'throw' }), arm('!product', [{ kind: 'step', step: 'render' }], { not: true })],
+      },
+    ];
+    const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+    expect(g.forks).toEqual([]);
+    expect(shape(root)).toEqual(['anchor → lookup', 'lookup → render · WHEN NOT product']);
+  });
+
+  it('stops claiming a side when both arms reach the same step', () => {
+    const root: WireBlock = [
+      { kind: 'step', step: 'check' },
+      {
+        kind: 'fork',
+        form: 'if',
+        on: 'a',
+        arms: [
+          arm('a', [{ kind: 'step', step: 'log' }, { kind: 'step', step: 'go' }]),
+          arm('!(a)', [{ kind: 'step', step: 'log', again: true }], { not: true }),
+        ],
+      },
+    ];
+    const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+    const toLog = g.edges.find((e) => e.to === 'log')!;
+    expect(toLog.arm).toBeUndefined();
+    expect(toLog.when).toBe('a || !(a)');
+  });
+
+  it('runs on either way past an `if` with no else', () => {
+    const root: WireBlock = [
+      { kind: 'step', step: 'lookup' },
+      { kind: 'fork', form: 'if', on: 'verified', arms: [arm('verified', [{ kind: 'step', step: 'mail' }])] },
+      { kind: 'step', step: 'reply' },
+    ];
+    expect(shape(root)).toEqual([
+      'anchor → lookup',
+      'lookup → mail · WHEN verified',
+      'mail → reply',
+      'lookup → reply',
+    ]);
+  });
+
+  it('reads on into what a step sets in motion before the next step', () => {
+    const root: WireBlock = [
+      { kind: 'step', step: 'save', body: [{ kind: 'step', step: 'write' }] },
+      { kind: 'step', step: 'reply' },
+    ];
+    expect(shape(root)).toEqual(['anchor → save', 'save → write', 'write → reply']);
+  });
+
+  it('says the run a line happens inside', () => {
+    const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
+    expect(shape([{ kind: 'block', block: 'inline', via, body: [{ kind: 'step', step: 'sign' }] }])).toEqual([
+      'anchor → sign [via generateToken]',
+    ]);
+    expect(shape([{ kind: 'block', block: 'loop', by: 'item of items', loop: 'each', body: [{ kind: 'step', step: 'save' }] }])).toEqual([
+      'anchor → save [for each item of items]',
+    ]);
+  });
+
+  it('carries on past a helper that answers on every path', () => {
+    // express-realworld: `login()` throws on each guard and returns on one; the
+    // handler's own `res.json` still follows the call.
+    const root: WireBlock = [
+      {
+        kind: 'block',
+        block: 'inline',
+        body: [{ kind: 'fork', form: 'if', on: 'bad', arms: [arm('bad', [{ kind: 'step', step: '422' }], { ends: 'reply' })] }],
+      },
+      { kind: 'step', step: '200' },
+    ];
+    expect(shape(root)).toEqual(['anchor → 422 · WHEN bad [via a helper]', 'anchor → 200']);
+  });
+
+  it('lets nothing float: a step the fold could not place follows the anchor', () => {
+    const g = orderGraph({ root: [{ kind: 'cut', why: 'folded' }], truncated: 1 } as WireProgram, 'anchor');
+    expect(g.edges).toEqual([]);
+  });
+
+  it('settles the rows of a step reached twice rather than looping', () => {
+    const root: WireBlock = [{ kind: 'step', step: 'db' }, { kind: 'step', step: 'check' }, { kind: 'step', step: 'db' }];
+    expect(shape(root)).toEqual(['anchor → db', 'db → check', 'check → db']);
+    expect(rowsOf(root)).toEqual({ anchor: 0, db: 1, check: 2 });
+  });
+
+  it('never spreads a cyclic reading over more rows than it has boxes', () => {
+    // A helper the code comes back to from inside a decision makes the graph
+    // cyclic. Relaxing over a cycle never settles — it added a row on every
+    // pass until the bound, so on a real screen sixteen boxes landed on sixty
+    // rows and the picture was a 9,000px ribbon of empty space that no fit
+    // could open on.
+    const root: WireBlock = [
+      { kind: 'step', step: 'logout' },
+      { kind: 'step', step: 'flags' },
+      {
+        kind: 'fork',
+        form: 'if',
+        on: 'options?.showAlert',
+        arms: [
+          arm('options?.showAlert', [{ kind: 'step', step: 'logout', again: true }]),
+          arm('!options?.showAlert', [{ kind: 'step', step: 'quiet' }], { not: true }),
+        ],
+      },
+    ];
+    const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+    // The cycle is real and still drawn — it is only the ROW that ignores it.
+    expect(g.edges.some((e) => e.to === 'logout' && e.from.startsWith('fork:'))).toBe(true);
+    const depths = [...g.depth.values()];
+    expect(Math.max(...depths)).toBeLessThan(g.depth.size);
+    // Every row between the top and the deepest holds something.
+    expect(new Set(depths).size).toBe(Math.max(...depths) + 1);
+  });
+
+  it('names each kind of run', () => {
+    const via = { id: 'f', kind: 'function' as const, name: 'gen', qualifiedName: 'gen', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
+    const block = (over: Partial<Extract<WireItem, { kind: 'block' }>>) => runWords({ kind: 'block', block: 'inline', body: [], ...over } as Extract<WireItem, { kind: 'block' }>);
+    expect(block({ via })).toBe('via gen');
+    expect(block({})).toBe('via a helper');
+    expect(block({ block: 'later', by: 'then' })).toBe('later · then');
+    expect(block({ block: 'loop', by: 'item of items', loop: 'each' })).toBe('for each item of items');
+    expect(block({ block: 'loop', by: 'queue.length', loop: 'while' })).toBe('again while queue.length');
+    expect(block({ block: 'together', by: 'Promise.all' })).toBe('together · Promise.all');
+  });
+
+  it('builds a picture the canvas can draw, and nothing when there is no body', () => {
+    const model = buildOrderModel(
+      payload([step('findOne'), step('200')], [{ kind: 'step', step: 'findOne' }, { kind: 'step', step: '200' }])
+    );
+    expect(model).not.toBeNull();
+    expect([...model!.nodes.keys()].sort()).toEqual(['200', 'anchor', 'findOne']);
+    expect(model!.layout.nodes).toHaveLength(3);
+    // The anchor is on top: layer 0 is the bottom.
+    const layer = (id: string) => model!.layout.nodes.find((n) => n.id === id)!.layer;
+    expect(layer('anchor')).toBeGreaterThan(layer('findOne'));
+    expect(layer('findOne')).toBeGreaterThan(layer('200'));
+    expect(buildOrderModel({ ...payload([], []), program: null })).toBeNull();
+  });
+
+  it('draws a decision as a point, and the selection reaches through it', () => {
+    const root: WireBlock = [
+      { kind: 'step', step: 'lookup' },
+      {
+        kind: 'fork',
+        form: 'if',
+        on: 'ready',
+        arms: [arm('ready', [{ kind: 'step', step: 'inside' }]), arm('!ready', [{ kind: 'step', step: 'bail' }], { not: true, ends: 'return' })],
+      },
+    ];
+    const model = buildOrderModel(payload([step('lookup'), step('inside'), step('bail')], root))!;
+    expect(model.forks!.get('fork:0')).toEqual({ id: 'fork:0', on: 'ready', form: 'if', label: 'ready?' });
+    // The point sits between the step before the fork and the arms; it is not a step.
+    const at = (id: string) => model.layout.nodes.find((n) => n.id === id)!;
+    expect(at('fork:0').y).toBeGreaterThan(at('lookup').y);
+    expect(at('fork:0').y).toBeLessThan(at('inside').y);
+    expect(model.nodes.has('fork:0')).toBe(false);
+    expect(model.counts.effect).toBe(3);
+    // The lines out of it say the arm; the line into it says nothing.
+    const label = (to: string) => [...model.edges.values()].find((e) => e.to === to)!.label;
+    expect(label('fork:0')).toBe('');
+    expect(label('inside')).toBe('yes');
+    expect(label('bail')).toBe('no');
+    // At rest the arms are labelled — the conditions are this picture's content.
+    const pills = placeLabels(model, null, true);
+    expect([...pills.pills.values()].map((p) => p.text).sort()).toEqual(['→ no', '→ yes']);
+    // Selecting the step before the decision reaches through the point: the
+    // arms' lines light, instead of dying at a box the reader cannot click.
+    const reach = selectionReach(model, 'lookup');
+    expect(reach.has('fork:0')).toBe(true);
+    const armEdge = model.layout.edges.find((e) => e.source === 'fork:0' && e.target === 'inside')!;
+    expect(stepEdgeVisible(model, armEdge, 'lookup')).toBe(true);
+    expect(stepEdgeVisible(model, armEdge, 'lookup', reach)).toBe(true);
+    // …and selecting an arm lights its sibling, through the same point.
+    const sibling = model.layout.edges.find((e) => e.source === 'fork:0' && e.target === 'bail')!;
+    expect(stepEdgeVisible(model, sibling, 'inside')).toBe(true);
+  });
+});

+ 659 - 0
__tests__/ui-screens-model.test.ts

@@ -0,0 +1,659 @@
+/**
+ * The Screens view's model, without a browser.
+ *
+ * What is under test is what makes the picture readable when a hub is
+ * selected — the case the view exists for, and the case that first shipped as
+ * a pile of pills under a knot of lines:
+ *
+ * - a label says the clause that decides the transition, not the first thirty
+ *   characters of a chain two siblings share;
+ * - a screen's row is its distance from the entry, and shared chrome hangs
+ *   where what it opens is, never dragging a screen up beside the home screen;
+ * - a return trip leaves the top of its box and arrives at the bottom of the
+ *   other, so it is drawn around the boxes rather than through them;
+ * - every pill sits at the far end of its line, in a lane, and no two overlap;
+ *   one that fits nowhere is counted rather than drawn on top of something.
+ *
+ * The endpoint that feeds it is exercised against a real index in
+ * `expo-router.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildScreensModel,
+  clauses,
+  edgeLabel,
+  hoverPill,
+  laneCount,
+  nearestEdge,
+  pairId,
+  pillText,
+  pillWidth,
+  placeLabels,
+  pointAt,
+  screenCurve,
+  screenEdgePath,
+  tAtY,
+  EDGE_LABEL_MAX,
+  PILL_HEIGHT,
+  SCREEN_LAYER_GAP,
+  type Curve,
+  type PillPlacement,
+  type Point,
+  type ScreensModel,
+} from '../ui/src/lib/screens-model';
+import { linkId, portPoint, NODE_HEIGHT, PORT_PITCH, type MapNodeLayout } from '../ui/src/lib/map-model';
+import type {
+  WireNodeRef,
+  WireScreen,
+  WireScreenLink,
+  WireScreenOrigin,
+  WireScreensPayload,
+} from '../ui/src/lib/wire';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function ref(name: string): WireNodeRef {
+  return {
+    id: `function:${name}`,
+    kind: 'function',
+    name,
+    qualifiedName: name,
+    file: `src/${name}.tsx`,
+    line: 1,
+    endLine: 20,
+    language: 'tsx',
+    test: false,
+  };
+}
+
+const R = (path: string): string => `route:${path}`;
+
+function screen(path: string): WireScreen {
+  return {
+    id: R(path),
+    path,
+    file: `src/app${path === '/' ? '/index' : path}.tsx`,
+    line: 1,
+    component: ref(path === '/' ? 'Index' : path.replace(/[^a-z0-9]/gi, '')),
+    incoming: 0,
+    outgoing: 0,
+  };
+}
+
+function origin(name: string, sharedBy?: number): WireScreenOrigin {
+  return { id: `function:${name}`, node: ref(name), outgoing: 1, ...(sharedBy ? { sharedBy } : {}) };
+}
+
+let seq = 0;
+/** `from`/`to` are screen paths, or a `function:` id for an origin. */
+function link(from: string, to: string, when = '', over: Partial<WireScreenLink> = {}): WireScreenLink {
+  const id = (s: string): string => (s.startsWith('function:') ? s : R(s));
+  return {
+    id: `l${seq++}`,
+    from: id(from),
+    to: id(to),
+    fromOrigin: from.startsWith('function:'),
+    via: [],
+    when,
+    sites: [],
+    synthesized: false,
+    ...over,
+  };
+}
+
+function payload(
+  screens: WireScreen[],
+  links: WireScreenLink[],
+  origins: WireScreenOrigin[] = [],
+  entry: string | null = R('/')
+): WireScreensPayload {
+  return {
+    routed: true,
+    entry,
+    screens,
+    origins,
+    links,
+    dropped: 0,
+    index: { lastIndexedAt: null, edges: 0, files: 0 },
+    timing: { elapsedMs: 0 },
+  };
+}
+
+function nodeOf(model: ScreensModel, id: string): MapNodeLayout {
+  const node = model.layout.nodes.find((n) => n.id === id);
+  expect(node, `no node ${id}`).toBeTruthy();
+  return node!;
+}
+
+function layerOf(model: ScreensModel, id: string): number {
+  return nodeOf(model, id).layer;
+}
+
+function edgeOf(model: ScreensModel, from: string, to: string) {
+  const id = linkId({ source: from, target: to });
+  const edge = model.layout.edges.find((e) => e.id === id);
+  expect(edge, `no edge ${from} -> ${to}`).toBeTruthy();
+  return edge!;
+}
+
+interface Rect {
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+}
+const rectOf = (p: PillPlacement): Rect => ({ x: p.x - p.width / 2, y: p.y - PILL_HEIGHT / 2, w: p.width, h: PILL_HEIGHT });
+const boxOf = (n: MapNodeLayout): Rect => ({ x: n.x, y: n.y, w: n.width, h: n.height });
+const overlaps = (a: Rect, b: Rect): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
+
+/** A home screen that opens twelve screens, six of which come back. */
+function hub(): WireScreensPayload {
+  const targets = Array.from({ length: 12 }, (_, i) => `/t${i}`);
+  return payload(
+    [screen('/'), screen('/home'), ...targets.map(screen)],
+    [
+      link('/', '/home'),
+      ...targets.map((t, i) => link('/home', t, `ready && step === ${i}`)),
+      ...targets.slice(0, 6).map((t, i) => link(t, '/home', `done${i}`)),
+    ]
+  );
+}
+
+/* ---------------------------------------------------------------- specs -- */
+
+describe('clauses', () => {
+  it('splits on the top-level && only', () => {
+    expect(clauses('a && (b && c) && d')).toEqual(['a', '(b && c)', 'd']);
+    expect(clauses('!(a || b) && items[i && j]')).toEqual(['!(a || b)', 'items[i && j]']);
+  });
+
+  it('leaves a string alone', () => {
+    expect(clauses("x === 'a && b' && y")).toEqual(["x === 'a && b'", 'y']);
+    expect(clauses('t === `${a && b}` && z')).toEqual(['t === `${a && b}`', 'z']);
+  });
+
+  it('returns a disjunction whole — it has no innermost term', () => {
+    expect(clauses('a && b || c')).toEqual(['a && b || c']);
+  });
+
+  it('handles the edges', () => {
+    expect(clauses('')).toEqual([]);
+    expect(clauses('visible')).toEqual(['visible']);
+  });
+});
+
+describe('edgeLabel', () => {
+  const chain = 'uncollected && !(selectedDetectionItems.length > 0) && canProceed && ';
+
+  it('says the innermost clause, with an ellipsis for what came before', () => {
+    const collect = edgeLabel([link('/home', '/capture/collect', `${chain}guide.dontShowAgain.captureGuide`)]);
+    const intro = edgeLabel([link('/home', '/guide', `${chain}!guide.dontShowAgain.captureGuide`)]);
+    expect(collect).toBe('…guide.dontShowAgain.captureGuide');
+    expect(intro).toBe('…NOT guide.dontShowAgain.captureGuide');
+    // The whole point: two arms of a fork no longer read the same.
+    expect(collect).not.toBe(intro);
+  });
+
+  it('prints a single clause without an ellipsis, and nothing when unconditional', () => {
+    expect(edgeLabel([link('/home', '/queue', 'visible')])).toBe('visible');
+    expect(edgeLabel([link('/home', '/queue')])).toBe('');
+  });
+
+  it('cuts an innermost clause that is itself too long, saying so at the end', () => {
+    const label = edgeLabel([link('/a', '/b', 'x && ' + 'y'.repeat(60))]);
+    expect(label.length).toBe(EDGE_LABEL_MAX);
+    expect(label.startsWith('…')).toBe(true);
+    expect(label.endsWith('…')).toBe(true);
+  });
+
+  it('counts several transitions between one pair', () => {
+    expect(edgeLabel([link('/a', '/b', 'x'), link('/a', '/b')])).toBe('2 ways · 1 conditional');
+    expect(edgeLabel([link('/a', '/b'), link('/a', '/b')])).toBe('2 ways');
+  });
+});
+
+describe('layering by distance from the entry', () => {
+  it('hangs shared chrome one row above the shallowest screen it opens', () => {
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/home'), screen('/soak-test')],
+        [link('/', '/home'), link('/home', '/soak-test'), link('function:TopBar', '/soak-test')],
+        [origin('TopBar', 10)]
+      )
+    );
+    // Higher layer = higher on the picture.
+    expect(layerOf(model, R('/'))).toBe(layerOf(model, R('/home')) + 1);
+    expect(layerOf(model, R('/soak-test'))).toBe(layerOf(model, R('/home')) - 1);
+    // The top bar sits beside /home, not beside the entry — so what it opens
+    // is below it AND below the screen the user actually opened it from.
+    expect(layerOf(model, 'function:TopBar')).toBe(layerOf(model, R('/home')));
+    expect(edgeOf(model, 'function:TopBar', R('/soak-test')).route).toBe('down');
+  });
+
+  it('never lets chrome pull a screen up the picture', () => {
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/a'), screen('/b'), screen('/c')],
+        [link('/', '/a'), link('/a', '/b'), link('/b', '/c'), link('function:TopBar', '/c')],
+        [origin('TopBar', 4)]
+      )
+    );
+    expect(layerOf(model, R('/c'))).toBe(layerOf(model, R('/')) - 3);
+    expect(layerOf(model, 'function:TopBar')).toBe(layerOf(model, R('/b')));
+  });
+
+  it('seeds what only an origin opens, from the top', () => {
+    const model = buildScreensModel(
+      payload([screen('/'), screen('/detail')], [link('function:openDetail', '/detail')], [origin('openDetail')])
+    );
+    expect(layerOf(model, 'function:openDetail')).toBe(layerOf(model, R('/')));
+    expect(layerOf(model, R('/detail'))).toBe(layerOf(model, R('/')) - 1);
+    // Reached through chrome is reached.
+    expect(model.unreached).toBe(0);
+  });
+
+  it('measures distance over every transition, not the two-cycle-broken set', () => {
+    // Three returns against one arrival: the Map's break would keep /a -> /
+    // and drop / -> /a, and then /a would have no way of being one below /.
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/a')],
+        [link('/', '/a'), link('/a', '/', 'x'), link('/a', '/', 'y'), link('/a', '/', 'z')]
+      )
+    );
+    expect(layerOf(model, R('/a'))).toBe(layerOf(model, R('/')) - 1);
+  });
+
+  it('puts what nothing reaches in a band at the bottom, one empty row below the rest', () => {
+    const model = buildScreensModel(
+      payload([screen('/'), screen('/home'), screen('/orphan')], [link('/', '/home')])
+    );
+    expect(layerOf(model, R('/orphan'))).toBe(0);
+    expect(layerOf(model, R('/home'))).toBe(2);
+    expect(layerOf(model, R('/'))).toBe(3);
+    expect(model.unreached).toBe(1);
+    expect(model.nodes.get(R('/orphan'))!.unreached).toBe(true);
+  });
+
+  it('draws the same picture twice', () => {
+    const a = buildScreensModel(hub());
+    const b = buildScreensModel(hub());
+    expect(a.layout).toEqual(b.layout);
+  });
+});
+
+describe('directional ports', () => {
+  it('routes a return from the top of its source to the bottom of its target', () => {
+    const model = buildScreensModel(
+      payload([screen('/'), screen('/home')], [link('/', '/home'), link('/home', '/', 'logout')])
+    );
+    const root = nodeOf(model, R('/'));
+    const home = nodeOf(model, R('/home'));
+    const down = edgeOf(model, R('/'), R('/home'));
+    const up = edgeOf(model, R('/home'), R('/'));
+    expect(down.route).toBe('down');
+    expect(up.route).toBe('up');
+    expect(up.back).toBe(true);
+    // Down: bottom of / to top of /home. Up: top of /home to bottom of /.
+    expect(portPoint(root, down.id, 'source').y).toBe(root.y + NODE_HEIGHT);
+    expect(portPoint(home, down.id, 'target').y).toBe(home.y);
+    expect(portPoint(home, up.id, 'source').y).toBe(home.y);
+    expect(portPoint(root, up.id, 'target').y).toBe(root.y + NODE_HEIGHT);
+    // And the node component draws exactly those ports: one of each on the
+    // sides that face each other, nothing on the sides that do not.
+    expect(home.ports.top.map((p) => p.type).sort()).toEqual(['source', 'target']);
+    expect(root.ports.bottom.map((p) => p.type).sort()).toEqual(['source', 'target']);
+    expect(home.ports.bottom).toEqual([]);
+    expect(root.ports.top).toEqual([]);
+  });
+
+  it('joins two screens on one row over the top', () => {
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/a'), screen('/b')],
+        [link('/', '/a'), link('/', '/b'), link('/a', '/b', 'next')]
+      )
+    );
+    const a = nodeOf(model, R('/a'));
+    const b = nodeOf(model, R('/b'));
+    const level = edgeOf(model, R('/a'), R('/b'));
+    expect(a.layer).toBe(b.layer);
+    expect(level.route).toBe('level');
+    expect(portPoint(a, level.id, 'source').y).toBe(a.y);
+    expect(portPoint(b, level.id, 'target').y).toBe(b.y);
+  });
+
+  it('widens a hub to keep its ports apart, and spaces rows for the labels', () => {
+    const targets = Array.from({ length: 20 }, (_, i) => `/t${i}`);
+    const model = buildScreensModel(
+      payload([screen('/'), screen('/home'), ...targets.map(screen)], [
+        link('/', '/home'),
+        ...targets.map((t) => link('/home', t)),
+      ])
+    );
+    const home = nodeOf(model, R('/home'));
+    expect(home.ports.bottom).toHaveLength(20);
+    expect(home.width).toBeGreaterThanOrEqual((20 + 1) * PORT_PITCH);
+    expect(home.y - nodeOf(model, R('/')).y).toBe(NODE_HEIGHT + SCREEN_LAYER_GAP);
+    expect(model.layerGap).toBe(SCREEN_LAYER_GAP);
+  });
+});
+
+describe('the curve', () => {
+  it('runs from port to port through the vertical midpoint, monotonic in y', () => {
+    const c = screenCurve('down', 0, 0, 100, 116);
+    expect(pointAt(c, 0)).toEqual({ x: 0, y: 0 });
+    expect(pointAt(c, 1)).toEqual({ x: 100, y: 116 });
+    expect(pointAt(c, 0.3).y).toBeLessThan(pointAt(c, 0.6).y);
+    expect(screenEdgePath('down', 0, 0, 100, 116)).toBe('M0,0 C0,58 100,58 100,116');
+    // Height -> parameter -> height round-trips.
+    const y = pointAt(c, 0.3).y;
+    expect(tAtY(c, y, 'target')).toBeCloseTo(0.3, 5);
+    expect(tAtY(c, y, 'source')).toBeCloseTo(0.3, 5);
+  });
+
+  it('arches a level edge above its row, searchable from either end', () => {
+    const c = screenCurve('level', 0, 100, 200, 100);
+    expect(c.y0).toBe(c.y3);
+    expect(pointAt(c, 0.5).y).toBeLessThan(100);
+    expect(tAtY(c, 90, 'target')!).toBeGreaterThan(0.5);
+    expect(tAtY(c, 90, 'source')!).toBeLessThan(0.5);
+    // Above the apex there is no curve.
+    expect(tAtY(c, -900, 'target')).toBeNull();
+  });
+});
+
+describe('placing the labels', () => {
+  it('fits five lanes between rows at the Screens gap, three at the Map\'s', () => {
+    expect(laneCount(SCREEN_LAYER_GAP)).toBe(5);
+    expect(laneCount(74)).toBe(3);
+    expect(laneCount(10)).toBe(1);
+  });
+
+  it('puts every pill at the far end of its line, and none over another or over a box', () => {
+    const model = buildScreensModel(hub());
+    const home = nodeOf(model, R('/home'));
+    const laid = placeLabels(model, R('/home'));
+    // Twelve conditions out, six back; the entry's arrival is unconditional.
+    expect(laid.pills.size + laid.hidden).toBe(18);
+    expect(laid.hidden).toBe(0);
+
+    const pills = [...laid.pills.values()];
+    for (const a of pills) {
+      for (const b of pills) {
+        if (a !== b) expect(overlaps(rectOf(a), rectOf(b)), `${a.text} over ${b.text}`).toBe(false);
+      }
+      for (const node of model.layout.nodes) {
+        expect(overlaps(rectOf(a), boxOf(node)), `${a.text} over ${node.id}`).toBe(false);
+      }
+    }
+
+    for (let i = 0; i < 12; i++) {
+      const target = nodeOf(model, R(`/t${i}`));
+      const out = laid.pills.get(edgeOf(model, R('/home'), R(`/t${i}`)).id)!;
+      expect(out.end).toBe('target');
+      expect(out.text).toBe(`→ …step === ${i}`);
+      // Above the screen it opens, inside the gap — and nearer to it than to /home.
+      expect(out.y).toBeLessThan(target.y);
+      expect(target.y - out.y).toBeLessThanOrEqual(SCREEN_LAYER_GAP);
+      const farX = target.x + target.width / 2;
+      const nearX = home.x + home.width / 2;
+      expect(Math.abs(out.x - farX)).toBeLessThan(Math.abs(out.x - nearX) + 1);
+    }
+    for (let i = 0; i < 6; i++) {
+      const source = nodeOf(model, R(`/t${i}`));
+      const back = laid.pills.get(edgeOf(model, R(`/t${i}`), R('/home')).id)!;
+      expect(back.end).toBe('source');
+      expect(back.text).toBe(`← done${i}`);
+      // A return leaves the top of its screen: the pill is above that box too.
+      expect(back.y).toBeLessThan(source.y);
+    }
+  });
+
+  it('draws nothing at rest, and the same thing every time', () => {
+    const model = buildScreensModel(hub());
+    expect(placeLabels(model, null).pills.size).toBe(0);
+    const a = placeLabels(model, R('/home'));
+    const b = placeLabels(model, R('/home'));
+    expect([...a.pills.entries()]).toEqual([...b.pills.entries()]);
+  });
+
+  it('counts a pill that fits nowhere instead of drawing it on something', () => {
+    const model = buildScreensModel(hub());
+    // One lane only: the second pill above a screen that is both opened and
+    // returned from has nowhere to go.
+    const cramped: ScreensModel = { ...model, layerGap: 10 };
+    expect(laneCount(cramped.layerGap)).toBe(1);
+    const laid = placeLabels(cramped, R('/home'));
+    expect(laid.hidden).toBeGreaterThan(0);
+    expect(laid.pills.size + laid.hidden).toBe(18);
+    const pills = [...laid.pills.values()];
+    for (const a of pills) for (const b of pills) if (a !== b) expect(overlaps(rectOf(a), rectOf(b))).toBe(false);
+  });
+
+  it('labels the hovered line at its target end when nothing is selected', () => {
+    const model = buildScreensModel(hub());
+    const edge = edgeOf(model, R('/home'), R('/t3'));
+    const pill = hoverPill(model, edge.id, null)!;
+    expect(pill.end).toBe('target');
+    expect(pill.text).toBe('→ …step === 3');
+    expect(pill.y).toBeLessThan(nodeOf(model, R('/t3')).y);
+    // Seen from the target, the same line arrives.
+    const arriving = hoverPill(model, edge.id, R('/t3'))!;
+    expect(arriving.end).toBe('source');
+    expect(arriving.text).toBe('← …step === 3');
+  });
+
+  it('says nothing for an unconditional line unless told what to say', () => {
+    const model = buildScreensModel(hub());
+    const edge = edgeOf(model, R('/'), R('/home'));
+    expect(hoverPill(model, edge.id, null)).toBeNull();
+    expect(hoverPill(model, edge.id, R('/home'), '← always')?.text).toBe('← always');
+    expect(pillText(model.edges.get(edge.id)!, edge, null)).toBe('');
+  });
+
+  it('keeps a transient pill clear of the ones the selection placed', () => {
+    const model = buildScreensModel(hub());
+    const laid = placeLabels(model, R('/home'));
+    // /t6 is opened by /home (a pill above it) and, in this payload, returns
+    // nothing; a row hovered for an unconditional return needs a lane of its
+    // own above the same box.
+    const ret = link('/t6', '/home');
+    const withReturn = buildScreensModel({ ...hub(), links: [...hub().links, ret] });
+    const base = placeLabels(withReturn, R('/home'));
+    const edge = edgeOf(withReturn, R('/t6'), R('/home'));
+    expect(base.pills.has(edge.id)).toBe(false);
+    const pill = hoverPill(withReturn, edge.id, R('/home'), '← always', base)!;
+    for (const other of base.pills.values()) {
+      expect(overlaps(rectOf(pill), rectOf(other)), `over ${other.text}`).toBe(false);
+    }
+    expect(pill.lane).toBeGreaterThan(0);
+    void laid;
+  });
+
+  it('sizes a pill from its text', () => {
+    expect(pillWidth('→ x')).toBeGreaterThan(pillWidth('→'));
+    expect(pairId(link('/a', '/a'))).toBeNull();
+    expect(pairId(link('/a', '/b'))).toBe(linkId({ source: R('/a'), target: R('/b') }));
+  });
+});
+
+/* --------------------------------------------------------------- tracks -- */
+
+function orientation(a: Point, b: Point, c: Point): number {
+  return Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
+}
+
+/** Proper crossing of two segments (shared endpoints and touching do not count). */
+function segmentsCross(p1: Point, p2: Point, p3: Point, p4: Point): boolean {
+  const o1 = orientation(p1, p2, p3);
+  const o2 = orientation(p1, p2, p4);
+  const o3 = orientation(p3, p4, p1);
+  const o4 = orientation(p3, p4, p2);
+  return o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0 && o1 !== o2 && o3 !== o4;
+}
+
+function crossings(a: readonly Point[], b: readonly Point[]): number {
+  let n = 0;
+  for (let i = 1; i < a.length; i++) {
+    for (let j = 1; j < b.length; j++) {
+      if (segmentsCross(a[i - 1]!, a[i]!, b[j - 1]!, b[j]!)) n++;
+    }
+  }
+  return n;
+}
+
+describe('tracks — each line its own height through the gap', () => {
+  /** A hub with six screens to one side and four to the other, three of which return. */
+  function fan(): ScreensModel {
+    const left = ['/l0', '/l1', '/l2', '/l3', '/l4', '/l5'];
+    const right = ['/r0', '/r1', '/r2', '/r3'];
+    return buildScreensModel(
+      payload(
+        [screen('/'), screen('/home'), ...left.map(screen), ...right.map(screen)],
+        [
+          link('/', '/home'),
+          ...[...left, ...right].map((t, i) => link('/home', t, `c${i}`)),
+          ...left.slice(0, 3).map((t, i) => link(t, '/home', `back${i}`)),
+        ]
+      )
+    );
+  }
+
+  /** The hub's lines into the row below it, split by which side their far end sits on. */
+  function sides(model: ScreensModel): Array<Array<{ curve: Curve; farX: number; id: string }>> {
+    const home = nodeOf(model, R('/home'));
+    const centre = home.x + home.width / 2;
+    const lines = model.layout.edges
+      .filter((e) => (e.source === R('/home') || e.target === R('/home')) && e.route !== 'level')
+      .map((e) => {
+        const curve = model.curves.get(e.id)!;
+        const far = e.source === R('/home') ? { x: curve.x3, y: curve.y3 } : { x: curve.x0, y: curve.y0 };
+        return { id: e.id, curve, farX: far.x, farY: far.y };
+      })
+      .filter((l) => l.farY > home.y + home.height);
+    return [lines.filter((l) => l.farX < centre), lines.filter((l) => l.farX >= centre)];
+  }
+
+  it('ranks a fan by reach: the farthest-out line runs nearest the hub, every line on its own track', () => {
+    const model = fan();
+    const home = nodeOf(model, R('/home'));
+    const bottom = home.y + home.height;
+    const [left, right] = sides(model);
+    expect(left!.length + right!.length).toBe(13);
+    // Left: farther left first; its track is the highest (smallest y).
+    const l = [...left!].sort((a, b) => a.farX - b.farX);
+    for (let i = 1; i < l.length; i++) expect(l[i]!.curve.y1).toBeGreaterThan(l[i - 1]!.curve.y1 + 4);
+    // Right: farther right first, mirrored.
+    const r = [...right!].sort((a, b) => b.farX - a.farX);
+    for (let i = 1; i < r.length; i++) expect(r[i]!.curve.y1).toBeGreaterThan(r[i - 1]!.curve.y1 + 4);
+    // Every track lies inside the gap under the hub, and both control points share it.
+    for (const line of [...l, ...r]) {
+      expect(line.curve.y1).toBeGreaterThan(bottom);
+      expect(line.curve.y1).toBeLessThan(bottom + SCREEN_LAYER_GAP);
+      expect(line.curve.y2).toBe(line.curve.y1);
+    }
+  });
+
+  it('never lets two lines of one fan cross — returns included', () => {
+    const model = fan();
+    for (const group of sides(model)) {
+      for (const a of group) {
+        for (const b of group) {
+          if (a.id >= b.id) continue;
+          expect(
+            crossings(model.polylines.get(a.id)!, model.polylines.get(b.id)!),
+            `${a.id} crosses ${b.id}`
+          ).toBe(0);
+        }
+      }
+    }
+  });
+
+  it('keeps a line that spans several rows on a track beside its fan, and runs the rest vertically', () => {
+    // Downward lines are always one row (a row IS distance from the entry);
+    // a return can come from any depth. Two rows down, straight back home.
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/home'), screen('/mid'), screen('/deep')],
+        [link('/', '/home'), link('/home', '/mid'), link('/mid', '/deep'), link('/deep', '/home', 'done')]
+      )
+    );
+    const home = nodeOf(model, R('/home'));
+    const deep = nodeOf(model, R('/deep'));
+    expect(home.layer - deep.layer).toBe(2);
+    const curve = model.curves.get(edgeOf(model, R('/deep'), R('/home')).id)!;
+    // The track sits in the gap right under /home — not at the midpoint, which
+    // would be inside the row between.
+    expect(curve.y1).toBeGreaterThan(home.y + home.height);
+    expect(curve.y1).toBeLessThan(home.y + home.height + SCREEN_LAYER_GAP);
+    expect(curve.y2).toBe(curve.y1);
+    // Both ends leave and arrive vertically.
+    expect(curve.x1).toBe(curve.x0);
+    expect(curve.x2).toBe(curve.x3);
+  });
+
+  it('nests level arches, the wider one higher', () => {
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/a'), screen('/b'), screen('/c')],
+        [link('/', '/a'), link('/', '/b'), link('/', '/c'), link('/a', '/b', 'x'), link('/a', '/c', 'y')]
+      )
+    );
+    const a = nodeOf(model, R('/a'));
+    const centre = a.x + a.width / 2;
+    const ab = model.curves.get(edgeOf(model, R('/a'), R('/b')).id)!;
+    const ac = model.curves.get(edgeOf(model, R('/a'), R('/c')).id)!;
+    // Both arches leave a's top towards the same side (the row is b, c, a or a, b, c).
+    expect(Math.sign(ab.x3 - centre)).toBe(Math.sign(ac.x3 - centre));
+    const [wide, narrow] = Math.abs(ab.x3 - ab.x0) > Math.abs(ac.x3 - ac.x0) ? [ab, ac] : [ac, ab];
+    expect(wide.y1).toBeLessThan(narrow.y1);
+    expect(narrow.y1).toBeLessThan(a.y);
+  });
+
+  it('draws the same tracks twice', () => {
+    expect([...fan().curves.entries()]).toEqual([...fan().curves.entries()]);
+  });
+});
+
+describe('pointing at a line', () => {
+  it('answers the nearest line within reach, and nothing beyond it', () => {
+    const model = buildScreensModel(hub());
+    const edge = edgeOf(model, R('/home'), R('/t4'));
+    const on = model.polylines.get(edge.id)![12]!;
+    expect(nearestEdge(model, { x: on.x + 1, y: on.y + 1 }, null, 10)?.id).toBe(edge.id);
+    expect(nearestEdge(model, { x: -5000, y: -5000 }, null, 10)).toBeNull();
+    // Reach is a distance, not a hint.
+    expect(nearestEdge(model, { x: on.x + 30, y: on.y + 30 }, null, 10)).toBeNull();
+  });
+
+  it('only considers the lines it is asked about', () => {
+    const model = buildScreensModel(hub());
+    const near = edgeOf(model, R('/home'), R('/t4'));
+    const other = edgeOf(model, R('/home'), R('/t9'));
+    const on = model.polylines.get(near.id)![12]!;
+    expect(nearestEdge(model, on, new Set([other.id]), 1e9)?.id).toBe(other.id);
+    expect(nearestEdge(model, on, new Set(), 1e9)).toBeNull();
+  });
+
+  it('tells two lines a few pixels apart from each other', () => {
+    const model = buildScreensModel(hub());
+    const home = nodeOf(model, R('/home'));
+    // Two lines to neighbouring screens on the same side: at a height in the
+    // gap where both run, the pointer just above one, then just below the
+    // other, meets each in turn.
+    const [a, b] = model.layout.edges
+      .filter((e) => e.source === R('/home'))
+      .map((e) => ({ id: e.id, curve: model.curves.get(e.id)! }))
+      .filter((l) => l.curve.x3 < home.x)
+      .sort((p, q) => p.curve.x3 - q.curve.x3);
+    expect(a && b).toBeTruthy();
+    const y = (a!.curve.y1 + b!.curve.y1) / 2;
+    const x = Math.max(a!.curve.x3, b!.curve.x3) + 40;
+    const hit = nearestEdge(model, { x, y: y - 1 }, null, 60)!;
+    const hit2 = nearestEdge(model, { x, y: y + 1 }, null, 60)!;
+    expect(new Set([hit.id, hit2.id]).size).toBeGreaterThanOrEqual(1);
+    expect([a!.id, b!.id]).toContain(hit.id);
+  });
+});

+ 414 - 0
__tests__/ui-search-model.test.ts

@@ -0,0 +1,414 @@
+/**
+ * The search palette and the trail, without a browser (CG-45).
+ *
+ * Two things here can be silently wrong rather than merely ugly. The palette's
+ * flat item list must be exactly the concatenation of the sections it draws, or
+ * ↑/↓/Enter follows a different row than the one under the highlight. And the
+ * trail's wire format must round-trip, because it is the whole reason a walk
+ * survives a reload or travels in a shared link.
+ *
+ * The geometry-free half of the same split as `ui-symbol-model.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildEntryPalette,
+  buildSearchPalette,
+  groupByKind,
+  interleaveResults,
+  kindGroupTitle,
+  locationOf,
+  moveSelection,
+  parseFlowQuery,
+} from '../ui/src/lib/search-model';
+import { decodeTrail, encodeTrail, hopLabel, type TrailHop } from '../ui/src/lib/trail-codec';
+import type { WireEntryPoints, WireSearch, WireSearchResult } from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function result(over: Partial<WireSearchResult> = {}): WireSearchResult {
+  return {
+    id: over.id ?? `method:${over.name ?? 'load'}`,
+    kind: 'method',
+    name: 'load',
+    qualifiedName: 'Service::load',
+    file: 'src/service.ts',
+    line: 42,
+    endLine: 60,
+    language: 'typescript',
+    test: false,
+    matchKind: 'exact',
+    ...over,
+  } as WireSearchResult;
+}
+
+function answer(items: WireSearchResult[]): WireSearch {
+  return {
+    query: 'q',
+    text: 'q',
+    filters: { kinds: [], languages: [], paths: [], names: [] },
+    results: { total: items.length, shown: items.length, truncated: false, items },
+    groups: [],
+  };
+}
+
+/* ----------------------------------------------------------- flow query -- */
+
+describe('the flow grammar', () => {
+  it('recognises the three shapes the placeholder advertises', () => {
+    expect(parseFlowQuery('how does execute reach getFile')).toEqual({
+      from: 'execute',
+      to: 'getFile',
+    });
+    expect(parseFlowQuery('execute -> getFile')).toEqual({ from: 'execute', to: 'getFile' });
+    expect(parseFlowQuery('execute → getFile')).toEqual({ from: 'execute', to: 'getFile' });
+    expect(parseFlowQuery('  sync reaches indexFile?  ')).toEqual({
+      from: 'sync',
+      to: 'indexFile',
+    });
+  });
+
+  it('asks about the last segment of a qualified name', () => {
+    // `Class.method` names the method; the class is how you say WHICH one, and
+    // the search ranks that out on its own.
+    expect(parseFlowQuery('how does CodeGraph.sync reach Cache.read')).toEqual({
+      from: 'sync',
+      to: 'read',
+    });
+  });
+
+  it('leaves an ordinary search alone', () => {
+    expect(parseFlowQuery('getImpactRadius')).toBeNull();
+    expect(parseFlowQuery('kind:class Cache')).toBeNull();
+    expect(parseFlowQuery('how does this work')).toBeNull();
+    // A symbol reaching itself is not a path worth asking about.
+    expect(parseFlowQuery('sync -> sync')).toBeNull();
+  });
+});
+
+/* -------------------------------------------------------------- palette -- */
+
+describe('the palette', () => {
+  it('flattens exactly what it draws, in draw order', () => {
+    const palette = buildSearchPalette(
+      [
+        answer([
+          result({ id: 'm1', name: 'load', kind: 'method' }),
+          result({ id: 'f1', name: 'loader', kind: 'function' }),
+          result({ id: 'm2', name: 'reload', kind: 'method' }),
+        ]),
+      ],
+      null
+    );
+
+    // Groups appear where their best result did, so flattening reproduces the
+    // ranking the keyboard walks.
+    expect(palette.sections.map((s) => s.title)).toEqual(['Methods', 'Function']);
+    expect(palette.items.map((i) => i.id)).toEqual(['m1', 'm2', 'f1']);
+    expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
+    expect(palette.empty).toBeNull();
+  });
+
+  it('says nothing matched instead of drawing an empty box', () => {
+    const palette = buildSearchPalette([answer([])], null);
+    expect(palette.items).toEqual([]);
+    expect(palette.empty).toContain('No symbol or file');
+  });
+
+  it('interleaves a flow question so neither endpoint outranks the other', () => {
+    const a = [result({ id: 'a1' }), result({ id: 'a2' })];
+    const b = [result({ id: 'b1' }), result({ id: 'b2' })];
+    expect(interleaveResults(a, b).map((r) => r.id)).toEqual(['a1', 'b1', 'a2', 'b2']);
+
+    // A symbol that matched both halves keeps its earliest position.
+    expect(interleaveResults(a, [result({ id: 'a2' })]).map((r) => r.id)).toEqual(['a1', 'a2']);
+  });
+
+  it('offers the flow FIRST for a flow question, then what each name matches', () => {
+    const palette = buildSearchPalette(
+      [answer([result({ id: 'a', name: 'sync' })]), answer([result({ id: 'b', name: 'read' })])],
+      { from: 'sync', to: 'read' }
+    );
+    // First row, so Enter opens the path: the question asked for the path.
+    expect(palette.sections[0]?.title).toBe('Flow');
+    expect(palette.items[0]).toMatchObject({ type: 'flow', from: 'sync', to: 'read' });
+    expect(palette.items.map((i) => i.id).slice(1)).toEqual(['a', 'b']);
+    expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
+    expect(palette.hint).toContain('sync');
+    expect(palette.hint).toContain('read');
+  });
+
+  it('offers no flow row when the query is not a flow question', () => {
+    const palette = buildSearchPalette([answer([result({ id: 'a' })])], null);
+    expect(palette.sections.some((s) => s.title === 'Flow')).toBe(false);
+    expect(palette.items.every((i) => i.type !== 'flow')).toBe(true);
+  });
+
+  it('names a kind bucket in sentence case, singular when there is one', () => {
+    expect(kindGroupTitle('method', 3)).toBe('Methods');
+    expect(kindGroupTitle('method', 1)).toBe('Method');
+    expect(kindGroupTitle('type_alias', 2)).toBe('Type aliases');
+    expect(kindGroupTitle('class', 2)).toBe('Classes');
+  });
+
+  it('locates a symbol by file and line, and a file by its directory', () => {
+    expect(locationOf(result({ file: 'src/mcp/tools.ts', line: 412 }))).toBe('tools.ts:412');
+    // The name column is already the basename; repeating the path says nothing.
+    expect(
+      locationOf(result({ kind: 'file', file: 'src/bin/codegraph.ts', name: 'codegraph.ts' }))
+    ).toBe('src/bin');
+    expect(locationOf(result({ kind: 'file', file: 'README.md', name: 'README.md' }))).toBe(
+      'project root'
+    );
+  });
+
+  it('groups by kind without losing a row', () => {
+    const results = [
+      result({ id: '1', kind: 'class' }),
+      result({ id: '2', kind: 'method' }),
+      result({ id: '3', kind: 'class' }),
+    ];
+    const sections = groupByKind(results);
+    expect(sections.map((s) => s.title)).toEqual(['Classes', 'Method']);
+    expect(sections.flatMap((s) => s.items).map((i) => i.id)).toEqual(['1', '3', '2']);
+  });
+
+  it('wraps the selection at both ends', () => {
+    expect(moveSelection(0, -1, 3)).toBe(2);
+    expect(moveSelection(2, 1, 3)).toBe(0);
+    expect(moveSelection(0, 1, 3)).toBe(1);
+    // An empty list has one legal selection, and it is not -1.
+    expect(moveSelection(0, 1, 0)).toBe(0);
+  });
+});
+
+/* --------------------------------------------------------- entry points -- */
+
+function entryPoints(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
+  return {
+    frameworks: [],
+    routes: {
+      routed: false,
+      routeCount: 0,
+      items: { total: 0, shown: 0, truncated: false, items: [] },
+    },
+    tests: { total: 0, shown: 0, truncated: false, items: [] },
+    index: { lastIndexedAt: null, files: 0 },
+    timing: { elapsedMs: 0, cached: false },
+    files: {
+      total: 2,
+      shown: 2,
+      truncated: false,
+      items: [
+        {
+          ...result({ id: 'file:src/bin/codegraph.ts', kind: 'file', name: 'codegraph.ts' }),
+          file: 'src/bin/codegraph.ts',
+          calls: 9,
+          reaches: 37,
+          dependents: 3,
+        },
+      ] as any,
+    },
+    hubs: {
+      total: 1,
+      shown: 1,
+      truncated: false,
+      items: [{ ...result({ id: 'method:get', name: 'get' }), dependents: 264 }] as any,
+    },
+    ...over,
+  } as WireEntryPoints;
+}
+
+describe('the entry points', () => {
+  it('says what each row is derived from, not that it IS the entry point', () => {
+    const palette = buildEntryPalette(entryPoints());
+
+    expect(palette.sections.map((s) => s.title)).toEqual([
+      'Files that run something',
+      'Most depended on',
+    ]);
+    expect(palette.sections[0]?.items[0]?.meta).toBe(
+      '9 calls at module level · reaches 37 files'
+    );
+    expect(palette.sections[1]?.items[0]?.meta).toBe('264 dependents');
+    expect(palette.items).toHaveLength(2);
+  });
+
+  it('puts routes first, and carries the id that makes a row clickable', () => {
+    const palette = buildEntryPalette(
+      entryPoints({
+        routes: {
+          routed: true,
+          routeCount: 4,
+          items: {
+            total: 1,
+            shown: 1,
+            truncated: false,
+            items: [
+              {
+                url: 'GET /users',
+                method: 'GET',
+                path: '/users',
+                handler: 'listUsers',
+                handlerKind: 'function',
+                file: 'src/routes.ts',
+                line: 11,
+                handlerId: 'function:listUsers',
+                routeFile: 'src/routes.ts',
+                routeLine: 4,
+                routeId: 'route:src/routes.ts:4:GET:/users',
+              },
+            ],
+          },
+        },
+      })
+    );
+
+    expect(palette.sections[0]?.title).toBe('Routes');
+    const row = palette.items[0];
+    expect(row?.type).toBe('route');
+    if (row?.type === 'route') {
+      expect(row.url).toBe('GET /users');
+      expect(row.nodeId).toBe('function:listUsers');
+      expect(row.location).toBe('routes.ts:11');
+    }
+  });
+
+  it('shortens each section for the panel under the box', () => {
+    const many = entryPoints();
+    (many.hubs.items as any) = Array.from({ length: 10 }, (_, i) => ({
+      ...result({ id: `m${i}`, name: `hub${i}` }),
+      dependents: 100 - i,
+    }));
+    expect(buildEntryPalette(many, { perSection: 3 }).items).toHaveLength(4);
+    expect(buildEntryPalette(many).items).toHaveLength(11);
+  });
+
+  it('offers entry points under a typed query, BELOW the symbol matches', () => {
+    const entries = entryPoints({
+      routes: {
+        routed: true,
+        routeCount: 3,
+        items: {
+          total: 1,
+          shown: 1,
+          truncated: false,
+          items: [
+            {
+              url: 'POST /users',
+              method: 'POST',
+              path: '/users',
+              handler: 'createUser',
+              handlerKind: 'function',
+              file: 'src/handlers.ts',
+              line: 8,
+              handlerId: 'function:createUser',
+              routeFile: 'src/routes.ts',
+              routeLine: 4,
+              routeId: 'route:src/routes.ts:4:POST:/users',
+            },
+          ],
+        },
+      },
+    });
+
+    const palette = buildSearchPalette(
+      [answer([result({ id: 'class:Users', name: 'Users', kind: 'class' })])],
+      null,
+      { entries, query: 'users', entryRows: 6 }
+    );
+
+    // Symbol matches keep the top: someone typing a name asked for the name.
+    expect(palette.sections[0]?.title).toBe('Class');
+    const last = palette.sections[palette.sections.length - 1];
+    expect(last?.title).toBe('Entry points');
+    const row = last?.items[0];
+    expect(row?.type).toBe('entry');
+    // The row a plain search cannot produce: the URL WITH its handler.
+    expect(row?.name).toBe('POST /users');
+    expect(row?.meta).toBe('createUser · handlers.ts:8');
+    expect(row?.location).toBe('route');
+    // The keyboard's flat list still equals what is drawn.
+    expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
+  });
+
+  it('does not repeat a symbol the search above already found', () => {
+    const hub = { ...result({ id: 'method:get', name: 'get' }), dependents: 264 };
+    const entries = entryPoints({
+      hubs: { total: 1, shown: 1, truncated: false, items: [hub] as any },
+    });
+    const palette = buildSearchPalette([answer([result({ id: 'method:get', name: 'get' })])], null, {
+      entries,
+      query: 'get',
+      entryRows: 6,
+    });
+    expect(palette.sections.map((s) => s.title)).not.toContain('Entry points');
+  });
+
+  it('draws nothing at all before the answer arrives', () => {
+    const palette = buildEntryPalette(null);
+    expect(palette.sections).toEqual([]);
+    // Not an "empty" message: nothing is known yet, and saying "this index has
+    // nothing" while the request is in flight would be a claim, not a state.
+    expect(palette.empty).toBeNull();
+  });
+});
+
+/* ----------------------------------------------------------------- trail -- */
+
+function hop(id: string, dir: TrailHop['dir']): TrailHop {
+  return { id, name: null, kind: null, dir };
+}
+
+describe('the trail in the URL', () => {
+  it('round-trips six hops with their directions intact', () => {
+    const walked: TrailHop[] = [
+      hop('method:a', 'start'),
+      hop('method:b', 'down'),
+      hop('method:c', 'down'),
+      hop('method:d', 'up'),
+      hop('method:e', 'down'),
+      hop('file:src/bin/codegraph.ts', 'up'),
+    ];
+
+    const encoded = encodeTrail(walked);
+    const decoded = decodeTrail(encoded);
+
+    expect(decoded).toHaveLength(6);
+    expect(decoded.map((h) => h.id)).toEqual(walked.map((h) => h.id));
+    expect(decoded.map((h) => h.dir)).toEqual(['start', 'down', 'down', 'up', 'down', 'up']);
+    // Re-encoding is byte-identical, which is what makes a shared link stable.
+    expect(encodeTrail(decoded)).toBe(encoded);
+  });
+
+  it('keeps an id that begins with a direction letter', () => {
+    // `union:…` and `default:…` start with 'u' and 'd'; an optional direction
+    // prefix would swallow the first character of the id.
+    const hops = [hop('union:Shape', 'start'), hop('declaration:x', 'down')];
+    expect(decodeTrail(encodeTrail(hops)).map((h) => h.id)).toEqual([
+      'union:Shape',
+      'declaration:x',
+    ]);
+  });
+
+  it('survives an id carrying the separator, and a hand-mangled param', () => {
+    const hops = [hop('file:src/a,b.ts', 'start')];
+    expect(decodeTrail(encodeTrail(hops))[0]?.id).toBe('file:src/a,b.ts');
+
+    expect(decodeTrail(null)).toEqual([]);
+    expect(decodeTrail('')).toEqual([]);
+    // A token with no direction letter is dropped; a lone '%' would throw in
+    // decodeURIComponent, so the raw text is kept instead — a hop that names
+    // nothing is better than a trail that silently loses a position.
+    expect(decodeTrail('x,,smethod%3Aa,d%')).toEqual([
+      { id: 'method:a', name: null, kind: null, dir: 'start' },
+      { id: '%', name: null, kind: null, dir: 'down' },
+    ]);
+  });
+
+  it('labels an unresolved hop with something readable, never a raw hash', () => {
+    expect(hopLabel({ ...hop('method:x', 'down'), name: 'load' })).toBe('load');
+    expect(hopLabel(hop('file:src/bin/codegraph.ts', 'start'))).toBe('codegraph.ts');
+    expect(hopLabel(hop('method:ada8ef1603fc03e3566eec72dc91138f', 'down'))).toBe('ada8ef16…');
+  });
+});

+ 1216 - 0
__tests__/ui-server-api.test.ts

@@ -0,0 +1,1216 @@
+/**
+ * The `codegraph ui` read-only JSON API (CG-42).
+ *
+ * Everything runs against a real indexed fixture project over a real loopback
+ * server — no mocks — because the properties worth pinning are the ones that
+ * only exist end to end: the drift verdict comes from hashing bytes on disk
+ * against what the index stored, the refusals come from the same chokepoint the
+ * static server uses, and the caps only matter once a symbol really does have
+ * hundreds of callers.
+ *
+ * The fixture is built to produce each of those: a call chain three deep, a
+ * test file that reaches it, a type used only as a type, an import that cannot
+ * resolve, and one deliberately hot function with 500 callers.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+
+interface Response {
+  status: number;
+  headers: http.IncomingHttpHeaders;
+  body: string;
+}
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+let viewerDir: string;
+
+/**
+ * One request against a live server, with the loopback `Host` the boundary
+ * wants. Written with `http.request` rather than `fetch` so the `Host` header
+ * is ours to set — undici treats it as forbidden.
+ */
+function requestOn(port: number, requestPath: string, method = 'GET'): Promise<Response> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port,
+        path: requestPath,
+        method,
+        headers: { Host: `127.0.0.1:${port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            headers: res.headers,
+            body: Buffer.concat(chunks).toString('utf-8'),
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+/** The same, against the main fixture's server. */
+function request(requestPath: string, method = 'GET'): Promise<Response> {
+  return requestOn(server.port, requestPath, method);
+}
+
+/**
+ * Payloads are read as `any` on purpose: these tests assert the JSON contract
+ * the viewer sees over the wire, so typing them against the server's own
+ * interfaces would only prove the server agrees with itself.
+ */
+async function getJson(requestPath: string): Promise<any> {
+  const res = await request(requestPath);
+  expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
+  return JSON.parse(res.body);
+}
+
+async function getStatusAndJson(requestPath: string): Promise<{ status: number; body: any }> {
+  const res = await request(requestPath);
+  expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
+  return { status: res.status, body: JSON.parse(res.body) };
+}
+
+/** Find a symbol in the fixture by name, through the API itself. */
+async function idOf(name: string, kind?: string): Promise<string> {
+  const search = await getJson(`/api/search?q=${encodeURIComponent(name)}`);
+  const hit = search.results.items.find(
+    (r: any) => r.name === name && (kind === undefined || r.kind === kind)
+  );
+  expect(hit, `no ${kind ?? 'symbol'} named ${name} in the fixture`).toBeTruthy();
+  return hit.id as string;
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-api-'));
+  projectRoot = path.join(tempDir, 'project');
+  const srcDir = path.join(projectRoot, 'src');
+  const testsDir = path.join(projectRoot, '__tests__');
+  fs.mkdirSync(srcDir, { recursive: true });
+  fs.mkdirSync(testsDir, { recursive: true });
+
+  fs.writeFileSync(
+    path.join(srcDir, 'types.ts'),
+    `export interface Config {
+  ttlMs: number;
+  label: string;
+}
+
+export type CacheKey = string;
+`
+  );
+
+  fs.writeFileSync(
+    path.join(srcDir, 'cache.ts'),
+    `import { Config, CacheKey } from './types';
+
+export class Cache {
+  private store = new Map<string, string>();
+  private config: Config;
+
+  constructor(config: Config) {
+    this.config = config;
+  }
+
+  read(key: CacheKey): string | undefined {
+    return this.store.get(key);
+  }
+
+  write(key: CacheKey, value: string): void {
+    this.store.set(key, value);
+  }
+}
+`
+  );
+
+  fs.writeFileSync(
+    path.join(srcDir, 'service.ts'),
+    `import { Cache } from './cache';
+import { Config } from './types';
+// Not in the index: a package that was never installed here.
+import { serialize } from 'some-external-package';
+
+export class Service {
+  private cache: Cache;
+
+  constructor(config: Config) {
+    this.cache = new Cache(config);
+  }
+
+  load(key: string): string {
+    const hit = this.cache.read(key);
+    if (hit !== undefined) return hit;
+    const fresh = serialize(key);
+    this.cache.write(key, fresh);
+    return fresh;
+  }
+}
+`
+  );
+
+  fs.writeFileSync(
+    path.join(srcDir, 'handler.ts'),
+    `import { Service } from './service';
+
+export function handleRequest(service: Service, key: string): string {
+  return service.load(key);
+}
+`
+  );
+
+  // Module-level statements: the engine records them as edges out of the FILE
+  // node, which is the only reason `/api/entrypoints` can see an executable
+  // root at all. Nothing else in the fixture runs anything on the way down.
+  fs.writeFileSync(
+    path.join(srcDir, 'main.ts'),
+    `import { Service } from './service';
+import { handleRequest } from './handler';
+
+const service = new Service({ ttlMs: 5, label: 'main' });
+const first = handleRequest(service, 'boot');
+const second = service.load('warm');
+
+export const started = [first, second];
+`
+  );
+
+  // 500 callers into one function: the N+1 and capping behaviour only shows up
+  // at this scale, and the fixture keeps CI honest without needing the engine's
+  // own index to be present.
+  const callers = Array.from(
+    { length: 500 },
+    (_, i) => `export function caller${i}(): number {\n  return hot(${i});\n}`
+  ).join('\n\n');
+  fs.writeFileSync(
+    path.join(srcDir, 'hot.ts'),
+    `export function hot(n: number): number {
+  return n * 2;
+}
+
+${callers}
+`
+  );
+
+  // CRLF on purpose: tree-sitter numbers rows by `\n`, so a CRLF file must come
+  // back with the same line numbers the graph recorded — and without the stray
+  // `\r` rendering at the end of every line. This is what a Windows checkout
+  // with core.autocrlf looks like, and it is decided by bytes, not by the OS.
+  fs.writeFileSync(
+    path.join(srcDir, 'crlf.ts'),
+    ['export function windowsStyle(n: number): number {', '  return n + 1;', '}', ''].join('\r\n')
+  );
+
+  fs.writeFileSync(
+    path.join(testsDir, 'service.test.ts'),
+    `import { Service } from '../src/service';
+
+export function testLoadsThroughCache(): void {
+  const service = new Service({ ttlMs: 1, label: 'x' });
+  service.load('k');
+}
+
+// Module level, on purpose: a test file that RUNS something must still be
+// excluded from the entry points.
+testLoadsThroughCache();
+`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  // Hand the index over: the API opens its own read-only connection, which is
+  // also what happens in production (the CLI never shares an instance).
+  cg.close();
+
+  viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  await server?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('GET /api', () => {
+  it('lists the endpoints it answers', async () => {
+    const body = await getJson('/api');
+    // Not a blanket claim any more (CG-60): saved trails are the one thing
+    // this server writes, and it names it rather than implying there is none.
+    expect(body.readOnly).toBe(false);
+    expect(body.writes).toEqual(['POST /api/trails', 'DELETE /api/trails/<id>']);
+    const paths = body.endpoints.map((e: any) => e.path);
+    expect(paths).toEqual(
+      expect.arrayContaining([
+        '/api/stats',
+        '/api/search',
+        '/api/node/<id>',
+        '/api/source',
+        '/api/file/<path>',
+        '/api/routes',
+      ])
+    );
+  });
+
+  it('404s an unknown endpoint as JSON, never as the app shell', async () => {
+    const { status, body } = await getStatusAndJson('/api/nope');
+    expect(status).toBe(404);
+    expect(body.code).toBe('not-found');
+  });
+
+  it('answers HEAD with the headers and no body', async () => {
+    const res = await request('/api/stats', 'HEAD');
+    expect(res.status).toBe(200);
+    expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
+    expect(Number(res.headers['content-length'])).toBeGreaterThan(0);
+    expect(res.body).toBe('');
+  });
+});
+
+describe('GET /api/stats', () => {
+  it('reports the project, the index state and the graph counts', async () => {
+    const body = await getJson('/api/stats');
+
+    expect(body.project.root).toBe(projectRoot);
+    expect(body.project.name).toBe('project');
+
+    expect(body.index.state).toBe('complete');
+    expect(body.index.stale).toBe(false);
+    expect(typeof body.index.lastIndexedAt).toBe('number');
+    expect(body.index.backend).toBe('node-sqlite');
+    expect(typeof body.index.extractionVersion).toBe('number');
+
+    expect(body.graph.nodes).toBeGreaterThan(0);
+    expect(body.graph.edges).toBeGreaterThan(0);
+    expect(body.graph.files).toBeGreaterThanOrEqual(6);
+    expect(body.graph.nodesByKind.class).toBeGreaterThanOrEqual(2);
+    expect(body.graph.filesByLanguage.typescript).toBeGreaterThanOrEqual(6);
+
+    // The thresholds travel with the data so the viewer's copy cannot drift.
+    expect(body.thresholds).toEqual({ hub: 40, uncertainBelow: 0.6 });
+  });
+
+  it('reports a blast-radius scale the widest symbol in the index reaches', async () => {
+    const body = await getJson('/api/stats');
+    const scale = body.blastScale;
+
+    // `hot` is called by 500 distinct functions and nothing else in the fixture
+    // comes close, so the exact maximum is knowable here.
+    expect(scale.maxDirect).toBe(500);
+    // Its radius is at least its own callers; the sample is capped, so the
+    // count is a floor and the flag says so rather than claiming exhaustive.
+    expect(scale.maxWithinHops).toBeGreaterThanOrEqual(500);
+    expect(scale.hops).toBe(3);
+    expect(scale.sampled).toBeGreaterThan(0);
+    expect(scale.sampled).toBeLessThanOrEqual(24);
+    expect(scale.estimated).toBe(true);
+  });
+
+  it('serves the scale from cache — the second call does not re-traverse', async () => {
+    const first = await getJson('/api/stats');
+    const started = Date.now();
+    const second = await getJson('/api/stats');
+    expect(second.blastScale).toEqual(first.blastScale);
+    // 24 depth-3 traversals over a 500-caller graph are not free; a cached
+    // answer is. The margin is wide because this is a smoke test for the
+    // memo existing at all, not a benchmark.
+    expect(Date.now() - started).toBeLessThan(250);
+  });
+});
+
+describe('GET /api/search', () => {
+  it('ranks exact over prefix over substring, and groups by kind', async () => {
+    const body = await getJson('/api/search?q=Cache');
+
+    const first = body.results.items[0];
+    expect(first.name).toBe('Cache');
+    expect(first.kind).toBe('class');
+    expect(first.matchKind).toBe('exact');
+
+    const ranks = body.results.items.map((r: any) => r.matchKind);
+    const order = ['exact', 'prefix', 'substring', 'qualified', 'file', 'related'];
+    const asNumbers = ranks.map((r: string) => order.indexOf(r));
+    expect(asNumbers).toEqual([...asNumbers].sort((a, b) => a - b));
+
+    // Flattening the groups reproduces the flat ranking, so the palette can use
+    // either without them disagreeing.
+    const flattened = body.groups.flatMap((g: any) => g.items.map((i: any) => i.id));
+    expect(new Set(flattened)).toEqual(new Set(body.results.items.map((r: any) => r.id)));
+    for (const group of body.groups) expect(group.count).toBe(group.items.length);
+  });
+
+  it('returns a signature and a file:line for every result', async () => {
+    const body = await getJson('/api/search?q=handleRequest');
+    const hit = body.results.items.find((r: any) => r.name === 'handleRequest');
+    expect(hit.file).toBe('src/handler.ts');
+    expect(hit.line).toBeGreaterThan(0);
+    expect(hit.endLine).toBeGreaterThanOrEqual(hit.line);
+    expect(hit.signature).toContain('service');
+    expect(hit.qualifiedName).toBeTruthy();
+    expect(hit.language).toBe('typescript');
+  });
+
+  it('finds a mid-name match FTS tokens cannot', async () => {
+    const body = await getJson('/api/search?q=quest');
+    const names = body.results.items.map((r: any) => r.name);
+    expect(names).toContain('handleRequest');
+    const hit = body.results.items.find((r: any) => r.name === 'handleRequest');
+    expect(hit.matchKind).toBe('substring');
+  });
+
+  it('honours the kind: filter grammar', async () => {
+    const body = await getJson('/api/search?q=' + encodeURIComponent('kind:class Cache'));
+    expect(body.filters.kinds).toEqual(['class']);
+    expect(body.results.items.every((r: any) => r.kind === 'class')).toBe(true);
+  });
+
+  it('marks test files so the palette can rank them down', async () => {
+    const body = await getJson('/api/search?q=testLoadsThroughCache');
+    const hit = body.results.items.find((r: any) => r.name === 'testLoadsThroughCache');
+    expect(hit.test).toBe(true);
+  });
+
+  it('answers an empty search box with nothing, and a missing q with 400', async () => {
+    const empty = await getStatusAndJson('/api/search?q=');
+    expect(empty.status).toBe(200);
+    expect(empty.body.results.total).toBe(0);
+    expect(empty.body.groups).toEqual([]);
+
+    const missing = await getStatusAndJson('/api/search');
+    expect(missing.status).toBe(400);
+    expect(missing.body.code).toBe('bad-request');
+  });
+
+  it('returns an empty result set for a name nothing has', async () => {
+    const body = await getJson('/api/search?q=zzznotasymbolanywhere');
+    expect(body.results.total).toBe(0);
+  });
+});
+
+describe('GET /api/node/<id>', () => {
+  it('returns the symbol, its ancestors and its members in source order', async () => {
+    const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
+
+    expect(body.node.name).toBe('Cache');
+    expect(body.node.kind).toBe('class');
+    expect(body.node.file).toBe('src/cache.ts');
+    expect(body.node.lines).toBe(body.node.endLine - body.node.line + 1);
+    expect(body.node.exported).toBe(true);
+
+    // Outermost first: the file, then anything between it and the symbol.
+    expect(body.ancestors[0].kind).toBe('file');
+    expect(body.ancestors[0].file).toBe('src/cache.ts');
+
+    const members = body.members.items.map((m: any) => m.name);
+    expect(members).toEqual(expect.arrayContaining(['read', 'write', 'store', 'config']));
+    const lines = body.members.items.map((m: any) => m.line);
+    expect(lines).toEqual([...lines].sort((a, b) => a - b));
+    for (const member of body.members.items) {
+      expect(member.parentId).toBe(body.node.id);
+      expect(member.depth).toBe(1);
+    }
+    expect(body.members.total).toBe(body.members.shown);
+  });
+
+  it('gives every member its own fan-in and fan-out — the outline is the body', async () => {
+    const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
+    const byName = new Map(body.members.items.map((m: any) => [m.name, m]));
+
+    for (const member of body.members.items) {
+      expect(typeof member.fanIn).toBe('number');
+      expect(typeof member.fanOut).toBe('number');
+      expect(member.fanIn).toBeGreaterThanOrEqual(0);
+      expect(member.fanOut).toBeGreaterThanOrEqual(0);
+    }
+
+    // `Service.load` calls both, and `Cache` contains them: at least the
+    // containment edge plus one call each. Without these numbers a 700-line
+    // class's outline cannot say which member carries weight.
+    expect((byName.get('read') as any).fanIn).toBeGreaterThanOrEqual(2);
+    expect((byName.get('write') as any).fanIn).toBeGreaterThanOrEqual(2);
+    // The class itself calls nothing — its methods do, which is exactly why
+    // the per-member counts have to come from the members.
+    expect(body.counts.callees).toBe(0);
+    expect(body.members.items.some((m: any) => m.fanOut > 0)).toBe(true);
+  });
+
+  it('nests a file outline one level deeper, so a class shows its methods', async () => {
+    const body = await getJson(`/api/node/${await idOf('cache.ts', 'file')}`);
+    const byDepth = new Map<number, string[]>();
+    for (const member of body.members.items) {
+      byDepth.set(member.depth, [...(byDepth.get(member.depth) ?? []), member.name]);
+    }
+    expect(byDepth.get(1)).toContain('Cache');
+    expect(byDepth.get(2)).toEqual(expect.arrayContaining(['read', 'write']));
+  });
+
+  it('groups incoming edges by the calling symbol, with their call sites', async () => {
+    const readId = await idOf('read', 'method');
+    const body = await getJson(`/api/node/${readId}`);
+
+    const fromLoad = body.incoming.items.find((r: any) => r.node.name === 'load');
+    expect(fromLoad, 'Service.load should call Cache.read').toBeTruthy();
+    expect(fromLoad.node.file).toBe('src/service.ts');
+    expect(fromLoad.edgeKinds).toContain('calls');
+    expect(fromLoad.edgeCount).toBeGreaterThanOrEqual(1);
+    expect(fromLoad.lines.length).toBeGreaterThanOrEqual(1);
+    expect(fromLoad.lines).toEqual([...fromLoad.lines].sort((a: number, b: number) => a - b));
+    expect(typeof fromLoad.fanIn).toBe('number');
+    expect(fromLoad.hub).toBe(false);
+  });
+
+  it('carries every edge attribute the viewer draws with', async () => {
+    const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
+    const relation = body.incoming.items.find((r: any) => r.node.name === 'load');
+    const edge = relation.edges[0];
+
+    expect(edge.kind).toBe('calls');
+    expect(typeof edge.line).toBe('number');
+    expect(typeof edge.col).toBe('number');
+    expect(typeof edge.confidence).toBe('number');
+    expect(typeof edge.resolvedBy).toBe('string');
+    // Confidence decides the uncertain fold; the group agrees with its edges.
+    expect(relation.confidence).toBe(
+      Math.max(...relation.edges.map((e: any) => e.confidence ?? -1))
+    );
+    expect(relation.uncertain).toBe(relation.confidence < 0.6);
+    expect(relation.synthesized).toBe(false);
+  });
+
+  it('groups outgoing edges by the called symbol, ordered by call site', async () => {
+    const body = await getJson(`/api/node/${await idOf('load', 'method')}`);
+    const names = body.outgoing.items.map((r: any) => r.node.name);
+    expect(names).toEqual(expect.arrayContaining(['read', 'write']));
+
+    const firstLines = body.outgoing.items
+      .map((r: any) => r.lines[0])
+      .filter((l: number | undefined) => l !== undefined);
+    expect(firstLines).toEqual([...firstLines].sort((a, b) => a - b));
+  });
+
+  it('splits type references out of the callee rail', async () => {
+    // Type edges attach to the MEMBER that names the type, not to its class:
+    // `Service`'s constructor is where `Config` and `new Cache(...)` both live,
+    // which makes it the one place both halves of the split are visible.
+    const service = await getJson(`/api/node/${await idOf('Service', 'class')}`);
+    const ctor = service.members.items.find((m: any) => m.name === 'constructor');
+    const body = await getJson(`/api/node/${ctor.id}`);
+
+    const typeNames = body.typesUsed.map((t: any) => t.node.name);
+    expect(typeNames).toContain('Config');
+    expect(body.typesUsed.every((t: any) => t.edgeKinds.includes('references'))).toBe(true);
+
+    // A type reference is not also a callee row...
+    expect(body.outgoing.items.map((r: any) => r.node.name)).not.toContain('Config');
+    // ...but a class reached by any other edge kind still is: `new Cache(...)`
+    // is an `instantiates` edge, and moving it would hide a real dependency.
+    const instantiated = body.outgoing.items.find((r: any) => r.node.name === 'Cache');
+    expect(instantiated).toBeTruthy();
+    expect(instantiated.edgeKinds).toContain('instantiates');
+  });
+
+  it('summarizes which tests reach the symbol', async () => {
+    const reached = await getJson(`/api/node/${await idOf('load', 'method')}`);
+    expect(reached.tests.reached).toBe(true);
+    expect(reached.tests.hops).toBe(1);
+    expect(reached.tests.files).toContain('__tests__/service.test.ts');
+    expect(reached.tests.fileCount).toBeGreaterThanOrEqual(1);
+    expect(reached.tests.files.length).toBeLessThanOrEqual(6);
+    expect(reached.tests.exhaustive).toBe(true);
+
+    const unreached = await getJson(`/api/node/${await idOf('hot', 'function')}`);
+    expect(unreached.tests.reached).toBe(false);
+    expect(unreached.tests.hops).toBeNull();
+    expect(unreached.tests.files).toEqual([]);
+  });
+
+  it('counts the calls that leave the index instead of hiding them', async () => {
+    const body = await getJson(`/api/node/${await idOf('load', 'method')}`);
+    expect(body.outsideIndex.total).toBeGreaterThan(0);
+    const names = body.outsideIndex.samples.map((s: any) => s.name);
+    expect(names).toContain('serialize');
+    for (const sample of body.outsideIndex.samples) {
+      expect(typeof sample.line).toBe('number');
+      expect(typeof sample.kind).toBe('string');
+    }
+  });
+
+  it('summarizes the blast radius at three hops', async () => {
+    const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
+    expect(body.blast.hops).toBe(3);
+    expect(body.blast.direct).toBe(body.counts.callers);
+    // load → handleRequest / the test both sit inside three hops of Cache.read.
+    expect(body.blast.withinHops).toBeGreaterThan(body.blast.direct);
+    expect(body.blast.files).toBeGreaterThanOrEqual(2);
+    expect(body.blast.testFiles).toBeGreaterThanOrEqual(1);
+    expect(body.blast.routes).toBe(0);
+    expect(body.blast.topFiles[0].symbols).toBeGreaterThanOrEqual(1);
+  });
+
+  it('keeps every count equal to the list it labels', async () => {
+    const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
+    expect(body.counts.callers).toBe(body.incoming.total);
+    expect(body.counts.callees).toBe(body.outgoing.total);
+    expect(body.counts.typesUsed).toBe(body.typesUsed.length);
+    expect(body.counts.members).toBe(body.members.total);
+    expect(body.blast.direct).toBe(body.counts.callers);
+  });
+
+  it('reports fan-in, fan-out and the hub flag', async () => {
+    const quiet = await getJson(`/api/node/${await idOf('write', 'method')}`);
+    expect(quiet.counts.hub).toBe(false);
+    expect(quiet.counts.callers).toBeLessThan(40);
+    expect(quiet.counts.fanIn).toBeGreaterThanOrEqual(quiet.counts.callers);
+
+    const hot = await getJson(`/api/node/${await idOf('hot', 'function')}`);
+    expect(hot.counts.hub).toBe(true);
+    expect(hot.counts.callers).toBeGreaterThanOrEqual(500);
+  });
+
+  it('flags nothing as drifted while the fixture is untouched', async () => {
+    const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
+    expect(body.drift).toBe(false);
+  });
+
+  it('404s an id that names nothing, and 400s an empty one', async () => {
+    const missing = await getStatusAndJson('/api/node/method:notarealid');
+    expect(missing.status).toBe(404);
+    expect(missing.body.code).toBe('not-found');
+    expect(missing.body.hint).toBeTruthy();
+
+    const empty = await getStatusAndJson('/api/node/');
+    expect(empty.status).toBe(400);
+  });
+});
+
+describe('GET /api/node/<id> — the type hierarchy block', () => {
+  it('is null for a function, so the block costs a plain symbol nothing', async () => {
+    const body = await getJson(`/api/node/${await idOf('hot', 'function')}`);
+    expect(body.hierarchy).toBeNull();
+  });
+
+  it('is null for a class with nothing above or below it', async () => {
+    const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
+    expect(body.hierarchy).toBeNull();
+  });
+});
+
+describe('GET /api/node/<id> — the busiest symbol', () => {
+  it('caps the caller list, keeps the true total, and stays fast', async () => {
+    const hotId = await idOf('hot', 'function');
+    await request(`/api/node/${hotId}`); // warm the connection and the caches
+
+    const started = performance.now();
+    const res = await request(`/api/node/${hotId}`);
+    const elapsed = performance.now() - started;
+    expect(res.status).toBe(200);
+
+    const body = JSON.parse(res.body);
+    expect(body.incoming.total).toBeGreaterThanOrEqual(500);
+    expect(body.incoming.shown).toBe(300);
+    expect(body.incoming.truncated).toBe(true);
+    expect(body.incoming.items).toHaveLength(300);
+    // Grouped: one row per calling symbol, each carrying its own call sites.
+    expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe(300);
+    expect(body.counts.callers).toBeGreaterThanOrEqual(500);
+    expect(body.blast.direct).toBe(body.counts.callers);
+
+    // 500 callers resolved one query at a time would be nowhere near this.
+    expect(elapsed).toBeLessThan(100);
+  });
+});
+
+describe('GET /api/source', () => {
+  it('returns the requested slice with the index line numbering', async () => {
+    const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3');
+    expect(body.drift).toBe(false);
+    expect(body.file).toBe('src/cache.ts');
+    expect(body.language).toBe('typescript');
+    expect(body.from).toBe(1);
+    expect(body.to).toBe(3);
+    expect(body.lines).toHaveLength(3);
+    expect(body.lines[0]).toContain("import { Config, CacheKey } from './types'");
+    expect(body.totalLines).toBeGreaterThan(3);
+    expect(body.truncated).toBe(false);
+  });
+
+  it('serves the whole file when no range is given', async () => {
+    const body = await getJson('/api/source?file=src/handler.ts');
+    expect(body.from).toBe(1);
+    expect(body.to).toBe(body.totalLines);
+    expect(body.lines).toHaveLength(body.totalLines);
+  });
+
+  it('slices exactly the lines a symbol claims', async () => {
+    const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`);
+    const body = await getJson(
+      `/api/source?file=${node.node.file}&from=${node.node.line}&to=${node.node.endLine}`
+    );
+    expect(body.lines[0]).toContain('handleRequest');
+    expect(body.lines).toHaveLength(node.node.lines);
+  });
+
+  it('carries the classified source beside the lines, one entry per line', async () => {
+    const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3');
+    // Highlighting rides with the slice rather than behind its own endpoint:
+    // the two are only ever wanted together, and a second round-trip would let
+    // the code block paint unhighlighted source and then reflow it.
+    expect(body.highlight).toBeTruthy();
+    expect(body.highlight.classes).toEqual([
+      'other',
+      'ident',
+      'comment',
+      'string',
+      'keyword',
+      'number',
+      'type',
+      'def',
+    ]);
+    expect(body.highlight.lines).toHaveLength(body.lines.length);
+    // Every line's tokens reproduce that line exactly — the code block renders
+    // these, not the raw string.
+    for (let i = 0; i < body.lines.length; i++) {
+      const rebuilt = body.highlight.lines[i].map(([, text]: [number, string]) => text).join('');
+      expect(rebuilt).toBe(body.lines[i]);
+    }
+  });
+
+  it('refuses to slice a file that changed on disk after the last sync', async () => {
+    const target = path.join(projectRoot, 'src', 'handler.ts');
+    const original = fs.readFileSync(target);
+    try {
+      fs.writeFileSync(target, Buffer.concat([Buffer.from('// a new first line\n'), original]));
+
+      const body = await getJson('/api/source?file=src/handler.ts&from=1&to=3');
+      expect(body.drift).toBe(true);
+      // The whole point: no slice, rather than a slice of the wrong lines.
+      expect(body.lines).toBeUndefined();
+      // And nothing to render it with either — a highlight with no source is
+      // just a second way to draw the wrong lines.
+      expect(body.highlight).toBeUndefined();
+      expect(body.reason).toContain('changed on disk after the last index sync');
+
+      // And every screen that renders indexed line ranges is told.
+      const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`);
+      expect(node.drift).toBe(true);
+      const file = await getJson('/api/file/src/handler.ts');
+      expect(file.drift).toBe(true);
+    } finally {
+      fs.writeFileSync(target, original);
+    }
+  });
+
+  it('does not call an identical rewrite drift', async () => {
+    const target = path.join(projectRoot, 'src', 'handler.ts');
+    const original = fs.readFileSync(target);
+    // Same bytes, new mtime — what a checkout or a formatter no-op looks like.
+    fs.writeFileSync(target, original);
+
+    const body = await getJson('/api/source?file=src/handler.ts&from=1&to=2');
+    expect(body.drift).toBe(false);
+    expect(body.lines).toHaveLength(2);
+  });
+
+  it('keeps a CRLF file on the index line numbering, without the stray carriage returns', async () => {
+    const node = await getJson(`/api/node/${await idOf('windowsStyle', 'function')}`);
+    expect(node.node.file).toBe('src/crlf.ts');
+
+    const body = await getJson('/api/source?file=src/crlf.ts');
+    expect(body.drift).toBe(false);
+    expect(body.totalLines).toBe(3);
+    expect(body.lines).toEqual([
+      'export function windowsStyle(n: number): number {',
+      '  return n + 1;',
+      '}',
+    ]);
+    expect(body.lines.some((l: string) => l.includes('\r'))).toBe(false);
+
+    // The symbol's indexed range still names its own body.
+    const slice = await getJson(
+      `/api/source?file=src/crlf.ts&from=${node.node.line}&to=${node.node.endLine}`
+    );
+    expect(slice.lines[0]).toContain('windowsStyle');
+  });
+
+  it('refuses a path that escapes the project', async () => {
+    const traversal = await getStatusAndJson(
+      '/api/source?file=' + encodeURIComponent('../../../etc/passwd')
+    );
+    expect(traversal.status).toBe(403);
+    expect(traversal.body.code).toBe('refused');
+
+    const absolute = await getStatusAndJson(
+      '/api/source?file=' + encodeURIComponent('/etc/passwd')
+    );
+    expect(absolute.status).toBe(403);
+    expect(absolute.body.code).toBe('refused');
+    expect(absolute.body.error).toContain('absolute');
+  });
+
+  it('refuses a NUL byte in the path', async () => {
+    const { status, body } = await getStatusAndJson(
+      '/api/source?file=' + encodeURIComponent('src/cache.ts\u0000.png')
+    );
+    expect(status).toBe(403);
+    expect(body.code).toBe('refused');
+  });
+
+  it('404s a file that exists but is not indexed', async () => {
+    fs.writeFileSync(path.join(projectRoot, 'notes.md'), '# not indexed\n');
+    const { status, body } = await getStatusAndJson('/api/source?file=notes.md');
+    expect(status).toBe(404);
+    expect(body.code).toBe('not-found');
+    expect(body.hint).toContain('index');
+  });
+
+  it('rejects a range that names nothing', async () => {
+    const past = await getStatusAndJson('/api/source?file=src/handler.ts&from=99999');
+    expect(past.status).toBe(400);
+    expect(past.body.error).toContain('past the end');
+
+    const backwards = await getStatusAndJson('/api/source?file=src/handler.ts&from=10&to=4');
+    expect(backwards.status).toBe(400);
+
+    const nonNumeric = await getStatusAndJson('/api/source?file=src/handler.ts&from=abc');
+    expect(nonNumeric.status).toBe(400);
+  });
+});
+
+describe('GET /api/file/<path>', () => {
+  it('returns the file record and its outline in source order', async () => {
+    const body = await getJson('/api/file/src/cache.ts');
+
+    expect(body.file.path).toBe('src/cache.ts');
+    expect(body.file.language).toBe('typescript');
+    expect(body.file.size).toBeGreaterThan(0);
+    expect(body.file.contentHash).toMatch(/^[0-9a-f]{64}$/);
+    expect(body.file.generated).toBe(false);
+    expect(body.file.test).toBe(false);
+    expect(body.file.id).toMatch(/^file:/);
+    expect(body.drift).toBe(false);
+
+    const lines = body.outline.items.map((o: any) => o.line);
+    expect(lines).toEqual([...lines].sort((a, b) => a - b));
+
+    const cacheRow = body.outline.items.find((o: any) => o.name === 'Cache');
+    expect(cacheRow.depth).toBe(0);
+    expect(cacheRow.parentId).toBeNull();
+
+    const readRow = body.outline.items.find((o: any) => o.name === 'read');
+    expect(readRow.depth).toBe(1);
+    expect(readRow.parentId).toBe(cacheRow.id);
+    expect(readRow.fanIn).toBeGreaterThanOrEqual(1);
+    expect(typeof readRow.fanOut).toBe('number');
+
+    // The file node is the subject, not a row; imports have their own rail.
+    expect(body.outline.items.some((o: any) => o.kind === 'file')).toBe(false);
+    expect(body.outline.items.some((o: any) => o.kind === 'import')).toBe(false);
+  });
+
+  it('maps imports and imported-by to files', async () => {
+    const body = await getJson('/api/file/src/cache.ts');
+
+    const importedByFiles = body.importedBy.items.map((r: any) => r.file);
+    expect(importedByFiles).toContain('src/service.ts');
+
+    const importFiles = body.imports.items.map((r: any) => r.file);
+    expect(importFiles).toContain('src/types.ts');
+
+    // Never itself: same-file `imports` edges (the import declarations) are dropped.
+    expect(importFiles).not.toContain('src/cache.ts');
+    expect(importedByFiles).not.toContain('src/cache.ts');
+
+    const typesRow = body.imports.items.find((r: any) => r.file === 'src/types.ts');
+    expect(typesRow.symbolCount).toBeGreaterThanOrEqual(1);
+    expect(typesRow.symbols[0].name).toBeTruthy();
+    expect(typesRow.symbols[0].id).toBeTruthy();
+    expect(typesRow.test).toBe(false);
+  });
+
+  it('names the imports that never resolved rather than dropping them', async () => {
+    const body = await getJson('/api/file/src/service.ts');
+    const names = body.unresolvedImports.map((u: any) => u.name);
+    expect(names).toContain('some-external-package');
+  });
+
+  it('reports the wider cross-file relationship too', async () => {
+    const body = await getJson('/api/file/src/cache.ts');
+    expect(body.dependents).toContain('src/service.ts');
+    expect(body.dependencies).toContain('src/types.ts');
+  });
+
+  it('says whether the file runs anything at its top level', async () => {
+    // `src/main.ts` instantiates a Service and calls two functions outside
+    // every definition — code no outline row can show, because it belongs to
+    // no symbol. `src/cache.ts` only defines things.
+    const main = await getJson('/api/file/src/main.ts');
+    expect(main.topLevel.calls).toBeGreaterThanOrEqual(2);
+
+    const cache = await getJson('/api/file/src/cache.ts');
+    expect(cache.topLevel.calls).toBe(0);
+  });
+
+  it('404s a file that is not in the index and refuses one outside the project', async () => {
+    const missing = await getStatusAndJson('/api/file/src/nope.ts');
+    expect(missing.status).toBe(404);
+    expect(missing.body.code).toBe('not-found');
+
+    const outside = await getStatusAndJson(
+      '/api/file/' + encodeURIComponent('/etc/passwd')
+    );
+    expect(outside.status).toBe(403);
+    expect(outside.body.code).toBe('refused');
+  });
+});
+
+describe('GET /api/routes', () => {
+  it('says plainly that this project is not a routed app', async () => {
+    const body = await getJson('/api/routes');
+    expect(body.routed).toBe(false);
+    expect(body.entries).toEqual([]);
+    expect(body.routeCount).toBe(0);
+    expect(body.shown).toBe(0);
+    expect(body.truncated).toBe(false);
+  });
+
+  it('refuses a limit the manifest cannot answer truthfully', async () => {
+    // Below three, the engine's manifest reports every routed project as
+    // unrouted — a wrong answer, so the parameter is refused instead.
+    for (const limit of ['0', '2', '-1', 'abc']) {
+      const { status, body } = await getStatusAndJson(`/api/routes?limit=${limit}`);
+      expect(status, `limit=${limit}`).toBe(400);
+      expect(body.code).toBe('bad-request');
+    }
+  });
+
+  describe('a project that IS routed', () => {
+    let routedApi: GraphApi;
+    let routedServer: UiServerHandle;
+
+    beforeAll(async () => {
+      const routedRoot = path.join(tempDir, 'routed');
+      fs.mkdirSync(path.join(routedRoot, 'src'), { recursive: true });
+      fs.writeFileSync(
+        path.join(routedRoot, 'src', 'routes.ts'),
+        `import express from 'express';
+
+const app = express();
+
+export function listUsers(req: any, res: any): void { res.json([]); }
+export function getUser(req: any, res: any): void { res.json({}); }
+export function createUser(req: any, res: any): void { res.json({}); }
+export function deleteUser(req: any, res: any): void { res.json({}); }
+
+app.get('/users', listUsers);
+app.get('/users/:id', getUser);
+app.post('/users', createUser);
+app.delete('/users/:id', deleteUser);
+
+export default app;
+`
+      );
+      const routedCg = CodeGraph.initSync(routedRoot, {
+        config: { include: ['src/**/*.ts'], exclude: [] },
+      });
+      await routedCg.indexAll();
+      routedCg.resolveReferences();
+      routedCg.close();
+
+      routedApi = createGraphApi({ projectRoot: routedRoot });
+      routedServer = await startUiServer({
+        projectRoot: routedRoot,
+        viewerDir,
+        port: 0,
+        api: routedApi.handler,
+      });
+    }, 120_000);
+
+    afterAll(async () => {
+      routedApi?.close();
+      await routedServer?.close();
+    });
+
+    it('maps each URL to its handler, with a node id to navigate to', async () => {
+      const res = await requestOn(routedServer.port, '/api/routes');
+      const body = JSON.parse(res.body);
+
+      expect(body.routed).toBe(true);
+      expect(body.routeCount).toBe(4);
+      expect(body.shown).toBe(4);
+      expect(body.truncated).toBe(false);
+      expect(body.topHandlerFile).toBe('src/routes.ts');
+      expect(body.topHandlerFileCount).toBe(4);
+
+      const urls = body.entries.map((e: any) => e.url);
+      expect(urls).toEqual(
+        expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id'])
+      );
+
+      const listUsers = body.entries.find((e: any) => e.url === 'GET /users');
+      expect(listUsers.handler).toBe('listUsers');
+      expect(listUsers.handlerKind).toBe('function');
+      expect(listUsers.file).toBe('src/routes.ts');
+      expect(listUsers.line).toBeGreaterThan(0);
+
+      // The manifest carries no ids of its own; resolving them is what makes a
+      // route row clickable, so it has to actually resolve.
+      expect(listUsers.handlerId).toBeTruthy();
+      const handler = JSON.parse(
+        (await requestOn(routedServer.port, `/api/node/${listUsers.handlerId}`)).body
+      );
+      expect(handler.node.name).toBe('listUsers');
+    });
+
+    it('offers its routes as entry points, ahead of anything derived', async () => {
+      const res = await requestOn(routedServer.port, '/api/entrypoints');
+      const body = JSON.parse(res.body);
+
+      expect(body.routes.routed).toBe(true);
+      expect(body.routes.routeCount).toBe(4);
+      const urls = body.routes.items.items.map((e: any) => e.url);
+      expect(urls).toEqual(
+        expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id'])
+      );
+      // A route row has to be navigable, or it is a label.
+      expect(body.routes.items.items.every((e: any) => e.handlerId)).toBe(true);
+    });
+
+    it('honours the limit and says when it cut the list', async () => {
+      const res = await requestOn(routedServer.port, '/api/routes?limit=3');
+      const body = JSON.parse(res.body);
+      expect(body.routed).toBe(true);
+      expect(body.entries).toHaveLength(3);
+      expect(body.shown).toBe(3);
+      expect(body.truncated).toBe(true);
+      // The headline count is the whole graph's, not the page's.
+      expect(body.routeCount).toBe(4);
+    });
+  });
+});
+
+/**
+ * The acceptance bar from the issue, against the engine's OWN index rather than
+ * a fixture: `LRUCache.get` in `src/resolution/lru-cache.ts`, 500+ callers.
+ *
+ * `.codegraph/` is gitignored, so this only runs on a machine that has indexed
+ * this repository. The fixture test above covers the same properties in CI; this
+ * one is the check against the real, messy graph the number came from.
+ */
+describe.runIf(CodeGraph.isInitialized(path.resolve(__dirname, '..')))(
+  "the engine's own busiest symbol",
+  () => {
+    const repoRoot = path.resolve(__dirname, '..');
+    let repoApi: GraphApi;
+    let repoServer: UiServerHandle;
+
+    beforeAll(async () => {
+      repoApi = createGraphApi({ projectRoot: repoRoot });
+      repoServer = await startUiServer({
+        projectRoot: repoRoot,
+        viewerDir,
+        port: 0,
+        api: repoApi.handler,
+      });
+    });
+
+    afterAll(async () => {
+      repoApi?.close();
+      await repoServer?.close();
+    });
+
+    const repoGet = (requestPath: string): Promise<Response> =>
+      requestOn(repoServer.port, requestPath);
+
+    it('answers in under 100 ms with grouped, capped lists and correct counts', async () => {
+      const search = JSON.parse(
+        (await repoGet('/api/search?q=' + encodeURIComponent('LRUCache.get'))).body
+      );
+      const hit = search.results.items.find(
+        (r: any) => r.name === 'get' && r.file.endsWith('src/resolution/lru-cache.ts')
+      );
+      expect(hit, 'LRUCache.get should be in the engine\'s own index').toBeTruthy();
+
+      await repoGet(`/api/node/${hit.id}`); // warm
+
+      const started = performance.now();
+      const res = await repoGet(`/api/node/${hit.id}`);
+      const elapsed = performance.now() - started;
+
+      expect(res.status).toBe(200);
+      const body = JSON.parse(res.body);
+
+      expect(body.counts.fanIn).toBeGreaterThanOrEqual(500);
+      expect(body.counts.hub).toBe(true);
+      // Grouped by calling symbol, so the row count is the distinct-caller
+      // count, never the edge count.
+      expect(body.incoming.items).toHaveLength(body.incoming.shown);
+      expect(body.incoming.shown).toBeLessThanOrEqual(300);
+      expect(body.incoming.shown).toBe(Math.min(300, body.incoming.total));
+      expect(body.incoming.truncated).toBe(body.incoming.total > 300);
+      expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe(
+        body.incoming.shown
+      );
+      const edgesInRows = body.incoming.items.reduce(
+        (sum: number, r: any) => sum + r.edgeCount,
+        0
+      );
+      expect(edgesInRows).toBeLessThanOrEqual(body.counts.fanIn);
+      expect(body.blast.direct).toBe(body.counts.callers);
+      expect(body.tests.reached).toBe(true);
+
+      expect(elapsed).toBeLessThan(100);
+    });
+  }
+);
+
+describe('GET /api/entrypoints', () => {
+  it('finds the file that runs something, and reports what it reaches', async () => {
+    const body = await getJson('/api/entrypoints');
+
+    const files = body.files.items.map((f: any) => f.file);
+    expect(files).toContain('src/main.ts');
+
+    const main = body.files.items.find((f: any) => f.file === 'src/main.ts');
+    expect(main.kind).toBe('file');
+    expect(main.id).toMatch(/^file:/);
+    // `new Service(...)`, `handleRequest(...)` and `service.load(...)` all sit
+    // at module level.
+    expect(main.calls).toBeGreaterThanOrEqual(2);
+    // It imports from service.ts and handler.ts, so it wires files together.
+    expect(main.reaches).toBeGreaterThanOrEqual(2);
+    expect(typeof main.dependents).toBe('number');
+  });
+
+  it('leaves test files out — "where do I start" never means a test', async () => {
+    const body = await getJson('/api/entrypoints');
+
+    for (const file of body.files.items) expect(file.test).toBe(false);
+    // The fixture's test file calls its own helper at module level, so it IS a
+    // candidate by the raw graph signal and is excluded deliberately.
+    expect(body.files.items.map((f: any) => f.file)).not.toContain(
+      '__tests__/service.test.ts'
+    );
+    for (const hub of body.hubs.items) expect(hub.test).toBe(false);
+  });
+
+  it('ranks the most depended-on symbols as hubs, with their dependent counts', async () => {
+    const body = await getJson('/api/entrypoints');
+
+    const hot = body.hubs.items.find((h: any) => h.name === 'hot');
+    expect(hot, 'the 500-caller function should top the hubs').toBeTruthy();
+    expect(hot.dependents).toBe(500);
+    expect(body.hubs.items[0].name).toBe('hot');
+
+    const counts = body.hubs.items.map((h: any) => h.dependents);
+    expect(counts).toEqual([...counts].sort((a: number, b: number) => b - a));
+    // A file or a bare import is structure, not somewhere to start reading.
+    for (const hub of body.hubs.items) {
+      expect(['file', 'import', 'export', 'parameter']).not.toContain(hub.kind);
+    }
+  });
+
+  it('says a project without routes is not routed rather than failing', async () => {
+    const body = await getJson('/api/entrypoints');
+    expect(body.routes.routed).toBe(false);
+    expect(body.routes.items.items).toEqual([]);
+    expect(body.routes.routeCount).toBe(0);
+  });
+
+  it('honours limit, and keeps every list within it', async () => {
+    const body = await getJson('/api/entrypoints?limit=1');
+    expect(body.files.items.length).toBeLessThanOrEqual(1);
+    expect(body.hubs.items.length).toBe(1);
+    expect(body.hubs.total).toBeGreaterThanOrEqual(body.hubs.items.length);
+
+    const bad = await getStatusAndJson('/api/entrypoints?limit=0');
+    expect(bad.status).toBe(400);
+    expect(bad.body.code).toBe('bad-request');
+  });
+});
+
+describe('GET /api/nodes', () => {
+  it('answers a batch of ids in the order asked, and says which are missing', async () => {
+    const cacheId = await idOf('Cache', 'class');
+    const loadId = await idOf('load', 'method');
+    const body = await getJson(
+      `/api/nodes?id=${encodeURIComponent(loadId)}&id=${encodeURIComponent(cacheId)}&id=method%3Anot-a-real-id`
+    );
+
+    expect(body.items.map((n: any) => n.id)).toEqual([loadId, cacheId]);
+    expect(body.items[0].name).toBe('load');
+    expect(body.items[1].name).toBe('Cache');
+    expect(body.missing).toEqual(['method:not-a-real-id']);
+    // The REF shape, not the Symbol view payload: a trail redraws six names,
+    // not six rail sets.
+    expect(body.items[0].incoming).toBeUndefined();
+    expect(body.items[0].file).toBe('src/service.ts');
+  });
+
+  it('de-duplicates ids rather than answering twice', async () => {
+    const cacheId = await idOf('Cache', 'class');
+    const encoded = encodeURIComponent(cacheId);
+    const body = await getJson(`/api/nodes?id=${encoded}&id=${encoded}`);
+    expect(body.items).toHaveLength(1);
+  });
+
+  it('refuses an empty or oversized request with guidance', async () => {
+    const none = await getStatusAndJson('/api/nodes');
+    expect(none.status).toBe(400);
+    expect(none.body.hint).toContain('id=');
+
+    const ids = Array.from({ length: 61 }, (_, i) => `id=method%3A${i}`).join('&');
+    const many = await getStatusAndJson(`/api/nodes?${ids}`);
+    expect(many.status).toBe(400);
+    expect(many.body.error).toContain('Too many ids');
+  });
+});
+
+describe('an index that is not there', () => {
+  it('answers with the same guidance the CLI prints, not a stack trace', async () => {
+    const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-noindex-'));
+    const detached = createGraphApi({ projectRoot: emptyRoot });
+    const detachedServer = await startUiServer({
+      projectRoot: emptyRoot,
+      viewerDir,
+      port: 0,
+      api: detached.handler,
+    });
+    try {
+      const res = await requestOn(detachedServer.port, '/api/stats');
+
+      expect(res.status).toBe(503);
+      const body = JSON.parse(res.body);
+      expect(body.code).toBe('no-index');
+      expect(body.error).toContain('No CodeGraph index found');
+      expect(body.hint).toContain('codegraph init');
+      expect(body.error).not.toContain('    at ');
+    } finally {
+      detached.close();
+      await detachedServer.close();
+      fs.rmSync(emptyRoot, { recursive: true, force: true });
+    }
+  });
+});

+ 566 - 0
__tests__/ui-server.test.ts

@@ -0,0 +1,566 @@
+/**
+ * `codegraph ui` server — the loopback boundary (CG-41).
+ *
+ * This process serves the user's source code from a port on their machine, so
+ * the tests that matter are the refusals: a foreign `Host` (DNS rebinding is
+ * the only realistic attack on a loopback code viewer), a traversal out of the
+ * asset root, a write method, a cross-origin read. The happy path — index.html
+ * and hashed assets — is here mostly so a refusal that accidentally blocks
+ * everything can't pass.
+ *
+ * Requests go through `http.request`, not `fetch`: `Host` is a forbidden header
+ * name in undici, and forging it is the whole point of half these cases.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import {
+  browserOpenCommand,
+  cacheControlFor,
+  contentTypeFor,
+  isAllowedHost,
+  isAllowedOrigin,
+  isSafeRequestPath,
+  PathRefusalError,
+  resolveProjectFile,
+  resolveStaticAsset,
+  startUiServer,
+  type UiServerHandle,
+} from '../src/ui-server';
+
+interface Response {
+  status: number;
+  headers: http.IncomingHttpHeaders;
+  body: string;
+}
+
+/**
+ * One request with full control over the request line and headers.
+ *
+ * `setHost: false` stops node from adding its own `Host`, and `path` is sent
+ * verbatim — so a traversal case really does put `/../../x` on the wire.
+ */
+function request(
+  port: number,
+  requestPath: string,
+  options: { method?: string; headers?: Record<string, string> } = {}
+): Promise<Response> {
+  return new Promise((resolve, reject) => {
+    const headers: Record<string, string> = { Host: `127.0.0.1:${port}`, ...options.headers };
+    const req = http.request(
+      { host: '127.0.0.1', port, path: requestPath, method: options.method ?? 'GET', headers, setHost: false },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            headers: res.headers,
+            body: Buffer.concat(chunks).toString('utf-8'),
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+describe('codegraph ui server', () => {
+  let tempDir: string;
+  let viewerDir: string;
+  let projectRoot: string;
+  let server: UiServerHandle;
+
+  beforeAll(async () => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-server-'));
+
+    // A stand-in for dist/viewer: same shape (index.html + hashed assets/), so
+    // the tests don't need the Svelte build to have run.
+    viewerDir = path.join(tempDir, 'viewer');
+    fs.mkdirSync(path.join(viewerDir, 'assets'), { recursive: true });
+    fs.writeFileSync(
+      path.join(viewerDir, 'index.html'),
+      '<!doctype html><html><body><div id="app"></div>' +
+        '<script type="module" src="./assets/index-abc123.js"></script></body></html>'
+    );
+    fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.js'), 'export const viewer = 1;\n');
+    fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.css'), ':root{color:#16150f}\n');
+
+    projectRoot = path.join(tempDir, 'project');
+    fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
+    fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
+
+    // A file OUTSIDE both roots that a traversal would be trying to reach.
+    fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
+
+    server = await startUiServer({ projectRoot, viewerDir, port: 0 });
+  });
+
+  afterAll(async () => {
+    await server?.close();
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  describe('serving the viewer', () => {
+    it('serves index.html at the root', async () => {
+      const res = await request(server.port, '/');
+      expect(res.status).toBe(200);
+      expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
+      expect(res.body).toContain('<div id="app">');
+    });
+
+    it('serves index.html directly too', async () => {
+      const res = await request(server.port, '/index.html');
+      expect(res.status).toBe(200);
+      expect(res.body).toContain('<div id="app">');
+    });
+
+    it('serves hashed assets with their real content type', async () => {
+      const js = await request(server.port, '/assets/index-abc123.js');
+      expect(js.status).toBe(200);
+      expect(js.headers['content-type']).toBe('text/javascript; charset=utf-8');
+      expect(js.body).toContain('export const viewer');
+
+      const css = await request(server.port, '/assets/index-abc123.css');
+      expect(css.status).toBe(200);
+      expect(css.headers['content-type']).toBe('text/css; charset=utf-8');
+    });
+
+    it('caches hashed assets forever and index.html never', async () => {
+      const asset = await request(server.port, '/assets/index-abc123.js');
+      expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable');
+      const index = await request(server.port, '/');
+      expect(index.headers['cache-control']).toBe('no-store');
+    });
+
+    it('falls back to index.html for an unknown route, but not for a missing asset', async () => {
+      // A hash-routed app only ever asks for `/`, but a hand-typed deep path
+      // should still open the app.
+      const route = await request(server.port, '/s/some-symbol-id');
+      expect(route.status).toBe(200);
+      expect(route.body).toContain('<div id="app">');
+
+      // A missing FILE must 404 — answering with HTML would hand the browser a
+      // script that isn't one, and hide a broken build.
+      const asset = await request(server.port, '/assets/index-doesnotexist.js');
+      expect(asset.status).toBe(404);
+    });
+
+    it('answers HEAD with the same headers and no body', async () => {
+      const res = await request(server.port, '/', { method: 'HEAD' });
+      expect(res.status).toBe(200);
+      expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
+      expect(res.headers['content-length']).toBeDefined();
+      expect(res.body).toBe('');
+    });
+  });
+
+  describe('binding', () => {
+    it('listens on loopback only', () => {
+      const address = server.server.address();
+      expect(address).not.toBeNull();
+      expect(typeof address === 'object' ? address?.address : null).toBe('127.0.0.1');
+      expect(server.url).toBe(`http://127.0.0.1:${server.port}`);
+    });
+
+    it('falls back to the next free port when the preferred one is taken', async () => {
+      const blocker = http.createServer(() => {});
+      await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
+      const taken = (blocker.address() as { port: number }).port;
+
+      const second = await startUiServer({ projectRoot, viewerDir, port: taken });
+      try {
+        expect(second.port).not.toBe(taken);
+        expect(second.port).toBeGreaterThan(taken);
+        // …and it actually works on the port it landed on.
+        const res = await request(second.port, '/');
+        expect(res.status).toBe(200);
+      } finally {
+        await second.close();
+        await new Promise<void>((resolve) => blocker.close(() => resolve()));
+      }
+    });
+
+    it('refuses to move off a port the caller pinned', async () => {
+      const blocker = http.createServer(() => {});
+      await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
+      const taken = (blocker.address() as { port: number }).port;
+
+      try {
+        await expect(
+          startUiServer({ projectRoot, viewerDir, port: taken, portFallback: false })
+        ).rejects.toThrow(/already in use/i);
+      } finally {
+        await new Promise<void>((resolve) => blocker.close(() => resolve()));
+      }
+    });
+  });
+
+  describe('Host allowlist (DNS rebinding)', () => {
+    it('serves the loopback names', async () => {
+      for (const host of ['127.0.0.1', 'localhost', '[::1]', `localhost:${server.port}`, `[::1]:${server.port}`]) {
+        const res = await request(server.port, '/', { headers: { Host: host } });
+        expect(res.status, `Host: ${host}`).toBe(200);
+      }
+    });
+
+    it('refuses a foreign Host', async () => {
+      for (const host of ['evil.example', `evil.example:${server.port}`, 'attacker.localhost.evil.com']) {
+        const res = await request(server.port, '/', { headers: { Host: host } });
+        expect(res.status, `Host: ${host}`).toBe(403);
+        expect(res.body).not.toContain('<div id="app">');
+      }
+    });
+
+    it('refuses a loopback Host carrying someone else\u2019s port', async () => {
+      const res = await request(server.port, '/', { headers: { Host: '127.0.0.1:9' } });
+      expect(res.status).toBe(403);
+    });
+
+    it('refuses a malformed or missing Host', async () => {
+      const malformed = await request(server.port, '/', { headers: { Host: '127.0.0.1:notaport' } });
+      expect(malformed.status).toBe(403);
+      // Node's client insists on sending something for Host, so the empty-value
+      // case is covered by the unit assertions on isAllowedHost below.
+    });
+
+    it('refuses before touching the filesystem — even for an asset', async () => {
+      const res = await request(server.port, '/assets/index-abc123.js', {
+        headers: { Host: 'evil.example' },
+      });
+      expect(res.status).toBe(403);
+      expect(res.body).not.toContain('export const viewer');
+    });
+  });
+
+  describe('cross-origin', () => {
+    it('never sends CORS headers', async () => {
+      const res = await request(server.port, '/');
+      expect(res.headers['access-control-allow-origin']).toBeUndefined();
+      expect(res.headers['access-control-allow-credentials']).toBeUndefined();
+      expect(res.headers['access-control-allow-methods']).toBeUndefined();
+    });
+
+    it('refuses a request carrying a foreign Origin', async () => {
+      const res = await request(server.port, '/', { headers: { Origin: 'https://evil.example' } });
+      expect(res.status).toBe(403);
+    });
+
+    it('allows the viewer\u2019s own origin', async () => {
+      const res = await request(server.port, '/', {
+        headers: { Origin: `http://127.0.0.1:${server.port}` },
+      });
+      expect(res.status).toBe(200);
+    });
+
+    it('sends the hardening headers on every response', async () => {
+      const res = await request(server.port, '/');
+      expect(res.headers['x-content-type-options']).toBe('nosniff');
+      expect(res.headers['x-frame-options']).toBe('DENY');
+      expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'");
+      expect(res.headers['content-security-policy']).toContain("connect-src 'self'");
+    });
+  });
+
+  describe('methods', () => {
+    it('refuses every method it has never answered', async () => {
+      for (const method of ['PUT', 'PATCH', 'OPTIONS', 'TRACE']) {
+        const res = await request(server.port, '/', { method });
+        expect(res.status, method).toBe(405);
+        expect(res.headers['allow']).toBe('GET, HEAD, POST, DELETE');
+      }
+    });
+
+    /**
+     * The static side stayed a pure reader when `/api/trails` gained a write
+     * (CG-60). A POST at an asset path is 405 with `Allow: GET, HEAD` — the
+     * narrower answer, since nothing under the viewer bundle will ever take
+     * one.
+     */
+    it('refuses a write outside /api/, whatever it carries', async () => {
+      for (const method of ['POST', 'DELETE']) {
+        const res = await request(server.port, '/', {
+          method,
+          headers: { 'X-CodeGraph-UI': '1' },
+        });
+        expect(res.status, method).toBe(405);
+        expect(res.headers['allow']).toBe('GET, HEAD');
+      }
+    });
+
+    /**
+     * Under `/api/` a write is answered as JSON even when refused — the viewer
+     * parses these, and a text/plain body surfaces as a parse error rather than
+     * the refusal it is. No API is mounted on this server, so the refusal is
+     * the boundary's own and not an endpoint's.
+     */
+    it('refuses an unmarked write under /api/ as JSON', async () => {
+      const res = await request(server.port, '/api/trails', { method: 'POST' });
+      expect(res.status).toBe(403);
+      expect(res.headers['content-type']).toContain('application/json');
+      expect(JSON.parse(res.body).code).toBe('refused');
+    });
+  });
+
+  describe('paths outside the asset root', () => {
+    const traversals = [
+      '/../secret.txt',
+      '/../../secret.txt',
+      '/assets/../../secret.txt',
+      '/..%2fsecret.txt',
+      '/%2e%2e/secret.txt',
+      '/%2e%2e%2fsecret.txt',
+      '/....//secret.txt',
+    ];
+
+    it('never serves a file outside the viewer directory', async () => {
+      for (const traversal of traversals) {
+        const res = await request(server.port, traversal);
+        expect(res.body, traversal).not.toContain('SUPER-SECRET-VALUE');
+        expect(res.status, traversal).not.toBe(200);
+      }
+    });
+
+    it('404s an absolute system path rather than reading it', async () => {
+      const res = await request(server.port, '/etc/passwd');
+      expect(res.body).not.toContain('root:');
+      // No such file under the viewer root; an extension-less path is a route.
+      expect(res.status).toBe(200);
+      expect(res.body).toContain('<div id="app">');
+
+      const shadow = await request(server.port, '/etc/hosts.txt');
+      expect(shadow.status).toBe(404);
+    });
+
+    it('404s a NUL-truncation attempt', async () => {
+      const res = await request(server.port, '/index.html%00.png');
+      expect(res.status).toBe(404);
+    });
+  });
+
+  describe('/api is reserved', () => {
+    it('404s as JSON, never as the app shell', async () => {
+      const res = await request(server.port, '/api/nodes');
+      expect(res.status).toBe(404);
+      expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
+      expect(JSON.parse(res.body)).toHaveProperty('error');
+      expect(res.body).not.toContain('<div id="app">');
+    });
+
+    it('hands requests to a mounted handler with a decoded path and query', async () => {
+      const seen: Array<{ pathname: string; symbol: string | null; root: string }> = [];
+      const withApi = await startUiServer({
+        projectRoot,
+        viewerDir,
+        port: 0,
+        api: (_req, res, ctx) => {
+          seen.push({
+            pathname: ctx.pathname,
+            symbol: ctx.query.get('symbol'),
+            root: ctx.projectRoot,
+          });
+          res.writeHead(200, { 'Content-Type': 'application/json' });
+          res.end('{"ok":true}');
+          return true;
+        },
+      });
+      try {
+        const res = await request(withApi.port, '/api/node?symbol=parse%20Token');
+        expect(res.status).toBe(200);
+        expect(JSON.parse(res.body)).toEqual({ ok: true });
+        expect(seen).toEqual([{ pathname: '/api/node', symbol: 'parse Token', root: projectRoot }]);
+      } finally {
+        await withApi.close();
+      }
+    });
+
+    it('turns a throwing handler into a JSON 500, not a crashed server', async () => {
+      const withApi = await startUiServer({
+        projectRoot,
+        viewerDir,
+        port: 0,
+        api: () => {
+          throw new Error('handler blew up');
+        },
+      });
+      try {
+        const res = await request(withApi.port, '/api/boom');
+        expect(res.status).toBe(500);
+        expect(JSON.parse(res.body).error).toContain('handler blew up');
+        // Still alive afterwards.
+        expect((await request(withApi.port, '/')).status).toBe(200);
+      } finally {
+        await withApi.close();
+      }
+    });
+  });
+});
+
+describe('resolveProjectFile — the source read chokepoint', () => {
+  let tempDir: string;
+  let projectRoot: string;
+
+  beforeAll(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-paths-'));
+    projectRoot = path.join(tempDir, 'project');
+    fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
+    fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
+    fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
+  });
+
+  afterAll(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('resolves a file inside the project', () => {
+    expect(resolveProjectFile(projectRoot, 'src/auth.ts')).toBe(
+      fs.realpathSync(path.join(projectRoot, 'src', 'auth.ts'))
+    );
+  });
+
+  it('refuses traversal out of the project', () => {
+    for (const escape of ['../secret.txt', 'src/../../secret.txt', '..%2fsecret.txt']) {
+      expect(() => resolveProjectFile(projectRoot, escape), escape).toThrow(PathRefusalError);
+    }
+  });
+
+  it('refuses an absolute path', () => {
+    expect(() => resolveProjectFile(projectRoot, path.join(tempDir, 'secret.txt'))).toThrow(
+      PathRefusalError
+    );
+  });
+
+  it('refuses an empty path', () => {
+    expect(() => resolveProjectFile(projectRoot, '')).toThrow(PathRefusalError);
+    expect(() => resolveProjectFile(projectRoot, '   ')).toThrow(PathRefusalError);
+  });
+
+  it('refuses a NUL byte', () => {
+    expect(() => resolveProjectFile(projectRoot, 'src/auth.ts%00.png')).toThrow(PathRefusalError);
+  });
+
+  // `/etc` resolves to a non-existent `C:\etc` on Windows, so the sensitive-path
+  // list only means anything on POSIX.
+  it.runIf(process.platform !== 'win32')('refuses a sensitive system directory as the root', () => {
+    expect(() => resolveProjectFile('/etc', 'passwd')).toThrow(PathRefusalError);
+    expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError);
+  });
+
+  it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => {
+    const link = path.join(projectRoot, 'src', 'escape.ts');
+    fs.symlinkSync(path.join(tempDir, 'secret.txt'), link);
+    try {
+      expect(() => resolveProjectFile(projectRoot, 'src/escape.ts')).toThrow(PathRefusalError);
+    } finally {
+      fs.unlinkSync(link);
+    }
+  });
+});
+
+describe('security helpers', () => {
+  it('isAllowedHost accepts only loopback names on our port', () => {
+    expect(isAllowedHost('127.0.0.1', 4747)).toBe(true);
+    expect(isAllowedHost('127.0.0.1:4747', 4747)).toBe(true);
+    expect(isAllowedHost('localhost:4747', 4747)).toBe(true);
+    expect(isAllowedHost('LOCALHOST', 4747)).toBe(true);
+    expect(isAllowedHost('[::1]:4747', 4747)).toBe(true);
+
+    expect(isAllowedHost(undefined, 4747)).toBe(false);
+    expect(isAllowedHost('', 4747)).toBe(false);
+    expect(isAllowedHost('evil.example', 4747)).toBe(false);
+    expect(isAllowedHost('127.0.0.1:4748', 4747)).toBe(false);
+    expect(isAllowedHost('127.0.0.1.evil.example', 4747)).toBe(false);
+    expect(isAllowedHost('localhost.evil.example:4747', 4747)).toBe(false);
+    expect(isAllowedHost('127.0.0.1:4747:4747', 4747)).toBe(false);
+    // Unbracketed IPv6 is malformed per RFC 7230 — rejected, not guessed at.
+    expect(isAllowedHost('::1', 4747)).toBe(false);
+    // A non-loopback address that merely resolves here still fails the check.
+    expect(isAllowedHost('192.168.1.5:4747', 4747)).toBe(false);
+  });
+
+  it('isAllowedOrigin allows absent and same-origin, refuses everything else', () => {
+    expect(isAllowedOrigin(undefined, 4747)).toBe(true);
+    expect(isAllowedOrigin('http://127.0.0.1:4747', 4747)).toBe(true);
+    expect(isAllowedOrigin('http://localhost:4747', 4747)).toBe(true);
+    expect(isAllowedOrigin('http://[::1]:4747', 4747)).toBe(true);
+
+    expect(isAllowedOrigin('null', 4747)).toBe(false);
+    expect(isAllowedOrigin('https://evil.example', 4747)).toBe(false);
+    expect(isAllowedOrigin('http://127.0.0.1:4748', 4747)).toBe(false);
+    expect(isAllowedOrigin('file://', 4747)).toBe(false);
+    expect(isAllowedOrigin('not a url', 4747)).toBe(false);
+  });
+
+  it('isSafeRequestPath rejects a `..` segment however it is spelled', () => {
+    expect(isSafeRequestPath('/')).toBe(true);
+    expect(isSafeRequestPath('/assets/index-abc123.js')).toBe(true);
+    expect(isSafeRequestPath('/s/Some.Symbol')).toBe(true);
+
+    expect(isSafeRequestPath('/../secret')).toBe(false);
+    expect(isSafeRequestPath('/a/../../secret')).toBe(false);
+    expect(isSafeRequestPath('/%2e%2e/secret')).toBe(false);
+    expect(isSafeRequestPath('/..%2Fsecret')).toBe(false);
+    expect(isSafeRequestPath('/a%00b')).toBe(false);
+    expect(isSafeRequestPath('/a\\b')).toBe(false);
+    expect(isSafeRequestPath('/%zz')).toBe(false);
+  });
+
+  it('resolveStaticAsset returns null for anything that is not a file in the root', () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-static-'));
+    try {
+      fs.mkdirSync(path.join(dir, 'assets'));
+      fs.writeFileSync(path.join(dir, 'index.html'), 'x');
+      expect(resolveStaticAsset(dir, '/index.html')).toBe(
+        fs.realpathSync(path.join(dir, 'index.html'))
+      );
+      expect(resolveStaticAsset(dir, '/assets')).toBeNull(); // a directory
+      expect(resolveStaticAsset(dir, '/missing.js')).toBeNull();
+      expect(resolveStaticAsset(dir, '/../etc/passwd')).toBeNull();
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  it('contentTypeFor covers the viewer bundle and defaults safely', () => {
+    expect(contentTypeFor('a/index.html')).toBe('text/html; charset=utf-8');
+    expect(contentTypeFor('a/index-abc.js')).toBe('text/javascript; charset=utf-8');
+    expect(contentTypeFor('a/archivo.woff2')).toBe('font/woff2');
+    expect(contentTypeFor('a/thing.unknownext')).toBe('application/octet-stream');
+  });
+
+  it('cacheControlFor pins hashed assets and never index.html', () => {
+    expect(cacheControlFor(path.join('assets', 'index-abc.js'))).toContain('immutable');
+    expect(cacheControlFor('index.html')).toBe('no-store');
+  });
+});
+
+describe('browserOpenCommand', () => {
+  it('uses the platform opener', () => {
+    expect(browserOpenCommand('http://x', 'darwin')).toEqual({ command: 'open', args: ['http://x'] });
+    expect(browserOpenCommand('http://x', 'linux')).toEqual({ command: 'xdg-open', args: ['http://x'] });
+    expect(browserOpenCommand('http://x', 'win32')).toEqual({
+      command: 'cmd',
+      args: ['/c', 'start', '', 'http://x'],
+    });
+  });
+
+  it('honours the CODEGRAPH_BROWSER override', () => {
+    expect(browserOpenCommand('http://x', 'darwin', 'firefox')).toEqual({
+      command: 'firefox',
+      args: ['http://x'],
+    });
+    // Windows routes the override through cmd so a `.cmd`/`.bat` shim — which
+    // CreateProcess cannot launch directly — still works.
+    expect(browserOpenCommand('http://x', 'win32', 'C:\\tools\\open.cmd')).toEqual({
+      command: 'cmd',
+      args: ['/c', 'C:\\tools\\open.cmd', 'http://x'],
+    });
+    for (const off of ['none', 'NONE', '0', 'false', 'off', '', '  ']) {
+      expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull();
+    }
+  });
+});

+ 582 - 0
__tests__/ui-steps-api-servers.test.ts

@@ -0,0 +1,582 @@
+/**
+ * `GET /api/steps` on servers: an Express API, a NestJS API, a FastAPI service
+ * and a Spring controller, in one indexed fixture, shaped to cross every
+ * boundary an endpoint's picture has — the request and what runs before the
+ * handler, the database, a queue, an email, and the responses with their
+ * status codes. Mirrors `ui-steps-api.test.ts` (the mobile app).
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildSteps, projectKind } from '../src/ui-server/api/steps';
+import type { WireBlock } from '../src/ui-server/api/program';
+import { routeRoots } from '../src/ui-server/api/route-roots';
+
+let tmpDir: string;
+let cg: CodeGraph;
+
+function write(rel: string, content: string): void {
+  const full = path.join(tmpDir, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, content);
+}
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-servers-'));
+  write(
+    'package.json',
+    JSON.stringify({ name: 'api', dependencies: { express: '4', '@nestjs/core': '10', '@nestjs/common': '10', bullmq: '5', '@prisma/client': '5', typeorm: '0.3' } })
+  );
+  // ---- Express: a named handler behind middleware, and an inline handler.
+  write('src/server/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
+  write('src/server/queue.ts', "import { Queue } from 'bullmq'\nexport const emailQueue = new Queue('email')\n");
+  write('src/server/errors.ts', 'export class NotFoundError extends Error {}\n');
+  write('src/server/auth.ts', 'export function authenticate(req, res, next) {\n  next()\n}\n');
+  write('src/server/validate.ts', 'export function validate(schema) {\n  return (req, res, next) => next()\n}\n');
+  write(
+    'src/server/users.service.ts',
+    "import { prisma } from './db'\n" +
+      "import { emailQueue } from './queue'\n" +
+      "import { NotFoundError } from './errors'\n" +
+      'export async function createUser(req, res) {\n' +
+      '  const user = await prisma.user.create({ data: { email: req.body.email, name: req.body.name } })\n' +
+      "  await emailQueue.add('welcome', { userId: user.id })\n" +
+      '  if (!user.verified) {\n' +
+      '    await sendVerification(user)\n' +
+      '  }\n' +
+      '  res.status(201).json({\n' +
+      '    id: user.id,\n' +
+      '    token: signToken(user.id),\n' +
+      '  })\n' +
+      '}\n' +
+      'function signToken(id) {\n' +
+      "  return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: '1d' })\n" +
+      '}\n' +
+      'export async function getUser(id: string) {\n' +
+      '  const user = await prisma.user.findUnique({ where: { id } })\n' +
+      "  if (!user) throw new NotFoundError('no such user')\n" +
+      '  return user\n' +
+      '}\n' +
+      'async function sendVerification(user) {\n' +
+      '  await transporter.sendMail({ to: user.email })\n' +
+      '}\n' +
+      'export async function acceptUser(req, res) {\n' +
+      '  const user = await prisma.user.update({ where: { id: req.params.id }, data: { accepted: true } })\n' +
+      '  res.status(202)\n' +
+      '  res.json(user)\n' +
+      '}\n'
+  );
+  write(
+    'src/server/users.routes.ts',
+    "import { Router } from 'express'\n" +
+      "import { authenticate } from './auth'\n" +
+      "import { validate } from './validate'\n" +
+      "import { createUser, getUser, acceptUser } from './users.service'\n" +
+      'const router = Router()\n' +
+      "router.post('/users', authenticate, validate(userSchema), createUser)\n" +
+      "router.post('/users/:id/accept', authenticate, acceptUser)\n" +
+      "router.get('/users/:id', authenticate, async (req, res) => {\n" +
+      '  const user = await getUser(req.params.id)\n' +
+      '  res.json(user)\n' +
+      '})\n' +
+      'export default router\n'
+  );
+  // ---- NestJS: guards on the class and the method, DI into a service, a queue consumer.
+  write(
+    'src/nest/cats.service.ts',
+    "import { Injectable } from '@nestjs/common'\n" +
+      "import { InjectRepository } from '@nestjs/typeorm'\n" +
+      "import { Repository } from 'typeorm'\n" +
+      "import { InjectQueue } from '@nestjs/bullmq'\n" +
+      "import { Queue } from 'bullmq'\n" +
+      "import { Cat } from './cat.entity'\n" +
+      '@Injectable()\n' +
+      'export class CatsService {\n' +
+      '  constructor(\n' +
+      '    @InjectRepository(Cat) private readonly catsRepository: Repository<Cat>,\n' +
+      "    @InjectQueue('cats') private readonly catsQueue: Queue\n" +
+      '  ) {}\n' +
+      '  async create(dto) {\n' +
+      '    const cat = await this.catsRepository.save(dto)\n' +
+      "    await this.catsQueue.add('index', { id: cat.id })\n" +
+      '    return cat\n' +
+      '  }\n' +
+      '  async findOne(id: string) {\n' +
+      '    return this.catsRepository.findOne({ where: { id } })\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write('src/nest/cat.entity.ts', "import { Entity } from 'typeorm'\n@Entity()\nexport class Cat {\n  id: string\n}\n");
+  write(
+    'src/nest/cats.controller.ts',
+    "import { Controller, Get, Post, Body, Param, UseGuards, NotFoundException } from '@nestjs/common'\n" +
+      "import { AuthGuard } from '@nestjs/passport'\n" +
+      "import { CatsService } from './cats.service'\n" +
+      "import { RolesGuard } from './roles.guard'\n" +
+      "@Controller('cats')\n" +
+      "@UseGuards(AuthGuard('jwt'))\n" +
+      'export class CatsController {\n' +
+      '  constructor(private readonly catsService: CatsService) {}\n' +
+      '  @Post()\n' +
+      '  @UseGuards(RolesGuard)\n' +
+      '  async create(@Body() dto: CreateCatDto) {\n' +
+      '    return this.catsService.create(dto)\n' +
+      '  }\n' +
+      "  @Get(':id')\n" +
+      "  async findOne(@Param('id') id: string) {\n" +
+      '    const cat = await this.catsService.findOne(id)\n' +
+      "    if (!cat) throw new NotFoundException('no cat')\n" +
+      '    return cat\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write('src/nest/roles.guard.ts', "import { Injectable } from '@nestjs/common'\n@Injectable()\nexport class RolesGuard {\n  canActivate() { return true }\n}\n");
+  write(
+    'src/nest/cats.processor.ts',
+    "import { Processor, Process } from '@nestjs/bull'\n" +
+      "@Processor('cats')\n" +
+      'export class CatsProcessor {\n' +
+      "  @Process('index')\n" +
+      '  async handleIndex(job) {\n' +
+      '    await searchClient.index(job.data)\n' +
+      '  }\n' +
+      '}\n'
+  );
+  // ---- FastAPI: a dependency on the route, SQLModel, an HTTPException, a Celery task.
+  write(
+    'api/items.py',
+    'from fastapi import APIRouter, Depends, HTTPException\n' +
+      'from sqlmodel import select\n' +
+      'from .deps import get_current_user, SessionDep\n' +
+      'from .models import Item, ItemCreate\n' +
+      'from .tasks import send_welcome\n' +
+      '\n' +
+      'router = APIRouter()\n' +
+      '\n' +
+      '@router.post("/items", dependencies=[Depends(get_current_user)])\n' +
+      'def create_item(session: SessionDep, item_in: ItemCreate):\n' +
+      '    item = Item.model_validate(item_in)\n' +
+      '    session.add(item)\n' +
+      '    session.commit()\n' +
+      '    if item.price < 0:\n' +
+      '        raise HTTPException(status_code=422, detail="bad price")\n' +
+      '    send_welcome.delay(item.id)\n' +
+      '    return item\n'
+  );
+  write('api/deps.py', 'def get_current_user():\n    return None\n\nSessionDep = None\n');
+  write('api/models.py', 'class Item:\n    pass\n\nclass ItemCreate:\n    pass\n');
+  write('api/tasks.py', 'from celery import shared_task\n\n@shared_task\ndef send_welcome(item_id):\n    return item_id\n');
+  // A router with its own prefix, included by an aggregate router, mounted at a literal prefix — and one at a computed one.
+  write(
+    'api/orders.py',
+    'from fastapi import APIRouter\n' +
+      '\n' +
+      'router = APIRouter(prefix="/orders", tags=["orders"])\n' +
+      '\n' +
+      '@router.get("/")\n' +
+      'def list_orders():\n' +
+      '    return []\n' +
+      '\n' +
+      '@router.get("/{order_id}")\n' +
+      'def get_order(order_id: int):\n' +
+      '    return order_id\n'
+  );
+  write('api/v1.py', 'from fastapi import APIRouter\nfrom .orders import router as orders_router\napi_router = APIRouter()\napi_router.include_router(orders_router)\n');
+  write(
+    'api/main.py',
+    'from fastapi import FastAPI\nfrom .items import router\nfrom .v1 import api_router\nfrom .config import settings\napp = FastAPI()\napp.include_router(router)\napp.include_router(api_router, prefix="/api/v1")\napp.include_router(api_router, prefix=settings.LEGACY)\n'
+  );
+  write('api/config.py', 'settings = None\n');
+  write('requirements.txt', 'fastapi\nsqlmodel\ncelery\n');
+  // ---- Spring: a repository typed on a field, ResponseEntity replies, a guard annotation.
+  write(
+    'src/main/java/demo/OwnerController.java',
+    'package demo;\n' +
+      'import org.springframework.web.bind.annotation.*;\n' +
+      'import org.springframework.http.*;\n' +
+      '@RestController\n' +
+      '@RequestMapping("/owners")\n' +
+      'public class OwnerController {\n' +
+      '  private final OwnerRepository owners;\n' +
+      '  public OwnerController(OwnerRepository owners) { this.owners = owners; }\n' +
+      '  @PostMapping("/new")\n' +
+      '  @PreAuthorize("hasRole(\'ADMIN\')")\n' +
+      '  public ResponseEntity<Owner> create(@RequestBody Owner owner) {\n' +
+      '    if (owner.getName() == null) {\n' +
+      '      return ResponseEntity.badRequest().build();\n' +
+      '    }\n' +
+      '    Owner saved = owners.save(owner);\n' +
+      '    return ResponseEntity.status(HttpStatus.CREATED).body(saved);\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write(
+    'src/main/java/demo/OwnerRepository.java',
+    'package demo;\nimport org.springframework.data.jpa.repository.JpaRepository;\npublic interface OwnerRepository extends JpaRepository<Owner, Integer> {\n}\n'
+  );
+  write('src/main/java/demo/Owner.java', 'package demo;\npublic class Owner {\n  private String name;\n  public String getName() { return name; }\n}\n');
+  // ---- ASP.NET Minimal API, endpoint-group style: the class is the group,
+  // the handler is the first argument, the app's extension supplies `/api/`.
+  write(
+    'src/Web/Endpoints/TodoItems.cs',
+    'using Microsoft.AspNetCore.Http.HttpResults;\n' +
+      'namespace Demo.Web.Endpoints;\n' +
+      'public class TodoItems : IEndpointGroup\n' +
+      '{\n' +
+      '    public static void Map(RouteGroupBuilder groupBuilder)\n' +
+      '    {\n' +
+      '        groupBuilder.RequireAuthorization();\n' +
+      '        groupBuilder.MapPost(CreateTodoItem);\n' +
+      '        groupBuilder.MapPut(UpdateTodoItem, "{id}");\n' +
+      '    }\n' +
+      '    public static async Task<Created<int>> CreateTodoItem(ISender sender, CreateTodoItemCommand command)\n' +
+      '    {\n' +
+      '        var id = await sender.Send(command);\n' +
+      '        return TypedResults.Created($"/{nameof(TodoItems)}/{id}", id);\n' +
+      '    }\n' +
+      '    public static async Task<Results<NoContent, BadRequest>> UpdateTodoItem(ISender sender, int id, UpdateTodoItemCommand command)\n' +
+      '    {\n' +
+      '        if (id != command.Id)\n' +
+      '            return TypedResults.BadRequest();\n' +
+      '        await sender.Send(command);\n' +
+      '        return TypedResults.NoContent();\n' +
+      '    }\n' +
+      '}\n'
+  );
+  write(
+    'src/Web/Infrastructure/WebApplicationExtensions.cs',
+    'using Microsoft.AspNetCore.Builder;\n' +
+      'namespace Demo.Web.Infrastructure;\n' +
+      'public static class WebApplicationExtensions\n' +
+      '{\n' +
+      '    public static WebApplication MapEndpoints(this WebApplication app)\n' +
+      '    {\n' +
+      '        var groupName = "x";\n' +
+      '        var group = app.MapGroup($"/api/{groupName}").WithTags(groupName);\n' +
+      '        return app;\n' +
+      '    }\n' +
+      '}\n'
+  );
+  cg = CodeGraph.initSync(tmpDir);
+  await cg.indexAll();
+});
+
+afterAll(() => {
+  cg?.close();
+  if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+const q = (params: Record<string, string>) => new URLSearchParams(params);
+const route = (name: string) => {
+  const r = cg.getNodesByKind('route').find((r) => r.name === name);
+  if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
+  return r;
+};
+const effect = (p: Awaited<ReturnType<typeof buildSteps>>, category: string) => p.steps.find((s) => s.kind === 'effect' && s.effect?.category === category);
+
+/**
+ * The `program` reading, one line per item, indented — the rail as a reader
+ * meets it. A step prints its label, a fork its condition and each arm's, a
+ * bracketed run its words.
+ */
+function inOrder(p: Awaited<ReturnType<typeof buildSteps>>): string[] {
+  const label = (id: string) => p.steps.find((s) => s.id === id)?.label ?? id;
+  const out: string[] = [];
+  const walk = (block: WireBlock, indent: string): void => {
+    for (const item of block) {
+      if (item.kind === 'step') {
+        out.push(`${indent}${label(item.step)}${item.again ? ' (again)' : ''}`);
+        if (item.body) walk(item.body, `${indent}  `);
+      } else if (item.kind === 'fork') {
+        out.push(`${indent}${item.form} ${item.on}`);
+        for (const arm of item.arms) {
+          out.push(`${indent}  ${arm.when}${arm.ends ? ` → ${arm.ends}` : ''}`);
+          walk(arm.body, `${indent}    `);
+        }
+      } else if (item.kind === 'block') {
+        out.push(`${indent}${item.block} ${item.via?.name ?? item.by ?? ''}`.trimEnd());
+        walk(item.body, `${indent}  `);
+      } else out.push(`${indent}cut ${item.why}`);
+    }
+  };
+  if (p.program) walk(p.program.root, '');
+  return out;
+}
+
+describe('in the code’s order', () => {
+  it('reads an Express handler as it is written, helper and all', async () => {
+    // `create` → `emailQueue.add` → `if (!user.verified) sendVerification(user)`
+    // → `res.status(201).json({ token: signToken(user.id) })`. The token is
+    // signed INSIDE the reply, so it is drawn before the 201, not beside it.
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users').id }));
+    expect(p.defaultView).toBe('order');
+    expect(inOrder(p)).toEqual([
+      'prisma.user.create({ data })',
+      "emailQueue.add('welcome', { userId })",
+      'if !user.verified',
+      '  !user.verified',
+      '    inline sendVerification',
+      '      transporter.sendMail({ to })',
+      'inline signToken',
+      '  jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })',
+      '201',
+    ]);
+  });
+
+  it('reads a Python handler, its raise ending the arm it is in', async () => {
+    // `session.add` / `session.commit` are one database box drawn at both its
+    // sites; `raise HTTPException(422)` is an early exit, so the arm that
+    // raises answers there and the Celery job is on the other one.
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /items').id }));
+    expect(inOrder(p)).toEqual([
+      'session.add +1',
+      'session.add +1',
+      'if item.price < 0',
+      '  item.price < 0 → reply',
+      '    422',
+      '  !(item.price < 0)',
+      '    send_welcome.delay(item.id)',
+    ]);
+  });
+
+  it('reads a Java handler, its early return as the fork’s other arm', async () => {
+    // `if (owner.getName() == null) return badRequest();` — one comparison
+    // flips rather than wrapping, so the arm that runs on says `!= null`.
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /owners/new').id }));
+    expect(inOrder(p)).toEqual([
+      'if owner.getName() == null',
+      '  owner.getName() == null → reply',
+      '    400',
+      '  owner.getName() != null → reply',
+      '    owners.save(owner)',
+      '    201',
+    ]);
+  });
+
+  it('reads a C# handler’s two outcomes', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('PUT /api/TodoItems/{id}').id }));
+    expect(inOrder(p)).toEqual([
+      'if id != command.Id',
+      '  id != command.Id → reply',
+      '    400',
+      '  id == command.Id → reply',
+      '    204',
+    ]);
+  });
+
+  it('reads a Nest handler through the service it delegates to', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /cats').id }));
+    expect(inOrder(p)).toEqual(['inline create', '  this.catsRepository.save(dto)', '  handleIndex']);
+  });
+
+  it('has no order to read for a screen, and says so', async () => {
+    // A route with an inline handler still has a body; what has none is a
+    // symbol nothing in the picture happens inside.
+    const p = await buildSteps(cg, tmpDir, q({ symbol: 'RolesGuard' }));
+    expect(p.program).toBeNull();
+    expect(p.defaultView).toBe('tree');
+  });
+});
+
+describe('route roots', () => {
+  it('names the handler an API route runs, the route itself for an inline handler', () => {
+    const roots = routeRoots(cg, cg.getNodesByKind('route'));
+    expect(roots.get(route('POST /users').id)).toMatchObject({ inline: false, node: { name: 'createUser' } });
+    expect(roots.get(route('GET /users/:id').id)).toMatchObject({ inline: true });
+    expect(roots.get(route('POST /cats').id)?.node.qualifiedName).toContain('CatsController');
+    expect(roots.get(route('POST /items').id)?.node.name).toBe('create_item');
+    expect(roots.get(route('POST /owners/new').id)?.node.name).toBe('create');
+  });
+  it('calls the project an API', () => {
+    expect(projectKind(cg.getNodesByKind('route'), 0)).toBe('api');
+  });
+});
+
+describe('Express', () => {
+  it('draws the handler’s database write, the queue job, the email, and the 201 — after the middleware', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users').id }));
+    expect(p.project).toBe('api');
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.kind).toBe('screen');
+    expect(anchor.sub).toBe('createUser');
+    expect(anchor.screen).toMatchObject({ path: 'POST /users', endpoint: true, inline: false, component: { name: 'createUser' } });
+    expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/users', in: 'users.routes.ts', after: ['authenticate', 'validate(…)'] });
+
+    const db = effect(p, 'database')!;
+    expect(db.label).toBe('prisma.user.create({ data })');
+    expect(db.effect).toMatchObject({ model: 'user', access: 'write', by: { name: 'createUser' } });
+    expect(db.sub).toBe('database · user · write · createUser');
+    const queue = effect(p, 'queue')!;
+    expect(queue.label).toBe("emailQueue.add('welcome', { userId })");
+    const mail = effect(p, 'email')!;
+    expect(mail.label).toBe('transporter.sendMail({ to })');
+    const mailLink = p.links.find((l) => l.to === mail.id)!;
+    expect(mailLink.via.map((v) => v.name)).toEqual(['sendVerification']);
+    expect(mailLink.when).toBe('!user.verified');
+    const res = effect(p, 'response')!;
+    expect(res.label).toBe('201');
+    expect(res.effect?.statuses).toEqual([201]);
+    const resLink = p.links.find((l) => l.to === res.id)!;
+    expect(resLink.sites[0]).toMatchObject({ text: 'res.status(201).json', args: '{ id, token }', status: 201 });
+    // The token is signed while the reply is built: the link says so, and
+    // the row reads in the code's order — the write, the job, the mail, the
+    // signing (inside the reply's arguments), then the reply.
+    const auth = effect(p, 'auth')!;
+    expect(auth.label).toBe('jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })');
+    const authLink = p.links.find((l) => l.to === auth.id)!;
+    expect(authLink.via.map((v) => v.name)).toEqual(['signToken']);
+    expect(authLink.within).toBe('res.status(201).json');
+    expect(resLink.within).toBeUndefined();
+    const row = p.steps.filter((s) => s.depth === 1).sort((a, b) => a.order! - b.order!).map((s) => s.label);
+    expect(row).toEqual([
+      'prisma.user.create({ data })',
+      "emailQueue.add('welcome', { userId })",
+      'transporter.sendMail({ to })',
+      'jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })',
+      '201',
+    ]);
+  });
+
+  it('walks an inline handler as the route itself, into the service’s read and its 404', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('GET /users/:id').id }));
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.sub).toBe('inline handler · users.routes.ts');
+    expect(anchor.trigger).toMatchObject({ kind: 'request', name: 'GET', of: '/users/:id', after: ['authenticate'] });
+    const db = effect(p, 'database')!;
+    expect(db.effect).toMatchObject({ model: 'user', access: 'read', by: { name: 'getUser' } });
+    // `res.json(user)` in the inline handler sets no status: a 200, the
+    // route's own reply box; the service's `NotFoundError` is `getUser`'s box.
+    const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response');
+    expect(replies.map((s) => [s.effect!.by.name, s.label]).sort()).toEqual([
+      ['GET /users/:id', '200'],
+      ['getUser', '404'],
+    ]);
+    const own = replies.find((s) => s.effect!.by.name === 'GET /users/:id')!;
+    expect(p.links.find((l) => l.to === own.id)!.sites[0]).toMatchObject({ text: 'res.json', args: 'user', status: 200 });
+    const notFound = replies.find((s) => s.effect!.by.name === 'getUser')!;
+    const link = p.links.find((l) => l.to === notFound.id)!;
+    expect(link.sites[0]).toMatchObject({ text: 'NotFoundError', status: 404, when: '!user' });
+    expect(link.via.map((v) => v.name)).toEqual(['getUser']);
+  });
+
+  it('a status set by the statement before the reply is that reply’s, not a 200', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users/:id/accept').id }));
+    const res = effect(p, 'response')!;
+    expect(res.label).toBe('202');
+    expect(p.links.find((l) => l.to === res.id)!.sites.map((x) => [x.text, x.status])).toEqual([
+      ['res.status', 202],
+      ['res.json', 202],
+    ]);
+  });
+});
+
+describe('NestJS', () => {
+  it('reads the guards on the class and the method, follows DI into the repository and the queue', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /cats').id }));
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.sub).toBe('create');
+    expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/cats', in: 'cats.controller.ts', after: ["UseGuards(AuthGuard('jwt'))", 'UseGuards(RolesGuard)'] });
+    const db = effect(p, 'database')!;
+    expect(db.label).toBe('this.catsRepository.save(dto)');
+    expect(db.effect).toMatchObject({ model: 'cats', access: 'write', by: { name: 'create' } });
+    const dbLink = p.links.find((l) => l.to === db.id)!;
+    expect(dbLink.via.map((v) => v.name)).toEqual(['create']);
+    // The job put on the `cats` queue lands on the processor that consumes it
+    // — an arrival, not a call outside the index; the site is the `add` as written.
+    expect(effect(p, 'queue')).toBeUndefined();
+    const landing = p.steps.find((s) => s.kind === 'event' && s.node?.name === 'handleIndex')!;
+    expect(landing).toBeDefined();
+    expect(landing.event).toBe('index');
+    expect(landing.trigger).toEqual({ kind: 'decorator', name: 'Process', of: "'index'", in: 'cats.processor.ts' });
+    const toLanding = p.links.find((l) => l.to === landing.id)!;
+    expect(toLanding.kind).toBe('event');
+    expect(toLanding.synthesized).toBe(true);
+    expect(toLanding.sites[0]).toMatchObject({ text: 'this.catsQueue.add', args: "'index', { id }" });
+    expect(toLanding.label).toBe('via queue-job · job index · queue cats · registered at src/nest/cats.processor.ts:4');
+  });
+
+  it('a thrown exception is the 404 the request gets', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('GET /cats/:id').id }));
+    const res = effect(p, 'response')!;
+    expect(res.label).toBe('404');
+    const resLink = p.links.find((l) => l.to === res.id)!;
+    expect(resLink.sites[0]).toMatchObject({ text: 'NotFoundException', args: "'no cat'", status: 404, when: '!cat' });
+    expect(effect(p, 'database')?.effect).toMatchObject({ access: 'read' });
+  });
+
+  it('a queue consumer says the job that fires it', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ symbol: 'handleIndex' }));
+    expect(p.steps.find((s) => s.anchor)?.trigger).toEqual({ kind: 'decorator', name: 'Process', of: "'index'", in: 'cats.processor.ts' });
+  });
+});
+
+describe('FastAPI', () => {
+  it('names a mounted router’s routes by the path a request takes — the include prefix, then the router’s own', () => {
+    const names = cg.getNodesByKind('route').map((r) => r.name);
+    expect(names).toContain('GET /api/v1/orders');
+    expect(names).toContain('GET /api/v1/orders/{order_id}');
+    expect(names).toContain('POST /items');
+    expect(names).not.toContain('GET /');
+  });
+
+  it('reads the dependency on the route, the session writes, the 422 and the Celery task', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /items').id }));
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.sub).toBe('create_item');
+    expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/items', in: 'items.py', after: ['Depends(get_current_user)'] });
+    const db = effect(p, 'database')!;
+    expect(db.effect?.apis).toEqual(['session.add', 'session.commit']);
+    expect(db.effect).toMatchObject({ access: 'write' });
+    const res = effect(p, 'response')!;
+    expect(res.label).toBe('422');
+    const resLink = p.links.find((l) => l.to === res.id)!;
+    expect(resLink.sites[0]).toMatchObject({ text: 'HTTPException', args: 'status_code=422, detail="bad price"', status: 422, when: 'item.price < 0' });
+    const queue = effect(p, 'queue')!;
+    expect(queue.label).toBe('send_welcome.delay(item.id)');
+  });
+});
+
+describe('ASP.NET endpoint groups', () => {
+  it('names the group’s routes under the app’s /api/ head and starts the walk at the handler, with its replies', async () => {
+    const names = cg.getNodesByKind('route').map((r) => r.name);
+    expect(names).toContain('POST /api/TodoItems');
+    expect(names).toContain('PUT /api/TodoItems/{id}');
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('PUT /api/TodoItems/{id}').id }));
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.sub).toBe('UpdateTodoItem');
+    expect(anchor.trigger).toMatchObject({ kind: 'request', name: 'PUT', of: '/api/TodoItems/{id}' });
+    // One box per outcome, each line carrying its own condition.
+    const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response');
+    const outcomes = replies.map((s) => [s.label, p.links.find((l) => l.to === s.id)!.when]).sort();
+    expect(outcomes).toEqual([
+      ['204', 'id == command.Id'],
+      ['400', 'id != command.Id'],
+    ]);
+  });
+});
+
+describe('Spring', () => {
+  it('types the repository off the field, reads the annotation guard, and both replies with their codes', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /owners/new').id }));
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.sub).toBe('create');
+    expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/owners/new', in: 'OwnerController.java', after: ["PreAuthorize(\"hasRole('ADMIN')\")"] });
+    const db = effect(p, 'database')!;
+    expect(db.label).toBe('owners.save(owner)');
+    expect(db.effect).toMatchObject({ model: 'Owner', access: 'write' });
+    const dbLink = p.links.find((l) => l.to === db.id)!;
+    expect(dbLink.when).toBe('owner.getName() != null');
+    const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response');
+    const outcomes = replies.map((s) => [s.label, s.effect!.statuses, p.links.find((l) => l.to === s.id)!.when]).sort();
+    expect(outcomes).toEqual([
+      ['201', [201], 'owner.getName() != null'],
+      ['400', [400], 'owner.getName() == null'],
+    ]);
+  });
+});

+ 381 - 0
__tests__/ui-steps-api.test.ts

@@ -0,0 +1,381 @@
+/**
+ * `GET /api/steps` — what happens from a screen, as typed steps.
+ *
+ * Against a real index of a small Expo + React Native app, shaped to cross
+ * every boundary the endpoint classifies: a screen whose handler (a
+ * `useCallback`) calls a Swift method through an `RCT_EXTERN_MODULE` shim,
+ * the Swift side sending an event the screen listens to, the listener calling
+ * an API function that leaves the index (`client.post`), a store action in a
+ * store file, and a navigation to a second screen behind a condition. The
+ * pure layout is tested without an index in `ui-steps-model.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildSteps, crossing, effectCategory, isStoreFile } from '../src/ui-server/api/steps';
+
+let tmpDir: string;
+let cg: CodeGraph;
+
+function write(rel: string, content: string): void {
+  const full = path.join(tmpDir, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, content);
+}
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-'));
+  write('package.json', JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', 'react-native': '0.76' } }));
+  write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
+  write('src/app/index.tsx', "import { router } from 'expo-router'\nexport default function Home() {\n  return null\n}\n");
+  write(
+    'src/components/capture/capture-view.tsx',
+    "import { NativeModules, NativeEventEmitter } from 'react-native'\n" +
+      'export const captureView = NativeModules.CaptureView\n' +
+      'export const nativeEmitter = new NativeEventEmitter(NativeModules.CaptureEvents)\n'
+  );
+  write('src/api/client.ts', "import axios from 'axios'\nexport const client = axios.create({ baseURL: 'x' })\n");
+  write(
+    'src/api/frames.ts',
+    "import { client } from './client'\n" +
+      'export async function uploadARCapture(uri: string) {\n' +
+      "  await client.post('/frames', { uri })\n" +
+      "  return client.get('/frames/status')\n" +
+      '}\n'
+  );
+  write(
+    'src/storage/capture.storage.ts',
+    "import { create } from 'zustand'\n" +
+      'const useCaptureStorage = create<State>((set) => ({\n' +
+      '  zipUri: null,\n' +
+      '  setZipUri: (zipUri: string) => set({ zipUri }),\n' +
+      '}))\n' +
+      'export default useCaptureStorage\n'
+  );
+  write(
+    'src/app/capture/review.tsx',
+    "import { useCallback, useEffect } from 'react'\n" +
+      "import { router } from 'expo-router'\n" +
+      "import { captureView, nativeEmitter } from '../../components/capture/capture-view'\n" +
+      "import { uploadARCapture } from '../../api/frames'\n" +
+      "import useCaptureStorage from '../../storage/capture.storage'\n" +
+      'export default function ReviewScreen({ unlimited }: { unlimited: boolean }) {\n' +
+      '  const setZipUri = useCaptureStorage((s) => s.setZipUri)\n' +
+      '  const handleApprove = useCallback(() => {\n' +
+      '    captureView.finalizeCaptureSession()\n' +
+      '  }, [])\n' +
+      '  const handleZipComplete = useCallback(async (data: { uri: string }) => {\n' +
+      '    setZipUri(data.uri)\n' +
+      '    await uploadARCapture(data.uri)\n' +
+      "    Alert.alert('Uploaded', data.uri, [{ text: 'OK' }])\n" +
+      "    if (unlimited) router.replace('/')\n" +
+      '  }, [unlimited])\n' +
+      '  useEffect(() => {\n' +
+      "    const sub = nativeEmitter.addListener('onZipComplete', handleZipComplete)\n" +
+      '    return () => sub.remove()\n' +
+      '  }, [handleZipComplete])\n' +
+      '  const form = useForm({ onSubmit: () => handleSubmit() })\n' +
+      '  function handleSubmit() {\n' +
+      '    captureView.finalizeCaptureSession()\n' +
+      '  }\n' +
+      '  return <Button onPress={handleApprove} />\n' +
+      '}\n'
+  );
+  write(
+    'src/app/capture/index.tsx',
+    "import { memo, useCallback } from 'react'\n" +
+      "import { captureView } from '../../components/capture/capture-view'\n" +
+      'function CaptureComponent() {\n' +
+      '  const handleOpen = useCallback(() => {\n' +
+      '    captureView.finalizeCaptureSession()\n' +
+      '  }, [])\n' +
+      '  return <Button onPress={() => handleOpen()} />\n' +
+      '}\n' +
+      'const MemoizedCaptureComponent = memo(CaptureComponent)\n' +
+      'export default function CapturePage() {\n' +
+      '  return <MemoizedCaptureComponent />\n' +
+      '}\n'
+  );
+  write(
+    'ios/CaptureView.m',
+    '#import <React/RCTViewManager.h>\n@interface RCT_EXTERN_MODULE(CaptureView, RCTViewManager)\nRCT_EXTERN_METHOD(finalizeCaptureSession)\n@end\n'
+  );
+  write(
+    'ios/CaptureView.swift',
+    'import Foundation\n' +
+      'class CaptureView: RCTViewManager {\n' +
+      '  @objc func finalizeCaptureSession() {\n' +
+      '    let result = zip()\n' +
+      '    if result {\n' +
+      '      CaptureEvents.shared.emitZipComplete()\n' +
+      '    }\n' +
+      '  }\n' +
+      '  func zip() -> Bool { return true }\n' +
+      '}\n'
+  );
+  write(
+    'ios/CaptureEvents.swift',
+    'import Foundation\n' +
+      'class CaptureEvents: RCTEventEmitter {\n' +
+      '  static let shared = CaptureEvents()\n' +
+      '  func emitZipComplete() {\n' +
+      '    sendEvent(withName: "onZipComplete", body: nil)\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write(
+    'src/api/remove-thing.ts',
+    "import { client } from './client'\n" +
+      'export async function removeThing(name: string) {\n' +
+      "  await client.post('/things/remove', { name })\n" +
+      '}\n'
+  );
+  // The dialog-confirm-then-act pattern: the prompt is an effect box AND the
+  // thing that fires the handler bound in its buttons.
+  write(
+    'src/app/confirm.tsx',
+    "import { Alert, Button } from 'react-native'\n" +
+      "import { removeThing } from '../api/remove-thing'\n" +
+      'export default function ConfirmScreen() {\n' +
+      '  return (\n' +
+      '    <Button\n' +
+      '      title="remove"\n' +
+      '      onPress={() =>\n' +
+      "        Alert.prompt('Remove thing', 'Which one?', [\n" +
+      "          { text: 'Cancel' },\n" +
+      "          { text: 'OK', onPress: (name) => { if (name) removeThing(name) } },\n" +
+      '        ])\n' +
+      '      }\n' +
+      '    />\n' +
+      '  )\n' +
+      '}\n'
+  );
+  cg = CodeGraph.initSync(tmpDir);
+  await cg.indexAll();
+});
+
+afterAll(() => {
+  cg?.close();
+  if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+const q = (params: Record<string, string>) => new URLSearchParams(params);
+
+describe('classification helpers', () => {
+  it('crossing: JS → native is a bridge, native → JS an event, anything else nothing', () => {
+    expect(crossing('tsx', 'swift')).toBe('bridge');
+    expect(crossing('swift', 'tsx')).toBe('event');
+    expect(crossing('typescript', 'javascript')).toBeNull();
+    expect(crossing('swift', 'objc')).toBeNull();
+  });
+  it('store files', () => {
+    expect(isStoreFile('src/storage/capture.storage.ts')).toBe(true);
+    expect(isStoreFile('src/stores/user.ts')).toBe(true);
+    expect(isStoreFile('src/features/cart/cart.slice.ts')).toBe(true);
+    expect(isStoreFile('src/components/button.tsx')).toBe(false);
+    expect(isStoreFile('src/restore/thing.ts')).toBe(false);
+  });
+  it('effects: a curated table, by reference text', () => {
+    expect(effectCategory('client.post')).toBe('network');
+    expect(effectCategory('fetch')).toBe('network');
+    expect(effectCategory('AsyncStorage.setItem')).toBe('storage');
+    expect(effectCategory('Linking.openURL')).toBe('device');
+    expect(effectCategory('DdRum.addAction')).toBe('telemetry');
+    expect(effectCategory('Math.max')).toBeNull();
+    expect(effectCategory('i18n.t')).toBeNull();
+  });
+});
+
+describe('buildSteps', () => {
+  it('walks a screen through its handler, the bridge, the event, the store and the request', async () => {
+    const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
+    expect(review).toBeDefined();
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id }));
+
+    const byLabel = new Map(payload.steps.map((s) => [s.label, s]));
+    const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
+    expect(kinds['/capture/review']).toBe('screen');
+    expect(payload.steps.find((s) => s.anchor)?.label).toBe('/capture/review');
+    // The handler is wired to the tap, so it is a trigger; the call it makes
+    // crosses into Swift, so that is a bridge; the Swift side's event lands
+    // on the named listener; the listener writes the store, leaves the index
+    // through `client.post`, and navigates home behind `unlimited`.
+    expect(kinds['handleApprove']).toBe('trigger');
+    expect(kinds['finalizeCaptureSession']).toBe('bridge');
+    expect(kinds['handleZipComplete']).toBe('event');
+    expect(byLabel.get('handleZipComplete')?.event).toBe('onZipComplete');
+    expect(byLabel.get('handleZipComplete')?.events).toEqual(['onZipComplete']);
+    expect(kinds['setZipUri']).toBe('store');
+    // One box per (function, category): both calls the upload makes into the
+    // network, labelled by the first and counting the rest.
+    const network = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!;
+    expect(network.label).toBe('client.post +1');
+    expect(network.effect?.apis).toEqual(['client.post', 'client.get']);
+    expect(network.effect?.by.name).toBe('uploadARCapture');
+    expect(kinds['/']).toBe('screen');
+    // Another screen is a boundary: drawn, marked, not entered.
+    expect(byLabel.get('/')?.cut).toBe('screen');
+
+    const link = (from: string, to: string) =>
+      payload.links.find((l) => l.from === byLabel.get(from)!.id && l.to === byLabel.get(to)!.id);
+    const req = link('handleZipComplete', 'client.post +1');
+    const tap = link('/capture/review', 'handleApprove');
+    expect(tap?.kind).toBe('handler');
+    // What fires it — read at the site: the JSX prop and its element, and the
+    // function that writes the binding.
+    expect(tap?.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' });
+    expect(byLabel.get('handleApprove')?.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' });
+    // A function called from under an `on*` option is a handler too — the
+    // Formik shape — and the option names what fires it.
+    expect(kinds['handleSubmit']).toBe('trigger');
+    expect(link('/capture/review', 'handleSubmit')?.trigger).toEqual({ kind: 'option', name: 'onSubmit', of: 'useForm', in: 'ReviewScreen' });
+    // The listener registration is a callback binding on the handler link.
+    expect(link('/capture/review', 'handleZipComplete')?.trigger).toEqual({ kind: 'callback', name: 'addListener', of: "'onZipComplete'", in: 'ReviewScreen' });
+    expect(link('handleApprove', 'finalizeCaptureSession')?.kind).toBe('bridge');
+    const evt = link('finalizeCaptureSession', 'handleZipComplete');
+    expect(evt?.kind).toBe('event');
+    expect(evt?.synthesized).toBe(true);
+    expect(evt?.via.map((v) => v.name)).toEqual(['emitZipComplete']);
+    expect(evt?.when).toBe('result');
+    expect(evt?.label).toContain('event onZipComplete');
+    const storeLink = link('handleZipComplete', 'setZipUri');
+    expect(storeLink?.kind).toBe('store');
+    // Every call-shaped site says what it passes.
+    expect(storeLink?.sites[0]?.args).toBe('data.uri');
+    expect(link('handleApprove', 'finalizeCaptureSession')?.sites[0]?.args).toBe('');
+    // One call behind an effect box: the box says it. Several: the panel does.
+    const alert = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'device')!;
+    expect(alert.label).toBe("Alert.alert('Uploaded', data.uri, […])");
+    expect(network.label).toBe('client.post +1');
+    expect(req?.sites.map((s) => `${s.text}(${s.args})`)).toEqual(["client.post('/frames', { uri })", "client.get('/frames/status')"]);
+    expect(req?.kind).toBe('effect');
+    expect(req?.via.map((v) => v.name)).toEqual(['uploadARCapture']);
+    const nav = link('handleZipComplete', '/');
+    expect(nav?.kind).toBe('navigates');
+    expect(nav?.when).toBe('unlimited');
+    expect(nav?.sites[0]?.text).toBe('replace /');
+    // Every site carries the whole condition it runs under — one scenario each.
+    expect(nav?.sites[0]?.when).toBe('unlimited');
+    expect(evt?.sites[0]?.when).toBe('result');
+    expect(storeLink?.sites[0]?.when).toBe('');
+
+    // Rows: the anchor on 0, then one more step away each. The listener is
+    // registered BY the screen (`addListener('onZipComplete', handleZipComplete)`),
+    // so it sits one step from the anchor as a handler and the native event
+    // arrives at it from further down — a link back up the picture — and
+    // names the event on the box.
+    expect(byLabel.get('/capture/review')?.depth).toBe(0);
+    expect(byLabel.get('handleApprove')?.depth).toBe(1);
+    expect(byLabel.get('finalizeCaptureSession')?.depth).toBe(2);
+    expect(byLabel.get('handleZipComplete')?.depth).toBe(1);
+    expect(link('/capture/review', 'handleZipComplete')?.kind).toBe('handler');
+    expect(network.depth).toBe(2);
+    expect(payload.through).toBe(false);
+    expect(payload.truncated).toEqual({ steps: 0, hubs: 0, chrome: 0 });
+    // No cap fired; the only thing not entered is the other screen.
+    expect(payload.steps.filter((s) => s.cut !== null).map((s) => [s.label, s.cut])).toEqual([['/', 'screen']]);
+  });
+
+  it('walks through a memo-wrapped component into the screen body', async () => {
+    const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
+    const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
+    // The wrapper and the component are render hops, folded into the link;
+    // the handler — called from an inline arrow under `onPress` — is the
+    // first box, the native call the next.
+    expect(kinds['handleOpen']).toBe('trigger');
+    expect(kinds['finalizeCaptureSession']).toBe('bridge');
+    const toHandler = payload.links.find((l) => l.to === payload.steps.find((s) => s.label === 'handleOpen')!.id)!;
+    expect(toHandler.via.map((v) => v.name)).toEqual(['MemoizedCaptureComponent', 'CaptureComponent']);
+    expect(toHandler.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'CaptureComponent' });
+    expect(payload.steps.map((s) => s.label)).not.toContain('CaptureComponent');
+  });
+
+  it('enters other screens when asked to continue through them', async () => {
+    const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, through: '1' }));
+    expect(payload.through).toBe(true);
+    expect(payload.steps.find((s) => s.label === '/')?.cut).toBeNull();
+  });
+
+  it('anchors by name, prefers the screen, and lists the rest as ambiguous', async () => {
+    const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' }));
+    expect(payload.anchor.name).toBe('handleApprove');
+    expect(payload.steps[0]?.kind).toBe('anchor');
+    expect(payload.steps.map((s) => s.label)).toContain('finalizeCaptureSession');
+  });
+
+  it('a depth cap is announced on the step it stopped at', async () => {
+    const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, depth: '2' }));
+    // The bridge is two steps out: drawn, not explored — and says so.
+    const bridge = payload.steps.find((s) => s.label === 'finalizeCaptureSession')!;
+    expect(bridge.cut).toBe('depth');
+    expect(payload.links.some((l) => l.kind === 'event')).toBe(false);
+    // The listener still sits one step out, so the event step keeps its
+    // handler kind: nothing arrived at it from native within the cap.
+    expect(payload.steps.find((s) => s.label === 'handleZipComplete')?.kind).toBe('trigger');
+  });
+
+  it('refuses a missing anchor and an unknown id', async () => {
+    await expect(buildSteps(cg, tmpDir, q({}))).rejects.toThrow(/anchor/);
+    await expect(buildSteps(cg, tmpDir, q({ anchor: 'function:nope' }))).rejects.toThrow(/No symbol/);
+    await expect(buildSteps(cg, tmpDir, q({ symbol: 'nothingNamedThis' }))).rejects.toThrow(/Nothing/);
+  });
+});
+
+describe('screen regions', () => {
+  it('a screen names every step’s region: the screen body for its own code, inherited down the walk', async () => {
+    const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id }));
+    const byLabel = Object.fromEntries(payload.steps.map((s) => [s.label, s]));
+    // Every step of a screen's picture belongs somewhere.
+    for (const s of payload.steps) if (!s.anchor) expect(s.region, s.label).toBeDefined();
+    // A handler declared in the screen body belongs to the screen's own component…
+    expect(byLabel['handleApprove']!.region!.label).toBe('ReviewScreen');
+    // …and what it reaches inherits the region that got there first.
+    expect(byLabel['finalizeCaptureSession']!.region!.id).toBe(byLabel['handleApprove']!.region!.id);
+    expect(byLabel['setZipUri']!.region!.label).toBe('ReviewScreen');
+  });
+
+  it('a step reached through a folded component belongs to that component — the fold’s first node', async () => {
+    const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
+    const handler = payload.steps.find((s) => s.label === 'handleOpen')!;
+    const toHandler = payload.links.find((l) => l.to === handler.id)!;
+    expect(handler.region!.label).toBe(toHandler.via[0]!.name);
+  });
+
+  it('an anchor with a body carries no regions — its rows read in the code’s order', async () => {
+    const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' }));
+    for (const s of payload.steps) expect(s.region).toBeUndefined();
+  });
+});
+
+describe('fired from a dialog', () => {
+  it('a handler bound inside a dialog’s buttons arrives from the dialog, not from the screen', async () => {
+    const confirm = cg.getNodesByKind('route').find((r) => r.name === '/confirm')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: confirm.id }));
+    const prompt = payload.steps.find((s) => s.kind === 'effect' && s.label.startsWith('Alert.prompt'))!;
+    const handler = payload.steps.find((s) => s.label === 'removeThing')!;
+    const into = payload.links.filter((l) => l.to === handler.id);
+    expect(into).toHaveLength(1);
+    expect(into[0]!.from).toBe(prompt.id);
+    expect(into[0]!.trigger?.of).toBe('Alert.prompt');
+    // A handler CALLED from under a binding says what it passes, as every
+    // call-shaped site does — the argument is what a wrapper wraps.
+    expect(into[0]!.sites[0]!.args).toBe('name');
+    // One step deeper than the prompt that fires it, in the prompt's region.
+    expect(handler.depth).toBe(prompt.depth + 1);
+    expect(handler.region!.id).toBe(prompt.region!.id);
+    // …and what the handler does hangs on below.
+    const post = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!;
+    expect(payload.links.some((l) => l.from === handler.id && l.to === post.id)).toBe(true);
+  });
+});

+ 474 - 0
__tests__/ui-steps-cross-tier.test.ts

@@ -0,0 +1,474 @@
+/**
+ * Cross-tier channels (`src/resolution/tier-synthesizer.ts`) and the Steps
+ * picture they make: a monorepo with a Next.js client (`apps/web`) and an
+ * Express + NestJS API (`apps/api`) in one indexed fixture. The page's form
+ * posts to its own route, a service puts a job on a queue that a processor
+ * consumes, a service emits an event a listener handles, and a chat component
+ * talks to a gateway over a socket in both directions. Mirrors
+ * `ui-steps-api-servers.test.ts` (the servers) and `ui-steps-api.test.ts`
+ * (the mobile app).
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildSteps } from '../src/ui-server/api/steps';
+import type { Edge, Node } from '../src/types';
+
+let tmpDir: string;
+let cg: CodeGraph;
+
+function write(rel: string, content: string): void {
+  const full = path.join(tmpDir, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, content);
+}
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-tier-'));
+  write(
+    'package.json',
+    JSON.stringify({
+      name: 'mono',
+      workspaces: ['apps/*'],
+      dependencies: {
+        next: '15',
+        react: '19',
+        express: '4',
+        axios: '1',
+        bullmq: '5',
+        '@nestjs/common': '10',
+        '@nestjs/core': '10',
+        '@nestjs/bull': '10',
+        '@nestjs/event-emitter': '2',
+        '@nestjs/websockets': '10',
+        'socket.io': '4',
+        'socket.io-client': '4',
+        '@prisma/client': '5',
+      },
+    })
+  );
+
+  // ---- The web app: a page, a client form that posts to the API and calls a
+  // server action, a card that reads through an axios instance, a chat.
+  write(
+    'apps/web/app/users/page.tsx',
+    "import { NewUserForm } from '../../components/new-user-form'\n" +
+      'export default function UsersPage() {\n' +
+      '  return <NewUserForm />\n' +
+      '}\n'
+  );
+  write(
+    'apps/web/components/new-user-form.tsx',
+    "'use client'\n" +
+      "import { useCallback, useState } from 'react'\n" +
+      "import { createUserAction } from '../app/actions'\n" +
+      'export function NewUserForm() {\n' +
+      "  const [email, setEmail] = useState('')\n" +
+      '  const handleSubmit = useCallback(async (e) => {\n' +
+      '    e.preventDefault()\n' +
+      '    if (!email) return\n' +
+      "    const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify({ email }) })\n" +
+      '    if (res.ok) await createUserAction({ email })\n' +
+      '  }, [email])\n' +
+      '  return <form onSubmit={handleSubmit}><input value={email} onChange={(e) => setEmail(e.target.value)} /></form>\n' +
+      '}\n'
+  );
+  write(
+    'apps/web/app/actions.ts',
+    "'use server'\n" +
+      "import { prisma } from '../lib/db'\n" +
+      "import { redirect } from 'next/navigation'\n" +
+      'export async function createUserAction(data) {\n' +
+      '  await prisma.user.create({ data })\n' +
+      "  redirect('/users')\n" +
+      '}\n'
+  );
+  write('apps/web/lib/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
+  write('apps/web/lib/api.ts', "import axios from 'axios'\nexport const api = axios.create({ baseURL: '/api' })\n");
+  write(
+    'apps/web/components/user-card.tsx',
+    "'use client'\n" +
+      "import { api } from '../lib/api'\n" +
+      'export function UserCard({ id, url }) {\n' +
+      '  async function load() {\n' +
+      '    const { data } = await api.get(`/users/${id}`)\n' +
+      "    const external = await fetch('https://api.stripe.com/v1/charges')\n" +
+      '    const dynamic = await fetch(url)\n' +
+      '    const orders = await fetch(`${process.env.API_URL}/api/users/${id}/orders`)\n' +
+      '    return [data, external, dynamic, orders]\n' +
+      '  }\n' +
+      '  return null\n' +
+      '}\n'
+  );
+  write(
+    'apps/web/components/chat.tsx',
+    "'use client'\n" +
+      "import { useEffect, useState } from 'react'\n" +
+      "import { io } from 'socket.io-client'\n" +
+      'const socket = io()\n' +
+      'export function Chat() {\n' +
+      '  const [messages, setMessages] = useState([])\n' +
+      '  useEffect(() => {\n' +
+      "    socket.on('message', (m) => {\n" +
+      '      setMessages((prev) => [...prev, m])\n' +
+      '    })\n' +
+      '  }, [])\n' +
+      '  function send(text) {\n' +
+      "    socket.emit('message', text)\n" +
+      '  }\n' +
+      '  return null\n' +
+      '}\n'
+  );
+
+  // ---- The API: Express routes, a queue and its Nest processor, a Nest
+  // service emitting an event and its listener, a gateway, a BullMQ worker.
+  write(
+    'apps/api/src/app.ts',
+    "import express from 'express'\n" +
+      "import { createUser, getUser, listOrders } from './users'\n" +
+      'const app = express()\n' +
+      "app.post('/api/users', createUser)\n" +
+      "app.get('/api/users/:id', getUser)\n" +
+      "app.get('/api/users/:id/orders', listOrders)\n" +
+      "const v1 = require('./v1')\n" +
+      "app.use('/api/v1', authenticate, v1)\n" +
+      'export default app\n'
+  );
+  // A mounted router, two levels deep: its routes are written relative to the mount.
+  write(
+    'apps/api/src/v1/index.ts',
+    "import { Router } from 'express'\n" +
+      "import ordersRouter from '../orders.routes'\n" +
+      'const router = Router()\n' +
+      "router.use('/orders', ordersRouter)\n" +
+      'export default router\n'
+  );
+  write(
+    'apps/api/src/orders.routes.ts',
+    "import { Router } from 'express'\n" +
+      "import { prisma } from './db'\n" +
+      'const router = Router()\n' +
+      "router.get('/', listAllOrders)\n" +
+      "router.post('/:id/refund', refund)\n" +
+      'export async function listAllOrders(req, res) {\n' +
+      '  res.json(await prisma.order.findMany())\n' +
+      '}\n' +
+      'export async function refund(req, res) {\n' +
+      '  res.status(202).end()\n' +
+      '}\n' +
+      'export default router\n'
+  );
+  write(
+    'apps/web/components/orders.tsx',
+    "'use client'\n" +
+      "import useSWR from 'swr'\n" +
+      'export function Orders() {\n' +
+      "  const { data } = useSWR<Order[]>('/api/v1/orders', fetcher)\n" +
+      '  async function loadOrders() {\n' +
+      "    const res = await fetch('/api/v1/orders')\n" +
+      '    return res.json()\n' +
+      '  }\n' +
+      '  return data\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/users.ts',
+    "import { prisma } from './db'\n" +
+      "import { emailQueue, reportQueue } from './queue'\n" +
+      'export async function createUser(req, res) {\n' +
+      '  const user = await prisma.user.create({ data: req.body })\n' +
+      "  await emailQueue.add('welcome', { userId: user.id })\n" +
+      '  if (req.body.plan) {\n' +
+      "    await reportQueue.add('monthly', { userId: user.id })\n" +
+      '  }\n' +
+      '  res.status(201).json(user)\n' +
+      '}\n' +
+      'export async function getUser(req, res) {\n' +
+      '  const user = await prisma.user.findUnique({ where: { id: req.params.id } })\n' +
+      '  res.json(user)\n' +
+      '}\n' +
+      'export async function listOrders(req, res) {\n' +
+      '  res.json(await prisma.order.findMany({ where: { userId: req.params.id } }))\n' +
+      '}\n'
+  );
+  write('apps/api/src/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
+  write('apps/api/src/queue.ts', "import { Queue } from 'bullmq'\nexport const emailQueue = new Queue('email')\nexport const reportQueue = new Queue('reports')\n");
+  write(
+    'apps/api/src/email.processor.ts',
+    "import { Processor, Process } from '@nestjs/bull'\n" +
+      "@Processor('email')\n" +
+      'export class EmailProcessor {\n' +
+      '  constructor(private readonly mailer: MailerService) {}\n' +
+      "  @Process('welcome')\n" +
+      '  async sendWelcome(job) {\n' +
+      '    await this.mailer.sendMail({ to: job.data.email })\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/reports.worker.ts',
+    "import { Worker } from 'bullmq'\n" +
+      "export const reportWorker = new Worker('reports', async (job) => {\n" +
+      '  await buildReport(job.data)\n' +
+      '})\n' +
+      'export async function buildReport(data) {\n' +
+      '  return data\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/users.service.ts',
+    "import { Injectable } from '@nestjs/common'\n" +
+      "import { EventEmitter2 } from '@nestjs/event-emitter'\n" +
+      '@Injectable()\n' +
+      'export class UsersService {\n' +
+      '  constructor(private readonly eventEmitter: EventEmitter2) {}\n' +
+      '  async create(dto) {\n' +
+      '    const user = { id: 1, ...dto }\n' +
+      "    this.eventEmitter.emit('user.created', user)\n" +
+      '    return user\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/notifications.listener.ts',
+    "import { Injectable } from '@nestjs/common'\n" +
+      "import { OnEvent } from '@nestjs/event-emitter'\n" +
+      '@Injectable()\n' +
+      'export class NotificationsListener {\n' +
+      "  @OnEvent('user.created')\n" +
+      '  handleUserCreated(user) {\n' +
+      '    return notify(user)\n' +
+      '  }\n' +
+      "  @OnEvent('user.*')\n" +
+      '  audit(payload) {\n' +
+      '    return log(payload)\n' +
+      '  }\n' +
+      "  @OnEvent('order.paid')\n" +
+      '  handleOrderPaid(order) {\n' +
+      '    return order\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/chat.gateway.ts',
+    "import { WebSocketGateway, SubscribeMessage, WebSocketServer } from '@nestjs/websockets'\n" +
+      '@WebSocketGateway()\n' +
+      'export class ChatGateway {\n' +
+      '  @WebSocketServer() server\n' +
+      "  @SubscribeMessage('message')\n" +
+      '  handleMessage(client, payload) {\n' +
+      "    this.server.emit('message', payload)\n" +
+      '    return payload\n' +
+      '  }\n' +
+      '}\n'
+  );
+  // A test suite calling the API is the test's story: never a source.
+  write(
+    'apps/api/src/__tests__/users.test.ts',
+    "import { it } from 'vitest'\n" +
+      "it('creates a user', async () => {\n" +
+      "  await fetch('/api/users', { method: 'POST' })\n" +
+      '})\n'
+  );
+  cg = CodeGraph.initSync(tmpDir);
+  await cg.indexAll();
+});
+
+afterAll(() => {
+  cg?.close();
+  if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+const q = (params: Record<string, string>) => new URLSearchParams(params);
+const sym = (name: string, file?: string): Node => {
+  const found = cg.getNodesByName(name).filter((n) => n.kind !== 'route' && n.kind !== 'file' && (!file || n.filePath.endsWith(file)));
+  if (!found[0]) throw new Error(`no symbol ${name}`);
+  return found[0];
+};
+const route = (name: string): Node => {
+  const r = cg.getNodesByKind('route').find((r) => r.name === name);
+  if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
+  return r;
+};
+const synthesized = (from: Node, by: string): Edge[] =>
+  cg.getOutgoingEdges(from.id).filter((e) => e.provenance === 'heuristic' && (e.metadata as Record<string, unknown>)?.synthesizedBy === by);
+const effect = (p: Awaited<ReturnType<typeof buildSteps>>, category: string) => p.steps.find((s) => s.kind === 'effect' && s.effect?.category === category);
+
+describe('http-client: a literal path in a client call reaches its own route', () => {
+  it('binds fetch("/api/users", { method: "POST" }) to POST /api/users, remembering the registration', () => {
+    const edges = synthesized(sym('handleSubmit'), 'http-client');
+    expect(edges).toHaveLength(1);
+    expect(edges[0]!.target).toBe(route('POST /api/users').id);
+    expect(edges[0]!.kind).toBe('calls');
+    expect(edges[0]!.line).toBe(9);
+    expect(edges[0]!.metadata).toEqual({
+      synthesizedBy: 'http-client',
+      channel: 'http',
+      callee: 'fetch',
+      tier: 'client→server',
+      method: 'POST',
+      href: '/api/users',
+      registeredAt: 'apps/api/src/app.ts:4',
+    });
+  });
+
+  it('joins an axios instance’s literal baseURL, matches a template hole to a :param, and a base-URL hole by the tail', () => {
+    const edges = synthesized(sym('load'), 'http-client');
+    const byHref = new Map(edges.map((e) => [(e.metadata as Record<string, unknown>).href, e]));
+    expect([...byHref.keys()].sort()).toEqual(['/api/users/${…}', '/api/users/${…}/orders']);
+    expect(byHref.get('/api/users/${…}')!.target).toBe(route('GET /api/users/:id').id);
+    expect((byHref.get('/api/users/${…}')!.metadata as Record<string, unknown>).method).toBe('GET');
+    expect(byHref.get('/api/users/${…}/orders')!.target).toBe(route('GET /api/users/:id/orders').id);
+  });
+
+  it('produces nothing for an external URL, a variable url, or a call in a test suite', () => {
+    // `load` makes four calls; only two name a route (asserted above).
+    expect(synthesized(sym('load'), 'http-client')).toHaveLength(2);
+    const testFns = cg.getNodesInFile('apps/api/src/__tests__/users.test.ts');
+    for (const n of testFns) expect(synthesized(n, 'http-client')).toHaveLength(0);
+    const incoming = cg.getIncomingEdgesTo([route('POST /api/users').id], ['calls']).filter((e) => e.provenance === 'heuristic');
+    expect(incoming.map((e) => e.source)).toEqual([sym('handleSubmit').id]);
+  });
+});
+
+describe('express mounts: a mounted router’s routes are named by the path a request takes', () => {
+  it('composes app.use("/api/v1") and router.use("/orders") onto the routes, and a client path binds to the composed name', () => {
+    const names = cg.getNodesByKind('route').map((r) => r.name);
+    expect(names).toContain('GET /api/v1/orders');
+    expect(names).toContain('POST /api/v1/orders/:id/refund');
+    expect(names).not.toContain('GET /');
+    const edges = synthesized(sym('loadOrders'), 'http-client');
+    expect(edges).toHaveLength(1);
+    expect(edges[0]!.target).toBe(route('GET /api/v1/orders').id);
+    expect((edges[0]!.metadata as Record<string, unknown>).registeredAt).toBe('apps/api/src/orders.routes.ts:4');
+    // `useSWR<Order[]>('/api/v1/orders')` — a type argument between the name and the call.
+    const hook = synthesized(sym('Orders'), 'http-client');
+    expect(hook).toHaveLength(1);
+    expect(hook[0]!.target).toBe(route('GET /api/v1/orders').id);
+    expect((hook[0]!.metadata as Record<string, unknown>).callee).toBe('useSWR');
+  });
+});
+
+describe('queue-job: a job put on a named queue reaches its consumer', () => {
+  it('pairs emailQueue.add("welcome") with the @Process("welcome") method of the @Processor("email") class', () => {
+    const edges = synthesized(sym('createUser'), 'queue-job');
+    const welcome = edges.find((e) => (e.metadata as Record<string, unknown>).event === 'welcome')!;
+    expect(welcome).toBeDefined();
+    expect(welcome.target).toBe(sym('sendWelcome').id);
+    expect(welcome.line).toBe(5);
+    expect(welcome.metadata).toEqual({ synthesizedBy: 'queue-job', channel: 'queue', callee: 'emailQueue.add', event: 'welcome', queue: 'email', registeredAt: 'apps/api/src/email.processor.ts:5' });
+  });
+
+  it('pairs reportQueue.add("monthly") with the BullMQ Worker on that queue', () => {
+    const edges = synthesized(sym('createUser'), 'queue-job');
+    const monthly = edges.find((e) => (e.metadata as Record<string, unknown>).event === 'monthly')!;
+    expect(monthly).toBeDefined();
+    const target = cg.getNode(monthly.target)!;
+    expect(target.filePath).toBe('apps/api/src/reports.worker.ts');
+    expect((monthly.metadata as Record<string, unknown>).queue).toBe('reports');
+    expect((monthly.metadata as Record<string, unknown>).registeredAt).toBe('apps/api/src/reports.worker.ts:2');
+  });
+});
+
+describe('event-bus: an emitted event reaches its listeners; a socket message crosses tiers both ways', () => {
+  it('pairs eventEmitter.emit("user.created") with @OnEvent("user.created") and the "user.*" glob, not "order.paid"', () => {
+    const edges = synthesized(sym('create', 'users.service.ts'), 'event-bus');
+    const targets = edges.map((e) => cg.getNode(e.target)!.name).sort();
+    expect(targets).toEqual(['audit', 'handleUserCreated']);
+    const direct = edges.find((e) => e.target === sym('handleUserCreated').id)!;
+    expect(direct.metadata).toEqual({ synthesizedBy: 'event-bus', channel: 'event', callee: 'this.eventEmitter.emit', event: 'user.created', registeredAt: 'apps/api/src/notifications.listener.ts:5' });
+  });
+
+  it('a client’s socket.emit lands on the gateway’s @SubscribeMessage, client → server', () => {
+    const edges = synthesized(sym('send'), 'event-bus');
+    expect(edges).toHaveLength(1);
+    expect(edges[0]!.target).toBe(sym('handleMessage').id);
+    expect(edges[0]!.metadata).toEqual({ synthesizedBy: 'event-bus', channel: 'socket', callee: 'socket.emit', event: 'message', tier: 'client→server', registeredAt: 'apps/api/src/chat.gateway.ts:5' });
+  });
+
+  it('the gateway’s server.emit lands in the component that registered socket.on inline, server → client', () => {
+    const edges = synthesized(sym('handleMessage'), 'event-bus');
+    expect(edges).toHaveLength(1);
+    expect(edges[0]!.target).toBe(sym('Chat').id);
+    expect(edges[0]!.metadata).toEqual({ synthesizedBy: 'event-bus', channel: 'socket', callee: 'this.server.emit', event: 'message', tier: 'server→client', registeredAt: 'apps/web/components/chat.tsx:8' });
+  });
+});
+
+describe('the Steps picture across the tiers', () => {
+  it('draws the route as a boundary the form crosses to (⇢), and enters it on request', async () => {
+    const boundary = await buildSteps(cg, tmpDir, q({ symbol: 'UsersPage' }));
+    expect(boundary.project).toBe('web');
+    const handler = boundary.steps.find((s) => s.kind === 'trigger' && s.node?.name === 'handleSubmit')!;
+    expect(handler).toBeDefined();
+    expect(handler.trigger).toMatchObject({ kind: 'prop', name: 'onSubmit', of: 'form' });
+    const bridge = boundary.steps.find((s) => s.kind === 'bridge' && s.screen?.path === 'POST /api/users')!;
+    expect(bridge).toBeDefined();
+    expect(bridge.cut).toBe('screen');
+    expect(bridge.sub).toBe('createUser');
+    expect(bridge.trigger).toEqual({ kind: 'request', name: 'POST', of: '/api/users', in: 'app.ts' });
+    const link = boundary.links.find((l) => l.from === handler.id && l.to === bridge.id)!;
+    expect(link.kind).toBe('bridge');
+    expect(link.synthesized).toBe(true);
+    expect(link.when).toBe('email');
+    expect(link.sites[0]).toMatchObject({ text: 'fetch', args: "'/api/users', { method, body }", line: 9 });
+    expect(link.label).toContain('POST /api/users');
+    expect(link.label).toContain('to the server');
+    expect(link.label).toContain('registered at apps/api/src/app.ts:4');
+    // The fetch is the crossing, not also a network call outside the index;
+    // the route is not entered, so the handler's write is not drawn — the
+    // server action's is, since a function the code crosses to is walked.
+    expect(effect(boundary, 'network')).toBeUndefined();
+    const writes = boundary.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'database');
+    expect(writes.map((s) => s.effect!.by.name)).toEqual(['createUserAction']);
+
+    const through = await buildSteps(cg, tmpDir, q({ symbol: 'UsersPage', through: '1' }));
+    const entered = through.steps.find((s) => s.kind === 'bridge' && s.screen?.path === 'POST /api/users')!;
+    expect(entered.cut).toBeNull();
+    const db = through.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'database');
+    expect(db.map((s) => s.effect!.by.name).sort()).toEqual(['createUser', 'createUserAction']);
+    const res = effect(through, 'response')!;
+    expect(res.label).toBe('201');
+    expect(res.effect!.by.name).toBe('createUser');
+    const welcome = through.steps.find((s) => s.kind === 'event' && s.event === 'welcome')!;
+    expect(welcome).toBeDefined();
+    expect(welcome.node!.name).toBe('sendWelcome');
+    expect(welcome.trigger).toEqual({ kind: 'decorator', name: 'Process', of: "'welcome'", in: 'email.processor.ts' });
+    const toWelcome = through.links.find((l) => l.to === welcome.id)!;
+    expect(toWelcome.kind).toBe('event');
+    expect(toWelcome.sites[0]).toMatchObject({ text: 'emailQueue.add', args: "'welcome', { userId }", line: 5 });
+    expect(toWelcome.label).toBe('via queue-job · job welcome · queue email · registered at apps/api/src/email.processor.ts:5');
+    expect(effect(through, 'queue')?.effect?.apis ?? []).not.toContain('emailQueue.add');
+    const mail = effect(through, 'email')!;
+    expect(mail.effect!.by.name).toBe('sendWelcome');
+  });
+
+  it('a server action called from a client component is a crossing to the server, by its directive', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ symbol: 'NewUserForm' }));
+    const action = p.steps.find((s) => s.node?.name === 'createUserAction')!;
+    expect(action).toBeDefined();
+    expect(action.kind).toBe('bridge');
+    const link = p.links.find((l) => l.to === action.id)!;
+    expect(link.kind).toBe('bridge');
+    expect(link.when).toBe('email && res.ok');
+    expect(link.label).toContain('server action');
+    expect(link.sites[0]).toMatchObject({ text: 'calls createUserAction', args: '{ email }' });
+    expect(effect(p, 'database')?.effect?.by.name).toBe('createUserAction');
+  });
+
+  it('a socket message arriving in a component is an event landing, drawn as a boundary', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ symbol: 'handleMessage' }));
+    const chat = p.steps.find((s) => s.kind === 'event' && s.node?.name === 'Chat')!;
+    expect(chat).toBeDefined();
+    expect(chat.event).toBe('message');
+    expect(chat.cut).toBe('component');
+    const link = p.links.find((l) => l.to === chat.id)!;
+    expect(link.kind).toBe('event');
+    expect(link.label).toContain('from the server');
+  });
+});

+ 322 - 0
__tests__/ui-steps-model.test.ts

@@ -0,0 +1,322 @@
+/**
+ * The Steps view's model, without a browser: rows by the server's depth, the
+ * words in a box by kind, one edge per pair with the Screens view's label
+ * rule, and the panel's two lists.
+ */
+import { describe, it, expect } from 'vitest';
+import { armWords, buildStepsModel, countWords, kindWord, kindWords, stepEdgeVisible, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
+import { placeLabels } from '../ui/src/lib/screens-model';
+import type { WireNodeRef, WireStep, WireStepLink, WireStepSite, WireStepsPayload } from '../ui/src/lib/wire';
+
+function ref(name: string, file = 'src/a.tsx', language: WireNodeRef['language'] = 'tsx'): WireNodeRef {
+  return { id: `function:${name}`, kind: 'function', name, qualifiedName: name, file, line: 1, endLine: 9, language, test: false };
+}
+
+function step(label: string, kind: WireStep['kind'], depth: number, extra: Partial<WireStep> = {}): WireStep {
+  const node = kind === 'effect' ? null : ref(label, extra.node?.file ?? 'src/a.tsx');
+  return { id: node?.id ?? `effect:fn:${label}`, kind, anchor: depth === 0, node, label, sub: 'src/a.tsx', depth, cut: null, ...extra };
+}
+
+function link(from: WireStep, to: WireStep, extra: Partial<WireStepLink> = {}): WireStepLink {
+  return { id: `${from.id} ${to.id}`, from: from.id, to: to.id, kind: 'calls', via: [], when: '', label: '', synthesized: false, uncertain: false, sites: [], ...extra };
+}
+
+function payload(steps: WireStep[], links: WireStepLink[]): WireStepsPayload {
+  return {
+    anchor: steps[0]!.node!,
+    ambiguous: [],
+    project: 'app',
+    steps,
+    links,
+    depth: 8,
+    limit: 120,
+    through: false,
+    truncated: { steps: 0, hubs: 0, chrome: 0 },
+    index: { lastIndexedAt: null, edges: 0, files: 0 },
+    timing: { elapsedMs: 1 },
+  };
+}
+
+describe('steps model', () => {
+  const screen = step('/capture/review', 'screen', 0, { screen: { path: '/capture/review', component: ref('ReviewScreen') } });
+  const handler = step('handleApprove', 'trigger', 1);
+  const bridge = step('finalizeCaptureSession', 'bridge', 2, { node: ref('finalizeCaptureSession', 'ios/CaptureView.swift', 'swift') });
+  const event = step('handleZipComplete', 'event', 3, { event: 'onZipComplete' });
+  const effect = step('client.post', 'effect', 4, { sub: 'network · uploadARCapture', effect: { api: 'client.post', apis: ['client.post'], category: 'network', by: ref('uploadARCapture'), line: 3 } });
+  const store = step('setZipUri', 'store', 4, { node: ref('setZipUri', 'src/storage/capture.storage.ts') });
+  const home = step('/', 'screen', 4, { screen: { path: '/', component: null } });
+  const links = [
+    link(screen, handler, { kind: 'handler', trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } }),
+    link(handler, bridge, { kind: 'bridge', when: '!busy' }),
+    link(bridge, event, { kind: 'event', synthesized: true, via: [ref('emitZipComplete', 'ios/CaptureEvents.swift', 'swift')], when: 'result', label: 'via rn-event-channel · event onZipComplete' }),
+    link(event, effect, { kind: 'effect', via: [ref('uploadARCapture')] }),
+    link(event, store, { kind: 'store' }),
+    link(event, home, { kind: 'navigates', when: 'unlimited' }),
+    // A second way from the event to the store, unconditional: the pair is one edge saying "2 ways".
+    { ...link(event, store, { kind: 'store', when: 'retry' }), id: 'second' },
+  ];
+  const model = buildStepsModel(payload([screen, handler, bridge, event, effect, store, home], links));
+
+  it('puts the anchor on top and each row one step further away', () => {
+    const y = (id: string) => model.layout.nodes.find((n) => n.id === id)!.y;
+    expect(y(screen.id)).toBeLessThan(y(handler.id));
+    expect(y(handler.id)).toBeLessThan(y(bridge.id));
+    expect(y(bridge.id)).toBeLessThan(y(event.id));
+    expect(y(event.id)).toBeLessThan(y(effect.id));
+    expect(y(effect.id)).toBe(y(store.id));
+    expect(y(effect.id)).toBe(y(home.id));
+  });
+
+  it('one edge per pair, labelled with the innermost condition or a count', () => {
+    const edges = [...model.edges.values()];
+    expect(edges).toHaveLength(6);
+    // A link into a handler says the event, not the conditions.
+    const toHandler = edges.find((e) => e.to === handler.id)!;
+    expect(toHandler.label).toBe('onPress · <Button>');
+    const toBridge = edges.find((e) => e.to === bridge.id)!;
+    expect(toBridge.label).toBe('NOT busy');
+    expect(toBridge.kind).toBe('bridge');
+    const toEvent = edges.find((e) => e.to === event.id)!;
+    expect(toEvent.synthesized).toBe(true);
+    expect(toEvent.label).toBe('result');
+    const toStore = edges.find((e) => e.to === store.id)!;
+    expect(toStore.links).toHaveLength(2);
+    expect(toStore.label).toBe('2 ways · 1 conditional');
+    expect(toStore.kind).toBe('store');
+  });
+
+  it('counts steps per kind', () => {
+    expect(model.counts).toEqual({ anchor: 0, screen: 2, trigger: 1, bridge: 1, event: 1, store: 1, effect: 1 });
+  });
+
+  it('words a box by its kind', () => {
+    expect(stepLabel(bridge)).toBe('⇢ finalizeCaptureSession');
+    expect(stepLabel(event)).toBe('⇠ onZipComplete');
+    expect(stepLabel({ ...event, events: ['onZipComplete', 'onZipError', 'onCameraReady'] })).toBe('⇠ onZipComplete +2');
+    expect(stepLabel(screen)).toBe('/capture/review');
+    expect(stepSub(event)).toBe('handleZipComplete · a.tsx');
+    expect(stepSub(bridge)).toBe('native · CaptureView.swift');
+    expect(stepSub(store)).toBe('store · capture.storage.ts');
+    expect(stepSub(effect)).toBe('network · uploadARCapture');
+    expect(kindWord('effect')).toBe('outside the index');
+    expect(triggerWords({ kind: 'option', name: 'onSubmit', of: 'useFormik', in: 'LoginButton' })).toBe('onSubmit · useFormik(…)');
+    expect(triggerWords({ kind: 'callback', name: 'addListener', of: "'onZipComplete'", in: 'X' })).toBe("addListener('onZipComplete')");
+    expect(triggerWords({ kind: 'callback', name: 'useEffect', of: null, in: 'X' })).toBe('useEffect');
+    expect(stepSub({ ...handler, trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } })).toBe('onPress · <Button> · a.tsx');
+    expect(stepViaText(links[2]!)).toBe('emitZipComplete');
+  });
+
+  it('labels a selected step at the far end of each line, and lists its links', () => {
+    const pills = placeLabels(model, event.id);
+    expect(pills.hidden).toBe(0);
+    const words = [...pills.pills.values()].map((p) => p.text).sort();
+    expect(words).toEqual(['← result', '→ 2 ways · 1 conditional', '→ unlimited']);
+    const lists = stepNeighbourhood(payload([screen, handler, bridge, event, effect, store, home], links), event.id);
+    expect(lists.arrivesFrom.map((l) => l.from)).toEqual([bridge.id]);
+    expect(lists.leadsTo.map((l) => l.to)).toEqual([effect.id, store.id, home.id, store.id]);
+  });
+});
+
+describe('a decision drawn where it is made', () => {
+  // The real shape this exists for: `return (await hasSeenWelcome(id)) ?
+  // '/home/' : '/welcome/'` inside a store action, whose two returned routes
+  // are two `navigates` edges out of ONE box. Each carried the whole
+  // predicate — one of them the other's negation — and at rest the tree drew
+  // both with no label at all, so nothing said it was a choice.
+  const ON = 'await hasSeenWelcome(welcomeUserId())';
+  const BRANCH = '140:9';
+  const site = (when: string, not?: true): WireStepSite => ({
+    file: 'src/org-user.storage.ts',
+    line: 140,
+    text: `push ${when}`,
+    when,
+    decision: { branch: BRANCH, on: ON, arm: when, form: 'ternary', ...(not ? { not: true as const } : {}) },
+  });
+
+  const anchor = step('/terms-of-service', 'screen', 0, { anchor: true });
+  const resolve = step('resolvePostLoginRoute', 'store', 1, { node: ref('resolvePostLoginRoute', 'src/org-user.storage.ts') });
+  const home = step('/home', 'screen', 2, { screen: { path: '/home', component: null } });
+  const welcome = step('/welcome', 'screen', 2, { screen: { path: '/welcome', component: null } });
+  const links = [
+    link(anchor, resolve, { kind: 'store' }),
+    link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON)] }),
+    link(resolve, welcome, { kind: 'navigates', when: `!(${ON})`, sites: [site(`!(${ON})`, true)] }),
+  ];
+  const model = buildStepsModel(payload([anchor, resolve, home, welcome], links));
+  const edgeTo = (id: string) => [...model.edges.values()].find((e) => e.to === id)!;
+
+  it('says the condition once, under the box that decides it', () => {
+    expect(model.decisions).toHaveLength(1);
+    const d = model.decisions[0]!;
+    expect(d.label).toBe('await hasSeenWelcome(welcomeUserId())?');
+    // Under the deciding box and centred on it — not under the arms. The
+    // condition may take more room than the box, since reading it is the
+    // whole point of the caption.
+    const box = model.layout.nodes.find((n) => n.id === resolve.id)!;
+    expect(d.x + d.width / 2).toBeCloseTo(box.x + box.width / 2, 5);
+    expect(d.width).toBeGreaterThanOrEqual(box.width);
+    expect(d.y).toBeGreaterThan(box.y + box.height - 1);
+  });
+
+  it('each line out answers, instead of carrying the whole predicate', () => {
+    expect(edgeTo(home.id).arm).toBe('yes');
+    expect(edgeTo(home.id).label).toBe('yes');
+    expect(edgeTo(welcome.id).arm).toBe('no');
+    expect(edgeTo(welcome.id).label).toBe('no');
+    // The line into the deciding box is not an arm of anything.
+    expect(edgeTo(resolve.id).arm).toBeUndefined();
+  });
+
+  it('labels the arms at rest — and only the arms', () => {
+    const arms = new Set([...model.edges.values()].filter((e) => e.arm !== undefined).map((e) => e.id));
+    const pills = placeLabels(model, null, arms);
+    expect([...pills.pills.values()].map((p) => p.text).sort()).toEqual(['→ no', '→ yes']);
+    // With nothing asked for, the tree stays unlabelled as it always was.
+    expect(placeLabels(model, null, false).pills.size).toBe(0);
+  });
+
+  it('keeps a lone arm, and a step reached either way, on a plain line', () => {
+    // One drawn arm is a guard clause, not a choice.
+    const only = buildStepsModel(
+      payload([anchor, resolve, home], [link(anchor, resolve, { kind: 'store' }), link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON)] })])
+    );
+    expect(only.decisions).toEqual([]);
+    expect([...only.edges.values()].every((e) => e.arm === undefined)).toBe(true);
+
+    // A connector with a site that runs under NO condition is not exclusively
+    // an arm — the step happens either way — so it never claims a side.
+    const both = buildStepsModel(
+      payload(
+        [anchor, resolve, home, welcome],
+        [
+          link(anchor, resolve, { kind: 'store' }),
+          link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON), { file: 'x.ts', line: 9, text: 'push', when: '' }] }),
+          link(resolve, welcome, { kind: 'navigates', when: `!(${ON})`, sites: [site(`!(${ON})`, true)] }),
+        ]
+      )
+    );
+    expect(both.decisions).toEqual([]);
+  });
+
+  it('words a switch arm by its own value, and the default by else', () => {
+    expect(armWords({ on: 'status', arm: "status === 'expired'", form: 'switch' })).toBe("'expired'");
+    expect(armWords({ on: 'status', arm: 'anything', form: 'switch', not: true })).toBe('else');
+    expect(armWords({ on: 'ready', arm: 'ready', form: 'if' })).toBe('yes');
+    expect(armWords({ on: 'ready', arm: '!ready', form: 'if', not: true })).toBe('no');
+  });
+});
+
+describe('words per project', () => {
+  it('names the same box for an app, an API and a web app', () => {
+    expect(kindWord('screen', 'app')).toBe('screen');
+    expect(kindWord('screen', 'api')).toBe('endpoint');
+    expect(kindWord('screen', 'web')).toBe('page');
+    // A route that leads with a verb is an endpoint wherever it is.
+    const endpoint = { id: 'r', kind: 'screen', anchor: false, node: null, label: 'POST /users', sub: 'createUser', depth: 1, cut: null, screen: { path: 'POST /users', component: null, endpoint: true, inline: false } } as const;
+    expect(kindWord('screen', 'web', endpoint)).toBe('endpoint');
+    expect(kindWords('store', 'api')).toEqual(['data call', 'data calls']);
+    expect(kindWords('bridge', 'app')).toEqual(['native call', 'native calls']);
+    expect(countWords(11, 'effect', 'api')).toBe('11 outside the index');
+    expect(countWords(1, 'trigger')).toBe('1 handler');
+    expect(countWords(3, 'trigger')).toBe('3 handlers');
+  });
+  it('says what fires a server-side step', () => {
+    expect(triggerWords({ kind: 'request', name: 'POST', of: '/users', in: 'users.routes.ts', after: ['authenticate', 'validate(…)'] })).toBe('POST /users · after authenticate, validate(…)');
+    expect(triggerWords({ kind: 'decorator', name: 'Process', of: "'email'", in: 'x.ts' })).toBe("@Process('email')");
+    expect(triggerWords({ kind: 'load', name: 'GET', of: '/blog/[slug]', in: 'page.tsx' })).toBe('page load · /blog/[slug]');
+  });
+});
+
+describe('row order', () => {
+  it('lays a row out in the order the server gave, not by id', () => {
+    const anchor = step('/login', 'screen', 0, { anchor: true });
+    const a = step('User.findOne', 'effect', 1, { order: 0 });
+    const b = step('jwt.sign', 'effect', 1, { order: 1 });
+    const c = step('200', 'effect', 1, { order: 2 });
+    const d = step('401', 'effect', 1, { order: 3 });
+    const model = buildStepsModel(payload([anchor, d, c, b, a], [link(anchor, a), link(anchor, b), link(anchor, c), link(anchor, d)]));
+    const row = model.layout.nodes.filter((n) => n.id !== anchor.id).sort((x, y) => x.x - y.x).map((n) => n.id);
+    expect(row).toEqual([a.id, b.id, c.id, d.id]);
+  });
+});
+
+describe('a screen laid out by region', () => {
+  const A = { id: 'component:PanelA', label: 'PanelA' };
+  const B = { id: 'component:PanelB', label: 'PanelB' };
+  const anchor = step('/', 'screen', 0, { anchor: true });
+  const a1 = step('tapSave', 'trigger', 1, { order: 0, region: A });
+  const a2 = step('tapUndo', 'trigger', 1, { order: 1, region: A });
+  const a3 = step('saveThing', 'store', 2, { order: 0, region: A, node: ref('saveThing', 'src/things.storage.ts') });
+  const b1 = step('tapShare', 'trigger', 1, { order: 2, region: B, node: ref('tapShare', 'src/b.tsx') });
+  const links = [
+    link(anchor, a1),
+    link(anchor, a2),
+    link(anchor, b1),
+    link(a1, a3, { kind: 'store' }),
+    link(a1, b1),
+    // Another region's way into a shared store — a lead-to line like any other.
+    link(b1, a3, { kind: 'store' }),
+  ];
+  const model = buildStepsModel(payload([anchor, a1, a2, b1, a3], links));
+  const at = (id: string) => model.layout.nodes.find((n) => n.id === id)!;
+  const between = (id: string, zone: { x: number; width: number }) => {
+    const n = at(id);
+    return n.x >= zone.x && n.x + n.width <= zone.x + zone.width;
+  };
+  const edge = (from: string, to: string) => model.layout.edges.find((e) => e.source === from && e.target === to)!;
+
+  it('names the regions in the order the walk met them, each holding its own boxes', () => {
+    expect(model.regions!.map((z) => z.label)).toEqual(['PanelA', 'PanelB']);
+    const [zoneA, zoneB] = model.regions!;
+    expect(between(a1.id, zoneA!)).toBe(true);
+    expect(between(a2.id, zoneA!)).toBe(true);
+    expect(between(a3.id, zoneA!)).toBe(true);
+    expect(between(b1.id, zoneB!)).toBe(true);
+    // Side by side, not overlapping: the second region starts past the first.
+    expect(zoneB!.x).toBeGreaterThanOrEqual(zoneA!.x + zoneA!.width);
+  });
+
+  it('keeps a step above what it sets in motion, inside its region', () => {
+    expect(at(anchor.id).y).toBeLessThan(at(a1.id).y);
+    expect(at(a1.id).y).toBe(at(a2.id).y);
+    expect(at(a3.id).y).toBeGreaterThan(at(a1.id).y);
+  });
+
+  it('at rest hides only the screen’s own fan and what points back up; every other lead-to draws', () => {
+    expect(model.regionEntries).toEqual(new Set([a1.id, b1.id]));
+    // One line from the screen into each region stands in for its whole fan.
+    expect(stepEdgeVisible(model, edge(anchor.id, a1.id), null)).toBe(true);
+    expect(stepEdgeVisible(model, edge(anchor.id, a2.id), null)).toBe(false);
+    expect(stepEdgeVisible(model, edge(anchor.id, b1.id), null)).toBe(true);
+    // A region's internal line, and another region's way into a shared step.
+    expect(stepEdgeVisible(model, edge(a1.id, a3.id), null)).toBe(true);
+    expect(stepEdgeVisible(model, edge(b1.id, a3.id), null)).toBe(true);
+    // Two boxes on one row point sideways — back-ish, a click away as everywhere.
+    expect(stepEdgeVisible(model, edge(a1.id, b1.id), null)).toBe(false);
+    // Selecting a step brings out everything that touches it, and only that.
+    expect(stepEdgeVisible(model, edge(a1.id, b1.id), a1.id)).toBe(true);
+    expect(stepEdgeVisible(model, edge(anchor.id, a2.id), a1.id)).toBe(false);
+  });
+
+  it('stacks a handler above the store it calls, even when both are one hop from the screen', () => {
+    // Anchor distance is flat inside a region: both of these are depth 1, and
+    // side by side their link was a level arch, hidden at rest — the store
+    // floated. The region's own links order its rows instead.
+    const C = { id: 'component:PanelC', label: 'PanelC' };
+    const root = step('/', 'screen', 0, { anchor: true });
+    const h = step('tapCopy', 'trigger', 1, { order: 0, region: C });
+    const s = step('copyThing', 'store', 1, { order: 1, region: C, node: ref('copyThing', 'src/c.storage.ts') });
+    const m = buildStepsModel(payload([root, h, s], [link(root, h), link(root, s), link(h, s, { kind: 'store' })]));
+    const y = (id: string) => m.layout.nodes.find((n) => n.id === id)!.y;
+    expect(y(s.id)).toBeGreaterThan(y(h.id));
+    const e = m.layout.edges.find((x) => x.source === h.id && x.target === s.id)!;
+    expect(e.route).toBe('down');
+    expect(stepEdgeVisible(m, e, null)).toBe(true);
+  });
+
+  it('a payload without regions keeps the rows, and the Map’s at-rest rule', () => {
+    const plain = buildStepsModel(payload([step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1)], [link(step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1))]));
+    expect(plain.regions).toBeNull();
+    expect(plain.regionEntries).toBeNull();
+  });
+});

+ 270 - 0
__tests__/ui-steps-program.test.ts

@@ -0,0 +1,270 @@
+/**
+ * The Steps view's second reading: the anchor's body in the code's order.
+ *
+ * `buildProgram` is pure over the records the walk makes — no graph, no
+ * source — so this suite hands it records by hand and reads the block tree
+ * back. The end-to-end reading over real fixtures is in
+ * `ui-steps-api-servers.test.ts`; what is pinned here is the FOLD: which sites
+ * become arms of one decision, what ends an arm, and where a helper is drawn.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { BranchGuard, SiteLoop } from '../src/graph/branch-guards';
+import { buildProgram, type ProgramInput, type ProgramSite, type WireBlock, type WireItem } from '../src/ui-server/api/program';
+
+/* ------------------------------------------------------------ material -- */
+
+let nextLine = 1;
+
+/** A guard, with the fields the fold reads: which decision, which arm, how the arm leaves. */
+function g(text: string, opts: Partial<BranchGuard> = {}): BranchGuard {
+  return { text, negated: false, form: 'if', line: 1, branch: `b:${text}`, ...opts };
+}
+
+/** A site at the next line, reaching a step. */
+function at(step: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite> = {}): ProgramSite {
+  const line = nextLine++;
+  return { step, link: `l:${step}`, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra };
+}
+
+/** A loop the site is written inside. */
+function loop(text: string, kind: SiteLoop['kind'] = 'each', branch = `l:${text}`): SiteLoop {
+  return { text, kind, branch };
+}
+
+/** A site that folds into a helper. */
+function into(fn: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite> = {}): ProgramSite {
+  const line = nextLine++;
+  return { into: fn, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra };
+}
+
+function program(sites: Record<string, ProgramSite[]>, replies: string[] = [], into: Record<string, string> = {}) {
+  nextLine = 1;
+  const input: ProgramInput = {
+    sites: new Map(Object.entries(sites)),
+    root: 'root',
+    node: (id) => ({ id, kind: 'function', name: id, qualifiedName: id, file: 'a.ts', line: 1, endLine: 2, language: 'typescript', test: false }),
+    step: (id) => ({ reply: replies.includes(id), into: into[id] ?? null }),
+  };
+  return buildProgram(input);
+}
+
+/** The shape of a block, one line per item, indented — what a reader would see. */
+function shape(block: WireBlock, indent = ''): string[] {
+  const out: string[] = [];
+  for (const item of block) {
+    if (item.kind === 'step') {
+      out.push(`${indent}${item.step}${item.again ? ' (again)' : ''}${item.within ? ` inside ${item.within}` : ''}`);
+      if (item.body) out.push(...shape(item.body, `${indent}  `));
+    } else if (item.kind === 'fork') {
+      out.push(`${indent}${item.form} ${item.on}`);
+      for (const arm of item.arms) {
+        out.push(`${indent}  arm ${arm.when}${arm.ends ? ` ends:${arm.ends}` : ''}`);
+        out.push(...shape(arm.body, `${indent}    `));
+      }
+    } else if (item.kind === 'block') {
+      out.push(`${indent}${item.block}${item.via ? ` via ${item.via.name}` : item.by ? ` ${item.by}` : ''}${item.again ? ' (again)' : ''}`);
+      out.push(...shape(item.body, `${indent}  `));
+    } else out.push(`${indent}cut ${item.why}`);
+  }
+  return out;
+}
+
+/* --------------------------------------------------------------- tests -- */
+
+describe('buildProgram', () => {
+  it('reads a straight line in the code’s order', () => {
+    const p = program({ root: [at('a'), at('b'), at('c')] });
+    expect(shape(p!.root)).toEqual(['a', 'b', 'c']);
+  });
+
+  it('is nothing when the anchor has no body to read', () => {
+    expect(program({})).toBeNull();
+    expect(buildProgram({ sites: new Map(), root: null, node: () => null, step: () => null })).toBeNull();
+  });
+
+  it('makes an if and its else two arms of ONE fork', () => {
+    const cond = 'user && ok';
+    const p = program({
+      root: [at('lookup'), at('sign', [g(cond)]), at('200', [g(cond)]), at('401', [g(cond, { negated: true, form: 'else' })])],
+    });
+    expect(shape(p!.root)).toEqual([
+      'lookup',
+      'if user && ok',
+      '  arm user && ok',
+      '    sign',
+      '    200',
+      '  arm !(user && ok)',
+      '    401',
+    ]);
+    const fork = p!.root[1] as Extract<WireItem, { kind: 'fork' }>;
+    expect(fork.arms).toHaveLength(2);
+  });
+
+  it('ends an arm that answers the request', () => {
+    const cond = 'user';
+    const p = program(
+      { root: [at('200', [g(cond)]), at('401', [g(cond, { negated: true, form: 'else' })])] },
+      ['200', '401']
+    );
+    expect(shape(p!.root)).toEqual(['if user', '  arm user ends:reply', '    200', '  arm !user ends:reply', '    401']);
+  });
+
+  it('draws an early exit as the fork’s other arm, with how it leaves', () => {
+    // `if (!product) { res.status(404); throw }` then the rest — the guard on
+    // the code AFTER carries the same branch, negated, and how the exit left.
+    const p = program({
+      root: [
+        at('404', [g('!product', { branch: 'b:1' })]),
+        at('save', [g('!product', { negated: true, form: 'guard', branch: 'b:1', exit: 'throw' })]),
+      ],
+    }, ['404']);
+    expect(shape(p!.root)).toEqual(['if !product', '  arm !product ends:reply', '    404', '  arm product', '    save']);
+  });
+
+  it('draws an early exit whose arm holds nothing as a terminal', () => {
+    const p = program({ root: [at('go', [g('busy', { negated: true, form: 'guard', exit: 'return' })])] });
+    expect(shape(p!.root)).toEqual(['if busy', '  arm busy ends:return', '  arm !busy', '    go']);
+  });
+
+  it('nests forks the way the code nests them', () => {
+    const outer = g('product', { branch: 'b:outer' });
+    const inner = g('reviewed', { branch: 'b:inner' });
+    const p = program(
+      {
+        root: [
+          at('400', [outer, inner]),
+          at('201', [outer, { ...inner, negated: true, form: 'guard', exit: 'throw' }]),
+          at('404', [{ ...outer, negated: true, form: 'else' }]),
+        ],
+      },
+      ['400', '201', '404']
+    );
+    expect(shape(p!.root)).toEqual([
+      'if product',
+      '  arm product',
+      '    if reviewed',
+      '      arm reviewed ends:reply',
+      '        400',
+      '      arm !reviewed ends:reply',
+      '        201',
+      '  arm !product ends:reply',
+      '    404',
+    ]);
+  });
+
+  it('puts every case of one switch under one fork', () => {
+    const branch = 'b:switch';
+    const p = program({
+      root: [
+        at('a', [g("kind === 'a'", { form: 'case', branch })]),
+        at('b', [g("kind === 'b'", { form: 'case', branch })]),
+        at('d', [g('kind: default', { form: 'case', branch })]),
+      ],
+    });
+    // The head says what is being decided on; each arm its own case.
+    expect(shape(p!.root)).toEqual([
+      'switch kind',
+      "  arm kind === 'a'",
+      '    a',
+      "  arm kind === 'b'",
+      '    b',
+      '  arm kind: default',
+      '    d',
+    ]);
+  });
+
+  it('keeps two try/catch blocks apart', () => {
+    const p = program({
+      root: [
+        at('first', [g('on error', { form: 'catch', branch: 'b:try1' })]),
+        at('second', [g('on error', { form: 'catch', branch: 'b:try2' })]),
+      ],
+    });
+    expect(shape(p!.root)).toEqual(['try on error', '  arm on error', '    first', 'try on error', '  arm on error', '    second']);
+  });
+
+  it('draws a folded helper where it is called, and says what it is inside', () => {
+    const p = program({
+      root: [into('helper', [], { within: 'res.json' }), at('200')],
+      helper: [at('sign')],
+    });
+    expect(shape(p!.root)).toEqual(['inline via helper', '  sign', '200']);
+    const block = p!.root[0] as Extract<WireItem, { kind: 'block' }>;
+    expect(block.within).toBe('res.json');
+    expect(block.via?.name).toBe('helper');
+  });
+
+  it('puts a call written inside another call’s arguments first', () => {
+    // `res.json({ token: generateToken(…) })` spans lines 14–21 and the token is
+    // signed on line 19: the signing happens BEFORE the reply it is part of.
+    const reply: ProgramSite = { step: '200', at: { line: 14, column: 4, end: { line: 21, column: 6 } }, guards: [] };
+    const signed: ProgramSite = { step: 'sign', at: { line: 19, column: 13, end: { line: 19, column: 34 } }, guards: [] };
+    const p = program({ root: [reply, signed] });
+    expect(shape(p!.root)).toEqual(['sign', '200']);
+  });
+
+  it('reads a function once, however many times it is called', () => {
+    const p = program({
+      root: [into('helper'), at('x'), into('helper')],
+      helper: [at('work')],
+    });
+    expect(shape(p!.root)).toEqual(['inline via helper', '  work', 'x', 'inline via helper (again)']);
+  });
+
+  it('reads on into a step the walk entered, and stops at one it did not', () => {
+    // A step explores from its own function: `store`'s is `storeFn`, whose
+    // sites are its body. A boundary — another screen, an effect — has none.
+    const entered = program({ root: [at('store')], storeFn: [at('write')] }, [], { store: 'storeFn' });
+    expect(shape(entered!.root)).toEqual(['store', '  write']);
+    const boundary = program({ root: [at('store')], storeFn: [at('write')] });
+    expect(shape(boundary!.root)).toEqual(['store']);
+  });
+
+  it('says a helper that calls itself was already read', () => {
+    const p = program({ root: [into('a')], a: [at('x'), into('a')] });
+    expect(shape(p!.root)).toEqual(['inline via a', '  x', '  inline via a (again)']);
+  });
+
+  it('puts work registered to run later in a block of its own', () => {
+    const p = program({
+      root: [at('now'), at('afterwards', [], { trigger: { kind: 'callback', name: 'then', of: null } })],
+    });
+    expect(shape(p!.root)).toEqual(['now', 'later then', '  afterwards']);
+  });
+
+  it('puts calls started together in one block', () => {
+    const p = program({
+      root: [at('a', [], { within: 'Promise.all' }), at('b', [], { within: 'Promise.all' }), at('c')],
+    });
+    expect(shape(p!.root)).toEqual(['together Promise.all', '  a inside Promise.all', '  b inside Promise.all', 'c']);
+  });
+
+  it('says a run of calls happens once per item', () => {
+    const p = program({
+      root: [at('before'), at('each', [], { loops: [loop('item of items')] }), at('after')],
+    });
+    expect(shape(p!.root)).toEqual(['before', 'loop item of items', '  each', 'after']);
+  });
+
+  it('nests a loop and a fork by which one is written outside the other', () => {
+    // `for (…) { if (ready) { go() } }` — the loop starts first, so it is
+    // outside; the guard's own branch position is what decides, not its order
+    // in the chain.
+    const inner = g('ready', { branch: '9:4' });
+    const p = program({ root: [at('go', [inner], { loops: [loop('item of items', 'each', '8:2')] })] });
+    expect(shape(p!.root)).toEqual(['loop item of items', '  if ready', '    arm ready', '      go']);
+
+    // `if (ready) { for (…) { go() } }` — the same two constructs, the other
+    // way round, told apart by where each begins.
+    const outer = g('ready', { branch: '8:2' });
+    const q = program({ root: [at('go', [outer], { loops: [loop('item of items', 'each', '9:4')] })] });
+    expect(shape(q!.root)).toEqual(['if ready', '  arm ready', '    loop item of items', '      go']);
+  });
+
+  it('closes a fork when the code leaves it', () => {
+    const cond = g('ready');
+    const p = program({ root: [at('inside', [cond]), at('after')] });
+    expect(shape(p!.root)).toEqual(['if ready', '  arm ready', '    inside', 'after']);
+  });
+});

+ 577 - 0
__tests__/ui-symbol-model.test.ts

@@ -0,0 +1,577 @@
+/**
+ * The Symbol view's decisions, without a browser (CG-44).
+ *
+ * Everything the screen does that could be wrong rather than merely ugly lives
+ * in `ui/src/lib/` as plain functions over the `/api/node` payload: which lines
+ * survive into a windowed body, which identifier a call-site link lands on,
+ * which callers fold away, which reference is a guess. Those are the parts
+ * worth pinning — the geometry that needs a real layout (row placement,
+ * connector paths) is verified against a running viewer instead.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  assignRefs,
+  buildCalleeRail,
+  buildCallerRail,
+  buildCodeBlock,
+  buildOutline,
+  edgeWord,
+  graphCallLines,
+  kindPhrase,
+  refsByLine,
+  showsBody,
+  synthesizedBy,
+  FULL_BODY_LINES,
+  HEAD_LINES,
+  type LineRef,
+} from '../ui/src/lib/symbol-model';
+import { decodeLine, plainLine, tokensByLine } from '../ui/src/lib/highlight';
+import type { WireRelation, WireSymbolPayload } from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function nodeRef(over: Partial<WireSymbolPayload['node']> = {}): any {
+  return {
+    id: 'method:a',
+    kind: 'method',
+    name: 'load',
+    qualifiedName: 'Service::load',
+    file: 'src/service.ts',
+    line: 10,
+    endLine: 20,
+    language: 'typescript',
+    test: false,
+    ...over,
+  };
+}
+
+function relation(over: Partial<WireRelation> & { node?: any } = {}): WireRelation {
+  const { node, ...rest } = over;
+  const lines = rest.lines ?? [12];
+  return {
+    edgeKinds: ['calls'],
+    edges: lines.map((line) => ({ kind: 'calls' as const, line, col: 4 })),
+    edgeCount: lines.length,
+    lines,
+    confidence: 0.9,
+    uncertain: false,
+    synthesized: false,
+    ...rest,
+    node: nodeRef(node),
+  } as WireRelation;
+}
+
+function payload(over: Partial<WireSymbolPayload> = {}): WireSymbolPayload {
+  return {
+    node: { ...nodeRef(), startColumn: 2, endColumn: 3, lines: 11 },
+    ancestors: [],
+    members: { total: 0, shown: 0, truncated: false, items: [] },
+    incoming: { total: 0, shown: 0, truncated: false, items: [] },
+    outgoing: { total: 0, shown: 0, truncated: false, items: [] },
+    typesUsed: [],
+    counts: { callers: 0, callees: 0, typesUsed: 0, fanIn: 0, fanOut: 0, members: 0, hub: false },
+    tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 },
+    outsideIndex: { total: 0, byKind: {}, samples: [] },
+    blast: null,
+    drift: false,
+    ...over,
+  } as WireSymbolPayload;
+}
+
+const body = (count: number, from = 1): string[] =>
+  Array.from({ length: count }, (_, i) => `line ${from + i}`);
+
+/* ---------------------------------------------------------------- words -- */
+
+describe('edge wording', () => {
+  it('names the relationships that are not a plain call, and leaves calls unlabelled', () => {
+    // Labelling every row "calls" is noise that hides the rows where the
+    // relationship is something else.
+    expect(edgeWord({ kind: 'calls' })).toBe('');
+    expect(edgeWord({ kind: 'instantiates' })).toBe('creates');
+    expect(edgeWord({ kind: 'references' })).toBe('uses type');
+    expect(edgeWord({ kind: 'references', valueRef: true })).toBe('passes as value');
+    expect(edgeWord({ kind: 'implements' })).toBe('implements');
+  });
+
+  it('names the synthesizer behind a heuristic edge, and nothing for a parsed one', () => {
+    const parsed = relation();
+    expect(synthesizedBy(parsed)).toBeNull();
+
+    const synthesized = {
+      ...parsed,
+      synthesized: true,
+      edges: [{ kind: 'calls', line: 12, provenance: 'heuristic', synthesizedBy: 'react-render' }],
+    } as WireRelation;
+    expect(synthesizedBy(synthesized)).toBe('react-render');
+  });
+
+  it('falls back to a truthful placeholder when the synthesizer did not name itself', () => {
+    const synthesized = {
+      ...relation(),
+      synthesized: true,
+      edges: [{ kind: 'calls', line: 12, provenance: 'heuristic' }],
+    } as WireRelation;
+    expect(synthesizedBy(synthesized)).toBe('synthesized');
+  });
+});
+
+describe('kindPhrase', () => {
+  it('reads the modifiers a reader acts on, and stays silent about the default ones', () => {
+    expect(kindPhrase({ kind: 'method', async: true })).toBe('method · async');
+    expect(kindPhrase({ kind: 'type_alias' })).toBe('type');
+    expect(kindPhrase({ kind: 'method', visibility: 'public' })).toBe('method');
+    expect(kindPhrase({ kind: 'method', static: true, visibility: 'private' })).toBe(
+      'method · static · private'
+    );
+  });
+});
+
+/* -------------------------------------------------------------- windows -- */
+
+describe('buildCodeBlock', () => {
+  it('shows a body of 260 lines or fewer whole, with no gaps', () => {
+    const block = buildCodeBlock(1, body(FULL_BODY_LINES), [5, 200]);
+    expect(block.whole).toBe(true);
+    expect(block.windows).toHaveLength(1);
+    expect(block.windows[0]?.start).toBe(1);
+    expect(block.windows[0]?.lines).toHaveLength(FULL_BODY_LINES);
+    expect(block.gapsAfter).toEqual([]);
+    expect(block.tailGap).toBe(0);
+  });
+
+  it('keeps the head plus a window round every call site once the body is longer', () => {
+    // One call, far past the head: head + one ±4 window, one gap between them.
+    const block = buildCodeBlock(1, body(400), [300]);
+    expect(block.whole).toBe(false);
+    expect(block.windows).toHaveLength(2);
+    expect(block.windows[0]).toMatchObject({ start: 1 });
+    expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES);
+    expect(block.windows[1]?.start).toBe(296);
+    expect(block.windows[1]?.lines).toHaveLength(9);
+    expect(block.gapsAfter).toEqual([215]);
+    // 400 − 304 lines never reached the screen, and the block says how many.
+    expect(block.tailGap).toBe(96);
+  });
+
+  it('merges windows that all but touch, rather than drawing a one-line gap', () => {
+    const block = buildCodeBlock(1, body(400), [300, 310]);
+    // 296–304 and 306–314 are two apart: one window, no gap row between them.
+    expect(block.windows).toHaveLength(2);
+    expect(block.windows[1]).toMatchObject({ start: 296 });
+    expect(block.windows[1]?.lines).toHaveLength(19);
+    expect(block.gapsAfter).toEqual([215]);
+  });
+
+  it('ignores call sites already inside the head', () => {
+    const block = buildCodeBlock(1, body(400), [3, 40]);
+    expect(block.windows).toHaveLength(1);
+    expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES);
+    expect(block.tailGap).toBe(320);
+  });
+
+  it("numbers windows from the symbol's real first line, not from one", () => {
+    const block = buildCodeBlock(778, body(400, 778), [1000]);
+    expect(block.windows[0]?.start).toBe(778);
+    expect(block.windows[1]?.start).toBe(996);
+    expect(block.windows[1]?.lines[0]).toBe('line 996');
+  });
+
+  it('never runs a window past the end of the body', () => {
+    const block = buildCodeBlock(1, body(400), [399]);
+    const last = block.windows[block.windows.length - 1];
+    expect((last?.start ?? 0) + (last?.lines.length ?? 0) - 1).toBe(400);
+    expect(block.tailGap).toBe(0);
+  });
+
+  it('windows only on edges that reach the graph, not on unresolved references', () => {
+    // A function calling `console.log` 200 times would otherwise window around
+    // nearly every line, and the head-plus-windows rule would buy nothing.
+    const view = payload({
+      outgoing: { total: 1, shown: 1, truncated: false, items: [relation({ lines: [300] })] },
+      outsideIndex: {
+        total: 1,
+        byKind: { calls: 1 },
+        samples: [{ name: 'console.log', kind: 'calls', line: 350, col: 4 }],
+      },
+    });
+    expect(graphCallLines(view)).toEqual([300]);
+    expect(refsByLine(view).has(350)).toBe(true);
+  });
+});
+
+/* ----------------------------------------------------------------- refs -- */
+
+describe('assignRefs', () => {
+  const toks = (line: string) => plainLine(line);
+  const ref = (over: Partial<LineRef>): LineRef => ({
+    ident: 'withLock',
+    col: null,
+    targetId: 'method:x',
+    uncertain: false,
+    outside: false,
+    title: '',
+    ...over,
+  });
+
+  it('marks the callee, not the receiver the column actually points at', () => {
+    // The recorded column is the start of the calling EXPRESSION, so an exact
+    // hit is the exception: `this` sits at column 11, `withLock` at 27.
+    const line = '    return this.indexMutex.withLock(async () => {';
+    const tokens = toks(line);
+    const claimed = assignRefs(tokens, [ref({ col: 11 })]);
+    const [index] = [...claimed.keys()];
+    expect(tokens[index as number]?.text).toBe('withLock');
+  });
+
+  it('prefers the token the column lands inside when there is one', () => {
+    const line = 'render(); render();';
+    const tokens = toks(line);
+    const second = line.lastIndexOf('render');
+    const claimed = assignRefs(tokens, [ref({ ident: 'render', col: second })]);
+    const [index] = [...claimed.keys()];
+    expect(tokens[index as number]?.col).toBe(second);
+  });
+
+  it('gives two refs to the same name two different tokens', () => {
+    const tokens = toks('render(); render();');
+    const claimed = assignRefs(tokens, [
+      ref({ ident: 'render', col: null, targetId: 'a' }),
+      ref({ ident: 'render', col: null, targetId: 'b' }),
+    ]);
+    expect(claimed.size).toBe(2);
+    expect(new Set([...claimed.values()].map((r) => r.targetId))).toEqual(new Set(['a', 'b']));
+  });
+
+  it('claims nothing when the identifier is not on the line', () => {
+    // Better a missing link than an accent underline on the wrong word.
+    expect(assignRefs(toks('return 1;'), [ref({ ident: 'nowhere' })]).size).toBe(0);
+  });
+
+  it('never marks a word inside a comment or a string as a call site', () => {
+    // The classification comes from the server's grammar; what this pins is
+    // that the overlay respects it. Anything else — a keyword, a type name a
+    // grammar happened to scope as `storage.type` — stays claimable, because a
+    // grammar's opinion about a scope name must not decide what navigates.
+    const comment = [
+      { cls: 'comment', text: '// call render here', col: 0 },
+    ];
+    expect(assignRefs(comment, [ref({ ident: 'render' })]).size).toBe(0);
+
+    const string = [
+      { cls: 'keyword', text: 'const', col: 0 },
+      { cls: 'other', text: ' s = ', col: 5 },
+      { cls: 'string', text: '"render"', col: 10 },
+      { cls: 'other', text: ';', col: 18 },
+    ];
+    expect(assignRefs(string, [ref({ ident: 'render' })]).size).toBe(0);
+  });
+
+  it('still claims an identifier a grammar classified as something else', () => {
+    // Go scopes `string` as storage.type; Java does the same to a declared
+    // type name. A link that disappeared over that would be a highlighting
+    // change silently breaking navigation.
+    const tokens = [
+      { cls: 'keyword', text: 'Duration', col: 0 },
+      { cls: 'other', text: '.Since(t)', col: 8 },
+    ];
+    const claimed = assignRefs(tokens, [ref({ ident: 'Duration', col: 0 })]);
+    expect(claimed.size).toBe(1);
+    expect(tokens[[...claimed.keys()][0] as number]?.text).toBe('Duration');
+  });
+});
+
+describe('refsByLine', () => {
+  it('carries type references too, so a line that only names a type gets its port', () => {
+    const view = payload({
+      typesUsed: [relation({ node: { id: 'interface:c', kind: 'interface', name: 'Config' }, lines: [11] })],
+    });
+    const refs = refsByLine(view);
+    expect(refs.get(11)?.[0]).toMatchObject({ ident: 'Config', outside: false });
+  });
+
+  it('uses the last segment of a qualified name — that is what is in the source', () => {
+    const view = payload({
+      outgoing: {
+        total: 1,
+        shown: 1,
+        truncated: false,
+        items: [relation({ node: { id: 'm:1', name: 'Cache.read' }, lines: [12] })],
+      },
+    });
+    expect(refsByLine(view).get(12)?.[0]?.ident).toBe('read');
+  });
+
+  it('drops an unresolved "name" that is not an identifier at all', () => {
+    // The resolver's samples are raw bookkeeping; a captured arrow function
+    // cannot be found in the line, and searching for it would claim the wrong
+    // token.
+    const view = payload({
+      outsideIndex: {
+        total: 2,
+        byKind: { calls: 2 },
+        samples: [
+          { name: '(() => {\n  return t', kind: 'calls', line: 12, col: 0 },
+          { name: 'this.db', kind: 'function_ref', line: 13, col: 4 },
+        ],
+      },
+    });
+    const refs = refsByLine(view);
+    expect(refs.has(12)).toBe(false);
+    // `this.db` reduces to `db`, which IS in the line — kept, and marked as
+    // outside the index so it renders as text rather than a link.
+    expect(refs.get(13)?.[0]).toMatchObject({ ident: 'db', outside: true, targetId: null });
+  });
+});
+
+/* ---------------------------------------------------------------- rails -- */
+
+describe('buildCallerRail', () => {
+  const caller = (over: { id: string; file: string; test?: boolean; uncertain?: boolean; edges?: number }) =>
+    ({
+      ...relation({ lines: [4657] }),
+      node: {
+        ...nodeRef({ id: over.id, file: over.file, name: over.id }),
+        test: over.test ?? false,
+      },
+      edgeCount: over.edges ?? 1,
+      uncertain: over.uncertain ?? false,
+    }) as WireRelation;
+
+  it("puts the symbol's own file first and groups the rest by path", () => {
+    const view = payload({
+      node: { ...nodeRef({ file: 'src/service.ts' }), startColumn: 0, endColumn: 0, lines: 11 },
+      incoming: {
+        total: 3,
+        shown: 3,
+        truncated: false,
+        items: [
+          caller({ id: 'z', file: 'src/z.ts' }),
+          caller({ id: 'a', file: 'src/a.ts' }),
+          caller({ id: 'own', file: 'src/service.ts' }),
+        ],
+      },
+    });
+    const rail = buildCallerRail(view);
+    expect(rail.groups.map((g) => g.file)).toEqual(['src/service.ts', 'src/a.ts', 'src/z.ts']);
+    expect(rail.groups[0]?.same).toBe(true);
+    expect(rail.groups[1]?.same).toBe(false);
+  });
+
+  it('folds test callers away with their call and file counts intact', () => {
+    const view = payload({
+      incoming: {
+        total: 3,
+        shown: 3,
+        truncated: false,
+        items: [
+          caller({ id: 'prod', file: 'src/a.ts' }),
+          caller({ id: 't1', file: '__tests__/a.test.ts', test: true, edges: 4 }),
+          caller({ id: 't2', file: '__tests__/b.test.ts', test: true, edges: 2 }),
+        ],
+      },
+    });
+    const rail = buildCallerRail(view);
+    expect(rail.groups).toHaveLength(1);
+    expect(rail.tests.rows).toHaveLength(2);
+    expect(rail.tests.calls).toBe(6);
+    expect(rail.tests.files).toEqual(['__tests__/a.test.ts', '__tests__/b.test.ts']);
+    // The header count stays the real one — nothing is silently dropped.
+    expect(rail.total).toBe(3);
+  });
+
+  it('folds an uncertain test caller as uncertain, not as a test', () => {
+    // Uncertainty is a claim about the EDGE. Filing it under "tests" would
+    // present a name-only guess as an established call.
+    const view = payload({
+      incoming: {
+        total: 1,
+        shown: 1,
+        truncated: false,
+        items: [caller({ id: 'g', file: '__tests__/a.test.ts', test: true, uncertain: true })],
+      },
+    });
+    const rail = buildCallerRail(view);
+    expect(rail.uncertain).toHaveLength(1);
+    expect(rail.tests.rows).toHaveLength(0);
+    expect(rail.groups).toHaveLength(0);
+  });
+
+  it('reports the callers the API had to cap away', () => {
+    const view = payload({
+      incoming: { total: 545, shown: 1, truncated: true, items: [caller({ id: 'a', file: 'src/a.ts' })] },
+    });
+    expect(buildCallerRail(view).hiddenGroups).toBe(544);
+  });
+});
+
+describe('buildCalleeRail', () => {
+  it('anchors each row to its first call site and folds the guesses to the bottom', () => {
+    const view = payload({
+      outgoing: {
+        total: 2,
+        shown: 2,
+        truncated: false,
+        items: [
+          relation({ node: { id: 'sure' }, lines: [12, 18] }),
+          { ...relation({ node: { id: 'guess' }, lines: [15] }), uncertain: true, confidence: 0.4 },
+        ],
+      },
+    });
+    const rail = buildCalleeRail(view);
+    expect(rail.rows).toHaveLength(1);
+    expect(rail.rows[0]?.anchor).toBe(12);
+    expect(rail.rows[0]?.lines).toEqual([12, 18]);
+    expect(rail.uncertain).toHaveLength(1);
+  });
+
+  it('separates calls that leave the index from type references that do', () => {
+    const view = payload({
+      outsideIndex: { total: 24, byKind: { calls: 21, references: 2, function_ref: 1 }, samples: [] },
+    });
+    const rail = buildCalleeRail(view);
+    expect(rail.outsideCalls).toBe(22);
+    expect(rail.outsideTypeRefs).toBe(2);
+  });
+
+  it('leaves a row with no recorded line unanchored rather than guessing a height', () => {
+    const view = payload({
+      outgoing: {
+        total: 1,
+        shown: 1,
+        truncated: false,
+        items: [{ ...relation({ lines: [] }), lines: [], edges: [] } as WireRelation],
+      },
+    });
+    expect(buildCalleeRail(view).rows[0]?.anchor).toBeNull();
+  });
+});
+
+/* -------------------------------------------------------------- outline -- */
+
+describe('members outline', () => {
+  it('dims data members and indents the ones nested a level deeper', () => {
+    const view = payload({
+      members: {
+        total: 2,
+        shown: 2,
+        truncated: false,
+        items: [
+          { ...nodeRef({ kind: 'property', name: 'store' }), parentId: 'x', depth: 1, fanIn: 1, fanOut: 0 },
+          { ...nodeRef({ kind: 'method', name: 'read' }), parentId: 'y', depth: 2, fanIn: 3, fanOut: 5 },
+        ] as any,
+      },
+    });
+    const rows = buildOutline(view);
+    expect(rows[0]).toMatchObject({ dimmed: true, nested: false });
+    expect(rows[1]).toMatchObject({ dimmed: false, nested: true });
+  });
+});
+
+describe('showsBody', () => {
+  it("swaps a large container's body for its outline, and keeps a large function's", () => {
+    expect(showsBody('class', 700)).toBe(false);
+    expect(showsBody('file', 2000)).toBe(false);
+    expect(showsBody('class', 40)).toBe(true);
+    // A 700-line function IS its body — there is no outline to show instead.
+    expect(showsBody('function', 700)).toBe(true);
+    expect(showsBody('method', 259)).toBe(true);
+  });
+});
+
+/* ---------------------------------------------------------------- lexer -- */
+
+describe('client-side token decoding', () => {
+  // The classification itself is the server's job (`src/ui-server/highlight/`,
+  // the engine's own tree-sitter parse); what is worth pinning here is the decoding — the
+  // columns the call-site overlay matches against, and the plain fallback that
+  // has to keep links working when no grammar covers a file.
+  const CLASSES = ['other', 'ident', 'comment', 'string', 'keyword', 'number'];
+
+  it('resolves class ids through the payload table', () => {
+    const tokens = decodeLine(
+      [
+        [4, 'const'],
+        [0, ' '],
+        [1, 'x'],
+        [0, ' = '],
+        [5, '1'],
+        [0, '; '],
+        [2, '// note'],
+      ],
+      CLASSES
+    );
+    expect(tokens.map((t) => `${t.cls}:${t.text}`)).toEqual([
+      'keyword:const',
+      'other: ',
+      'ident:x',
+      'other: = ',
+      'number:1',
+      'other:; ',
+      'comment:// note',
+    ]);
+  });
+
+  it('derives each column from the running text, which is how a ref finds its identifier', () => {
+    const tokens = decodeLine(
+      [
+        [0, '  '],
+        [4, 'return'],
+        [0, ' '],
+        [1, 'render'],
+        [0, '();'],
+      ],
+      CLASSES
+    );
+    expect(tokens.find((t) => t.text === 'render')?.col).toBe('  return '.length);
+    expect(tokens.at(-1)?.col).toBe('  return render'.length);
+  });
+
+  it('treats an unknown class id as unstyled rather than throwing', () => {
+    expect(decodeLine([[99, 'x']], CLASSES)[0]?.cls).toBe('other');
+  });
+
+  it('splits identifiers even with no grammar, so the links still land', () => {
+    expect(plainLine('  return this.mutex.withLock();').map((t) => `${t.cls}:${t.text}`)).toEqual([
+      'other:  ',
+      'ident:return',
+      'other: ',
+      'ident:this',
+      'other:.',
+      'ident:mutex',
+      'other:.',
+      'ident:withLock',
+      'other:();',
+    ]);
+  });
+
+  it('splits non-ASCII identifiers, because a symbol name can be one', () => {
+    expect(plainLine('取得データ()').map((t) => t.cls)).toEqual(['ident', 'other']);
+  });
+
+  it('keys a slice by real file line, not by offset into the slice', () => {
+    const byLine = tokensByLine(['a();', 'b();'], 120, {
+      engine: 'tree-sitter',
+      grammar: 'typescript',
+      classes: CLASSES,
+      lines: [
+        [
+          [1, 'a'],
+          [0, '();'],
+        ],
+        [
+          [1, 'b'],
+          [0, '();'],
+        ],
+      ],
+    });
+    expect([...byLine.keys()]).toEqual([120, 121]);
+    expect(byLine.get(121)?.[0]?.text).toBe('b');
+  });
+
+  it('falls back per line when the payload carries no highlight block at all', () => {
+    const byLine = tokensByLine(['render();'], 5, undefined);
+    expect(byLine.get(5)?.map((t) => t.cls)).toEqual(['ident', 'other']);
+  });
+});

+ 179 - 0
__tests__/ui-trails-model.test.ts

@@ -0,0 +1,179 @@
+/**
+ * What a saved trail's row says, without a browser (CG-60).
+ *
+ * The endpoint's own behaviour is pinned in `ui-trails.test.ts` against a real
+ * index; this is the wording layer, and the rule it exists to protect is that
+ * **a trail that has decayed never reads as intact**. A saved trail is somebody's
+ * explanation of a codebase that has since moved underneath it, and a row that
+ * prints "6 hops" while two of them are gone is a lie by omission at exactly the
+ * moment the trail needs fixing.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  hopStatusWord,
+  isOpenable,
+  replacedTrail,
+  trailDecay,
+  trailExport,
+  trailMeta,
+  trailNameProblem,
+  trailOpens,
+  trailTitle,
+} from '../ui/src/lib/trails-model';
+import type { WireTrail, WireTrailHop, WireTrailHopStatus } from '../ui/src/lib/wire';
+
+function hop(
+  name: string,
+  status: WireTrailHopStatus = 'ok',
+  dir: WireTrailHop['dir'] = 'down'
+): WireTrailHop {
+  const alive = status !== 'missing';
+  return {
+    dir,
+    name,
+    qualifiedName: name,
+    kind: 'function',
+    savedFile: 'src/a.ts',
+    savedLine: 10,
+    status,
+    id: alive ? `function:${name}` : null,
+    file: alive ? 'src/a.ts' : null,
+    line: alive ? 10 : null,
+    note: status === 'ok' ? null : `${name} ${status}`,
+  };
+}
+
+function trail(hops: WireTrailHop[], over: Partial<WireTrail> = {}): WireTrail {
+  const resolved = hops.filter((h) => h.id !== null);
+  return {
+    id: 'a-walk',
+    name: 'A walk',
+    note: '',
+    author: 'Ada',
+    createdAt: '2026-08-01T00:00:00.000Z',
+    updatedAt: '2026-08-02T00:00:00.000Z',
+    hops,
+    resolved: resolved.length,
+    intact: hops.every((h) => h.status === 'ok'),
+    encoded: resolved.length > 0 ? resolved.map((h) => `d${h.id}`).join(',') : null,
+    openFrom: 1,
+    openCount: resolved.length,
+    openId: resolved.length > 0 ? (resolved[resolved.length - 1] as WireTrailHop).id : null,
+    ...over,
+  };
+}
+
+describe('trailMeta', () => {
+  it('reports the SAVED length, whatever became of the hops', () => {
+    const decayed = trail([hop('a', 'ok', 'start'), hop('b', 'missing'), hop('c')]);
+    expect(trailMeta(decayed)).toBe('3 hops · Ada');
+  });
+
+  it('drops the author when there is not one', () => {
+    expect(trailMeta(trail([hop('a', 'ok', 'start')], { author: '' }))).toBe('1 hop');
+  });
+});
+
+describe('trailDecay', () => {
+  it('is null for a trail nothing has happened to', () => {
+    expect(trailDecay(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
+  });
+
+  it('warns about hops that are gone, naming them', () => {
+    const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('gone', 'missing')]));
+    expect(decay?.tone).toBe('warn');
+    expect(decay?.text).toContain('1 hop moved or renamed');
+    expect(decay?.text).toContain('gone');
+  });
+
+  it('caps how many it names', () => {
+    const hops = ['a', 'b', 'c', 'd', 'e'].map((n) => hop(n, 'missing'));
+    const decay = trailDecay(trail(hops));
+    expect(decay?.text).toContain('and 2 more');
+  });
+
+  it('notes a move without warning about it — a moved hop still opens', () => {
+    const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'moved')]));
+    expect(decay?.tone).toBe('note');
+    expect(decay?.text).toContain('moved to another file');
+  });
+
+  it('puts a missing hop ahead of a merely moved one', () => {
+    const decay = trailDecay(trail([hop('m', 'moved'), hop('g', 'missing')]));
+    expect(decay?.text).toContain('moved or renamed');
+  });
+
+  it('warns about an ambiguous hop — the trail may no longer mean what it said', () => {
+    const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'ambiguous')]));
+    expect(decay?.tone).toBe('warn');
+    expect(decay?.text).toContain('more than one symbol');
+  });
+});
+
+describe('trailOpens', () => {
+  it('says nothing when the whole trail opens', () => {
+    expect(trailOpens(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
+  });
+
+  it('names the range when only part of it does', () => {
+    const partial = trail([hop('a'), hop('b'), hop('c')], {
+      openFrom: 2,
+      openCount: 2,
+    });
+    expect(trailOpens(partial)).toBe('Opens hops 2–3 of 3.');
+  });
+
+  it('says so plainly when nothing resolves', () => {
+    const dead = trail([hop('a', 'missing')], { encoded: null, openCount: 0, openId: null });
+    expect(trailOpens(dead)).toContain('None of this trail resolves');
+    expect(isOpenable(dead)).toBe(false);
+  });
+});
+
+describe('trailTitle', () => {
+  it('draws the whole walk with its arrows, and when it was saved', () => {
+    const walked = trail([hop('a', 'ok', 'start'), hop('b', 'ok', 'down'), hop('c', 'ok', 'up')]);
+    expect(trailTitle(walked)).toBe('a → b ← c — saved 2026-08-02');
+  });
+});
+
+describe('saving', () => {
+  it('refuses an empty or over-long name before the round-trip', () => {
+    expect(trailNameProblem('   ', 120)).toContain('name');
+    expect(trailNameProblem('x'.repeat(121), 120)).toContain('too long');
+    expect(trailNameProblem('ok', 120)).toBeNull();
+  });
+
+  it('spots the trail a name would replace, whitespace and all', () => {
+    const list = [trail([hop('a', 'ok', 'start')], { name: 'A walk' })];
+    expect(replacedTrail('  A   walk  ', list)?.name).toBe('A walk');
+    expect(replacedTrail('Another walk', list)).toBeNull();
+  });
+});
+
+describe('trailExport', () => {
+  it('exports the SAVED identity of each hop, not today’s resolution', () => {
+    const moved = trail([hop('a', 'ok', 'start'), hop('b', 'moved')]);
+    const raw = JSON.parse(trailExport(moved));
+    expect(raw.version).toBe(1);
+    // `savedFile`, so dropping the file into another checkout re-runs the same
+    // resolution rather than baking this index's answer in.
+    expect(raw.hops[1].file).toBe('src/a.ts');
+    expect(raw.hops[1].qualifiedName).toBe('b');
+    expect(raw.hops.map((h: { dir: string }) => h.dir)).toEqual(['start', 'down']);
+  });
+
+  it('survives a hop with no id at all', () => {
+    const raw = JSON.parse(trailExport(trail([hop('gone', 'missing')])));
+    expect(raw.hops[0].id).toBe('');
+  });
+});
+
+describe('hopStatusWord', () => {
+  it('has a word for every status', () => {
+    for (const status of ['ok', 'moved', 'ambiguous', 'missing'] as const) {
+      expect(hopStatusWord(status)).toBeTruthy();
+    }
+  });
+});

+ 562 - 0
__tests__/ui-trails.test.ts

@@ -0,0 +1,562 @@
+/**
+ * Saved trails (CG-60) — the viewer's only write.
+ *
+ * Two things are worth a real end-to-end fixture rather than a unit test, and
+ * they are the two the feature exists for:
+ *
+ * 1. **A trail survives a re-index.** The suite indexes a project, saves a
+ *    trail, then EDITS the files so every node id changes (a symbol shifts down
+ *    a file, another moves to a different file, a third is deleted), re-indexes,
+ *    and asserts the trail still opens and says what became of each hop. That
+ *    cannot be faked: node ids contain a start line, so the ids really do all
+ *    change.
+ * 2. **The write boundary.** `POST` without the marker header, from a foreign
+ *    `Origin`, or against a `--read-only` server has to be refused — by a real
+ *    loopback server, because the refusals live in the request handler and not
+ *    in the endpoint.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import {
+  encodeResolvedRun,
+  isTrailId,
+  parseTrail,
+  slugify,
+  TRAILS_RELATIVE_DIR,
+  type WireTrailHop,
+} from '../src/ui-server/api';
+
+interface Res {
+  status: number;
+  headers: http.IncomingHttpHeaders;
+  body: any;
+}
+
+let tempDir: string;
+let projectRoot: string;
+let viewerDir: string;
+let api: GraphApi;
+let server: UiServerHandle;
+let readOnlyApi: GraphApi;
+let readOnlyServer: UiServerHandle;
+
+interface CallOptions {
+  method?: string;
+  body?: unknown;
+  /** Send the write marker header. On by default for a write. */
+  marker?: boolean;
+  contentType?: string | null;
+  origin?: string;
+}
+
+/**
+ * One request against a live server.
+ *
+ * `http.request` rather than `fetch` so `Host` is ours to set — undici treats
+ * it as a forbidden header, and the `Host` allowlist is half of what is being
+ * tested here.
+ */
+function callOn(port: number, requestPath: string, opts: CallOptions = {}): Promise<Res> {
+  const method = opts.method ?? 'GET';
+  const isWrite = method === 'POST' || method === 'DELETE';
+  const payload = opts.body === undefined ? null : Buffer.from(JSON.stringify(opts.body), 'utf-8');
+  const headers: Record<string, string> = { Host: `127.0.0.1:${port}` };
+  if (isWrite && (opts.marker ?? true)) headers['X-CodeGraph-UI'] = '1';
+  if (opts.origin) headers['Origin'] = opts.origin;
+  if (payload) {
+    const type = opts.contentType === undefined ? 'application/json' : opts.contentType;
+    if (type !== null) headers['Content-Type'] = type;
+    headers['Content-Length'] = String(payload.length);
+  }
+
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      { host: '127.0.0.1', port, path: requestPath, method, headers, setHost: false },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () => {
+          const text = Buffer.concat(chunks).toString('utf-8');
+          let parsed: unknown = text;
+          try {
+            parsed = JSON.parse(text);
+          } catch {
+            /* a text/plain refusal is a legitimate answer on the static side */
+          }
+          resolve({ status: res.statusCode ?? 0, headers: res.headers, body: parsed });
+        });
+      }
+    );
+    req.on('error', reject);
+    if (payload) req.write(payload);
+    req.end();
+  });
+}
+
+function call(requestPath: string, opts: CallOptions = {}): Promise<Res> {
+  return callOn(server.port, requestPath, opts);
+}
+
+/** The id of a fixture symbol, looked up through the API itself. */
+async function idOf(name: string): Promise<string> {
+  const res = await call(`/api/search?q=${encodeURIComponent(name)}`);
+  const hit = res.body.results.items.find((r: any) => r.name === name);
+  expect(hit, `no symbol named ${name}`).toBeTruthy();
+  return hit.id as string;
+}
+
+function trailsDir(): string {
+  return path.join(projectRoot, TRAILS_RELATIVE_DIR);
+}
+
+/** Re-index in place, the way a `codegraph sync` would after an edit. */
+async function reindex(): Promise<void> {
+  const cg = CodeGraph.openSync(projectRoot);
+  await cg.sync();
+  cg.resolveReferences();
+  cg.close();
+}
+
+const SRC = () => path.join(projectRoot, 'src');
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-trails-'));
+  projectRoot = path.join(tempDir, 'project');
+  fs.mkdirSync(SRC(), { recursive: true });
+
+  fs.writeFileSync(
+    path.join(SRC(), 'handler.ts'),
+    `import { load } from './service';
+
+export function handleRequest(key: string): string {
+  return load(key);
+}
+`
+  );
+  fs.writeFileSync(
+    path.join(SRC(), 'service.ts'),
+    `import { read } from './cache';
+
+export function load(key: string): string {
+  return read(key);
+}
+
+export function retired(): string {
+  return 'nothing calls me after the edit';
+}
+`
+  );
+  fs.writeFileSync(
+    path.join(SRC(), 'cache.ts'),
+    `export function read(key: string): string {
+  return key;
+}
+`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  cg.close();
+
+  viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+
+  readOnlyApi = createGraphApi({
+    projectRoot,
+    readOnly: true,
+    readOnlyReason: 'This viewer was started with --read-only, so trails cannot be saved.',
+  });
+  readOnlyServer = await startUiServer({
+    projectRoot,
+    viewerDir,
+    port: 0,
+    api: readOnlyApi.handler,
+  });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  readOnlyApi?.close();
+  await server?.close();
+  await readOnlyServer?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+/* ------------------------------------------------------------- pure bits -- */
+
+describe('trail ids', () => {
+  it('slugs a name into something that is a filename and not a path', () => {
+    expect(slugify('How a request reaches the handler')).toBe(
+      'how-a-request-reaches-the-handler'
+    );
+    expect(slugify('  Spaces   and --- dashes  ')).toBe('spaces-and-dashes');
+    expect(slugify('../../etc/passwd')).toBe('etc-passwd');
+    // A name with no ASCII word characters still has to produce a valid id.
+    expect(slugify('日本語')).toBe('trail');
+    expect(isTrailId(slugify('../../etc/passwd'))).toBe(true);
+  });
+
+  it('refuses anything that is not a slug', () => {
+    for (const bad of ['..', 'a/b', 'A', 'has.dot', '-leading', '', 'a b']) {
+      expect(isTrailId(bad), bad).toBe(false);
+    }
+  });
+});
+
+describe('parseTrail', () => {
+  it('rejects a file that is not a trail rather than half-reading it', () => {
+    expect(parseTrail('x', 'not json')).toBeNull();
+    expect(parseTrail('x', '[]')).toBeNull();
+    expect(parseTrail('x', '{"name":"a"}')).toBeNull();
+    expect(parseTrail('x', '{"name":"a","hops":[]}')).toBeNull();
+    expect(parseTrail('x', '{"name":"","hops":[{"qualifiedName":"a"}]}')).toBeNull();
+  });
+
+  it('takes its id from the FILE, not from the field inside it', () => {
+    const trail = parseTrail('on-disk', '{"name":"a","id":"remembered","hops":[{"name":"f"}]}');
+    expect(trail?.id).toBe('on-disk');
+  });
+});
+
+describe('encodeResolvedRun', () => {
+  const hop = (id: string | null, dir: 'start' | 'down' | 'up' = 'down'): WireTrailHop => ({
+    dir,
+    name: id ?? 'gone',
+    qualifiedName: id ?? 'gone',
+    kind: 'function',
+    savedFile: 'src/a.ts',
+    savedLine: 1,
+    status: id ? 'ok' : 'missing',
+    id,
+    file: id ? 'src/a.ts' : null,
+    line: id ? 1 : null,
+    note: null,
+  });
+
+  it('never stitches across a hole — it takes the longest consecutive run', () => {
+    const run = encodeResolvedRun([hop('a', 'start'), hop(null), hop('c'), hop('d')]);
+    expect(run.encoded).toBe('sc,dd');
+    expect(run.openFrom).toBe(3);
+    expect(run.openCount).toBe(2);
+    expect(run.openId).toBe('d');
+  });
+
+  it('writes the run’s first hop as a start, whatever it was saved as', () => {
+    const run = encodeResolvedRun([hop(null), hop('b', 'up')]);
+    expect(run.encoded).toBe('sb');
+  });
+
+  it('answers nothing when nothing resolves', () => {
+    expect(encodeResolvedRun([hop(null), hop(null)])).toEqual({
+      encoded: null,
+      openFrom: 0,
+      openCount: 0,
+      openId: null,
+    });
+  });
+});
+
+/* ----------------------------------------------------------- the endpoint -- */
+
+describe('GET /api/trails', () => {
+  it('is an empty list, not an error, before anything is saved', async () => {
+    const res = await call('/api/trails');
+    expect(res.status).toBe(200);
+    expect(res.body.trails).toEqual([]);
+    expect(res.body.readOnly).toBe(false);
+    expect(res.body.directory).toBe(TRAILS_RELATIVE_DIR);
+  });
+
+  it('is listed by GET /api', async () => {
+    const res = await call('/api');
+    expect(res.body.endpoints.some((e: any) => e.path === '/api/trails')).toBe(true);
+    // The old blanket claim is gone: the server writes exactly one thing.
+    expect(res.body.readOnly).toBe(false);
+    expect(res.body.writes).toContain('POST /api/trails');
+  });
+});
+
+describe('POST /api/trails', () => {
+  it('saves the walk and answers with the whole list', async () => {
+    const hops = [
+      { dir: 'start', id: await idOf('handleRequest') },
+      { dir: 'down', id: await idOf('load') },
+      { dir: 'down', id: await idOf('read') },
+    ];
+    const res = await call('/api/trails', {
+      method: 'POST',
+      body: { name: 'How a request is served', note: 'the whole path', hops },
+    });
+
+    expect(res.status).toBe(200);
+    expect(res.body.saved).toBe('how-a-request-is-served');
+    expect(res.body.replaced).toBe(false);
+    expect(res.body.trails).toHaveLength(1);
+
+    const trail = res.body.trails[0];
+    expect(trail.name).toBe('How a request is served');
+    expect(trail.note).toBe('the whole path');
+    expect(trail.intact).toBe(true);
+    expect(trail.resolved).toBe(3);
+    expect(trail.openCount).toBe(3);
+    expect(trail.hops.map((h: any) => h.name)).toEqual(['handleRequest', 'load', 'read']);
+    // The identity that survives an edit, recorded beside the id hint.
+    expect(trail.hops[1].qualifiedName).toBe('load');
+    expect(trail.hops[1].savedFile).toBe('src/service.ts');
+  });
+
+  it('writes one readable JSON file into .codegraph/ui/trails', () => {
+    const file = path.join(trailsDir(), 'how-a-request-is-served.json');
+    expect(fs.existsSync(file)).toBe(true);
+    const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
+    expect(raw.version).toBe(1);
+    expect(raw.hops).toHaveLength(3);
+    expect(raw.hops[0].qualifiedName).toBe('handleRequest');
+    expect(typeof raw.createdAt).toBe('string');
+    // Nothing but trails lands there — no temp file survives the rename.
+    expect(fs.readdirSync(trailsDir())).toEqual(['how-a-request-is-served.json']);
+  });
+
+  it('replaces a trail saved under the same name, keeping its createdAt', async () => {
+    const before = (await call('/api/trails')).body.trails[0];
+    const res = await call('/api/trails', {
+      method: 'POST',
+      body: {
+        name: 'How a request is served',
+        hops: [{ dir: 'start', id: await idOf('handleRequest') }],
+      },
+    });
+    expect(res.body.replaced).toBe(true);
+    expect(res.body.trails).toHaveLength(1);
+    expect(res.body.trails[0].createdAt).toBe(before.createdAt);
+    expect(res.body.trails[0].hops).toHaveLength(1);
+    expect(res.body.trails[0].note).toBe('');
+  });
+
+  it('gives a different name its own file rather than colliding', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      body: { name: 'How a request is served!', hops: [{ dir: 'start', id: await idOf('load') }] },
+    });
+    expect(res.body.saved).toBe('how-a-request-is-served-2');
+    expect(res.body.trails).toHaveLength(2);
+  });
+
+  it('refuses a hop the index does not hold', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      body: { name: 'invented', hops: [{ dir: 'start', id: 'function:not-a-real-id' }] },
+    });
+    expect(res.status).toBe(400);
+    expect(res.body.error).toContain('Hop 1 is not in the index');
+  });
+
+  it('refuses a nameless or hopless trail', async () => {
+    const noName = await call('/api/trails', { method: 'POST', body: { name: '  ', hops: [] } });
+    expect(noName.status).toBe(400);
+    const noHops = await call('/api/trails', { method: 'POST', body: { name: 'x', hops: [] } });
+    expect(noHops.status).toBe(400);
+    expect(noHops.body.error).toContain('at least one hop');
+  });
+});
+
+describe('DELETE /api/trails/<id>', () => {
+  it('removes the file and answers with the list that is left', async () => {
+    const res = await call('/api/trails/how-a-request-is-served-2', { method: 'DELETE' });
+    expect(res.status).toBe(200);
+    expect(res.body.deleted).toBe('how-a-request-is-served-2');
+    expect(res.body.trails).toHaveLength(1);
+    expect(fs.existsSync(path.join(trailsDir(), 'how-a-request-is-served-2.json'))).toBe(false);
+  });
+
+  it('is a 404 for a trail that is not there', async () => {
+    const res = await call('/api/trails/never-existed', { method: 'DELETE' });
+    expect(res.status).toBe(404);
+  });
+
+  it('refuses an id shaped like a path before it is joined to anything', async () => {
+    const res = await call('/api/trails/..%2f..%2fetc%2fpasswd', { method: 'DELETE' });
+    // The `..` segments are caught on the RAW url, before WHATWG parsing folds
+    // them away — a traversal attempt is a 404, never the app shell.
+    expect([400, 404]).toContain(res.status);
+    expect(res.headers['content-type']).toContain('application/json');
+  });
+});
+
+/* ------------------------------------------------------- the write boundary */
+
+describe('the write boundary', () => {
+  it('refuses a POST without the marker header', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      marker: false,
+      body: { name: 'forged', hops: [] },
+    });
+    expect(res.status).toBe(403);
+    expect(res.body.code).toBe('refused');
+    expect(String(res.body.error)).toContain('x-codegraph-ui');
+  });
+
+  it('refuses a POST whose body claims to be a form', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      contentType: 'application/x-www-form-urlencoded',
+      body: { name: 'forged', hops: [] },
+    });
+    expect(res.status).toBe(403);
+    expect(String(res.body.error)).toContain('application/json');
+  });
+
+  it('refuses a POST from a foreign origin even with the marker', async () => {
+    const res = await call('/api/trails', {
+      method: 'POST',
+      origin: 'https://evil.example',
+      body: { name: 'forged', hops: [] },
+    });
+    expect(res.status).toBe(403);
+  });
+
+  it('refuses a write anywhere but /api/, and still serves the asset on GET', async () => {
+    const post = await call('/index.html', { method: 'POST', body: { a: 1 } });
+    expect(post.status).toBe(405);
+    expect(post.headers.allow).toBe('GET, HEAD');
+    const get = await call('/index.html');
+    expect(get.status).toBe(200);
+  });
+
+  it('still refuses a method it has never answered', async () => {
+    const res = await call('/api/trails', { method: 'PUT' });
+    expect(res.status).toBe(405);
+  });
+
+  it('refuses every write under --read-only, but still lists what is there', async () => {
+    const list = await callOn(readOnlyServer.port, '/api/trails');
+    expect(list.status).toBe(200);
+    expect(list.body.readOnly).toBe(true);
+    expect(list.body.readOnlyReason).toContain('--read-only');
+    expect(list.body.trails.length).toBeGreaterThan(0);
+
+    const save = await callOn(readOnlyServer.port, '/api/trails', {
+      method: 'POST',
+      body: { name: 'nope', hops: [{ dir: 'start', id: 'x' }] },
+    });
+    expect(save.status).toBe(403);
+    expect(save.body.code).toBe('refused');
+
+    const remove = await callOn(readOnlyServer.port, '/api/trails/how-a-request-is-served', {
+      method: 'DELETE',
+    });
+    expect(remove.status).toBe(403);
+  });
+});
+
+/* ------------------------------------------------- surviving a re-index --- */
+
+describe('a saved trail survives a re-index', () => {
+  it('re-resolves hops by qualified name once every node id has changed', async () => {
+    // Save the three-hop walk again, plus a fourth hop that is about to be
+    // deleted outright, so one trail exercises every outcome at once.
+    const saved = await call('/api/trails', {
+      method: 'POST',
+      body: {
+        name: 'The whole walk',
+        hops: [
+          { dir: 'start', id: await idOf('handleRequest') },
+          { dir: 'down', id: await idOf('load') },
+          { dir: 'down', id: await idOf('read') },
+          { dir: 'down', id: await idOf('retired') },
+        ],
+      },
+    });
+    const before = saved.body.trails.find((t: any) => t.id === 'the-whole-walk');
+    expect(before.intact).toBe(true);
+    const idsBefore = before.hops.map((h: any) => h.id);
+
+    // Now move the world underneath it:
+    //  - `handleRequest` shifts down its file (a node id contains its start
+    //    line, so its id changes while it is the same symbol);
+    //  - `read` moves to a different file entirely;
+    //  - `retired` is deleted.
+    fs.writeFileSync(
+      path.join(SRC(), 'handler.ts'),
+      `import { load } from './service';
+
+// A comment inserted above the symbol. This alone renames it.
+// Another line.
+// And another.
+
+export function handleRequest(key: string): string {
+  return load(key);
+}
+`
+    );
+    fs.writeFileSync(
+      path.join(SRC(), 'service.ts'),
+      `import { read } from './store';
+
+export function load(key: string): string {
+  return read(key);
+}
+`
+    );
+    fs.writeFileSync(path.join(SRC(), 'cache.ts'), `export const unused = 1;\n`);
+    fs.writeFileSync(
+      path.join(SRC(), 'store.ts'),
+      `export function read(key: string): string {
+  return key;
+}
+`
+    );
+    await reindex();
+
+    const after = (await call('/api/trails')).body.trails.find(
+      (t: any) => t.id === 'the-whole-walk'
+    );
+
+    // Every id really did change — otherwise this test proves nothing.
+    const idsAfter = after.hops.map((h: any) => h.id);
+    expect(idsAfter[0]).not.toBe(idsBefore[0]);
+    expect(idsAfter[0]).toBeTruthy();
+
+    const [handle, load, read, retired] = after.hops;
+    expect(handle.status).toBe('ok');
+    expect(handle.file).toBe('src/handler.ts');
+    expect(handle.line).toBeGreaterThan(handle.savedLine);
+
+    expect(load.status).toBe('ok');
+
+    // Moved to another file: still resolved, and the row says where from.
+    expect(read.status).toBe('moved');
+    expect(read.savedFile).toBe('src/cache.ts');
+    expect(read.file).toBe('src/store.ts');
+    expect(read.note).toContain('src/cache.ts');
+    expect(read.note).toContain('src/store.ts');
+
+    // Deleted: named honestly, with no invented target.
+    expect(retired.status).toBe('missing');
+    expect(retired.id).toBeNull();
+    expect(retired.note).toContain('moved or renamed');
+
+    // And it still opens — the first three hops, not the fourth.
+    expect(after.intact).toBe(false);
+    expect(after.resolved).toBe(3);
+    expect(after.openFrom).toBe(1);
+    expect(after.openCount).toBe(3);
+    expect(after.openId).toBe(idsAfter[2]);
+    expect(after.encoded?.split(',')).toHaveLength(3);
+    expect(after.encoded?.startsWith('s')).toBe(true);
+  }, 120_000);
+});

+ 301 - 0
__tests__/vue-router.test.ts

@@ -0,0 +1,301 @@
+/**
+ * Vue Router as a Screens app (`src/resolution/frameworks/vue-router.ts`,
+ * `src/resolution/vue-router-synthesizer.ts`): routes read out of
+ * `createRouter({ routes: [...] })` and bound to the `.vue` view each names,
+ * and the navigation between them — which in Vue is usually written by route
+ * NAME rather than by path.
+ *
+ * The fixture is vue-realworld's shape: a `src/router/index.js` table of lazy
+ * views, `router.push({ name })` from the script, `<router-link :to>` from the
+ * template. Mirrors `react-router.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildScreens } from '../src/ui-server/api/screens';
+import { parseVueRoutes, vueNavVerb, routeNameInExpression } from '../src/resolution/frameworks/vue-router';
+import type { Node } from '../src/types';
+
+// =============================================================================
+// Reading the routes array
+// =============================================================================
+
+const ROUTER_SOURCE =
+  'import { createRouter, createWebHistory } from "vue-router"\n' +
+  'const router = createRouter({\n' +
+  '  history: createWebHistory(),\n' +
+  '  routes: [\n' +
+  '    {\n' +
+  '      name: "home",\n' +
+  '      path: "/",\n' +
+  '      component: () => import("@/views/Home")\n' +
+  '    },\n' +
+  '    {\n' +
+  '      name: "login",\n' +
+  '      path: "/login",\n' +
+  '      component: () => import("@/views/Login")\n' +
+  '    },\n' +
+  '    {\n' +
+  '      name: "settings",\n' +
+  '      path: "/settings",\n' +
+  '      component: () => import("@/views/Settings"),\n' +
+  '      meta: { requiresAuth: true }\n' +
+  '    },\n' +
+  '    {\n' +
+  '      name: "profile",\n' +
+  '      path: "/profile/:username",\n' +
+  '      component: Profile,\n' +
+  '      children: [\n' +
+  '        { path: "favorites", component: Favorites }\n' +
+  '      ]\n' +
+  '    }\n' +
+  '  ]\n' +
+  '})\n' +
+  'export default router\n';
+
+describe('vue-router: parseVueRoutes', () => {
+  const entries = parseVueRoutes(ROUTER_SOURCE);
+
+  it('gives every entry its OWN name — the name is written above the path it belongs to', () => {
+    expect(entries.map((e) => [e.name, e.path])).toEqual([
+      ['home', '/'],
+      ['login', '/login'],
+      ['settings', '/settings'],
+      ['profile', '/profile/:username'],
+    ]);
+  });
+
+  it('reads the component from a lazy import and from an identifier', () => {
+    expect(entries.map((e) => e.component)).toEqual(['Home', 'Login', 'Settings', 'Profile']);
+  });
+
+  it('skips a child route, whose path is relative to a parent this does not compose', () => {
+    expect(entries.some((e) => e.path === 'favorites')).toBe(false);
+  });
+
+  it('is nothing on a file that declares no routes', () => {
+    expect(parseVueRoutes('export const paths = [{ path: "/x" }]\n')).toEqual([]);
+    expect(parseVueRoutes('const x = 1\n')).toEqual([]);
+  });
+});
+
+describe('vue-router: navigation call names', () => {
+  it.each([
+    ['router.push', 'push'],
+    ['router.replace', 'replace'],
+    ['$router.push', 'push'],
+    ['navigateTo', 'navigateTo'],
+  ])('%s → %s', (name, verb) => {
+    expect(vueNavVerb(name)).toBe(verb);
+  });
+
+  it.each(['push', 'replace', 'paths.push', 'list.replace', 'go', 'back'])(
+    '%s is not a navigation — an unqualified push is an array’s',
+    (name) => {
+      expect(vueNavVerb(name)).toBeNull();
+    }
+  );
+
+  it('reads the route name out of an object destination, and nothing out of a path one', () => {
+    expect(routeNameInExpression('{ name: "login" }')).toBe('login');
+    expect(routeNameInExpression("{ name: 'profile', params: { username } }")).toBe('profile');
+    expect(routeNameInExpression('{ path: "/", query }')).toBeNull();
+    expect(routeNameInExpression("'/login'")).toBeNull();
+  });
+});
+
+// =============================================================================
+// The whole picture, indexed
+// =============================================================================
+
+describe('vue-router: a routed app end to end', () => {
+  let tmpDir: string;
+  let cg: CodeGraph;
+
+  function write(rel: string, content: string): void {
+    const full = path.join(tmpDir, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  beforeAll(async () => {
+    await initGrammars();
+    await loadAllGrammars();
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-router-'));
+    write('package.json', JSON.stringify({ name: 'conduit', dependencies: { vue: '3', 'vue-router': '4' } }));
+    write(
+      'src/router/index.js',
+      'import { createRouter, createWebHistory } from "vue-router"\n' +
+        'const router = createRouter({\n' +
+        '  history: createWebHistory(),\n' +
+        '  routes: [\n' +
+        '    { name: "home", path: "/", component: () => import("@/views/Home") },\n' +
+        '    { name: "login", path: "/login", component: () => import("@/views/Login") },\n' +
+        '    { name: "register", path: "/register", component: () => import("@/views/Register") },\n' +
+        '    { name: "settings", path: "/settings", component: () => import("@/views/Settings") },\n' +
+        '    { name: "profile", path: "/profile/:username", component: () => import("@/views/Profile") }\n' +
+        '  ]\n' +
+        '})\n' +
+        'export default router\n'
+    );
+    write(
+      'src/views/Home.vue',
+      '<template>\n' +
+        '  <div><TheHeader /></div>\n' +
+        '</template>\n' +
+        '<script setup>\n' +
+        'import { useRouter } from "vue-router"\n' +
+        'import TheHeader from "@/components/TheHeader.vue"\n' +
+        'const router = useRouter()\n' +
+        'function goTo(tag) {\n' +
+        '  router.push({ path: "/", query: { tag } })\n' +
+        '}\n' +
+        '</script>\n'
+    );
+    write(
+      'src/views/Login.vue',
+      '<template>\n' +
+        '  <form @submit="submit"><router-link :to="{ name: \'register\' }">Need an account?</router-link></form>\n' +
+        '</template>\n' +
+        '<script setup>\n' +
+        'import { useRouter } from "vue-router"\n' +
+        'const router = useRouter()\n' +
+        'function submit() {\n' +
+        '  login().then(() => router.push({ name: "home" }))\n' +
+        '}\n' +
+        '</script>\n'
+    );
+    write(
+      'src/views/Register.vue',
+      '<template>\n' +
+        '  <router-link to="/login">Have an account?</router-link>\n' +
+        '</template>\n' +
+        '<script setup>\n' +
+        'const nothing = 1\n' +
+        '</script>\n'
+    );
+    write(
+      'src/views/Settings.vue',
+      '<template>\n' +
+        '  <button @click="save">Save</button>\n' +
+        '</template>\n' +
+        '<script setup>\n' +
+        'import { useRouter } from "vue-router"\n' +
+        'const router = useRouter()\n' +
+        'const target = "/nowhere"\n' +
+        'function save(user) {\n' +
+        '  router.push({ name: "profile", params: { username: user.username } })\n' +
+        '}\n' +
+        'function bail() {\n' +
+        '  router.push(target)\n' +
+        '}\n' +
+        '</script>\n'
+    );
+    write(
+      'src/views/Profile.vue',
+      '<template>\n  <div>Profile</div>\n</template>\n<script setup>\nconst x = 1\n</script>\n'
+    );
+    write(
+      'src/components/TheHeader.vue',
+      '<template>\n' +
+        '  <nav>\n' +
+        '    <router-link :to="{ name: \'home\' }">Home</router-link>\n' +
+        '    <router-link to="/settings">Settings</router-link>\n' +
+        '    <a href="https://example.com">Elsewhere</a>\n' +
+        '  </nav>\n' +
+        '</template>\n' +
+        '<script setup>\nconst y = 1\n</script>\n'
+    );
+    // The precision floor: an array's `push` with a string that IS a route.
+    write('src/utils/trail.js', 'export function trail() {\n  const paths = []\n  paths.push("/login")\n  return paths\n}\n');
+    cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+  });
+
+  afterAll(() => {
+    cg?.close();
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  const route = (name: string): Node => {
+    const r = cg.getNodesByKind('route').find((r) => r.name === name);
+    if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
+    return r;
+  };
+  const sym = (name: string): Node => {
+    const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
+    if (!n) throw new Error(`no symbol ${name}`);
+    return n;
+  };
+  const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
+  const hrefs = (from: Node) =>
+    navs(from)
+      .map((e) => (e.metadata as Record<string, unknown>).href as string)
+      .sort();
+
+  it('names every route in the table and binds it to the .vue view it names', () => {
+    expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
+      '/',
+      '/login',
+      '/profile/:username',
+      '/register',
+      '/settings',
+    ]);
+    // The binding is a `calls` edge to the component, never the same-named
+    // symbol a `references` edge would have found in the JS half of the app.
+    const bound = cg.getOutgoingEdges(route('/login').id).find((e) => e.kind === 'calls');
+    expect(cg.getNode(bound!.target)).toMatchObject({ name: 'Login', kind: 'component', filePath: 'src/views/Login.vue' });
+  });
+
+  it('router.push({ name }) reaches the route with that name', () => {
+    const login = navs(sym('submit'));
+    expect(login).toHaveLength(1);
+    expect(login[0]!.target).toBe(route('/').id);
+    expect(login[0]!.metadata).toMatchObject({ href: 'home', navMethod: 'push', by: 'name' });
+    const save = navs(sym('save'));
+    expect(save[0]!.target).toBe(route('/profile/:username').id);
+    expect(save[0]!.metadata).toMatchObject({ href: 'profile', by: 'name' });
+  });
+
+  it('router.push({ path }) reaches the route with that path', () => {
+    const goTo = navs(sym('goTo'));
+    expect(goTo).toHaveLength(1);
+    expect(goTo[0]!.target).toBe(route('/').id);
+    expect(goTo[0]!.metadata).toMatchObject({ href: '/', navMethod: 'push' });
+    expect((goTo[0]!.metadata as Record<string, unknown>).by).toBeUndefined();
+  });
+
+  it('a <router-link> navigates from the component that renders it, by name or by path', () => {
+    expect(hrefs(sym('TheHeader'))).toEqual(['/settings', 'home']);
+    const byHref = new Map(navs(sym('TheHeader')).map((e) => [(e.metadata as Record<string, unknown>).href, e]));
+    expect(byHref.get('home')!.target).toBe(route('/').id);
+    expect(byHref.get('home')!.provenance).toBe('heuristic');
+    expect(byHref.get('home')!.metadata).toMatchObject({ synthesizedBy: 'vue-router-link', navMethod: 'link', by: 'name' });
+    expect(byHref.get('/settings')!.target).toBe(route('/settings').id);
+    expect(hrefs(sym('Register'))).toEqual(['/login']);
+  });
+
+  it('a destination nothing declares is left unresolved, and an array’s push is never claimed', () => {
+    // `router.push(target)` where target is "/nowhere" — a real string, no route.
+    expect(navs(sym('bail'))).toEqual([]);
+    expect(navs(sym('trail'))).toEqual([]);
+  });
+
+  it('lands on the Screens tab as transitions between screens', async () => {
+    const screens = await buildScreens(cg, tmpDir);
+    expect(screens.routed).toBe(true);
+    expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/login', '/profile/:username', '/register', '/settings']);
+    const at = (p: string) => screens.screens.find((s) => s.path === p)!;
+    expect(at('/login').component?.name).toBe('Login');
+    const toProfile = screens.links.find((l) => l.from === at('/settings').id && l.to === at('/profile/:username').id)!;
+    expect(toProfile).toBeDefined();
+    expect(toProfile.via.map((v) => v.name)).toEqual(['save']);
+    expect(toProfile.sites[0]).toMatchObject({ href: 'profile', method: 'push' });
+    expect(screens.links.find((l) => l.from === at('/login').id && l.to === at('/register').id)).toBeDefined();
+    expect(screens.dropped).toBe(0);
+  });
+});

BIN
assets/codegraph-ui-symbol-view.png


+ 2 - 1
codegraph-kernel/src/buffers.rs

@@ -104,7 +104,7 @@ pub const NODE_KINDS: [&str; 23] = [
 ];
 
 /// Mirror of EDGE_KINDS in src/types.ts — order is the wire contract.
-pub const EDGE_KINDS: [&str; 12] = [
+pub const EDGE_KINDS: [&str; 13] = [
     "contains",
     "calls",
     "imports",
@@ -117,6 +117,7 @@ pub const EDGE_KINDS: [&str; 12] = [
     "instantiates",
     "overrides",
     "decorates",
+    "navigates",
 ];
 
 /// ReferenceKind code for the internal-only `function_ref` (#756).

+ 29 - 1
codegraph-kernel/src/tsjs/extractors.rs

@@ -293,6 +293,29 @@ impl<'t> Walker<'t> {
 
     // --- extractVariable (TS/JS branch) ------------------------------------------------
 
+    /// A top-level binding exported by a LATER statement rather than at its
+    /// declaration: `export default NAME`, `export { NAME }`, `export { NAME as
+    /// default }`. The declaration's own `is_exported` (an `export_statement`
+    /// ancestor) cannot see these. One anchored regex over the file source.
+    /// Mirrors TreeSitterExtractor.isExportedLater.
+    pub(super) fn is_exported_later(&self, name: &str) -> bool {
+        if name.is_empty()
+            || !name.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$').unwrap_or(false)
+            || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
+        {
+            return false;
+        }
+        let n = regex::escape(name);
+        let pattern = format!(
+            r"(?m)^[ \t]*export\s+(?:default\s+{n}\s*;?[ \t]*$|\{{[^}}]*\b{n}\b[^}}]*\}})",
+            n = n
+        );
+        match regex::Regex::new(&pattern) {
+            Ok(re) => re.is_match(self.src),
+            Err(_) => false,
+        }
+    }
+
     pub(super) fn extract_variable(&mut self, node: Node<'t>) {
         let is_const = self.is_const_decl(node);
         let kind: &'static str = if is_const { "constant" } else { "variable" };
@@ -373,7 +396,12 @@ impl<'t> Walker<'t> {
             let has_inline_fns = object_of_fns
                 .map(|o| self.object_has_inline_functions(o))
                 .unwrap_or(false);
-            let extract_object_methods = is_exported && object_of_fns.is_some() && has_inline_fns;
+            // "Exported" includes the two-statement form `const useStore =
+            // create(…)` … `export default useStore` (is_exported_later), the
+            // shape most React Native stores are written in. Mirrors
+            // TreeSitterExtractor.isExportedLater.
+            let extract_object_methods =
+                (is_exported || self.is_exported_later(&name)) && object_of_fns.is_some() && has_inline_fns;
 
             let rtk_endpoints = match value {
                 Some(v) if v.kind() == "call_expression" => self.find_rtk_endpoints_object(v),

+ 6 - 1
codegraph-kernel/src/tsjs/fnref.rs

@@ -39,6 +39,11 @@ pub fn dispatch(kind: &str) -> Option<Mode> {
         "variable_declarator" => Some(Mode::VarInit),
         "pair" => Some(Mode::Value),
         "array" => Some(Mode::List),
+        // A JSX attribute value or child (`onPress={handleSubmit}`): the
+        // expression's one named child is the value. Mirrors TS_JS_SPEC.
+        "jsx_expression" => Some(Mode::List),
+        // An object literal's shorthand members (`return { handleApprove }`).
+        "object" => Some(Mode::List),
         _ => None,
     }
 }
@@ -117,7 +122,7 @@ pub fn capture(container: Node, mode: Mode, src: &str) -> Vec<(Candidate, Mode)>
 /// `this.<member>` member_expression special form (object EXACTLY `this`).
 fn normalize<'t>(node: Node<'t>, src: &str) -> Vec<(String, Node<'t>)> {
     match node.kind() {
-        "identifier" => vec![(src[node.byte_range()].to_string(), node)],
+        "identifier" | "shorthand_property_identifier" => vec![(src[node.byte_range()].to_string(), node)],
         "member_expression" => {
             let obj = node.child_by_field_name("object");
             let prop = node.child_by_field_name("property");

+ 46 - 1
codegraph-kernel/src/tsjs/mod.rs

@@ -704,13 +704,20 @@ impl<'t> Walker<'t> {
             self.extract_variable_type_annotation(node, owner);
         }
 
-        // Nested NAMED functions become their own nodes.
+        // Nested NAMED functions become their own nodes — and so does the
+        // function a React handler hook binds a name to (`const onPress =
+        // useCallback(() => {…}, [])`). Mirrors TreeSitterExtractor's
+        // reactHookBoundName.
         if is_function_type(kind) {
             let name = self.extract_name(node);
             if name != "<anonymous>" {
                 self.extract_function(node, None);
                 return;
             }
+            if let Some(bound) = self.react_hook_bound_name(node) {
+                self.extract_function(node, Some(bound));
+                return;
+            }
         }
 
         if is_class_type(self.variant, kind) {
@@ -735,6 +742,44 @@ impl<'t> Walker<'t> {
 
     // --- name / signature / modifier helpers ------------------------------------
 
+    /// The declarator name a React handler hook binds an anonymous function
+    /// to — `const NAME = useCallback(<node>, [...])` (also `React.useCallback`,
+    /// `useEffectEvent`, `useEvent`) — or None for any other shape. The node
+    /// must be the call's FIRST argument and the call's value must be bound
+    /// directly by a `variable_declarator`.
+    fn react_hook_bound_name(&self, node: Node<'t>) -> Option<String> {
+        if !matches!(node.kind(), "arrow_function" | "function_expression") {
+            return None;
+        }
+        let args = node.parent()?;
+        if args.kind() != "arguments" {
+            return None;
+        }
+        let first = args.named_child(0)?;
+        if first.start_byte() != node.start_byte() || first.end_byte() != node.end_byte() {
+            return None;
+        }
+        let call = args.parent()?;
+        if call.kind() != "call_expression" {
+            return None;
+        }
+        let callee = call.child_by_field_name("function")?;
+        let callee_text = self.text(callee);
+        let hook = callee_text.strip_prefix("React.").unwrap_or(callee_text);
+        if !matches!(hook, "useCallback" | "useEffectEvent" | "useEvent") {
+            return None;
+        }
+        let declarator = call.parent()?;
+        if declarator.kind() != "variable_declarator" {
+            return None;
+        }
+        let name_node = declarator.child_by_field_name("name")?;
+        if name_node.kind() != "identifier" {
+            return None;
+        }
+        Some(self.text(name_node).to_string())
+    }
+
     /// extractName / extractNameRaw for the TS/JS configs.
     fn extract_name(&self, node: Node) -> String {
         // javascriptExtractor.resolveName: field_definition names its key the

+ 44 - 1
docs/design/callback-edge-synthesis.md

@@ -38,7 +38,7 @@ We synthesize `dispatcher → callback` edges that static parsing misses. It wor
 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}):
+# synthesized edges (provenance='heuristic', metadata.synthesizedBy in {callback,event-emitter,…,http-client,queue-job,event-bus}):
 sqlite3 /tmp/codegraph-corpus/excalidraw/.codegraph/codegraph.db \
   "select s.name||' → '||t.name||'  '||coalesce(e.metadata,'') from edges e \
    join nodes s on e.source=s.id join nodes t on e.target=t.id where e.provenance='heuristic';"
@@ -51,6 +51,49 @@ fixture lives at `/tmp/cb-fixture/bus.js` (ephemeral — recreate or move into `
 
 ---
 
+## Cross-tier channels (`src/resolution/tier-synthesizer.ts`, 2026-08-28)
+
+The web's RN bridge: one pass, registered before the in-process emitter pass (the more specific edge wins a duplicate
+`source>target` pair in the merge), gated on JS-family files, never sourced from a test suite or a generated file.
+Three channels, each keyed on a literal on both sides, each edge `kind:'calls'`, `provenance:'heuristic'`, with
+`synthesizedBy`, `channel` (`http` | `queue` | `event` | `socket`), `tier` (`client→server` / `server→client`) when the
+direction is known, the `event` / `queue` / `method` / `href` it paired on, `line` + `column` of the call, and
+`registeredAt` = the other side (route registration, decorator, `.on`):
+
+- **`http-client`** — `fetch` / `$fetch` / `ofetch` / `axios` / `ky` / `got` / `useFetch` / `useSWR`, `<client>.get|post|…(`
+  where the receiver is a known client name or a binding made by `axios.create(…)` / `ky.extend(…)` (same file or the file it
+  is imported from — `resolveImportPath`, since import mappings carry no resolved path), with a literal / template first
+  argument (`new URL('/x', base)` and `{ url, method }` configs read too) → the ONE route `METHOD path` in the index it
+  denotes. A hole fills a `:param` / `{id}` / `[id]` / catch-all segment and never a literal one; a hole in front of the path
+  (`${API_URL}/users`) matches by the route's tail; a line a framework resolver made a route node on is a registration, not a
+  client call; a tie between routes is nothing. No fan-out cap — the match is exact.
+- **`queue-job`** — `<queue>.add('job', …)` where the queue is named (`new Queue('email')`, `@InjectQueue('email') x`, in the
+  file or its import) or queue-shaped → the `@Process('job')` method of the `@Processor('email')` class (a WorkerHost's
+  `process` when there is none), `new Worker('email', handler)` (an inline handler → the enclosing function, else the
+  enclosing constant), Bull's `queue.process('job', handler)`. Most specific pairing wins (queue+job > job > the queue's
+  default); an unnamed queue pairs only on a unique job name. Fan-out cap 6.
+- **`event-bus`** — `.emit|emitAsync('x')` on a bus-shaped receiver (`eventEmitter`, `bus`, `pubsub`, …) → `@OnEvent`
+  handlers, `*` / `**` globs honoured; on a socket-shaped receiver (`socket`, `io`, `server`, `client`, `.to(room)`, …) from a
+  file without a socket server → `@SubscribeMessage('x')` and server-side `socket.on('x')` (`client→server`); from a file
+  with one (`@WebSocketGateway`, `io.on('connection')`, `new Server`) → client-side `socket.on('x', …)`, named or inline
+  (→ the enclosing component) (`server→client`). Plain `.on` ↔ `.emit` stays the emitter pass's. Fan-out cap 6.
+
+The Steps view (`ui-server/api/steps.ts`) reads `tier` / `channel` before the languages in `crossing()`, so a hop between
+two TS files draws as a bridge (`⇢ POST /api/users`, a boundary like another screen) or an event (`⇠ welcome`); explore's
+Flow section labels them (`context/index.ts`, `mcp/tools.ts`). A Next `'use server'` action needs no edge: `steps.ts` marks
+the call at request time from the directive. Validated on `bradtraversy/proshop_mern` (30 routes, 23 client→route edges,
+all correct on inspection, after Express mounts + chained `router.route()` landed) and `nestjs/nest` (`sample/26-queues`,
+`sample/30-event-emitter`); test `__tests__/ui-steps-cross-tier.test.ts`.
+
+## Next.js links (`src/resolution/next-router-synthesizer.ts`, 2026-08-28)
+
+`<Link href="/x">`, `<Link href={`/users/${id}`}>`, `<Link href={{ pathname }}>` and an internal `<a href>` are JSX
+attributes — no reference is ever extracted for them — so this pass reads them from the source, attributes each to the
+component it is written in, matches the href against the Next page table (`frameworks/nextjs.ts`) and synthesizes a
+`navigates` edge (`synthesizedBy:'next-link'`, `href`, `navMethod: 'link' | 'a'`, `registeredAt` = the JSX site). Only files
+under a Next app's root, never test files; ≤ 24 links per component (a navigation menu is not a decision); an external `<a>`
+is nothing. The Screens view walks back from these edges exactly as from `router.push`; they draw dashed.
+
 ## The hole
 
 ```ts

+ 95 - 0
docs/design/cg57-highlighting-parity.md

@@ -0,0 +1,95 @@
+# Highlighting parity: Shiki → the engine's own tree-sitter parse (CG-57)
+
+The viewer's code block used to be classified by a second highlighter — Shiki with 56 pruned
+TextMate grammars shipped in `dist/textmate/` — over source the engine had already parsed with a
+real grammar. CG-57 takes the classification off that tree instead. This file records what the swap
+changed, measured rather than asserted, so nobody has to re-derive it from a diff.
+
+Screenshots, one per language, before on the left and after on the right, same stylesheet:
+[`cg57-highlighting-parity/`](./cg57-highlighting-parity/) — `typescript.png`, `go.png`,
+`python.png`, `rust.png`, `swift.png`, `csharp.png`, `ruby.png`, `php.png`.
+
+## What it costs
+
+3 000 lines, cold, dev Mac (M-series), parse + classify + wire:
+
+| | TypeScript | Go | Python | Rust | Swift | C# | Ruby | PHP |
+|---|---|---|---|---|---|---|---|---|
+| Shiki + TextMate | ~700 ms | 43–57 ms | 35–47 ms | — | — | — | — | — |
+| Engine tree-sitter | 24–41 ms | ~30 ms | 25–29 ms | 18–19 ms | 25–27 ms | 20–25 ms | 14–16 ms | 20–22 ms |
+
+The task's budget was **< 100 ms per 3 000-line file warm**; every language clears it *cold*.
+TypeScript is the number that mattered: its TextMate grammar was 5–7× every other one and the cost
+was regex *execution*, not compilation, so nothing about the old module could have fixed it. The
+slice cache still exists — a re-render (resize, theme flip, stepping back through the trail) should
+cost nothing at all, and the whole-file view pages the same file repeatedly.
+
+## What it changes on screen
+
+Per-character comparison over ~40 lines of realistic source per language, counting only
+non-whitespace characters, and treating `ident` / `other` / `type` as one bucket because all three
+paint at plain ink:
+
+| language | painted identically | what moved |
+|---|---|---|
+| TypeScript | 91.3% | 33 `def`, 40 interpolation chars now code, 2 punctuation |
+| Go | 91.5% | 37 built-in type words, 13 `def` |
+| Python | 93.2% | 26 `def`, 15 keyword (`is not`, `__future__`) |
+| Rust | 96.3% | 21 `def`, 3 keyword |
+| Swift | 93.3% | 18 `def`, 14 keyword (`throws`/`rethrows`) |
+| C# | 88.3% | 30 built-in type words, 23 `def`, 31 interpolation chars now code |
+| Ruby | 83.8% | 23 `def`, 35 interpolation chars now code, 14 symbol literals, 3 keyword |
+| PHP | 85.9% | 33 built-in type words, 29 `def`, 15 phpdoc tag chars, 12 keyword |
+
+Every remaining difference is one of five deliberate categories:
+
+1. **`ident` → `def`.** The definition's own name now carries weight 600, everywhere rather than
+   only on the line the Symbol view opened at. It comes from the extractors' own definition tables
+   (`functionTypes`, `classTypes`, `methodTypes`, …) plus each language's `nameField`, so it cannot
+   drift from what indexing considers a definition.
+2. **`string` → code, inside an interpolation.** A template literal's `${…}`, an f-string's `{…}`,
+   Ruby's `#{…}` and C#'s `$"{…}"` are classified as code. This is the one difference that is not
+   cosmetic: the call-site overlay deliberately refuses to claim a token classed `string`, so
+   **calls inside interpolated strings now link and did not before.**
+3. **`keyword` → `type`, on built-in type words.** `string`, `int`, `u32`, `void`. The grammars
+   disagree with each other about what a built-in type is — tree-sitter-go calls `string` a
+   `type_identifier`, tree-sitter-typescript wraps it in a `predefined_type` whose child is an
+   anonymous token spelled `string` — and TextMate scoped them inconsistently too (plain in
+   TypeScript, `storage.type` in Go). They now all paint at plain ink, like a user-defined type
+   name, in every language.
+4. **Keyword-set corrections.** Python's `is not`, Rust's and Swift's modifiers, and Ruby's `new`
+   (which is a method, not a keyword — TextMate's `keyword.operator.new` matched it anyway).
+5. **`keyword` → `comment`, on phpdoc tags.** `@var` and friends recede with the comment they are
+   in, which is what the near-monochrome ramp asks for.
+
+## What is no longer highlighted
+
+Nine formats have extraction but no tree-sitter grammar. Three of them — `.svelte`, `.vue`,
+`.astro` — are classified through their `<script>` blocks with TypeScript or JavaScript, the same
+delegation the extractors do, so every symbol the engine indexed in those files is highlighted and
+the surrounding markup is not. The other six (Liquid, Razor, YAML, Twig, XML, `.properties`) render
+plain, where Shiki had grammars for them.
+
+That is a real, deliberate loss, and it is the alternative to a worse one. `tree-sitter-wasms`
+ships an `html` grammar that would cover most of them, but the ABI-13 builds in that package are
+the known cause of a shared-WASM-heap corruption that silently drops edges for *every other*
+language in the same process (see `VENDORED_WASM_LANGS` in `src/extraction/grammars.ts`), and the
+viewer runs in a process someone leaves open all day. Adding unvetted grammars to buy tag colouring
+on config files is not a trade worth making. Identifiers are still split out on those files, so the
+graph's call-site links land exactly as they do everywhere else — highlighting is the part that
+degrades, never the linking.
+
+## Reproducing this
+
+There is no committed harness: the "before" side needs the deleted Shiki module. Rebuild it from
+the last commit that had it —
+
+```
+git worktree add /tmp/cg48-baseline <ref-with-shiki>
+ln -s "$PWD/node_modules" /tmp/cg48-baseline/node_modules   # @shikijs/* must still be installed
+( cd /tmp/cg48-baseline && npx tsc && node scripts/prune-grammars.mjs )
+```
+
+— then run both `dist/ui-server/highlight/index.js` modules over the same lines and compare
+`classes[id]` per character. The screenshots were rendered from the same two token streams through
+the viewer's own token CSS at `--force-device-scale-factor=2`.

BIN
docs/design/cg57-highlighting-parity/csharp.png


BIN
docs/design/cg57-highlighting-parity/go.png


BIN
docs/design/cg57-highlighting-parity/php.png


BIN
docs/design/cg57-highlighting-parity/python.png


BIN
docs/design/cg57-highlighting-parity/ruby.png


BIN
docs/design/cg57-highlighting-parity/rust.png


BIN
docs/design/cg57-highlighting-parity/swift.png


BIN
docs/design/cg57-highlighting-parity/typescript.png


+ 1141 - 0
docs/design/codegraph-ui-design-spec.md

@@ -0,0 +1,1141 @@
+# codegraph ui — design specification
+
+Authoritative visual + interaction spec for the `codegraph ui` viewer (Kommandr epics CG-39 → CG-48 → CG-56;
+Pro layers in docker-app DOCKERAPP-10). Companion to the design proposal ("Reading the graph") and the
+interactive prototype; the prototype's stylesheet is appended verbatim at the end and is the source of truth
+for every measurement below. Screenshots: `CodeGraph/codegraph-web-prototype/screenshots/` (also attached to
+the Kommandr epics).
+
+Design proposal: https://claude.ai/code/artifact/58336c87-9780-4018-8c04-37fe53236e96
+Prototype: https://claude.ai/code/artifact/304bffb6-72d6-49c7-8f3a-9e4f244909f8
+Prototype sources: `CodeGraph/codegraph-web-prototype/` (`proto.css`, `proto.js`, `extract.mjs`, `build.mjs`)
+
+## 1. Principles (non-negotiable)
+
+1. One symbol at a time — no whole-graph picture, no node-link neighborhood graph (decided).
+2. Code order is the coordinate system — layouts by source line or dependency layer; deterministic; never force-directed.
+3. Edges grow out of the code — every call edge is drawn from the line that makes the call (gutter port → callee row at that height).
+4. Direction is spatial — callers left, callees right, flows read left→right, map dependencies point down.
+5. Collapse the tails, show the counts — hubs badge (fan-in ≥ 40), tests fold, confidence < 0.6 folds ("uncertain"), outside-index counts; nothing silently dropped.
+6. Honesty in the pixels — confidence = line style; heuristic (synthesized) edges dashed + wiring site; boundaries announced; drift banners; "no test within 3 hops" badge.
+
+## 2. Visual language
+
+The engine's paper/ink editorial system (`site/src/styles/theme.css`): flat, hairline rules, **square corners everywhere**
+(`border-radius: 0 !important` globally), no shadows, no gradients, sentence case, **no tiny all-caps tracked labels**,
+one oxblood accent used only for focus/selection/edges, one amber used only for the "untested" warning.
+Syntax highlighting is deliberately near-monochrome so the graph's edges are the only colour in the code.
+
+### 2.1 Color tokens
+
+| token | light | dark | used for |
+|---|---|---|---|
+| `--paper` | `#f7f6f2` | `#16150f` | page/body background (always set explicitly) |
+| `--paper-2` | `#f1efe8` | `#1c1a14` | trail bar, inputs, hovered code line, figure grounds |
+| `--press` | `#e8e6dd` | `#23211a` | hover fills, inline code background, bars |
+| `--press-2` | `#dedbd0` | `#2c2a22` | reserved (pressed state) |
+| `--ink` | `#16150f` | `#f3f1ea` | primary text, node borders, major rules |
+| `--ink-2` | `#56544a` | `#b8b5a8` | secondary text, strings, callers' names when uncertain |
+| `--ink-3` | `#87847a` | `#87847a` | tertiary text, comments, glyph borders, edge labels |
+| `--ink-4` | `#b4b1a5` | `#5d5b52` | line numbers, resting connectors, dimmed map nodes |
+| `--rule` | `#16150f` | `#f3f1ea` | top bar bottom rule, code/blast section rules |
+| `--rule-soft` | `#d6d3c8` | `#34322a` | rail dividers, chips, card borders |
+| `--rule-faint` | `#e6e3d9` | `#26241d` | row separators, map layer lines |
+| `--accent` | `#7a2230` | `#d48b96` | oxblood: call-site links, current trail hop, hot connectors, selected map edges |
+| `--accent-ink` | `#5e1a25` | `#e5a5ae` | accent text on accent-soft |
+| `--accent-soft` | `#f0e3e5` | `#33201f` | tinted rows ("you came from here"), hot code lines |
+| `--accent-line` | `#d9b3b9` | `#6b3a42` | accent borders/underlines at rest |
+| `--amber` | `#8a5a0b` | `#d9a94a` | "No test reaches this within 3 caller hops" badge only |
+| `--amber-soft` | `#f3e9d2` | `#2e2716` | that badge's fill |
+
+Theme selection: define the light set on bare `:root`; redefine under `@media (prefers-color-scheme: dark)` guarded as
+`:root:not([data-theme="light"])`; redefine again under `:root[data-theme="dark"]`. Never define a colour only inside a
+media/`[data-theme]` block. `body { background: var(--paper); color: var(--ink) }`.
+
+### 2.2 Type
+
+- UI: **Archivo** 400/500/600/700 (fallback `-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif`).
+- Code, symbol names, file paths, chips, trail, map labels: **IBM Plex Mono** 400/500/600 (+ italic 400)
+  (fallback `ui-monospace, 'SF Mono', Menlo, Consolas, monospace`).
+- Scale: body UI `13px/1.45`; code `12.5px/20px`; symbol title `600 20px/1.2` mono, letter-spacing −0.01em;
+  section labels (`Called by`, `Calls`, `Blast radius`) `600 13px` sans; rail rows `12.5px` mono name + `11px` sans meta;
+  chips `11px` mono; line numbers `11px` mono in `--ink-4`; badges `11.5px`; map node label `13px` mono, count `11px`;
+  flow card name `600 13px` mono, window `12px/19px` mono; trail `12px` mono. Headings sentence case, `text-wrap: balance`.
+- Code token classes: comment `--code-comment`; string `--ink-2`; keyword weight 500 (same ink); number `--ink-2`; definition
+  name on its own line weight 600; **call-site link** = `--accent`, underline `--accent-line`, offset 3px, hover/hot fill
+  `--accent-soft`; uncertain link = `--ink-2`, dotted underline `--ink-4`; link to a symbol outside the index = `--ink-2`,
+  underline `--rule-soft`, not clickable.
+  - *As built (CG-43) — comments are `--code-comment`, not `--ink-3`.* `--ink-3` measures 3.46:1 on `--paper` and 3.00:1 on
+    the hot-line tint `--accent-soft`, both under the 4.5:1 that 12.5px body text needs. `--code-comment` is the smallest
+    step along the same warm-grey ramp that clears 4.5:1 on every background a code line can have (`#6a675d` light —
+    paper 5.23, paper-2 4.92, accent-soft 4.53; `#8e8b81` dark — 5.36 / 5.10 / 4.51) while staying quieter than the
+    `--ink-2` strings and numbers use, so the recession order above is unchanged. Everything else in this list passes as
+    specified: ink 16.9/16.2, ink-2 7.03/8.89, accent 9.25/6.91 (8.02/5.80 on `--accent-soft`).
+  - *Line numbers remain `--ink-4` (1.99:1 light, 2.69:1 dark) — a known contrast gap, left as specified rather than
+    changed inside a rendering task. Worth a design call before phase 2.*
+
+### 2.3 Kind glyphs
+
+16×16 hollow square, 1px `--ink-3` border, letter in `500 9.5px` mono: `ƒ` function · `m` method · `C` class · `I`
+interface · `S` struct · `T` type alias · `E` enum · `e` enum member · `k` constant · `v` variable · `p` property/field ·
+`≡` file (dashed border) · `R` route · `⟨⟩` component · `N` namespace · `M` module · `Tr` trait · `U` union · `P` protocol.
+Container/type kinds get a `--press` fill.
+
+## 3. Layout and components
+
+### 3.1 App shell
+- Grid rows: **top bar 48px** / **trail bar 34px** / main. Top bar: brand (10px hollow square mark + "CodeGraph" 600 14px +
+  "ui" in `--ink-3`), view tabs (`Map · Symbol · Flow`, 5px 10px padding, active = 2px `--ink` bottom border), search input
+  (30px tall, `--paper-2` fill, `--rule-soft` border → `--ink` on focus, max-width 720px), project stats in `--ink-2` 12px.
+  Bottom rule of the top bar is `--rule` (1px); the trail bar's is `--rule-soft`.
+- Focus ring everywhere: `outline: 2px solid var(--accent); outline-offset: 1px`. `prefers-reduced-motion` disables transitions.
+
+### 3.2 Symbol view (`#/s/<id>?t=<trail>&hl=<line>`)
+- Grid: **left rail 300px** | stage `minmax(520px, 1fr)`; inside the stage: **center `minmax(480px, 1fr)`** | **right rail 320px**.
+  Left rail has its own scroll; center + right rail scroll together in the stage (so callee rows stay aligned to lines).
+  ≤ 1100px: 240px | `minmax(360px,1fr)` | 260px.
+- Rail headers sticky, `12px 14px 8px` padding, 600 13px, count in `--ink-3`, hint text right-aligned `11.5px` (`← step up`, `step down →`).
+- **Center**: padding `18px 22px 40px`. Header row: glyph, name (h1), kind word (`--ink-3` 12.5px, "· async · static · private"),
+  location `file:start–end · N lines` (11.5px mono, file is a link). "in ClassName" breadcrumb 11.5px mono `--ink-3`.
+  Badges row (gap 6px): `exported` · `hub · N callers` (border `--ink`) · tests badge (`Reached by tests · N files within 3 hops`,
+  hollow 8px swatch) or amber warning (filled swatch). Signature 12px mono `--ink-2`, docstring 12.5px `--ink-2` max 70ch,
+  relations row of chips (`extends X`, `implemented by …`, `uses types …` — chips 11.5px mono, `--rule-soft` border, 1px 6px).
+- **Code block**: 1px `--rule` top border + 6px; each line is a grid `44px | 1fr | 18px` (line number right-aligned, 12px
+  right padding; text `white-space: pre`; port cell). Hover line → `--paper-2`; hot/highlighted line → `--accent-soft`.
+  **Port**: 6×6 square, 1px `--ink-3` border, positioned right 4px / top 7px; filled `--ink-3` when the line has a
+  resolved (≥ 0.6) edge, hollow when only uncertain; accent fill+border when hot. Gap rows ("⋯ N lines without calls"):
+  11px `--ink-4`, dashed `--rule-soft` top/bottom, 2px margin, indented 44px. Long bodies: head 80 lines + ±4-line windows
+  around every call site; bodies ≤ 260 lines shown whole; containers show the outline instead of a body > 80 lines.
+- **Right rail rows** (`.rrow`): absolutely positioned, `left 14px right 12px`, **height 34px**, grid `16px | 1fr` gap 8px,
+  padding `0 6px`, 1px transparent border (→ `--ink` when keyboard-selected; `--accent-line` + `--accent-soft` when hot/origin).
+  Desired y = center of first call-site line − 17px; place in line order with `y = max(desired, prevY + 34 + 6)`;
+  the stage's min-height grows to fit. Name 12.5px mono (`×N` in `--ink-3` when called from N lines); meta 11px `--ink-3`:
+  file (or "same file"), edge word (`creates`, `passes as value`), tags (`hub · N`, `outside index`, `via <synthesizedBy>`)
+  as 10.5px bordered pills. Uncertain targets fold into a `<details>` ("+ Uncertain · N name-only matches, confidence < 0.6")
+  placed 8px below the last row; "+N more calls into symbols outside the index" note 11.5px.
+- **Connectors** (SVG overlay covering the stage content): one cubic Bézier per call line → row:
+  `M x0,ly C cx,ly cx,ry x1,ry` with `x0 = center right edge − 10`, `x1 = rail left + 14`, `cx = (x0+x1)/2`.
+  Resting: `--ink-4` 1px; hot: `--accent` 1.5px; uncertain: dasharray `2 3`; heuristic: dasharray `6 3` in `--ink-3`;
+  origin (the edge you arrived by): `--accent`. Left rail draws no connectors (separate scroll container); the origin
+  caller row is tinted instead. (Real build: consider converging left connectors into the header — open question.)
+- **Left rail**: file groups (`.filegroup` padding `10px 14px 4px`; path 11px mono `--ink-3`, count bold `--ink-2`; the
+  focus's own file first as "same file"); rows grid `16px | 1fr`, padding `5px 6px 5px 4px`, name 12.5px mono, meta row
+  with edge-kind label + call-site chips (`:4657`, 11px mono, `--rule-soft` border, 0 4px; click = open caller at that line).
+  Folds: `Tests · N calls from M files` (lists files), `Uncertain · N`. Origin row: `--accent-soft` fill + `--accent-line` border
+  + "you came from here". Empty state note 11.5px `--ink-3`.
+- **Blast radius strip**: 22px above, 1px `--rule` top border, 10px padding-top; "Blast radius" 600 + stats
+  (`<strong>N</strong> direct dependents · within 3 hops · files · test files · routes`, tabular-nums); bar 6px tall,
+  max-width 420px, `--press` track, light fill `--ink-2` = within-3 share, dark fill `--ink` = direct share, both scaled to the
+  widest radius in the index; legend 11.5px; `<details>` "What would need re-checking if this changed" listing dependents by file.
+- **Members outline** (classes, interfaces, structs, enums, files): rows grid `16px | minmax(160px,auto) | 1fr | auto`,
+  padding `6px 4px`, `--rule-faint` separators, name 12.5px mono, signature 11.5px mono `--ink-3` ellipsised,
+  counts `← in  → out` 11px mono tabular; nested members indented 22px; properties/enum members dimmed.
+- **Keyboard**: `/` or ⌘K search · ↑/↓ (or j/k) move in the active rail · ←/→ switch rail · Enter follow · Backspace or `[` back ·
+  `m` map · `f` flow · Esc back to Symbol view. Selection = 1px `--ink` border on the row, scrolled into view.
+
+### 3.3 Trail bar
+34px, `--paper-2`, mono 12px. `Trail` label in `--ink-3` sans; hops as buttons (glyph + name, padding 4px 8px) separated by
+`→` (stepped into a call) or `←` (stepped up to a caller) in `--ink-3`; current hop: `--accent` text, `--accent-line` border,
+`--paper` fill; hover `--press`. Right side: `Read as flow`, `Clear` (sans 4px 8px, `--rule-soft` border). Empty hint in `--ink-3`.
+
+### 3.4 File view (`#/file/<path>`)
+Grid **300px | minmax(480px,1fr) | 300px**: Imported by · outline (source order, nested, counts, `line` number right) · Imports.
+File rows 12px mono, 5px 14px padding, `--rule-faint` separators; files outside the index in `--ink-3`, not clickable.
+Header: file glyph, basename as h1, `lang · KB · N symbols · generated`, full path.
+
+**As built (phase 1, CG-46).** The two rails count **dependencies**, not import statements —
+`getFileDependencies` / `getFileDependents`, every cross-file edge except `contains`. The prototype
+drew `imports` edges alone, and on this repo that understates the answer: `src/graph/traversal.ts`
+imports two files and depends on four (it reaches `src/resolution/lru-cache.ts` through a call no
+import names). The import rows are still merged in — they carry the symbol NAMES, shown as a count
+on the row and in full in its tooltip. Rows sort production-first then alphabetically, tests last.
+Imports that resolved to nothing indexed are listed under **Outside the index**, in `--ink-3` and
+not clickable, so a file importing `react` and `fs` does not read as having one dependency.
+The header's `N symbols` is the OUTLINE's total, not the file record's node count (which includes
+the file node and its import declarations). A file that runs code at its top level — an edge out of
+the file node — carries a badge ("Runs N calls at the top level — see what it calls") that focuses
+the file node, the only place that code can be read. Outline rows are a fixed 28px and the list is
+windowed above 250 rows (this repo's own fixtures hold a 1,681-symbol `.d.ts`); the two constants
+live together in `ui/src/lib/file-model.ts`. Keyboard: ↑/↓ within a pane, ←/→ across the three
+panes, Enter follows; `?hl=<line>` selects the DEEPEST outline row whose range holds the line.
+
+**Whole-file source, as built (phase 2, CG-52).** `?src=1` on the same route. Four columns inside
+one scroller: sticky outline rail (240px, only at ≥ 1400px) | arcs 56px | source | callee rail 320px.
+The line grid, the 6x6 ports and the accent call-site links are the Symbol view's, unchanged — what
+differs is that **line positions are arithmetic, not measured**: every line is exactly 20px and sits
+at `10 + (n - 1) x 20`, so a 6 820-line file renders ~90 line elements and the arcs, ports, rail
+rows and connectors are all functions of a line number. `ui/src/lib/filecode-model.ts` holds the
+constant; `FileCodeBlock.svelte`'s CSS holds the other half of it, and they must move together.
+Source pages in 800 lines at a time from `/api/source`, each request reaching back 150 lines that are
+then discarded so a page starting inside a block comment does not render prose as code; a line whose
+page has not arrived still shows its number, its port and its place. Callee-rail rows are one per
+(CALLING symbol, called symbol) PAIR rather than one per callee — a row is anchored to a line and a
+helper called from two functions a thousand lines apart has no line that is both — and uncertain rows
+stay in place with their dotted underline rather than folding, because a fold has nowhere to sit on
+this screen. Arcs are half-ellipses bulging left, both ends on the arc column's right edge, depth a
+log function of the arc's own SPAN (so short arcs sit innermost and filtering never moves a survivor
+sideways); `--ink-4` 1px at rest, `--accent` 1.5px when the call line or the callee is under the
+pointer — never as a consequence of the crowding filter. Above 40 arcs only the focused symbol's are
+drawn (hovered symbol, else the symbol the scroll position is inside) and the header states the
+total. Clicking an arc scrolls to the callee's definition and marks it. Data: `GET /api/filecode/<path>`.
+
+### 3.5 Flow strip (`#/flow/<key>`)
+Header: "Flow" + a `<select>` of flows (`--paper-2`, `--rule-soft` border, 12.5px sans) + a 78ch note.
+Cards **380px** wide, `--rule-soft` border (`--ink` on hover, `--accent` when current), header grid `16px | 1fr` padding `10px 12px 6px`
+(name 600 13px mono, `file:line` 11px `--ink-3`), separator `--rule-faint`, source window `12px/19px` mono with line numbers
+(grid `40px | 1fr | 6px`), the call line tinted `--accent-soft` and the calling identifier as an accent link; ±3 lines around the call.
+Links between cards: **86px** wide; a 1px `--ink-3` line with a filled arrowhead (polygon `76,3 84,7 76,11` in a 86×14 box);
+label 11px mono `--ink-3` centred (`calls`, `line 2029`; `via callback · registered at file:line`); uncertain dasharray `2 3`;
+heuristic dasharray `5 3`. End cap: **240px**, dashed `--rule-soft` border, 12px text — "Where the graph stops" + the boundary
+(form, key, line) + uncertain continuations. In the real build the strip is a Svelte Flow canvas laid out left→right with the
+same card/link visuals.
+
+**End cap, as built (phase 2, CG-51).** Shown only when a flow does not reach everything the question named —
+a connected answer has no boundary to announce. 240px, 1px dashed `--rule-soft`, padding 12px, 12px/1.45 `--ink-2`,
+joined to the card it hangs off by an 86px `2 4` dotted link labelled "end of static path" with **no arrowhead**
+(an arrow would point at a continuation). Content: "**Where the graph stops.**" then, per dispatch site, the form and
+its line ("computed member call at line 61"), the static key in 11.5px mono when one is visible, "the key is a runtime
+value" when not, "N candidate targets ›" over clickable mono rows (`display` + `basename:line`, an already-named symbol
+first), then the name-only continuations under 0.6 as mono rows with their confidence and a dotted `--ink-4` underline,
+then the count of further resolved calls and the symbols never reached. Its height is arithmetic like a card's
+(`endCapText` builds the strings, `endCapHeight` measures them, the component renders exactly those), and the card it
+hangs off opens at the dispatch line and tints it `--accent-soft`. One cap per stopping symbol, not per flow.
+The verdict comes from `src/graph/dynamic-boundary-report.ts` — the detector `codegraph_explore` announces boundaries
+with — so the strip and the MCP answer cannot disagree.
+
+### 3.6 Map (`#/map`)
+Grid: canvas `minmax(600px,1fr)` | side panel **320px** (`--rule-soft` left border, 14px 16px padding).
+Nodes: rect `width = max(110, label.length × 7.3 + 28)`, **height 40**, `--paper` fill, 1px `--ink` stroke (2px + `--press` fill
+when hovered/selected; `--ink-4` when dimmed; test modules dashed `4 3` in `--ink-3`), label 13px mono at (10,17), count
+"N symbols · M files" 11px `--ink-3` at (10,32). Layers: vertical gap **74px**, horizontal gap **34px**, padding 44px; entry points at the
+top ("entry points" label), foundations at the bottom ("foundations — depend on nothing below"); faint layer lines `--rule-faint`.
+Layout: aggregate edges by module; break 2-cycles keeping the heavier direction; longest-path layering (a module sits one layer
+above everything it depends on); barycenter ordering, 3 sweeps; single-node layers centred; ports spread along each box
+(`x = left + width × (i+1)/(n+1)` over the node's sorted out/in edges) so bundles fan. Edges: cubic `M x0,y0 C x0,my x1,my x1,y1`
+(`my` = midpoint), `stroke-width = min(6, 1 + log2(count) × 0.7)`, `--ink` at opacity 0.28 (hot 0.95, dimmed 0.06); a 12px transparent
+hit path per edge; edges with count < 4 (< 6 when tests included) hidden until a touching module is selected; cycle back-edges only when
+selected, `--accent` opacity 0.6, dasharray `4 3`. Tooltip: `--paper`, 1px `--ink` border, 8px 10px, 12px: "src/a → src/b", "N edges",
+by kind, top 4 symbol pairs. Side panel: title, 2-sentence explanation, hidden-edge note, "Include tests, scripts, kernel & site" checkbox,
+"Mutual dependencies" fold, selected module's dependencies/dependents with counts and its files. Fit: SVG width 100%,
+`viewBox` to content, `height: max(100%, 0.9 × content)` so labels never scale below ~0.9. In the real build this is a Svelte Flow
+canvas (custom node + custom edge components; hidden handles as ports; pan/zoom/fitView) with the same geometry.
+
+### 3.7 Search palette
+Results panel under the input: 1px `--ink` border, max-height 420px; group headers 12px `--ink-3` (`Flow`, `Symbols & files`);
+rows grid `18px | 1fr | auto`, 6px 10px, `--rule-faint` separators, selected/hover `--press`; name 12.5px mono + signature 11.5px mono
+`--ink-3` + location 11px mono. Flow grammar: "how does X reach Y", "X -> Y", "X → Y".
+
+**As built (phase 1, CG-45).** Group headers are the result's KIND — `Methods`, `Functions`,
+`Classes`, `Files` — a group appearing where its best result did, so flattening the groups
+reproduces the ranking ↑/↓ walks. The prototype's two-group split (`Flow` / `Symbols & files`)
+waits for the Flow view: a flow question is recognised now, but until there is a path to draw it
+searches both endpoints and says so in one line above the results rather than offering a row that
+lands on a placeholder. A file's row shows its basename with its DIRECTORY in the location column —
+its name column already carries the path, and printing it twice reads as an error.
+
+At rest — an empty box, or the empty screen — the panel shows **entry points** from
+`/api/entrypoints`: routes (URL → handler), files that run something at module level (a CLI, a
+worker entry, a script — ranked by calls × the number of other files they reach), tests (ranked by
+how many other files each reaches), and the most depended-on symbols. Each section says what it is
+derived from, never that a file IS the entry point.
+
+**Entry points as a screen (CG-54, `#/entry`).** The same payload at full length, drawn with the
+caller rail's file-group + row shapes (`.filegroup` padding `10px 14px 4px`, path 11px mono
+`--ink-3` with the count in `--ink-2`; rows grid `16px | 1fr`, name 12.5px mono, meta 11px
+`--ink-3`), section headings 600 15px sentence-case with the count — and the detected framework —
+as 11.5px `--ink-3` meta beside them. Sections: **Routes** (verb ahead of the URL in the same
+mono at weight 500, handler + `file:line` in the meta, grouped by the file the URL is REGISTERED
+in), **Top-level files with calls**, **Tests**, **Most depended on**. A section whose list was cut
+prints "Showing N of \[at least] M"; "at least" is the honest reading wherever the server's count
+is a floor.
+
+A row that names a callable symbol carries a `Flow ›` chip (11px mono, `--rule-soft` border) that
+arms a flow from it; the panel then shows an `--accent-soft` bar with the name, an input, and
+`Draw the flow`, while every other armed-eligible row's chip becomes `→ here`. File and test rows
+carry no chip — `/api/flow` searches by NAME, and a file has none the path finder can look up.
+A project with fewer than three resolvable routes gets **no Routes heading at all**, not an empty
+one.
+
+In the search palette, entry points that mention the query appear **last**, under their own
+`Entry points` heading (12px `--ink-3`, like every other group): they are context on rows the
+search above may already have found, and a route row here names its HANDLER, which a `/api/search`
+hit on the same URL cannot. Rows whose target is already in the results are dropped.
+
+### 3.8 Drift banner and live refresh (CG-53)
+Drift banner: full-width block above the code, `--paper-2` fill, 1px `--rule-soft` border, padding `8px 12px`, 12.5px `--ink-2`, leading
+"⚠" glyph in `--ink-3`. **Never amber** — amber is the untested badge's colour and nothing else's — and never a modal.
+Toast: `--ink` fill, `--paper` text, 12.5px, `8px 14px`, bottom-centre, 2.6 s, one at a time.
+
+**As built.** The endpoint is **`/api/events`**, not `/events`: everything under `/api/` answers JSON for every outcome and is
+excluded from the SPA fallback, so a stream mounted outside that namespace would have come back as the app shell on a typo and as
+`text/plain` on a refusal. It carries four event types — `hello` (the index revision the client is synchronised against, and which of
+the two watchers came up), `changed` (source files on disk, before any sync), `index` (the graph moved, naming what the sync
+re-indexed) and `degraded` — plus a `: ping` comment frame every 25 s. The server WATCHES and never syncs: the project tree through
+the engine's own `FileWatcher` with a notify-only `syncFn`, the index through one non-recursive `fs.watch` on the data directory
+settled at 400 ms (capped at 3 s). Both start with the first subscriber and stop with the last.
+
+Three banner variants, because what follows the dash is what the screen actually did:
+- **Symbol view** — "indexed line ranges may be shifted; showing the file's current source. The next sync picks it up." The whole
+  CURRENT file replaces the body (parity with `codegraph_node` on a drifted file, issue #1474) and every line-anchored marking goes
+  with the old numbering: gutter ports, call-site links, the definition-name weight, the `?hl=` highlight, and the callee rail's
+  anchoring — its rows stack in source order and draw no connector. Above 400 lines the banner links to the whole-file view instead.
+- **Whole file (`?src=1`)** — the same, plus "with the call arcs, ports and rail switched off". The source still pages in; only the
+  margins go.
+- **File outline** — "the outline below is the shape the file had when it was indexed", with a link to the current source.
+
+Measured: banner 360 ms after a save; toast 440 ms after `codegraph sync` returns; 0 requests in 4 idle seconds.
+
+### 3.9 Export (CG-55)
+"Copy image" and "Download SVG" on the Flow strip's header and in the Map's side panel. The image renders the **light** theme
+whatever the viewer is set to, at **2x** device pixels for the raster, with **24px** of `--paper` padding around the drawing and a
+"CodeGraph" mark in 11px `--mono` `--ink-3` at the bottom right; a caption in the same type sits at the bottom left, naming the path
+or the root. SVG keeps fonts as `font-family` **stacks** (no embedding) and inlines the token colours as literal hex. PNG for an
+8-hop strip stays under 1 MB.
+
+**As built.** The exporter (`ui/src/lib/export-svg.ts`) **serialises the layout object**, it does not scrape the DOM — no
+`html-to-image`, no `foreignObject`, no new dependency. `buildFlowLayout` and `buildMapLayout` already compute every rectangle, port
+and curve before anything renders, so the image and the screen come from one piece of arithmetic and cannot drift apart; the export
+is a pure function testable with no browser. The price, and the thing to know before changing a card's padding: the *visual* rules
+(paddings, baselines, type sizes) are stated twice — in the component's `<style>` and in the exporter — while the *placing* numbers
+(heights, widths, columns) are imported from the layout models and stated once.
+
+- Output is presentation-only SVG (`rect`, `line`, `path`, `polygon`, `text`, `tspan`, `clipPath`) — no script, no `foreignObject`,
+  no external reference, no `data:` URL — which is what GitHub's sanitiser will accept in a README.
+- `scale` multiplies only the root `width`/`height`; the `viewBox` stays in CSS pixels, so the raster step draws an image whose
+  *intrinsic* size is already 2x rather than upscaling a 1x bitmap.
+- Fonts fall back through the stack in a raster (an SVG loaded as an image may not fetch a webfont). Every fallback in the mono
+  stack advances at ~0.6em like IBM Plex Mono, so the code grid survives; only the letterforms change. Embedding would add ~90 kB of
+  base64 to every export.
+- Text is truncated arithmetically with an ellipsis — the twin of the components' `text-overflow` — and clipped as well, so a wider
+  fallback font cannot spill a source line out of a card.
+- The end cap measures its own wrapped lines rather than trusting `endCapHeight`'s character estimate: a `min-height` box on screen
+  can grow, an image cannot.
+- The clipboard write is attempted with the `ClipboardItem` **promise** form (Safari discards the gesture across an `await`), and
+  falls back to downloading the PNG, saying which happened rather than claiming a copy it did not make.
+
+Measured on this repository: `execute -> rowToFileRecord` (8 hops) exports 3690x253 CSS px, **491 kB** PNG at 2x / 38 kB SVG; the
+16-module map exports 566x1077 and reproduces the on-screen picture exactly (16 boxes, 52 links, 9 layer rules, both band labels;
+with `src/index.ts` selected, 15 links and 4 dimmed boxes, matching the canvas).
+
+### 3.10 Type hierarchy (CG-58)
+Sits in the Symbol view between the header and the source block, above the members outline, for classes, interfaces, structs,
+traits, protocols, enums, unions and type aliases — and only when the type has an `extends`/`implements` edge in some direction.
+A vertical tree: **row height 24px**, names 12.5px mono with kind glyphs, ancestors above at **indent 0** (farthest first, so the
+focus's own parents sit adjacent to it), the focus in `--accent` (600), descendants below indented **22px per level**,
+breadth-first so every direct subtype precedes any indirect one. Connectors are orthogonal 1px `--ink-4` paths — down, then out —
+leaving the parent's glyph centre (indent + 26) and meeting the child's glyph (indent + 16): `extends` solid, `implements` dashed
+`4 3`, a synthesized edge dashed `6 3` in `--ink-3` with a `via <mechanism>` pill carrying its `registeredAt` as the tooltip.
+Rows are buttons, like outline rows; meta is the relation word (11px `--ink-3`) and the file (11px mono, "same file" when it
+matches the focus). Header hint reads "supertypes above · subtypes below". Fold: **more than 12** descendants shows the first 12
+and a `+N more implementations` button (`subclasses` when the folded rows are `extends`, `subtypes` when mixed); truncation or a
+bounded walk adds a note under the tree. A `polymorphic` type (≥ 8 direct implementers) leads with one line — *"A call through X
+dispatches to N implementations — no single static target."* — the only claim in the block a reader cannot get by counting rows.
+The header's `extends X` / `implemented by …` chips are **suppressed** while the tree is on screen: two renderings of one
+relation in one column is how a reader ends up trusting neither.
+**Overrides** are marked on the members outline (`overrides Base` / `satisfies Base`, 10.5px mono pill before the signature).
+Nothing in the engine emits an `overrides` edge, so this is a NAME match inside a chain the graph already links, and the tooltip
+says so. It is deliberately blind to signatures — an overload set would need type resolution the graph does not have.
+Layout is arithmetic (row height × index): no `ResizeObserver`, no measurement, same payload → same picture.
+
+### 3.11 Dead code and islands (CG-59)
+A screen (`#/dead`, `?exported=1`) and a mark on the Map.
+
+**The list.** Symbols no import, call or reference in the index reaches, ranked largest first and grouped by file with the
+Symbol view's `.filegroup` / `.row` shapes (design spec §3.2) — file path 11px mono `--ink-3` with the group's
+"N symbols · M lines" opposite it, then rows of kind glyph + 12.5px mono name + 11px mono `file:line` + an 11px `--ink-3` meta
+line ("method · 51 lines"). A dead container folds its unreachable members into a wrapped strip of 11px mono links under it
+rather than listing them as siblings — one finding, not eleven. Column max-width **760px**, 40px gutters, exactly like the
+entry-points panel.
+
+**The caveat is part of the screen, not a note on it.** A persistent 11.5px `--ink-3` line sits above the rows, between two
+hairline rules, and never collapses or dismisses: *"No static reference in the index — dynamic use is possible."* Under the list,
+every reason a candidate was left off is printed with its count ("1 677 in test files", "378 exported, or declared in a header",
+"40 overriding a member declared further up"), preceded by the scale — *"2 494 symbols in this index carry no incoming reference
+at all; 2 474 of them were left off this list."* Twenty rows drawn from twenty candidates and twenty drawn from two and a half
+thousand are different screens and only that sentence tells them apart.
+
+**One switch**, an 11px mono chip on the right of the caveat bar: `Internal only` (default) ↔ `Including exported`, carried in
+the URL. Turning it on adds symbols something outside the repository could import, and the screen grows an `--accent-soft` band
+with an `--accent-line` border saying so; each such row also carries an `exported` chip. Exported rows are never on the default
+list, because the index cannot check a caller it does not contain.
+
+**Islands, on the Map.** A module no link in the payload arrives at keeps its normal 1px `--ink` stroke — it is not a lesser
+module, it is an unreached one — and its 11px count line reads **"nothing depends on this"** in `--ink-2` *instead of* the
+symbol/file counts, which stay in the side panel. The island verdict is computed from the whole link set, so hiding test modules
+cannot manufacture one. Selecting the module adds a sentence in the panel. Note the box is sized from whichever string it will
+show, so the layout and the node must be given the same verdict.
+
+**Generated files recede everywhere** (`files.generated`, §2.6): a module whose files are *all* tool-generated draws in
+`--ink-4` with a `--rule-soft` stroke; a generated file in the Map panel's file list, a generated group on the dead code list,
+a generated result in the search palette and a generated file's title in the File view are all `--ink-4`. Partly-generated
+modules are not dimmed — a module with one `.pb.go` in it is still one somebody writes by hand.
+
+**What the list refuses to claim** is the whole design. Behind it, `src/graph/dead-code.ts` starts from "no incoming edge
+other than `contains`" and subtracts every candidate there is any reason to believe something reaches: exported symbols and
+header declarations, test and generated files, abstract and interface members, anything carrying a `decorates` edge, overrides
+of an ancestor's member, names the language calls by itself, vendored directories, files nothing in the index reaches (those are
+islands — the Map's job, not this list's), names the resolver failed to resolve somewhere, names shared with a symbol that IS
+referenced, and — the only rule that reads a file — names written more than once in a file that can reach them.
+
+### 3.12 Saved trails (CG-60)
+A **Save trail** button on the trail bar, and a list of what was saved on the empty screen and the entry-points panel.
+The viewer's only write.
+
+**Saving.** `Save trail` sits with `Read as flow` and `Clear` on the right of the trail bar — sans, `4px 8px`,
+`--rule-soft` border, same as its neighbours — and appears only once the trail has a hop and the answering side accepts
+writes. It opens a **one-field inline form** as a second row inside the bar (never a dialog: naming a walk is a thought the
+reader is already having, and anything modal stops the reading to ask about filing). The row is a 12px `--ink-2` sans label,
+a **30px** `--paper` input with a `--rule-soft` border exactly like the search box, `Save`/`Cancel`, and an 11.5px hint that
+says what will happen *before* it happens: `3 hops · saved to .codegraph/ui/trails`, or, in `--amber`,
+`Replaces the saved trail of the same name.` The name is pre-filled with the current symbol's; Escape closes; a failure
+(a read-only checkout, a full disk) prints in `--accent` beside the buttons rather than vanishing. The trail bar's grid row
+is `auto` for this — it keeps its 34px on its own and grows only while the form is open.
+
+**The list.** Rows follow the search-result grid — `18px | 1fr | auto`, kind glyph of the first hop, name 12.5px mono, then
+`N hops · author` 11px mono `--ink-3` — inside a `--rule-soft` box with `--rule-faint` between rows, so the empty screen
+reads as one list rather than two. Two 11px `--rule-soft` actions sit at the right of each row, always drawn and receding to
+`--ink-3` (a control that appears when the pointer arrives is one a keyboard reader has to guess at): `Export`, and `Delete`
+which arms to `Delete?` in `--accent`/`--accent-soft` before it removes anything. The section sits **above** "Where to start":
+a walk somebody named beats any ranking, when there is one. It draws nothing at all on the empty screen when there are no
+trails, and draws itself explained on the entry-points panel, which is where a reader goes looking for one.
+
+**The honesty line is the feature.** A saved trail is somebody's explanation of code that has since moved, so every hop is
+re-resolved against the current index on the way out and each row prints what became of it, in 11.5px under the name:
+`--amber` for *"1 hop moved or renamed since this was saved — parseToken no longer in the index."* or *"…now names more than
+one symbol — showing the closest match."*, `--ink-3` for a hop that merely moved file. Because the trail is a **path**, a hole
+in it cannot be stitched: the row opens the longest run of *consecutive* resolved hops and says so — `Opens hops 2–4 of 6.` —
+and a trail where nothing resolves is drawn `--ink-3` and is not clickable.
+
+**Where it lives.** One JSON file per trail under `.codegraph/ui/trails/<slug>.json`, written atomically (temp + rename),
+newest save first. `.codegraph/.gitignore` already ignores everything, so a trail is local by default; `Export` downloads the
+same file for a reader who wants to commit it somewhere. Each hop is stored as its **qualified name, kind and file** with the
+node id kept only as a fast path — a node id contains its start line, so a trail keyed on ids would break the first time
+anybody edited the code it describes, which is exactly when it matters. Saving under an existing name replaces that trail and
+keeps its `createdAt`.
+
+**What a write has to be.** `POST /api/trails` and `DELETE /api/trails/<id>`, under `/api/` and nowhere else, carrying the
+`X-CodeGraph-UI` header and `Content-Type: application/json` — neither of which a cross-origin form can produce without a
+CORS preflight this server answers none of. `--read-only` refuses both and the screens say so in the answering side's own
+words instead of showing a Save that fails.
+
+### 3.13 Screens (`#/screens`)
+Grid: canvas `minmax(600px,1fr)` | side panel **340px**. The Map's layout (§3.6) with three options: **layering** = BFS
+distance from the entry screen over every transition (entry on top; shared chrome one row above the shallowest screen it
+opens; whatever nothing reaches in a band at the bottom, one empty row below); `layerGap` **116** (five label lanes);
+`portPitch` **12** (a box is at least `(ports on its busier side + 1) × 12` wide); `ports: 'directional'` — down:
+bottom → top; up: **top → bottom**; level: **top → top**, an arch whose control points sit `0.66 × layerGap` above the row.
+**Tracks:** a line's control-point height is `y_hub + gap × (k+1)/(n+1)` for the k-th of the n lines in its fan (one side
+of one box, one direction), ranked by reach, farthest first, measured towards the hub's row; a line spanning several rows keeps
+its track in the gap beside its fan; level arches rise `gap × (0.66 − 0.26 × k/(n−1))`. Hover: the curve nearest the pointer,
+sampled at 24 points, within **10** screen px; no hit paths. Zoom **0.2–3**.
+Nodes as §3.6, sized for the screen's path (13px mono) over its component (11px sans); entry mark `●` in `--accent`;
+origins dashed `--ink-3`; unreached `--ink-4` stroke. Edges: the §3.6 cubic, `stroke-width = min(3, §3.6 width)`,
+`--ink` 0.32 (hot 0.95; soft 0.38 while another of the selected screen's lines is in focus; focus 1.0; dimmed 0.06);
+synthesized dasharray `5 3`; back `--accent` 0.6 dashed `4 3` (hot 0.85). Pills, on the selected screen's edges and the
+hovered one only: 10.5px mono on `--paper`, 1px `--rule` border (hot `--ink-3`; focus `--ink` + 0 2px 8px shadow), **17px**
+tall, width `chars × 6.3 + 12`; text = the innermost top-level `&&` clause of the condition, ≤ **36** chars, `…` prefix
+when outer guards precede it, `→` / `←` prefix for leaving / arriving at the selected screen, or "N ways · M conditional"
+for a pair with several. Placement: at the FAR end of the line; first lane centred **13px** outside the far box, lanes
+**21px** apart, at most 5 within the gap; each pill centred on its own curve at that height; laid left to right, first free
+lane; never over a box; overflow counted in the panel. Panel row hover: that pill prints the whole condition (wraps at
+360px), its line at 1.0, the rest at 0.38; a hovered line tints its row `--press`. Legend bottom-left, remembered per browser.
+
+**Frameworks.** The picture is a pure function of `route` nodes bound to the component that renders them and `navigates` edges
+from the function that pushes a path to the route it names, so any framework that produces those facts lands here. Expo Router
+(`resolution/frameworks/expo-router.ts`): `app/**` screen files, `router.push` / `navigate` / `replace` and a helper's return value.
+Next.js (`frameworks/nextjs.ts`, `next-router-synthesizer.ts`): App Router `app/**/page.tsx` and Pages Router pages (`(group)`
+stripped, `[slug]` → `:slug`, `[...all]` → `:all*`; `@slot` and `(.)intercepting` routes not modelled), bound to the default export;
+`router.push` / `replace` / `prefetch`, `redirect` / `permanentRedirect`, `NextResponse.redirect(new URL(…))` read like an Expo href
+(string, template with holes, `{ pathname }`, a conditional whose arms agree, a local `const href`) and matched against the Next
+pages only, from files under a Next app's root; `<Link href>` and an internal `<a href>` are markup, not calls, so a synthesizer
+reads them and draws a dashed `navigates` edge from the component (`synthesizedBy: 'next-link'`, the site as `registeredAt`).
+`app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens (`POST /api/users`, `ANY /api/users`), so the same
+index is a web app: pages on this tab, endpoints in Entry points, and a page's Steps picture firing from its load.
+React Router (`frameworks/react.ts` reads the routes, `frameworks/react-router.ts` the navigation, `react-router-synthesizer.ts`
+the markup): `<Route path component/element>` (v5 and v6) and `createBrowserRouter([{ path, element }])` are the routes, already
+named `:param` the way this table wants them; `history.push` / `.replace`, `useNavigate`'s `navigate`, a data router's
+`router.navigate` and a loader's `redirect` read their argument with the same Expo readers, and `<Link to>` / `<NavLink to>` /
+`<Navigate to>` / `<LinkContainer to>` are markup a synthesizer reads (`synthesizedBy: 'react-router-link'`). Two things are the
+app's, not the router's: the receiver has to name a router, because an unqualified `push` is an array's; and the app ROOT a call
+is read from is everything before the declaring file's `src/` (proshop's routes are in `frontend/src/App.js`, its screens in
+`frontend/src/screens/`). An optional parameter is registered twice — `/cart/:id?` answers `/cart` and `/cart/5` — because the
+matcher pairs a route with an href of the same length. A nested route's relative path and a splat are not destinations.
+Vue Router (`frameworks/vue-router.ts`, `vue-router-synthesizer.ts`): the routes are `createRouter({ routes: [...] })`, walked as
+objects rather than pattern-matched — a `name` is written ABOVE the `path` it belongs to, so a window around each `path` hands an
+entry its predecessor's name — and bound to the view each names by a `calls` edge, because a `references` candidate list is
+filtered to the ref's own language family and a `.js` router config can never name a `.vue` component. Navigation is usually a
+NAME (`router.push({ name: 'profile' })`, `:to="{ name }"`), which no other framework here does, so the table carries a `byName`
+index built by re-reading the config files its own route nodes came from; a `{ path }` object and a bare string fall back to the
+shared href readers. SvelteKit (`frameworks/sveltekit-router.ts`, `sveltekit-synthesizer.ts`): only a `+page.svelte` is a route
+(a `+layout` and a `+error` sit at a page's address without being one); `goto` takes its path first and `redirect(status, path)`
+second, the one framework here that does; `<a href>` is the link component. A route is joined to the `+page.svelte`
+that serves it (`sveltekit-page`), because a SvelteKit route is derived from a file PATH and its component has no name of its own
+to reference — every page file's component is called `+page` — so the match is the file, not a name; without it a page had no
+body and opened as a lone box. The page is in turn joined to the `+page.server.js` beside it by `callback-synthesizer.ts`'s
+`svelteKitLoadEdges` — a `calls` edge to its load and to each form action, because the framework joins those two by the file
+system and not by a call, and a page's own auth guard is written in its loader.
+TanStack Router (`frameworks/tanstack-router.ts`, `tanstack-router-synthesizer.ts`): routes come from `createFileRoute('/x/$id')`
+(the whole path as a literal) and from `createRoute({ path, getParentRoute })` composed up its parent chain within the file;
+`$id` normalises to `:id`, a `_pathless` segment and a `(group)` are not in the URL, and neither a `__root` route nor a file that
+renders an `<Outlet/>` is a page — the index beside it is. Its `to` is the route PATTERN with the values in `params`, so a
+destination is normalised the way a route NAME is rather than read as a URL, and `navigate` / `redirect` take it under a `to`
+key. **Every table above is per-app** (`RootedRouteTable`, `routesForFile`): one table for a whole repository is wrong the moment
+it holds two apps, because each has a `/` and a `/login` and the first indexed claims the address — measured at 82% of
+navigations pointing into a different app on a 477-app monorepo. The `roots` list decides only whether to resolve; the per-root
+split decides which app's routes to match, longest root first.
+
+### 3.13 Steps (`#/steps?anchor=<id>` | `?symbol=<name>`, `&depth=`)
+What happens from an anchor — a screen, a handler, any symbol — drawn with the Screens view's machinery (§3.12's
+layout, tracks, pills, nearest-line pointer, panel) over a different node universe. `/api/steps` walks FORWARD from
+the anchor over `calls` / `instantiates` / `navigates` / function-as-value `references` / function→function
+`contains`, folding everything that is not a step into the link's `via` and joining the branch guards along the
+fold into its `when`. A node is a step when it is a **screen** (a route, entered over `navigates`), a **trigger**
+(a function passed as a value — `onPress={handleX}`, `addListener('x', handleX)`), a **bridge** (the language
+family changes JS → native under the call), an **event** (native → JS: the RN event channel's edge, named on the
+box), a **store** action (a function in a store file — the graph has no store kind, so the file is the evidence and
+the legend says so), or an **effect** (a call that leaves the index into the network / storage / the device /
+telemetry, matched on the call text against a curated table — including a call through a project-made value such
+as `client.post` on an axios instance, whose edge resolves to the constant). A listener the screen registers is a
+trigger when first met and becomes the event's landing when the walk arrives from native. Two passes per fold —
+step-arriving edges first, then plumbing — so a handler is never both a box and folded `via` from the same
+component. **Another screen is a boundary** — drawn, marked `cut: 'screen'`, not entered (`&through=1` enters
+them; the summary's checkbox): the Screens view draws the way between screens, and a walk on through Home is the
+whole app; so is a native event that lands in a COMPONENT (the capture overlay taking `onCaptureProgress` is
+another screen's body — `cut: 'component'`). A bridge or event step needs evidence — a bridge resolver's edge or a synthesized channel's; a plain
+name-matched call across the families (`arr.flat()` landing on a Swift `flat`) is neither drawn nor walked. Effects
+are one box per (function, category), labelled by the first call and counting the rest (`client.post +1`), the calls
+listed in the panel. Every call-shaped site (a store action, a bridge call, an effect, a plain call to a step, a
+handler CALLED from under a binding — a bound one passes nothing, but `tryCatchSync(onClosePress)`'s argument is the
+whole answer to what a wrapper wraps) also
+carries **what it passes** — `graph/branch-guards.ts`'s `callArgumentsForFile`, read from the same cached tree as the
+guards: string literals and names whole, an object as its keys (`{ email, password }`), arrays `[…]`, functions
+`() => …`, nested calls `f(…)`, Swift labels kept (`withName: "onZipComplete"`), ≤ 96 chars — printed on the panel's
+site rows (`SecureStore.setItemAsync('userEmail', values.email) · index.tsx:226`) and in the tooltip, and an effect
+box with exactly one call behind it wears it as its label (`axios.post('/auth/login', { email, password })`, ≤ 56).
+The conditions say when a step runs; the arguments say with what; a **trigger** says what fires it. Read at the
+site the same way (`triggersForFile`): climb from the call through inline arrows to the first thing that binds it —
+a JSX attribute (`onPress` of `<Button>`), an `on*` option key (`onSubmit` of `useFormik({…})`), or an argument of a
+runs-later call (`useEffect`, `setTimeout`, `addListener('onZipComplete')`, `.then`); a named handler (`const
+handleX = useCallback(…)`) is a boundary, its own story. A function called from under such a binding is a
+**handler step** even though nothing passed it as a value (`onPress={() => handleLogin(values)}` — the common
+case, and the Formik case), and every call-shaped link carries its trigger: a store action or an effect fired
+straight from a tap says so. **And when the binding sits in the ARGUMENTS of a call that itself became an effect
+step, the line arrives from that box, not from the step that owns the fold** — `Alert.prompt('Add Folder', …,
+[{ onPress: (name) => createBackgroundFolder(name) }])` is two facts, the prompt as a device box and the prompt's
+button firing the handler, and "the screen fires it" says nothing when the screen fires everything. The walk keeps
+each effect call's span per function (`firedSpans`); a site whose trigger NAMES the call (`onPress ·
+Alert.prompt(…)`) and whose position falls inside that span is rewired to it, innermost span first, one step
+deeper — so the confirm-then-act chains a mobile app is full of read as chains: the delete alert leads to the
+delete, which leads to the request it sends. An `onSubmit · useFormik(…)` names no effect and stays where it was.
+The pill on a handler link says the event (`onPress · <Button>`,
+`onSubmit · useFormik(…)`), not the conditions; the box's second line says it before the file; the panel prints
+`FIRES FROM onPress · <Button> in LoginButton` above the `via` chain, which is set in `--ink-2` at the
+condition's size — it is the answer to "where on the screen", not an afterthought. Caps, each announced: depth in steps (default 8, ≤ 14, `cut: 'depth'` on the step it stopped
+at, drawn with `name …`), fan-out per node (80), folded nodes per step (300), steps per picture (120 default, ≤ 400);
+hubs (fan-in ≥ 40) and shared chrome (a component rendered by ≥ 5 parents — higher than the Screens view's 3, which
+attributes navigations rather than deciding what to walk into) are dead ends, counted in `truncated`. A step several
+events land on says `⇠ first +N` and lists them in the panel.
+
+**Regions — a screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order
+between them, so rows-by-distance degenerate there: on the mobile app's `/home`, 89 of 120 steps sat one hop out — one
+28,000px row, every line a near-horizontal sweep. The walk already knows the missing structure: a step reached out of the
+anchor descends through the fold's chain, whose first node is the top-level component (or hook) of the screen's tree, so
+the server names it on the step (`WireStep.region` — the fold's first node; the screen's own component for a call written
+in the screen body; the first-reaching parent's region for everything deeper — first reach wins, as `first` does, so a
+shared store is one box in the region that got there first and every other region's way in is a link). Endpoints and
+functions carry none: their rows already read in the code's order, and `view=order` is untouched. The viewer
+(`steps-model.ts`'s `packRegions`) then lays each region out as its own small column — a box above what it sets in
+motion, a line wrapping past ~720px — and tiles the columns into bands under a width budget aimed at a readable aspect,
+in the order the walk met them: the screen's own source order, top of the screen to the left. **Within a region the
+rows come from the region's own links** (longest lead-to path, settled by relaxation as the order reading's rows are),
+never from distance to the anchor, which is flat inside a region: a handler and the store it calls are both one hop
+from the screen, and side by side their line was a level arch, hidden at rest — the store looked wired to nothing.
+Each region wears a caption (`RegionCaption.svelte` — its component's name over a hairline spanning its width)
+and the key explains it. **At rest the picture hides exactly two things** (`stepEdgeVisible`): the anchor's own fan —
+the anchor leads to everything *by definition*, `/home`'s 104 ways of saying so were the moiré, so one line into each
+region's first box stands in for it — and, as everywhere on the canvas, what points back up the layering. Every other
+lead-to draws, a line between two regions included: the empty state's prompt firing the same handler as the header's IS
+the picture, and an earlier cut that reserved cross-region lines for selection made a box that leads three places read
+as wired to nothing. The two hidings compose well: a shared step fed from below — the toast action every handler calls
+— stays quiet through the back rule alone, no hub threshold needed, and selection still brings a step's whole story
+out. A box nothing points at is then a fact, not an accident — the region runs it directly, on render or mount or from
+a binding written inline (`Alert.prompt` in an empty-state view, a store read during render, `Keyboard.addListener` in
+an effect) — and the key says so; selecting it lights its line from the anchor, with what fires it. Same boxes, same
+tracked curves (over a tighter in-region gap), same pills, pointer and panel. Result across the app's 52 screens: widest
+picture ~3,400px (was 28,452), at-rest lines on `/home` 80 of 190 — the region-local structure plus 11 lines between
+regions — with zero boxes that lead somewhere while drawing nothing.
+
+**Decisions — a choice made inside a box, said under it.** A fork the tree can see is written *inside* a box and its
+arms *leave* that box: `resolvePostLoginRoute` ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'`, so two
+`navigates` lines leave one store action. Each carried the whole predicate, one of them the other's negation, truncated
+to the same forty characters — and at rest the tree labels nothing, so the picture never said it was a choice at all.
+Now sibling connectors out of one box that are arms of ONE fork are drawn as the choice they are: the condition once,
+in a caption under the deciding box (`DecisionCaption.svelte`, centred on it and allowed a little more width, since
+reading it is the point), and each line out saying only which way it is — `yes` / `no`, a case's own value, `else` for
+a default (`armWords`, the ONE place either reading words an arm, so the tree and the order reading can never disagree).
+These are **the only lines labelled at rest** in the tree: `placeLabels`' third argument took a boolean and now takes
+a *set* of edges, so the order reading still labels everything and the tree labels exactly the arms.
+
+What makes it possible is the same one idea the order reading's fold rests on, carried one step further out — onto the
+wire. `WireStepSite.decision` (`{ branch, on, arm, form, not? }`) records the decision a site's **innermost** guard
+belongs to: the innermost is the one decided AT the call, while the guards outside it are context both arms share.
+`steps.ts` had `BranchGuard.branch` in hand at all three link paths (arrivals, the known-step re-link, `effectLink`)
+and was dropping it. Two sites agreeing on `branch` and disagreeing on `arm` are the two ways of one fork — which a
+joined condition string can never say, however exactly one reads as the other's negation, and which no amount of
+`X` vs `!(X)` string-matching may be allowed to guess. **Honest by construction, three ways:** a connector is an arm
+only when EVERY site behind it carries the same decision (one site running under no condition means the step happens
+either way, so the line claims nothing); a fork with one drawn arm is a guard clause and keeps its condition on the
+line; and an early exit (`form: 'guard'`) never becomes a decision at all.
+
+**Servers (Express, NestJS, Fastify, Koa, Hono, FastAPI, Flask, Django, Spring, ASP.NET, Vapor, Gin).** The same picture over
+the same machinery; only the facts and the words change (`src/ui-server/api/route-roots.ts`, `effects.ts`,
+`docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md` §4). A route anchor's walk starts at the symbol the route runs —
+in order of evidence, the target of the route's `references` edge (the handler every server resolver names; a class for a
+ViewSet, whose methods the walk then enters), the component a screen file exports, or the route itself when the handler is
+an inline arrow (`inline handler · users.routes.ts` under the path) — and the box's second line is the handler's name. The
+anchor says what fires it: `FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the
+registration site (Express, Koa, Hono, Fastify), the guard / interceptor / role decorators on the method and on its class
+(Nest, Spring, ASP.NET, Django), a FastAPI `dependencies=[…]`; a function anchored by name says the job, event, message or
+schedule written on it (`@Process('email')`, `@Scheduled(…)`, `@KafkaListener(…)`). Effects gain the categories a request
+sets in motion — `database` (with the model / table when the call names one and read vs write from the method:
+`database · user · write · createUser`), `response`, `queue`, `email`, `payments`, `cache`, `auth`, `process`, and `storage`
+grown to files and buckets — matched on the call **as written**, the whole member chain read from the source at request
+time (`prisma.article.findFirst`, `this.jwtService.signAsync`, `res.status(404).json`), because the index keeps only the
+last segment of a deep chain and a bare `create` matched by name is a guess; on the receiver's declared type when the call
+leaves the index through it (`OwnerRepository owners` in a Spring controller, `Repository<Cat>` in a Nest service — read
+from the class body, `graph/branch-guards.ts`'s `memberTypesInTree`, the index keeps none of it); and, in a project with
+endpoints, on a thrown web exception (`throw new NotFoundException(…)`, `raise HTTPException(…)`). The same declared type
+sends `this.usersService.findByEmail(…)` into the class the type names instead of the name-only guess the graph holds — the
+panel says `by the receiver's declared type` on that hop. A **response** box is one outcome of the endpoint's contract as the
+code has it — one box per (function, status), so a handler answering 200 or 404 is two boxes and each line into them carries
+its own condition on the picture, the Screens view's idiom; its label is the status when it is literal (read out of
+`status(404)`, `HttpStatus.CREATED`, `http.StatusNotFound`, `status_code=422`, `NotFoundException`, `TypedResults.NoContent`,
+`.notFound`, `{ status: 201 }`, a `res.status(202)` the statement before, or the 200 a body-sending reply implies), and the
+panel prints one row per site — `WHEN NOT user → 404 · NotFoundException('no such user')`; the sites whose status the code does
+not spell out share one box labelled by their call. The payload says what the
+index is a picture of (`project: 'app' | 'api' | 'web'`, from the routes: endpoints make an API, endpoints beside pages or
+navigation a web app) and the viewer's words follow it in one place (`kindWord` / `kindWords` in `steps-model.ts`):
+endpoint / page / screen, data call / store action, a call to another tier / to the server / a native call; a route that
+leads with a verb is an endpoint wherever it is. The legend re-words itself the same way; the bare tab lists an API's
+endpoints grouped by router file when there are no screens. A production walk never enters a test double (`isTestPath`),
+and a repository-shaped method the walk cannot enter (an interface's, the ORM's) is the database. Conditions and arguments
+are read for Python, Java, Kotlin, C#, Go and C as for JS and Swift (§3.14); a language without rules yields nothing.
+
+**Across the tiers (a web app, a monorepo).** A web app is two programs that talk over a wire the graph cannot see, and the
+same picture wants the same evidence the RN bridge gives it: a string on both sides. `resolution/tier-synthesizer.ts` pairs them at
+index time (`provenance: 'heuristic'`, `synthesizedBy`, `channel`, `tier`, `registeredAt`): a client call with a literal path —
+`fetch('/api/users', { method: 'POST' })`, `axios.post`, `ky`, `got`, `$fetch`, `useFetch`, `useSWR`, or a project instance made by
+`axios.create({ baseURL })` — onto the one route `METHOD path` it names (`http-client`, `tier: 'client→server'`; a template hole fills
+a `:param` and never a literal segment, a hole in front of the path matches a route by its tail, a variable url or a path two routes
+serve alike is nothing); `queue.add('welcome')` on a named queue onto the `@Process('welcome')` method of the `@Processor` class, a
+WorkerHost's `process`, a `new Worker('email', handler)` or Bull's `queue.process` (`queue-job`, `channel: 'queue'`);
+`eventEmitter.emit('user.created')` onto `@OnEvent` listeners, globs honoured (`event-bus`, `channel: 'event'`); a client's
+`socket.emit('x')` onto the gateway's `@SubscribeMessage('x')` and the server's `server.emit('x')` back onto the component that
+registered `socket.on('x', …)` inline (`channel: 'socket'`, the `tier` each way). A Next server action needs no edge: a call from a file
+without the directive into a function whose file (or body) opens with `'use server'` is marked `client→server` at request time.
+`crossing()` reads the marker before the languages, so a hop between two TypeScript files can be a **bridge** or an **event**: an
+endpoint reached across a tier draws as a bridge box that keeps its endpoint face (`⇢ POST /api/users` over its handler's name, `FIRES
+FROM POST /api/users · after …`) and is a boundary exactly as another screen is — `cut: 'screen'`, entered with `&through=1`, the walk
+going on into the handler; a job, an event or a message arriving draws as `⇠ welcome` on its consumer, whose trigger already says
+`@Process('welcome')`. The site of such a hop is the call as written (`fetch('/api/users', { method, body })`, `emailQueue.add('welcome',
+{ userId })`) with its conditions, and the link's label says the channel, the way it crosses and where it was registered (`via http-client
+· POST /api/users · to the server · registered at app.ts:4`). A call a channel follows is not also drawn as a call outside the index
+— the crossing is the story — and a top-level `const worker = new Worker('q', async (job) => …)` lends its constant the file-scope calls
+within its lines, so the landing walks on into what the handler does. Test suites and generated files are never sources: forty supertest
+calls would make a route a hub. A mounted Express router (`app.use('/api', routes)`, nested) names its routes by the path a request takes.
+
+Rows = distance from the anchor as the server counted it (first discovery), anchor on top with the entry mark; **within a row,
+the code's order** — each step carries the position of the hop that first reached it (`WireStep.order`), a hop written inside
+another site's arguments counting before that site, so `generateToken(…)` in `res.json({ token: generateToken(…) })` sits left
+of the `200` it is part of; the layout (`map-model.ts` `order`) takes that as the row's initial order and its sweeps move a box
+only to sit under its parents. A link whose first hop is written inside another call's arguments says so (`WireStepLink.within`,
+`inside res.json(…)` in the panel and the tooltip) — the nesting is stated, never drawn as an edge out of an effect. Boxes:
+the §3.12 screen box for a screen or a handler; **bridge / event** add a 3px `--accent` left rule (the language
+changes under the code) and lead with `⇢` / `⇠ <event name>`; **store** sits on `--paper-2`; **effect** is dashed
+`--ink-3` (a place the graph cannot follow into), labelled by the API (`client.post`) over `category · caller`. Edges,
+pills, tooltip and the panel's hover contract are §3.12's verbatim; the panel adds *Start here →* (re-anchor) on any
+step with a symbol — and a **double-click on a step's box does the same**, so an endpoint or another screen reached as a
+boundary opens as its own chapter without a trip to the panel (a double-click on a Screens-tab box opens that screen's
+Steps picture likewise) — *Open as a flow →* on any link whose ends are both symbols (`#/flow?from=&to=`), a depth `<select>`
+(4–12) that rewrites the URL, per-kind counts, and the `truncated` notes. The bare tab (`#/steps`) is a chooser: the
+project's screens by connectivity, else its endpoints by router file, or a hint to search. A picture of at most 24 boxes
+is fitted to the right of the key (a per-side `fitView` padding) so its second row never sits under the legend; a larger
+one is fitted to the whole stage. `Picture` (`screens-model.ts`) is the structural interface
+the shared machinery works over; `steps-model.ts` builds one. Pure model tests: `ui-steps-model.test.ts`; the
+endpoint against a real RN + Expo fixture: `ui-steps-api.test.ts`.
+
+#### 3.13.1 In order — the same picture, laid out by when things happen (`&view=order`)
+Rows-by-distance is the right picture for a screen, where handlers fire on events and nothing orders them. It is the
+wrong one for a handler: on proshop's `POST /api/users/login` the tree puts `User.findOne`, `jwt.sign`, `200` and `401`
+side by side — each is one step from the anchor — when the code says *look the user up, then IF the password matches
+sign a token and answer 200, ELSE answer 401*, and the signing happens INSIDE the reply that carries it. So the same
+walk has a second reading, on **the same canvas, with the same boxes**: only the graph changes.
+
+```
+                POST /api/users/login · authUser
+                            │
+                    User.findOne({ email })
+                            │
+            ┌ user AND (await user.matchPassword(…))? ┐
+                 │                          │
+               → yes                      → no
+                 │                          │
+        jwt.sign({ id }, …)  auth     401  response
+                 │
+            200  response
+```
+
+**A line means "and then", not "leads to"** — that is the whole difference from the other reading, and the key says so.
+A row down is one more thing that has already happened, so the `200` sits below the `jwt.sign` it is built from and the
+`401` branches at the fork. The conditions are **drawn at rest** rather than only for a selected box
+(`placeLabels(model, selected, atRest)`) — on this picture the conditions ARE the content. An arm that answers, returns
+or throws simply has nothing leaving it.
+
+**A decision both of whose ways are drawn is a POINT, not two labelled lines.** Two edges that each carried the whole
+predicate — one of them negated, their chips truncated to near-identical strings — never said they were the same
+choice. (The tree answers the same problem differently, because there a fork is written INSIDE a box and its arms
+leave it: see **Decisions** in §3.13. Here the fork sits BETWEEN steps, so it gets a box of its own; either way
+`armWords` is the one place an arm is worded.) So a fork two or more of whose arms lead somewhere diverges from a
+small box of its own (`ForkPoint.svelte`,
+`fork:N` in the layout, quieter than a step: one centred line, border `--ink-2`): the box asks the condition once
+(`user AND (await …)?`; a switch asks its subject), and each line out answers with only the arm — `yes` / `no` for an
+`if` or a ternary, the case's own value with the subject stripped (`'expired'`), `else` for a default — as a pill at
+the arm's far end, the arm's full condition still riding the edge for the hover. The point is not a step: it takes no
+click, counts in no summary, and the panel lists nothing for it; selection instead reaches THROUGH it
+(`selectionReach`) — selecting the step before the fork lights both arms, selecting an arm lights its sibling — and
+the neighbour dimming follows the same closure. Client-side entirely: `orderGraph` mints the points from the fork
+items the wire already carries (`WireArm.not` marks the else side), so nothing changes on the server or in the tree
+reading. **A fork with ONE drawn arm keeps the plain line** — an early exit reads as a guard clause (`WHEN NOT
+product` on the line), never as a box with a single exit — and an arm reached from both sides of one decision drops
+the claim (`arm` is deleted on merge) rather than printing `yes` on a line that runs either way.
+
+**What it is made of.** Every hop the walk makes is recorded where the code writes it — the step it reached (or the
+helper it folded into), the call's position and span, the branch guards, the loops, what fires it — by the SAME pass
+that makes the links, so the two readings can never hold different steps (`WireStepsPayload.program`, built by
+`src/ui-server/api/program.ts` from the records `steps.ts` keeps; `ProgramSite` is one such record). `buildProgram` is
+pure over them: no graph, no source, no control-flow graph. `ui/src/lib/program-model.ts` then walks that block tree
+carrying a set of *tails* — the steps a next step would follow — and emits one edge per "and then"; the row of a step is
+the longest run of them from the anchor. A step reached twice (`session.add` before and after a check, a logout helper
+the code comes back to) makes the graph **cyclic**, and relaxing over a cycle never settles — it adds a row on every
+pass until the pass bound. So the lines that close a cycle are dropped before the rows are settled (`withoutBackEdges`,
+one walk from the anchor: a line back to something still open on the way here cannot be what decides its row); the
+cycle is still DRAWN, it just does not stretch the picture. Without this a real screen put sixteen boxes on sixty rows
+— a 9,400px ribbon of empty space that `fitView` opened on a gap, so the canvas came up blank.
+
+**What makes the fold possible** is that a guard names the DECISION it belongs to and not only its own words
+(`BranchGuard.branch` — where the branching construct starts): the `if` and the `else` of one statement carry the same
+branch with `negated` flipped, an early exit carries the branch of the `if` that returned, every case of a `switch`
+carries the branch of the switch, and two `try`/`catch` blocks in one function stay apart. Two sites are arms of ONE
+fork when they agree on the branch and disagree on the arm — which a joined condition string can never say. A guard
+also carries how the arm it is in leaves (`armExit`) and, for an early exit, how the arm that was not taken leaves
+(`exit`); that is `WireArm.ends`, and an arm that ends is an arm nothing leaves.
+
+**The block tree** (`WireItem`): a **step**, where the code writes it — with `inside res.json(…)` when it is written in
+another call's arguments, its own body under it when the walk entered it, and `again` when the same function has
+already been read (a function is read once per picture, however many times it is called); a **fork** — `if` / `switch` /
+`ternary` / `try` / an early exit — carrying its condition once, with an arm per side; a **run** that is not plain
+sequence, said on the line into it — a helper drawn in place (`via generateToken`), a body that repeats (`for each item
+of items`, read by `loopsForFile`; loops and forks nest by which construct BEGINS first), work registered to run later
+(`later · then`), calls started together (`together · Promise.all`); and a **cut** where the reading stopped. Source
+order is execution order for straight-line code and for arguments before their call; where it is not — a callback,
+concurrency — the line says so rather than pretending. A helper that answers on one path still returns on another, so
+the code after the call follows the call; what comes after is not inside it.
+
+**Honest by construction.** A fork exists only where a guard was READ: a language without rules, or a file that changed
+since the index sync, draws a plain sequence rather than an invented structure. Every cap is announced
+(`program.truncated`), and nothing floats — a step the fold could not place follows the anchor unconditionally.
+
+**Which reading opens** travels in the URL (`&view=order` / `&view=tree`) and the summary offers both; without one the
+answer's own `defaultView` decides — the code's order for a handler, an endpoint or any function, the tree for a
+screen. Tests: `ui-steps-program.test.ts` (the fold, over hand-made records), `ui-program-model.test.ts` (the graph and
+its rows), and one `in order` reading per framework in `ui-steps-api-servers.test.ts`.
+
+## 4. Libraries and versions
+- Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
+  hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a
+  fallback if crossing quality demands it (never ELK). Symbol view = DOM + one SVG overlay (`ResizeObserver` re-layout).
+- Syntax classification comes off **the engine's own tree-sitter parse** — no highlighter dependency, no second grammar set.
+  - *As built (CG-43, replaced in CG-57).* The first cut ran Shiki with 56 pruned TextMate grammars in `dist/textmate/`. That is
+    gone: `@shikijs/*` is off the dependency list, `scripts/prune-grammars.mjs` and `npm run build:textmate` are deleted, and
+    `scripts/check-ui-build.mjs` now asserts the tree-sitter grammars in `dist/extraction/wasm/` instead. A `.ts` file is read by
+    exactly the grammar that decided what its symbols are, so the viewer and the graph can never disagree about it.
+  - Eight token classes on the wire: `comment`, `string`, `number`, `keyword`, `type`, `def`, `ident`, `other`. Rules, not scope
+    tables — a node whose type mentions `comment` is a comment; inside a string every leaf is string *except* below an
+    interpolation, where code resumes (so `${user.name()}` still links); an **anonymous** leaf is a keyword when its text is a bare
+    word and punctuation otherwise; a **named** leaf is an identifier, a type name, or — from the extractors' own definition
+    tables — the name a definition declares. `punct` is folded into `other`: they paint identically and splitting them would
+    roughly double the token count on a dense line.
+  - The classification is a class NAME, never a colour, and the viewer paints it from the CSS custom properties above — so **one
+    token stream serves light and dark** with no refetch when `prefers-color-scheme` flips, and the ramp lives only in
+    `ui/src/lib/theme.css`. `type` is a distinct class painted at plain ink: the colouring is near-monochrome and a type name is not one
+    of the four things it moves off plain ink.
+  - Every code token is split into identifier runs before it goes on the wire, so the graph's call-site overlay claims a token the
+    classifier produced rather than re-cutting a line — which is what keeps a link landing on the callee's own name whatever
+    boundaries a grammar chose, and keeps links working in the plain-text fallback.
+  - Single-file components (`.svelte`, `.vue`, `.astro`) have no grammar of their own; their `<script>` blocks — where every
+    indexed symbol in those files lives — are classified as TypeScript or JavaScript, exactly the delegation the extractors
+    already do. The surrounding markup, and the config formats with file-level extraction only (YAML, XML, Twig, properties),
+    render plain with their identifiers still split out, so links land there too.
+  - Measured on this machine, 3 000 lines cold: **TypeScript 24–41 ms** (it was ~700 ms under Shiki, whose TS grammar cost 5–7×
+    every other one), Go ~30 ms, Python 25–29 ms, and Rust/Ruby/PHP/C#/Swift 14–27 ms. Slices are still cached by content hash +
+    range, so a re-render (resize, theme flip, stepping back through the trail) is a map lookup. Side-by-side parity screenshots
+    for the eight gate languages: `docs/design/cg57-highlighting-parity/`.
+- No native modules; no runtime dependency for the UI itself; the CLI serves **`dist/viewer/`** over `node:http`, loopback only.
+  (Not `dist/ui/` — `src/ui/` is the engine's *terminal* ui and tsc already compiles it there; see `ui/README.md`.)
+
+### 4.1 The component library (`@colbymchenry/codegraph-ui`, CG-61)
+The same `ui/src` tree builds a second way — `svelte-package` into `ui/dist` — so CodeGraph Pro renders the Symbol view, the Flow
+strip, the Map and the type-hierarchy tree over its own in-process engine reads without forking a component. One tree, because a fork is a second answer to
+the same question about the same graph.
+- **One seam: `GraphAdapter`** (`ui/src/lib/adapter.ts`) — eleven methods answering the `Wire*` shapes verbatim. `createHttpAdapter()`
+  is the loopback JSON API and is what the CLI's viewer runs on; a host implements the same methods and never makes a request.
+  The shapes live in `ui/src/lib/wire.ts`, which has no imports and no runtime, so a host can depend on the vocabulary alone.
+  `scripts/check-ui-package.mjs` asserts that nothing in the built package but `lib/adapter.js` reaches the network.
+- **`events` is optional.** No live channel means nothing connects and nothing polls; a host that learns of a sync some other way
+  calls `live.signal('index')`, the same code path the stream uses.
+- **Navigation is a driver, not a callback** (`ui/src/lib/navigation.ts`): the components build hrefs, because middle-click and
+  "copy link address" are how people read code. The default is the viewer's hash space; a host installs its own URL space. The
+  app's half — the hash parser and the live route — attaches window listeners at module scope and is **pruned out of the package**.
+- **Theming is colour and type only.** `theme.css` carries the §2.1 tokens and maps Svelte Flow's `--xy-*` variables onto them, so a
+  host never sees library defaults in the pane, controls or minimap. Geometry (34px rail rows, the 300/320px rails, the 20px code
+  line) is not themable: the Symbol view measures those against each other to put a callee row beside the line that calls it.
+- Versioned with the engine (`scripts/sync-ui-version.mjs`), because the payload shapes are versioned with the binary that serves
+  them. **Prepared, not published**: `"private": true` is the guard and `scripts/pack-npm.sh` only packs it under
+  `CODEGRAPH_PACK_UI=1`.
+
+**Beyond the mobile app.** The Steps picture reaches the same bar on an HTTP API — Express, NestJS, FastAPI,
+Spring (Java / Kotlin), ASP.NET and the rest (§3.13, "Servers"); the Screens picture still rests on Expo Router's
+facts. What a web app (Next.js, React Router, SvelteKit) has instead, the cross-tier channels (client `fetch` → own
+route, queues, server actions) and the ordered plan for them are `docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md`
+(P3, P4 and the validation numbers open; P0, P1, P2, P5, P6 built).
+
+### 3.14 Conditions, as a reader says them (`ui/src/lib/conditions.ts`)
+A `when` arrives from the graph as code joined by OUR operators — guards along a chain joined with ` && `, a negated
+guard wrapped `!(…)`, a link's several call sites joined with ` || ` — and those joins render as words: **WHEN**,
+**AND**, **OR**, **NOT**, set in capitals at weight 600 in the condition's own mono (no tracking — they are words in a
+sentence, not labels), so the joins read at a glance and the code between them reads as code. The rules behind them
+(`graph/branch-guards.ts`) cover JavaScript / TypeScript, Swift, Python, Java, Kotlin, C#, Go and C / C++: `if` /
+`elif` / `else`, `switch` / `when` / `match` / `select`, the ternary and Kotlin's `if` expression, `try` / `except` /
+`catch` (`on error`), `&&` / `||` / `and` / `or`, and the early exits before the site — a negated single comparison flips
+instead of wrapping (`if err != nil { return }` reads as `err == nil`, `if not item.title: raise` as `item.title`). The
+same trees answer what a call passes (`callSitesForFile`: Python `name=value`, C# `name: value`, a Go composite literal as
+`gin.H{…}`), the call as written (the whole member chain), the decorators / annotations / attributes on a definition and
+on its class, and the declared types of a class's members. The code inside one
+guard stays code (`isUploadInProgress || elapsed < 5000` is what the source
+says; a guard that is itself a disjunction keeps its parentheses, `graph/branch-guards.ts` adds them). A link with
+several call sites is several **scenarios**, never one long condition: the panel prints the clauses every site shares
+once (`WHEN NOT (busy || late)`), then one row per site with its own tail (`AND NOT user?.organization_id` · site ·
+file:line), or `always`; the connector's pill counts them (`4 ways · 4 conditional`) instead of quoting them. Both the
+Screens and the Steps view use this; a site's `when` on the wire is the whole condition for that site, the link's
+`when` only their summary.
+
+## 5. Copy rules
+Sentence case; controls say what happens ("Read as flow", "Clear"); counts always visible next to folds; honesty phrases fixed:
+"No test reaches this within 3 caller hops", "Reached by tests · N files within 3 hops", "Uncertain · N name-only matches, confidence < 0.6",
+"outside the index", "Where the graph stops", "changed on disk after the last index sync", "Index updated · reloaded", "Not live".
+
+---
+
+## Appendix — prototype stylesheet (verbatim; measurements above are derived from it)
+
+```css
+/* ---------- tokens: paper/ink editorial, one oxblood accent ---------- */
+:root {
+  --paper: #f7f6f2; --paper-2: #f1efe8; --press: #e8e6dd; --press-2: #dedbd0;
+  --ink: #16150f; --ink-2: #56544a; --ink-3: #87847a; --ink-4: #b4b1a5;
+  --rule: #16150f; --rule-soft: #d6d3c8; --rule-faint: #e6e3d9;
+  --accent: #7a2230; --accent-ink: #5e1a25; --accent-soft: #f0e3e5; --accent-line: #d9b3b9;
+  --amber: #8a5a0b; --amber-soft: #f3e9d2;
+  --sans: 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
+  --mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
+  --code-size: 12.5px; --code-lh: 20px;
+}
+@media (prefers-color-scheme: dark) {
+  :root:not([data-theme="light"]) {
+    --paper: #16150f; --paper-2: #1c1a14; --press: #23211a; --press-2: #2c2a22;
+    --ink: #f3f1ea; --ink-2: #b8b5a8; --ink-3: #87847a; --ink-4: #5d5b52;
+    --rule: #f3f1ea; --rule-soft: #34322a; --rule-faint: #26241d;
+    --accent: #d48b96; --accent-ink: #e5a5ae; --accent-soft: #33201f; --accent-line: #6b3a42;
+    --amber: #d9a94a; --amber-soft: #2e2716;
+  }
+}
+:root[data-theme="dark"] {
+  --paper: #16150f; --paper-2: #1c1a14; --press: #23211a; --press-2: #2c2a22;
+  --ink: #f3f1ea; --ink-2: #b8b5a8; --ink-3: #87847a; --ink-4: #5d5b52;
+  --rule: #f3f1ea; --rule-soft: #34322a; --rule-faint: #26241d;
+  --accent: #d48b96; --accent-ink: #e5a5ae; --accent-soft: #33201f; --accent-line: #6b3a42;
+  --amber: #d9a94a; --amber-soft: #2e2716;
+}
+
+html, body { height: 100%; }
+body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 13px; line-height: 1.45; -webkit-font-smoothing: antialiased; }
+* { box-sizing: border-box; border-radius: 0 !important; }
+a { color: inherit; text-decoration: none; }
+button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
+.mono { font-family: var(--mono); }
+.dim { color: var(--ink-3); }
+.hidden { display: none !important; }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
+
+#app { height: 100vh; display: grid; grid-template-rows: 48px 34px 1fr; }
+
+/* ---------- top bar ---------- */
+.topbar { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: 22px; padding: 0 18px; border-bottom: 1px solid var(--rule); background: var(--paper); position: relative; z-index: 30; }
+.brand { display: flex; align-items: baseline; gap: 8px; }
+.brand-mark { display: inline-block; width: 10px; height: 10px; border: 1.5px solid var(--ink); background: var(--paper); align-self: center; }
+.brand-name { font-weight: 600; letter-spacing: -0.01em; font-size: 14px; }
+.brand-sub { color: var(--ink-3); font-size: 12px; }
+.views { display: flex; gap: 2px; }
+.views a { padding: 5px 10px; color: var(--ink-2); border-bottom: 2px solid transparent; }
+.views a:hover { color: var(--ink); }
+.views a.active { color: var(--ink); border-bottom-color: var(--ink); }
+.search { position: relative; max-width: 720px; }
+#q { width: 100%; height: 30px; padding: 0 10px; border: 1px solid var(--rule-soft); background: var(--paper-2); color: var(--ink); font: 13px var(--sans); }
+#q:focus { border-color: var(--ink); outline: none; }
+#q::placeholder { color: var(--ink-3); }
+.q-results { position: absolute; top: 32px; left: 0; right: 0; background: var(--paper); border: 1px solid var(--ink); max-height: 420px; overflow: auto; z-index: 40; }
+.q-row { display: grid; grid-template-columns: 18px 1fr auto; gap: 10px; align-items: baseline; padding: 6px 10px; border-bottom: 1px solid var(--rule-faint); cursor: pointer; }
+.q-row:last-child { border-bottom: 0; }
+.q-row:hover, .q-row.sel { background: var(--press); }
+.q-row .nm { font-family: var(--mono); font-size: 12.5px; }
+.q-row .sig { color: var(--ink-3); font-family: var(--mono); font-size: 11.5px; margin-left: 6px; }
+.q-row .loc { color: var(--ink-3); font-family: var(--mono); font-size: 11px; white-space: nowrap; }
+.q-head { padding: 6px 10px 4px; color: var(--ink-3); font-size: 12px; border-bottom: 1px solid var(--rule-faint); }
+.project { color: var(--ink-2); font-size: 12px; white-space: nowrap; }
+
+/* kind glyph: hollow square variants, mono letter */
+.k { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid var(--ink-3); color: var(--ink-2); font: 500 9.5px var(--mono); flex: 0 0 auto; }
+.k.fn { border-style: solid; }
+.k.cls, .k.iface, .k.struct, .k.type { background: var(--press); }
+.k.file { border-style: dashed; }
+
+/* ---------- trail bar ---------- */
+.trailbar { display: flex; align-items: center; gap: 0; padding: 0 18px; border-bottom: 1px solid var(--rule-soft); background: var(--paper-2); overflow-x: auto; white-space: nowrap; font-family: var(--mono); font-size: 12px; }
+.trailbar .label { color: var(--ink-3); font-family: var(--sans); margin-right: 10px; }
+.hop { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; color: var(--ink-2); border: 1px solid transparent; }
+.hop:hover { color: var(--ink); background: var(--press); }
+.hop.cur { color: var(--accent); border-color: var(--accent-line); background: var(--paper); }
+.hop-arrow { color: var(--ink-3); padding: 0 2px; }
+.hop-arrow.up { color: var(--ink-2); }
+.trailbar .spacer { flex: 1; }
+.trailbar .tb-btn { font-family: var(--sans); color: var(--ink-2); padding: 4px 8px; border: 1px solid var(--rule-soft); margin-left: 8px; background: var(--paper); }
+.trailbar .tb-btn:hover { border-color: var(--ink); color: var(--ink); }
+.trailbar .empty { color: var(--ink-3); font-family: var(--sans); }
+
+/* ---------- main / focus layout ---------- */
+#main { min-height: 0; overflow: hidden; }
+.focus { display: grid; grid-template-columns: 300px minmax(520px, 1fr); height: 100%; min-height: 0; }
+.rail-left { border-right: 1px solid var(--rule-soft); overflow: auto; background: var(--paper); }
+.stage { position: relative; overflow: auto; }
+.stage-inner { position: relative; display: grid; grid-template-columns: minmax(480px, 1fr) 320px; min-height: 100%; }
+.center { padding: 18px 22px 40px 22px; min-width: 0; }
+.rail-right { position: relative; border-left: 1px solid var(--rule-faint); }
+.overlay { position: absolute; inset: 0; pointer-events: none; overflow: visible; }
+.overlay path { fill: none; stroke: var(--ink-4); stroke-width: 1; }
+.overlay path.hot { stroke: var(--accent); stroke-width: 1.5; }
+.overlay path.uncertain { stroke-dasharray: 2 3; }
+.overlay path.heur { stroke-dasharray: 6 3; stroke: var(--ink-3); }
+.overlay path.origin { stroke: var(--accent); }
+
+/* rail headings */
+.rail-h { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 14px 8px; font-weight: 600; font-size: 13px; border-bottom: 1px solid var(--rule-soft); position: sticky; top: 0; background: var(--paper); z-index: 2; }
+.rail-h .n { color: var(--ink-3); font-weight: 400; }
+.rail-h .hint { color: var(--ink-3); font-weight: 400; font-size: 11.5px; }
+.filegroup { padding: 10px 14px 4px; }
+.filegroup .fpath { font: 11px var(--mono); color: var(--ink-3); margin-bottom: 4px; display: flex; justify-content: space-between; gap: 8px; }
+.filegroup .fpath b { color: var(--ink-2); font-weight: 500; }
+.filegroup .fpath a:hover { color: var(--ink); text-decoration: underline; }
+.row { display: grid; grid-template-columns: 16px 1fr; gap: 8px; align-items: start; padding: 5px 6px 5px 4px; margin: 0 -6px; cursor: pointer; border: 1px solid transparent; position: relative; }
+.row:hover { background: var(--press); }
+.row.sel { border-color: var(--ink); }
+.row.origin { background: var(--accent-soft); border-color: var(--accent-line); }
+.row .nm { font: 12.5px var(--mono); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.row .meta { color: var(--ink-3); font-size: 11px; margin-top: 1px; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
+.row .kindlbl { color: var(--ink-3); }
+.row .chip { font: 11px var(--mono); color: var(--ink-2); border: 1px solid var(--rule-soft); padding: 0 4px; background: var(--paper); }
+.row .chip:hover { border-color: var(--ink); color: var(--ink); }
+.row.uncertain .nm, .row.stub .nm { color: var(--ink-2); }
+.row.uncertain .nm { text-decoration: underline dotted var(--ink-4); text-underline-offset: 3px; }
+.row.stub { cursor: default; }
+.row.stub .nm::after { content: ' ·'; color: var(--ink-4); }
+.fold { padding: 8px 14px; }
+.fold > summary { cursor: pointer; color: var(--ink-2); font-size: 12px; list-style: none; display: flex; gap: 6px; align-items: baseline; }
+.fold > summary::before { content: '+'; font-family: var(--mono); color: var(--ink-3); width: 10px; }
+.fold[open] > summary::before { content: '−'; }
+.fold .body { padding: 6px 0 0 16px; color: var(--ink-2); font-size: 12px; }
+.fold .body .fp { font: 11px var(--mono); color: var(--ink-2); padding: 2px 0; }
+.note { padding: 8px 14px; color: var(--ink-3); font-size: 11.5px; line-height: 1.4; }
+
+/* ---------- focus card ---------- */
+.card-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; }
+.card-h h1 { margin: 0; font: 600 20px/1.2 var(--mono); letter-spacing: -0.01em; }
+.card-h .kindword { color: var(--ink-3); font-size: 12.5px; }
+.card-h .loc { font: 11.5px var(--mono); color: var(--ink-2); }
+.card-h .loc a:hover { text-decoration: underline; }
+.badges { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+.badge { font-size: 11.5px; color: var(--ink-2); border: 1px solid var(--rule-soft); padding: 2px 7px; background: var(--paper); display: inline-flex; gap: 5px; align-items: center; }
+.badge.ok { border-color: var(--rule-soft); }
+.badge.warn { color: var(--amber); border-color: var(--amber); background: var(--amber-soft); }
+.badge.hub { border-color: var(--ink); }
+.badge .sw { width: 8px; height: 8px; border: 1px solid currentColor; display: inline-block; }
+.badge.warn .sw { background: currentColor; }
+.sig { margin-top: 10px; font: 12px var(--mono); color: var(--ink-2); white-space: pre-wrap; word-break: break-word; }
+.doc { margin-top: 8px; color: var(--ink-2); font-size: 12.5px; max-width: 70ch; white-space: pre-wrap; }
+.parents { margin-top: 6px; font: 11.5px var(--mono); color: var(--ink-3); }
+.parents a:hover { color: var(--ink); text-decoration: underline; }
+.rel { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: 12px; color: var(--ink-3); }
+.rel .chip { font: 11.5px var(--mono); color: var(--ink-2); border: 1px solid var(--rule-soft); padding: 1px 6px; cursor: pointer; background: var(--paper); }
+.rel .chip:hover { border-color: var(--ink); color: var(--ink); }
+
+/* code */
+.code { margin-top: 16px; border-top: 1px solid var(--rule); padding-top: 6px; font: var(--code-size)/var(--code-lh) var(--mono); }
+.ln { display: grid; grid-template-columns: 44px 1fr 18px; align-items: stretch; position: relative; }
+.ln:hover { background: var(--paper-2); }
+.ln.hot { background: var(--accent-soft); }
+.ln .no { color: var(--ink-4); text-align: right; padding-right: 12px; user-select: none; font-size: 11px; }
+.ln .tx { white-space: pre; overflow-x: auto; scrollbar-width: none; }
+.ln .tx::-webkit-scrollbar { display: none; }
+.ln .port { position: relative; }
+.ln .port i { position: absolute; right: 4px; top: 7px; width: 6px; height: 6px; border: 1px solid var(--ink-3); background: var(--paper); }
+.ln .port i.sure { background: var(--ink-3); }
+.ln.hot .port i { border-color: var(--accent); background: var(--accent); }
+.gap { color: var(--ink-4); padding: 2px 0 2px 44px; font-size: 11px; border-top: 1px dashed var(--rule-soft); border-bottom: 1px dashed var(--rule-soft); margin: 2px 0; }
+.t-c { color: var(--ink-3); }
+.t-s { color: var(--ink-2); }
+.t-k { font-weight: 500; }
+.t-n { color: var(--ink-2); }
+.t-def { font-weight: 600; }
+.ref { color: var(--accent); cursor: pointer; text-decoration: underline; text-decoration-color: var(--accent-line); text-underline-offset: 3px; }
+.ref:hover, .ref.hot { text-decoration-color: var(--accent); background: var(--accent-soft); }
+.ref.uncertain { color: var(--ink-2); text-decoration-style: dotted; text-decoration-color: var(--ink-4); }
+.ref.stub { color: var(--ink-2); text-decoration-color: var(--rule-soft); cursor: default; }
+
+/* callee rail rows (absolutely positioned to lines) */
+.rail-right .rrow { position: absolute; left: 14px; right: 12px; height: 34px; display: grid; grid-template-columns: 16px 1fr; gap: 8px; align-items: center; padding: 0 6px; border: 1px solid transparent; cursor: pointer; }
+.rail-right .rrow:hover { background: var(--press); }
+.rail-right .rrow.sel { border-color: var(--ink); }
+.rail-right .rrow.hot { background: var(--accent-soft); border-color: var(--accent-line); }
+.rail-right .rrow.origin { background: var(--accent-soft); }
+.rail-right .rrow .nm { font: 12.5px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.rail-right .rrow .meta { font-size: 11px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; gap: 8px; }
+.rail-right .rrow.uncertain .nm { color: var(--ink-2); text-decoration: underline dotted var(--ink-4); text-underline-offset: 3px; }
+.rail-right .rrow.stub { cursor: default; }
+.rail-right .rrow.stub .nm { color: var(--ink-2); }
+.rail-right .rrow .tag { font-size: 10.5px; color: var(--ink-3); border: 1px solid var(--rule-soft); padding: 0 4px; }
+.rail-right .rfold { position: absolute; left: 14px; right: 12px; }
+.rail-right .rfold summary { cursor: pointer; color: var(--ink-2); font-size: 12px; list-style: none; padding: 6px; }
+.rail-right .rfold summary::before { content: '+ '; font-family: var(--mono); color: var(--ink-3); }
+.rail-right .rfold[open] summary::before { content: '− '; }
+.rail-right .rfold .body .rrow { position: static; height: auto; padding: 4px 6px; }
+.rail-right .rnote { position: absolute; left: 20px; right: 12px; color: var(--ink-3); font-size: 11.5px; line-height: 1.4; }
+.rail-right .rail-h { position: sticky; }
+
+/* blast radius */
+.blast { margin-top: 22px; border-top: 1px solid var(--rule); padding-top: 10px; }
+.blast .bh { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; }
+.blast .bh b { font-weight: 600; }
+.blast .stat { font-size: 12.5px; color: var(--ink-2); }
+.blast .stat strong { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
+.blast .bar { height: 6px; background: var(--press); margin-top: 8px; position: relative; max-width: 420px; }
+.blast .bar i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--ink-2); }
+.blast .bar i.direct { background: var(--ink); }
+.blast .legend { color: var(--ink-3); font-size: 11.5px; margin-top: 4px; }
+.blast details { margin-top: 8px; }
+.blast summary { cursor: pointer; color: var(--ink-2); font-size: 12px; list-style: none; }
+.blast summary::before { content: '+ '; font-family: var(--mono); color: var(--ink-3); }
+.blast details[open] summary::before { content: '− '; }
+
+/* members outline (class / interface / file) */
+.outline { margin-top: 14px; border-top: 1px solid var(--rule); }
+.orow { display: grid; grid-template-columns: 16px minmax(160px, auto) 1fr auto; gap: 10px; align-items: baseline; padding: 6px 4px; border-bottom: 1px solid var(--rule-faint); cursor: pointer; }
+.orow:hover { background: var(--press); }
+.orow .nm { font: 12.5px var(--mono); }
+.orow .sig { font: 11.5px var(--mono); color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.orow .cnt { font: 11px var(--mono); color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.orow.nested { padding-left: 22px; }
+.orow.dimmed .nm { color: var(--ink-3); }
+.subh { margin: 18px 0 4px; font-weight: 600; font-size: 13px; display: flex; gap: 8px; align-items: baseline; }
+.subh .n { color: var(--ink-3); font-weight: 400; }
+
+/* ---------- file view ---------- */
+.fileview { display: grid; grid-template-columns: 300px minmax(480px, 1fr) 300px; height: 100%; }
+.fileview .rail-left, .fileview .rail-r2 { overflow: auto; }
+.fileview .rail-r2 { border-left: 1px solid var(--rule-soft); }
+.fileview .center { overflow: auto; }
+.filerow { display: block; padding: 5px 14px; font: 12px var(--mono); color: var(--ink-2); cursor: pointer; border-bottom: 1px solid var(--rule-faint); }
+.filerow:hover { background: var(--press); color: var(--ink); }
+.filerow.stubf { color: var(--ink-3); cursor: default; }
+
+/* ---------- flow view ---------- */
+.flow { height: 100%; overflow: auto; padding: 18px 22px; }
+.flow-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px 18px; margin-bottom: 14px; }
+.flow-h h2 { margin: 0; font-size: 16px; font-weight: 600; }
+.flow-h select { font: 12.5px var(--sans); border: 1px solid var(--rule-soft); background: var(--paper-2); color: var(--ink); padding: 4px 8px; }
+.strip { display: flex; align-items: flex-start; gap: 0; overflow-x: auto; padding-bottom: 18px; }
+.hopcard { flex: 0 0 380px; border: 1px solid var(--rule-soft); background: var(--paper); cursor: pointer; }
+.hopcard:hover { border-color: var(--ink); }
+.hopcard.cur { border-color: var(--accent); }
+.hopcard .hh { padding: 10px 12px 6px; border-bottom: 1px solid var(--rule-faint); display: grid; grid-template-columns: 16px 1fr; gap: 8px; align-items: start; }
+.hopcard .hh .nm { font: 600 13px var(--mono); }
+.hopcard .hh .loc { font: 11px var(--mono); color: var(--ink-3); }
+.hopcard .hh .stepno { color: var(--ink-3); font-size: 11px; font-family: var(--mono); }
+.hopcard .win { padding: 6px 0 8px; font: 12px/19px var(--mono); }
+.hopcard .win .ln { grid-template-columns: 40px 1fr 6px; }
+.hopcard .win .ln .no { font-size: 10.5px; }
+.hopcard .win .ln .tx { white-space: pre; overflow: hidden; text-overflow: ellipsis; }
+.hopcard .nosrc { padding: 10px 12px; color: var(--ink-3); font-size: 12px; }
+.hoplink { flex: 0 0 86px; display: flex; flex-direction: column; align-items: center; padding-top: 14px; color: var(--ink-3); font: 11px var(--mono); text-align: center; gap: 4px; }
+.hoplink svg { width: 86px; height: 14px; display: block; }
+.hoplink svg line { stroke: var(--ink-3); stroke-width: 1; }
+.hoplink svg polygon { fill: var(--ink-3); }
+.hoplink.uncertain svg line { stroke-dasharray: 2 3; }
+.hoplink.heur svg line { stroke-dasharray: 5 3; }
+.hoplink .lbl { max-width: 84px; line-height: 1.3; }
+.endcap { flex: 0 0 240px; border: 1px dashed var(--rule-soft); padding: 12px; color: var(--ink-2); font-size: 12px; line-height: 1.45; align-self: stretch; }
+.endcap b { color: var(--ink); font-weight: 600; }
+.flow-note { color: var(--ink-3); font-size: 12px; max-width: 78ch; line-height: 1.5; }
+
+/* ---------- map view ---------- */
+.mapview { display: grid; grid-template-columns: minmax(600px, 1fr) 320px; height: 100%; }
+.mapstage { position: relative; overflow: auto; }
+.mapstage svg { display: block; width: 100%; }
+.mapside details { margin: 4px 0 10px; }
+.mapside summary::-webkit-details-marker { display: none; }
+.mapside { border-left: 1px solid var(--rule-soft); overflow: auto; padding: 14px 16px; }
+.mapside h2 { margin: 0 0 6px; font-size: 15px; font-weight: 600; }
+.mapside p { margin: 0 0 10px; color: var(--ink-2); font-size: 12.5px; line-height: 1.5; max-width: 40ch; }
+.mapside .toggle { display: flex; gap: 8px; align-items: center; font-size: 12.5px; color: var(--ink-2); margin: 10px 0 14px; cursor: pointer; }
+.mapside .toggle input { margin: 0; accent-color: var(--ink); }
+.mapside .cyc { font: 11.5px var(--mono); color: var(--ink-2); padding: 3px 0; }
+.mapside .cyc b { color: var(--accent); font-weight: 500; }
+.mapside .modlist { margin-top: 8px; }
+.mapside .edgeinfo { margin-top: 12px; border-top: 1px solid var(--rule-soft); padding-top: 10px; }
+.mapside .edgeinfo .pair { font: 11.5px var(--mono); color: var(--ink-2); padding: 2px 0; display: flex; justify-content: space-between; gap: 10px; }
+.mapside .edgeinfo .pair b { color: var(--ink); font-weight: 500; }
+.mnode rect { fill: var(--paper); stroke: var(--ink); stroke-width: 1; }
+.mnode text { font: 13px var(--mono); fill: var(--ink); }
+.mnode .cnt { font-size: 11px; fill: var(--ink-3); }
+.mnode.test rect { stroke-dasharray: 4 3; stroke: var(--ink-3); }
+.mnode.test text { fill: var(--ink-2); }
+.mnode:hover rect, .mnode.sel rect { stroke-width: 2; fill: var(--press); }
+.mnode.dimmed rect { stroke: var(--ink-4); }
+.mnode.dimmed text { fill: var(--ink-4); }
+.medge { fill: none; stroke: var(--ink); stroke-opacity: 0.28; cursor: pointer; }
+.medge:hover, .medge.hot { stroke-opacity: 0.95; }
+.medge.dimmed { stroke-opacity: 0.06; }
+.medge.cycle { stroke: var(--accent); stroke-opacity: 0.6; }
+.medge-hit { fill: none; stroke: transparent; stroke-width: 12; cursor: pointer; }
+.layerlbl { font: 12px var(--sans); fill: var(--ink-3); }
+.layerline { stroke: var(--rule-faint); stroke-width: 1; }
+.tip { position: absolute; z-index: 20; background: var(--paper); border: 1px solid var(--ink); padding: 8px 10px; font-size: 12px; color: var(--ink); pointer-events: none; max-width: 320px; }
+.tip .mono { font-size: 11.5px; }
+.tip .row2 { display: flex; justify-content: space-between; gap: 12px; color: var(--ink-2); }
+
+/* ---------- misc ---------- */
+.toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); background: var(--ink); color: var(--paper); padding: 8px 14px; font-size: 12.5px; z-index: 50; max-width: 70ch; }
+.kbd { font: 11px var(--mono); border: 1px solid var(--rule-soft); padding: 0 4px; color: var(--ink-2); background: var(--paper); }
+.emptystate { padding: 40px; color: var(--ink-2); max-width: 60ch; line-height: 1.5; }
+.emptystate h2 { margin: 0 0 8px; font-size: 16px; }
+@media (max-width: 1100px) { .focus { grid-template-columns: 240px 1fr; } .stage-inner { grid-template-columns: minmax(360px, 1fr) 260px; } .fileview { grid-template-columns: 220px 1fr 220px; } .mapview { grid-template-columns: 1fr 260px; } }
+```

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

@@ -277,6 +277,11 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
 
 (Verify the exact supported set against `src/extraction/languages/` and
 `src/resolution/frameworks/` before starting — this table is a starting point.)
+| TypeScript/JS | Next.js (App Router + Pages Router) | page → `<Link>` / `router.push` / `redirect` → page; page load → data; client component → `'use server'` action → DB → `redirect`; `route.ts` handler → DB → response | R + S | ✅ 2026-08-28 (`frameworks/nextjs.ts`, `next-router-synthesizer.ts`, Steps `load` trigger + server-action crossing): fixture `__tests__/nextjs.test.ts` end to end (Screens `routed`, the push attributed back under its condition, the action's redirect); `leerob/next-saas-starter` indexes its pages and links. 🔬 agent A/B (`--model sonnet`, ≥2 runs/arm) not run yet |
+| TypeScript/JS | Express + React (MERN monorepo) | client `axios` / `fetch` literal path → own route → handler → Mongoose → response rows | S + R | ✅ 2026-08-28 (`tier-synthesizer.ts` `http-client` + Express mounts / chained `router.route()` / wrapped `const h = asyncHandler(…)` handlers / nested `package.json` detection): `bradtraversy/proshop_mern` 49 routes (19 pages + 30 endpoints), 23 client→route edges, every one spot-checked correct; `login → ⇢ POST /api/users/login → User.findOne → 401 rows → jwt.sign`. Node count stable across re-index. **Agent A/B (2026-08-28, `run-all.sh`, Sonnet/high, 2 runs/arm, warm daemon, CLI shim on — 0 CLI leaks):** *"How does submitting the login form reach the database, and what does the API respond with when the password is wrong?"* — with: **14s / 14s, 2 tool calls each, 0 Read, 0 Grep, 1 and 2 explores** (explore sufficiency: answered after 1 call in run 1, after 2 in run 2), answer names `submitHandler → login → POST /api/users/login → authUser → User.findOne → matchPassword → 401` end to end; without: **18s / 37s, 14 and 8 tool calls, 7 and 1 Read** (+7 Bash `cat`s in run 2, a subagent in run 1). Tokens processed 153k/165k vs 237k/369k. Pass bar met on the small repo (0 Read/Grep, faster, within 1–2 explores against a budget of 1). 🔬 medium/large (Ghost) and ≥3 prompts still to run |
+| TypeScript/JS | NestJS queues / events / sockets | `queue.add('job')` → `@Process('job')`; `emit('x')` → `@OnEvent('x')`; `socket.emit` → `@SubscribeMessage` and `server.emit` → `socket.on` | S | ✅ 2026-08-28 (`tier-synthesizer.ts` `queue-job`, `event-bus`): `nestjs/nest` `sample/26-queues` (`transcode` → `handleTranscode`) and `sample/30-event-emitter` (`order.created` → its listener) exact, 0 wrong edges after the `e2e/` and generic-event guards; `immich-app/immich` 0 edges — a generated SDK client and a wrapped queue API carry no literal, so silence (correct). 🔬 A/B not run |
+| Python | FastAPI (prefixed routers) | request → `Depends` → handler → session → `HTTPException` rows | R | ✅ 2026-08-28 `python.ts` `postExtract` composes `APIRouter(prefix=)` + literal `include_router(prefix=)`: `fastapi/full-stack-fastapi-template` 23 routes named `GET /items/{id}`, `POST /login/access-token` (were `GET /`); a computed mount prefix is skipped. 🔬 A/B not run |
+| C# | ASP.NET Minimal API endpoint groups | request → group handler → `ISender.Send` → `TypedResults` rows | R | ✅ 2026-08-28 `csharp.ts` handler-first `MapPost(Handler[, path])` under the class + `$"/api/{groupName}"` head (`jasontaylordev/CleanArchitecture` shape); fixture end to end in `ui-steps-api-servers.test.ts` (`PUT /api/TodoItems/{id}` → `204 · 400` rows); `jasontaylordev/CleanArchitecture` 10 routes, all the app's endpoints (were 0). 🔬 A/B not run |
 
 ### Retrieval A/Bs that are not coverage work
 

+ 253 - 0
docs/design/framework-coverage.md

@@ -0,0 +1,253 @@
+# Framework & language coverage — what is done, what is left
+
+**Last verified: 2026-08-29** against the build at that date. Re-verify with the
+queries in [Checking this file is still true](#checking-this-file-is-still-true)
+before trusting a row; this is a snapshot, not a live view.
+
+This file exists to be read cold. It says, for every framework and language the
+README claims, **which of the three pictures it can draw today** and what is
+missing from the ones it cannot — so a fresh session can pick up the next piece
+without re-deriving the map.
+
+---
+
+## The three axes
+
+A framework's support is not one thing. Three separate facts in the graph
+unlock three different pictures, and a framework can have any subset:
+
+| Fact in the graph | Unlocks | Produced by |
+|---|---|---|
+| **`route` nodes** bound to a handler or component | the **Entry points** tab; an endpoint or page can be a Steps anchor | a framework resolver's `extract()` |
+| **`navigates` edges** from the code that sends a user somewhere to the route it names | the **Screens** tab — without a single one, `buildScreens` returns `routed: false` and the tab stays hidden | a resolver's `resolve()` (calls) + a synthesizer (markup) |
+| **branch-guard rules** for the language | the `WHEN` label on every arrow, in Steps, Screens and `codegraph_explore`'s Flow section | `src/graph/branch-guards.ts` |
+
+The Screens picture is a pure function of the first two: *any* framework that
+produces route nodes and `navigates` edges lands on the tab, with no view code
+to write. That is why "add a router" is a small, self-contained job.
+
+---
+
+## Routers — routes AND navigation (done)
+
+Six. Each reads a literal destination and leaves a computed one, a path no
+route serves, and a conditional whose arms disagree unresolved rather than
+guessed.
+
+| Router | Resolver | Markup synthesizer | Tests | Validated on |
+|---|---|---|---|---|
+| Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — |
+| Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter |
+| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges) |
+| TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend |
+| Vue Router / Nuxt | `frameworks/vue-router.ts` | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges) |
+| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts` | sveltekit-realworld (31 edges) |
+
+Shared machinery all six use, in `frameworks/expo-router.ts`: `RouteTable` /
+`RootedRouteTable`, `routesForFile`, `addRouteTo`, `matchRoute`, `appRootFor`,
+`parseHrefExpression`, `readHrefViaLocal`, `nthArgumentText`, `readStringAt`,
+`toHref`. Plus `pageForHref` in `frameworks/nextjs.ts` (framework-agnostic
+despite where it lives) and the object-literal walker in
+`frameworks/object-literal.ts`.
+
+---
+
+## What is left
+
+Ordered by cost-to-value. Each row says what is missing, not merely that
+something is.
+
+### 1. Astro — the last web framework with routes but no navigation
+
+**Has:** `src/pages/` file routes (`.astro` pages + `.ts` endpoints,
+`[param]`/`[...rest]`), in `frameworks/astro.ts`.
+**Missing:** `navigates` edges. Astro is an MPA — navigation is a plain
+`<a href="/about">`, plus `Astro.redirect('/x')` in frontmatter and
+`redirect` entries in `astro.config`.
+**Size:** smallest job on this list. `sveltekit-synthesizer.ts`'s
+`svelteKitLinkEdges` is the same pass over the same tag against a different
+table; the resolver half is one `Astro.redirect` reader.
+**Validate on:** any `withastro/astro` example, or the Astro docs site.
+
+### 2. Server-rendered frameworks — a redirect is a transition, not just a response
+
+**Fourteen frameworks** have route nodes and no navigation: Django, Flask,
+FastAPI, Express, NestJS, Laravel, Drupal, Rails, Spring, Play, Gin/chi/gorilla,
+Axum/actix/Rocket, ASP.NET, Vapor.
+
+Be precise about what is missing. `redirect_to`, `HttpResponseRedirect`,
+`res.redirect`, PHP's `redirect()` are **already recognised as `response`
+effects** (`ui-server/api/effects.ts`), so they draw as a box in the Steps
+picture. What is missing is the edge to the page they name — so two pages never
+connect on the Screens tab.
+
+For a pure API this is correct and nothing should change: an endpoint is not a
+screen. It matters for the **server-rendered** half, where a classic MVC app
+gets no Screens picture at all today:
+
+| Framework | The destination to read | Why it is harder than a client router |
+|---|---|---|
+| Rails | `redirect_to :dashboard`, `redirect_to users_path` | destinations are named helpers (`*_path`/`*_url`) generated from `routes.rb`, not literals |
+| Django | `redirect('profile')`, `reverse('profile')` | same — a route *name*, like Vue's `{ name }`, which `vue-router.ts` already shows how to index |
+| Laravel | `redirect()->route('home')`, `->view()` | route names again |
+| Spring | `"redirect:/x"`, `RedirectView` | a literal inside a string return value |
+| ASP.NET | `RedirectToAction("Index", "Home")` | controller + action pair, not a path — needs the route table's reverse mapping |
+| Flask | `redirect(url_for('profile'))` | nested call; the name is `url_for`'s argument |
+
+The Vue name-index (`VueAppRoutes.byName`) is the closest existing precedent for
+all of these.
+
+### 3. Native UI — no route nodes at all
+
+| Platform | Routes would come from | Navigation would come from |
+|---|---|---|
+| SwiftUI | `NavigationStack(path:)`, `.navigationDestination(for:)` | `NavigationLink(value:)`, `path.append(…)` |
+| Jetpack Compose | `NavHost { composable("route") { … } }` | `navController.navigate("route")` |
+| Flutter / Dart | `MaterialApp(routes: {…})`, `GoRouter([...])` | `Navigator.push`, `context.go('/x')` |
+
+All three are named in `scripts/try-repo.sh`'s presets as not modelled
+(`icecubes`, `nowinandroid`). Compose and go_router are the most tractable —
+both name routes with string literals, which is the same shape every router
+above reads.
+
+### 4. ArkTS / HarmonyOS — closest to done of anything here
+
+**Has:** the hard half already. `arkuiRouterEdges` in
+`callback-synthesizer.ts` resolves `router.pushUrl('/pages/Detail')` to the
+target page struct.
+**Missing:** it emits a **`calls`** edge and no `route` node, so it never
+reaches the Screens tab.
+**Size:** an edge-kind change plus route nodes for `pages/` entries — no new
+analysis.
+
+---
+
+## Languages
+
+All ~30 languages in the README have full structural extraction; nothing is
+outstanding on that axis. The gap that is language-shaped is the **`WHEN`
+label**.
+
+**Guard rules exist** (`RULES_BY_LANGUAGE` in `src/graph/branch-guards.ts`) for:
+TypeScript, TSX, JavaScript, JSX, Swift, Python, Java, Kotlin, C#, Go, C, C++,
+Objective-C.
+
+(Metal and CUDA parse **as** C++ and ArkTS does **not** parse as TypeScript, so
+the first two inherit the C rules and the third has none.)
+
+**No rules** — boxes draw, arrows carry no condition, and no arguments or
+trigger labels are read: PHP, Ruby, Rust, Scala, Dart, Erlang, Lua, Luau, R,
+Solidity, COBOL, CFML, VB.NET, Nix, Terraform, Pascal/Delphi, Liquid, Razor,
+Twig, ArkTS, and the `.svelte` / `.vue` / `.astro` template languages.
+
+A language with no rules yields **nothing**, never a wrong label — that is the
+design, so an absent row here is a missing feature, not a bug.
+
+**Ruby and Rust sting most**: both have server frameworks in the README's table
+(Rails, Axum/actix/Rocket), so their Steps pictures draw responses and database
+calls with no conditions on any arrow. `scripts/try-repo.sh`'s `bookstack`
+preset says exactly this for PHP.
+
+---
+
+## Traps a new router will hit
+
+Each of these cost real debugging time; they are not hypothetical.
+
+1. **A `references` edge cannot cross a language family.** `applyLanguageGate`
+   in `name-matcher.ts` filters `references` candidates to
+   `sameLanguageFamily`, so a `.js` router config can never name a `.vue`
+   component — it silently binds to a same-named `.js` function in a store
+   instead. Bind a route to its component with **`calls`**, which
+   `route-roots.ts` reads as "the page a screen file exports".
+2. **One address, one screen.** A layout and the index route beside it resolve
+   to the same path (`+layout.svelte` vs `+page.svelte`, `dashboard.route.tsx`
+   vs `dashboard.index.tsx`, `_auth.invoices.tsx` vs `_auth.invoices.index.tsx`).
+   Emitting both puts one address on the map twice. Decide **per file** — the
+   sibling is not visible at extraction time.
+3. **The route table must be per app.** A repository with two apps has two `/`
+   and two `/login`; a global table hands the address to whichever was indexed
+   first. Measured at **82% of navigations pointing into a different app** on a
+   477-app monorepo before `RootedRouteTable` / `routesForFile`. The `roots`
+   list decides only *whether* to resolve.
+4. **Read fields from the object, not from a window around one.** A Vue route's
+   `name` is written above its `path`, so a text window handed every entry its
+   predecessor's name — silently, for every route in the file. Use
+   `frameworks/object-literal.ts`.
+5. **A receiver is required for a generic verb.** `push` and `replace` are two
+   of the most common method names in JavaScript; claiming a bare one puts every
+   `paths.push('/tmp/x')` one string-match away from a route.
+6. **One component can be several screens.** A listing rendered at `/`,
+   `/search/:keyword` and `/page/:n` is one component and three addresses;
+   `screenOfComponent` maps a component to **every** route it serves, and
+   `collapseSharedChrome` counts distinct screen COMPONENTS, not addresses —
+   counting addresses collapsed one component's four routes into an origin and
+   took the navigation away from all of them.
+7. **A destination can name several routes.** `parseHrefExpression` returns
+   one `HrefLiteral` carrying `alternates`, and `destinationsForHref` turns it
+   into one `{ node, href }` per arm. A synthesizer emits an edge apiece; a
+   resolver puts the first on the `ResolvedRef` and the rest in `alsoTargets`,
+   which `createEdges` fans out — the reference still resolves ONCE, so the
+   pipeline's cleanup and counts are untouched. Label each edge with the arm
+   that named it, or an edge points at one route while naming another's path.
+8. **Read markup with the same reader as calls.** A synthesizer that peeks at
+   the first character of `to={…}` misses every conditional and template the
+   `push(…)` path handles. Use `parseHrefExpression` on the balanced brace
+   contents.
+9. **A condition is read the same way for markup as for a call.** The Screens
+   walk used to skip the `when` on any synthesized edge, so every
+   `<Link to>` read as *always* while the `push()` beside it carried its guard.
+   The reader works fine at a markup site — a JSX `{step1 ? <Link/> : …}` is a
+   ternary like any other — and the site's own verb (`link`, `a`) is the honest
+   label; `return` belongs only to an edge whose destination came from
+   elsewhere, which is what `registeredAt` pointing at another line means.
+10. **A route is not always a screen.** The Screens picture is about
+   navigation, so it draws only routes named by a path; a route named with the
+   HTTP method that reaches it is an endpoint and belongs on Entry points. Nuxt
+   is the exception that names an endpoint like a page (`/api/users` from
+   `server/api/`), and is excluded by file path.
+11. **Detection runs before any file is indexed.** `declaredDependencies` caches
+   per file-count for exactly this reason — an earlier version cached the empty
+   pre-index answer and every framework whose dependency lived one directory
+   down stayed undetected.
+
+---
+
+## The bar for calling one done
+
+Per `CLAUDE.md`'s validation methodology, and what was actually done for the
+four routers added on 2026-08-29:
+
+1. **A real repo, not only a fixture.** Every defect in this session's work was
+   caught by a real repository and none by the fixture written first.
+2. **Recall against ground truth.** `grep` every navigation site in the source
+   and account for each one: resolved, or correctly unresolved because it is
+   computed.
+3. **Precision, site by site.** For every synthesized edge, read the line its
+   `registeredAt` names and confirm the tag or call there names that
+   destination. Target: zero false positives.
+4. **Controls re-indexed.** Node, edge, route and `navigates` counts on repos
+   the change should not touch — a change to shared machinery is not done until
+   they are byte-identical or the difference is explained.
+5. **Full suite green**, and a CHANGELOG entry in the user-facing voice.
+
+---
+
+## Checking this file is still true
+
+```bash
+# Which frameworks emit navigates edges
+grep -rn "edgeKind: 'navigates'\|kind: 'navigates'" src --include="*.ts" | sed 's|:.*||' | sort -u
+
+# Which languages have branch-guard rules
+sed -n "/^const RULES_BY_LANGUAGE/,/^\]);/p" src/graph/branch-guards.ts
+
+# Whether a repo's Screens tab is on, and how many transitions it has
+scripts/try-repo.sh <preset>        # prints the navigation count and says which tab is on
+```
+
+```sql
+-- In a repo's .codegraph/codegraph.db
+select count(*) from edges where kind='navigates';
+select name, file_path from nodes where kind='route' order by name;   -- duplicates = a layout drawn as a screen
+```

+ 504 - 0
docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md

@@ -0,0 +1,504 @@
+# Steps & Screens for APIs and web apps — handoff
+
+**Status:** plan, written 2026-08-28 at the end of the session that built the Steps view and the
+readings it rests on (Expo + React Native app, `amniservices-mobile-app`). **Updated the same day, later
+sessions: P0–P6 are built** (see the per-item notes marked *Built*); P7 has its first agent A/B (proshop, small) and
+the medium / large rows are open.
+**Next: BUILT (2026-08-29).** `docs/plans/2026-08-29-steps-in-code-order.md` — the Steps tab reading a handler in the
+code's order (a rail with forks) instead of the tree by distance; a derivation from this walk, no new walk. P0–P6 are
+in (`src/ui-server/api/program.ts`, `ui/src/lib/program-model.ts`, spec §3.13.1); validating it against four real
+servers also fixed three defects in THIS walk — a hop's span read from the wrong call, a name-match the call as
+written disproves, and a value with one `references` edge going unlent the file's calls. Every claim about what a
+resolver emits *today* was verified against the source on this date — re-verify before building on it,
+the resolvers move. What was learned building it, beyond the plan: the index keeps only the LAST
+segment of a deep member call (`create` for `prisma.user.create`) and name-matches it — often to the
+wrong `create` at confidence 0.4 — so the Steps walk reads every call **as written** from the tree at
+request time (`callSitesForFile`) and classifies on the chain; and the declared types of a class's
+members (`private readonly usersService: UsersService`, `OwnerRepository owners`) are read from the
+class body (`memberTypesInTree`) to send `this.usersService.findByEmail(…)` where the type says and to
+call `owners.save` the database. Validation pictures: `gothinkster/node-express-realworld-example-app`,
+`brocoders/nestjs-boilerplate`, `nestjs/nest/sample`, `fastapi/full-stack-fastapi-template`,
+`Netflix/dispatch`, `spring-projects/spring-petclinic`, `spring-petclinic/spring-petclinic-kotlin`,
+`dotnet-architecture/eShopOnWeb`, `jqlang/jq`, `redis/redis`, `android/nowinandroid`,
+`Dimillian/IceCubesApp`, `TryGhost/Ghost` — each shot headlessly (`codegraph ui --no-open` + Playwright)
+and read against the mobile app's picture.
+
+**Goal.** The two pictures — **Screens** (`#/screens`, design spec §3.12) and **Steps**
+(`#/steps`, §3.13) — must be as good on an Express / NestJS / Fastify API, a Next.js / React Router /
+SvelteKit web app, and a monorepo that has both, as they are on the mobile app today. "As good" is
+defined precisely in §1 below; it is not "draws something".
+
+Companion reading, in this order: `docs/design/codegraph-ui-design-spec.md` §1 (principles), §3.12,
+§3.13, §3.14; `CHANGELOG.md` `[Unreleased]` (the user-facing description of what shipped);
+`docs/design/dynamic-dispatch-coverage-playbook.md` (the coverage rules and the validation method —
+**"partial coverage is worse than none"** governs everything here); `CLAUDE.md` (tests, kernel, docs).
+
+---
+
+## 1. The bar: what "up to par" means
+
+On the mobile app, selecting `/capture/review` and walking to the upload gives the reader, per link:
+
+| Reading | Example | Where it comes from |
+|---|---|---|
+| **The step itself, typed** | `⇢ finalizeCaptureSession` (native call), `⇠ onZipComplete` (native event), `setZipUri` (store action), `axios.post(\`…/oauth/token\`, {…})` (leaves the index) | `src/ui-server/api/steps.ts` classification |
+| **FIRES FROM** — what triggers it | `onSubmit · useFormik(…) in LoginButton`, `onPress · <Button>`, `addListener('onZipComplete')` | `graph/branch-guards.ts` `triggersForFile` — read at request time from the cached tree |
+| **via** — the plumbing folded into the arrow | `via LoginButton → handleLogin`, `via uploadARCapture` | the walk's fold (`steps.ts`) |
+| **WHEN** — the conditions, as words, one row per scenario | `WHEN NOT (busy \|\| late)` once, then `AND NOT user?.organization_id` · site, `AND user?.organization_id AND (…)` · site … | `guardsForFile` + `ui/src/lib/conditions.ts` (`scenarios`, `whenTokens`) |
+| **with what** — the arguments as written | `SecureStore.setItemAsync('userEmail', values.email)`, `client.post('/frames', { uri })` | `callArgumentsForFile` |
+| **Honesty** | other screens are boundaries (`…`), caps announced on the step they hit, synthesized hops dashed, a crossing needs evidence | `steps.ts` caps + `evidenced` rule |
+
+An API or web project reaches the bar when its canonical flow — **request → guard/middleware →
+handler → service → database → queue/email/other service → response** for an API, **page → data
+fetch → user action → server action/route → database → redirect** for a web app — shows every one of
+those readings on every link, on a real mid-size repo, with the caps and the boundaries behaving as
+they do on the mobile app. Numbers to record per framework are in §7.
+
+---
+
+## 2. How the pictures work today — the facts they rest on
+
+Read this before touching anything. Each picture is a pure function of a small set of graph facts;
+extending the pictures to a new framework is almost entirely a matter of making the **same facts
+exist** for it, plus wording.
+
+### 2.1 Screens (`src/ui-server/api/screens.ts`, `ui/src/lib/screens-model.ts`)
+
+| Needs | Today comes from |
+|---|---|
+| `route` nodes named by path (`/capture/review`) | `resolution/frameworks/expo-router.ts` (file-path routing); React Router / Next.js *pages* routes from `frameworks/react.ts` also exist but carry no navigation |
+| route → the component that renders it (`calls` / `instantiates` edge out of the route) | expo-router resolver (default export of the screen file) |
+| `navigates` edges from the function that pushes a path to the route it names, with `metadata.href` / `navMethod` | expo-router resolver (literal / template / pathname-object hrefs) + `resolution/expo-router-synthesizer.ts` (helper return values → `provenance: 'heuristic'`) |
+| the attribution walk BACK from the navigation call to a screen's component, folding the chain into `via` | `screens.ts` (`attribute`, caps: 7 hops / 30 callers / 800 visited) |
+| `when` per site | `createSiteReader(...).when` in `api/when.ts` → `guardsForFile` |
+
+If there are no `navigates` edges the endpoint answers `routed: false` and the view says "No screen
+navigation in this graph". **That is what every API and every non-Expo web app gets today.**
+
+### 2.2 Steps (`src/ui-server/api/steps.ts`, `ui/src/lib/steps-model.ts`, `ui/src/views/StepsView.svelte`)
+
+The walk: from the anchor, breadth-first over `calls` / `instantiates` / `navigates` /
+function-as-value `references` (`metadata.fnRef`) / function→function `contains`, folding every node
+that is not a step into `via`. A node **is** a step when it is one of:
+
+| Kind | Evidence today | Server rule |
+|---|---|---|
+| `screen` | target is a `route` node | any edge into a route (a `navigates` edge in practice) |
+| `trigger` (handler) | a function passed as a value (`fnRef`), **or** called from under an event binding — JSX prop, `on*` option, runs-later callback (`triggerInTree`) — and not a component, not a store action | `steps.ts` classification, `looksLikeComponent` |
+| `bridge` ⇢ | language family changes JS→native **and** the edge is evidenced: `metadata.bridge === 'react-native'`, `resolvedBy === 'framework'`, or `provenance: 'heuristic'` | `crossing()` + `evidenced`; a plain name-matched cross-family call is dropped |
+| `event` ⇠ | native→JS, evidenced (`synthesizedBy: 'rn-event-channel'`) | same |
+| `store` | function in a store **file** (`STORE_FILE` regex: `stores?/`, `storage/`, `.store.ts`, `.slice.ts`…) — file-name evidence, the legend says so | `isStoreFile` |
+| `effect` | an unresolved call, or a call that resolved to a `constant`/`variable`, whose text matches the curated `EFFECTS` table: `network`, `storage`, `device`, `telemetry` | `effectCategory`; one box per (function, category), `apis[]` listed |
+
+Boundaries and caps (all announced on the step, `cut`): another **screen** (`through=1` enters it), a
+native event landing in a **component** of another screen, depth (8 default, ≤14), fan-out per node
+(80), folded nodes per step (300), steps per picture (120 default, ≤400); hubs (fan-in ≥ 40) and shared
+chrome (a component rendered by ≥ 5 parents) are dead ends counted in `truncated`. HOC wrappers
+(`memo(X)`) are seen through via the file-scope function reference within the wrapper's lines.
+
+**Where the anchor's root comes from:** for a `route` anchor the walk starts at the component the
+route renders — found as the first `calls`/`instantiates` edge OUT of the route node (`componentOf`
+in `buildSteps`). This is correct for Expo Router and **wrong for every API framework** (§3, P0).
+
+### 2.3 The request-time readings (`src/graph/branch-guards.ts`)
+
+Nothing about these is stored in the index; they parse the file (LRU of 8 trees, 256 KB cap) and
+answer per call site `(line, column)`:
+
+- `guardsForFile` → the branch conditions (JS-family + Swift rules; a disjunctive guard keeps its parens).
+- `callArgumentsForFile` → the argument list abbreviated (strings whole, objects as keys, `[…]`, `() => …`, `f(…)`, Swift labels).
+- `triggersForFile` → `{ kind: 'prop' | 'option' | 'callback', name, of }`: JSX attribute (event prop, or any prop given a function), `on*` object key (with the call it configures, through arrays), argument of a runs-later callee (`LATER_CALLEES`). Named handlers (`const handleX = useCallback(…)`) are boundaries.
+
+All three are **JS-family only** (`supportsBranchGuards`), Swift for guards. Python / Java / Go / Ruby
+/ PHP have no rules — an API in those languages gets no WHEN, no arguments, no FIRES FROM (§3, P5).
+
+### 2.4 The rest of the surface
+
+- Wire types are mirrored by hand in `ui/src/lib/wire.ts`; the adapter method `steps` is **optional**
+  (`ui/src/lib/adapter.ts`), `NavigationDriver.stepsHref` is **required** (a host driver must add it).
+- Conditions vocabulary: `ui/src/lib/conditions.ts` (`WHEN`/`AND`/`OR`/`NOT` tokens, `scenarios`, common-prefix factoring). Both views use it.
+- Tests: `__tests__/ui-steps-api.test.ts` (real RN + Expo fixture, end to end — **copy its shape for every new framework**), `ui-steps-model.test.ts`, `ui-conditions.test.ts`, `branch-guards.test.ts` (guards, arguments, triggers), `ui-screens-model.test.ts`, `expo-router.test.ts` (routed fixture + `buildScreens`).
+- **Kernel parity:** TypeScript/JS *extraction* runs in the Rust kernel (`codegraph-kernel/src/tsjs/`); the TS extractor is the wasm fallback. Any extractor change → mirror in Rust, `npm run build:kernel`, test on both paths (`CODEGRAPH_KERNEL=0` for wasm) plus `kernel-tsjs-parity.test.ts`. Resolvers, synthesizers and the request-time readings are TS-only — no parity work.
+- Verify visually: `npm run build` → `codegraph index` in the target project → `codegraph ui --no-open --port 4747 <project path>` → `GET /api/steps?symbol=<route name>` → headless playwright (`createRequire` from a repo that has it; `waitUntil: 'load'`, not `networkidle` — the viewer holds an SSE stream). See the auto-memory note `codegraph-viewer-workflow`.
+
+---
+
+## 3. What an API / web project gives us today (verified 2026-08-28)
+
+| Framework | Route nodes | Route → handler | Navigation | Notes |
+|---|---|---|---|---|
+| **Express / Koa** (`frameworks/express.ts`) | `GET /path` from `app|router.METHOD('/path', …)` | **named handler**: a `references` edge route → handler (last argument; earlier arguments = middleware, **not linked**). **Inline arrow handler**: the route node itself gets `calls` edges to every function its body calls (regex; `RESERVED_CALLS` filtered) — the route *is* the handler | none | `router.use('/prefix', sub)` mounting is not prepended to paths (label only) |
+| **NestJS** (`frameworks/nestjs.ts`) | `GET /users/:id` = `@Controller` prefix + `@Get` path; also GraphQL `@Query/@Mutation`, `@MessagePattern/@EventPattern`, `@SubscribeMessage` | `references` edge route → the decorated method; DI `this.svc.method()` resolves via receiver type (playbook: "no dynamic-dispatch hole") | none | `@UseGuards/@UseInterceptors/@UsePipes`, `@OnEvent`, `@Process/@Processor`, `@Cron` are **not** modelled — no guard chain, no event/queue channel |
+| **Next.js** (`frameworks/react.ts`) | `pages/**` and `app/**` files with `export default` → a route named by path (`/blog/:slug`) | none (the page component is the default export in the same file, not linked from the route) | none — no `navigates` for `<Link href>`, `router.push`, `redirect()` | `app/api/**/route.ts` handlers (`export async function GET`) are **not** routes; server actions (`'use server'`) unknown; `middleware.ts` unknown |
+| **React Router** | `<Route path component={C}/>` / `element={<C/>}`, object data-router (literal form) | `references` to the component | none | |
+| **SvelteKit / Vue / Nuxt / Astro** | file routes | `svelteKitLoadEdges`, `vueTemplateEdges`, Pinia/Vuex channels | none | |
+| **FastAPI / Django / Flask / Spring / Laravel / Rails / Gin / Axum** | routes + handler edges (resolvers); FastAPI `APIRouter(prefix=)` + literal `include_router(prefix=)` composed since 2026-08-28 (`python.ts` `postExtract`) | yes | — | WHEN / arguments / triggers built for Python, Java, Kotlin, C#, Go, C (P5) |
+
+What the viewer does with that today: **Entry points** lists every route with its handler (this is the
+"Screens" of an API today); **Screens** answers "no screen navigation" for all of them; **Steps**
+anchored on an API route finds no root (`componentOf` looks for `calls`/`instantiates` out of the route;
+Express named / Nest give `references`; an Express inline route's *first callee* becomes the root —
+wrong) and draws the anchor alone. So the first task is small and unblocking.
+
+Synthesizer channels that already exist and matter here (`resolution/callback-synthesizer.ts`):
+`eventEmitterEdges` (JS `.on('x', fn)` ↔ `.emit('x')`), `springEventEdges`, `laravelEventEdges`,
+`celeryDispatchEdges`, `sidekiqDispatchEdges`, `mediatrDispatchEdges`, `reduxThunkEdges`,
+`rtkQueryEdges`, `objectRegistryEdges`, `ginMiddlewareChainEdges`, `svelteKitLoadEdges`. There is **no**
+channel for: BullMQ / Bull (`queue.add('job')` ↔ `@Process('job')` / `new Worker('q', fn)`), Nest
+`EventEmitter2` (`emit('x')` ↔ `@OnEvent('x')`), socket.io / Nest gateways, **client `fetch` → server
+route**, tRPC, Next server actions. Those are the cross-tier hops — the API equivalent of the RN
+bridge — and they are where a web app's "capture → upload" story breaks today.
+
+---
+
+## 4. The mapping — same pictures, same words, different facts
+
+Keep the visual language exactly (spec §2 and §3.13): boxes, labelled arches, one accent, dashed = a
+place the graph cannot follow into, accent rule = the code crosses a boundary. Only the *evidence* and
+the *words* change.
+
+| Mobile app (built) | HTTP API | Web app (Next.js / React Router / SvelteKit) |
+|---|---|---|
+| **screen** `/capture/review` — a box; other screens are boundaries | **endpoint** `POST /users` — a box; another endpoint reached by an internal HTTP call is a boundary | **page** `/blog/[slug]` — a box; another page reached by `<Link>` / `router.push` / `redirect()` is a boundary — this is the Screens picture proper |
+| the entry screen `/`; Screens = transitions between screens | no entry; **Entry points** is the list. A "Routes" picture (endpoints + calls between them) only if a repo actually has inter-endpoint calls — measure before building | `/` (or the root layout); Screens = `<Link>` / `router.push` / `redirect` / `<a href>` between pages (P4) |
+| **handler** `handleLogin` — FIRES FROM `onSubmit · useFormik(…)` | **handler** `createUser` — FIRES FROM `POST /users` **after** `authenticate, validate(schema)` (Express middleware args), `@UseGuards(JwtGuard)` (Nest); a queue consumer FIRES FROM `@Process('email')` / `new Worker('email')`; a cron FIRES FROM `@Cron('0 * * * *')`; an event listener FIRES FROM `@OnEvent('user.created')` | a page's data fetch FIRES FROM **page load** (`getServerSideProps`, RSC render, `load()`); a client handler FIRES FROM `onSubmit · <form>` / `action={createPost}`; a server action FIRES FROM the form/handler that calls it |
+| **⇢ native call** (JS→Swift, RN bridge evidence) | **⇢ another tier**: outbound HTTP to another service (`fetch('https://…')` = effect `network`; to **our own** route with a literal path = a link to that endpoint box), queue publish (`queue.add('email', {…})` → ⇢ the consumer) | **⇢ server**: client `fetch('/api/users')` → the `route.ts` handler; a server action call from a client component; a tRPC mutation → its procedure |
+| **⇠ native event** (`sendEvent(withName:)` → listener) | **⇠ from a queue / bus**: the consumer landing (`@Process('email')`), an event landing (`@OnEvent`), a websocket message landing | **⇠ from the server**: SSE / websocket / push landing in a client handler; `revalidatePath` (announce, don't draw) |
+| **store action** (by store file) | **data**: an ORM / repository / query call — `prisma.user.findMany({ where, select })`, `this.userRepo.save(user)`, `User.findOne(…)`, `knex('users').insert(…)`, `db.query(sql)` — evidence = the receiver's import origin (prisma / typeorm / mongoose / drizzle / knex / pg / mysql2 / sequelize / kysely) or a known repository type; **the model or table comes from the receiver or the first argument**, read vs write from the method name | same as API on the server side; on the client, a store (Zustand / Redux / React Query cache) as today |
+| **outside the index**: `network`, `storage`, `device`, `telemetry` | add **`database`** (above), **`queue`** (bull/bullmq `add`, `sqs.send`, `kafka.produce`, `pubsub.publish`), **`email`** (nodemailer, sendgrid, resend, ses), **`payments`** (stripe, braintree), **`cache`** (redis / ioredis / memcached / `cache.set`), **`auth`** (jwt sign/verify, bcrypt/argon), **`response`** (below), `storage` gains S3 / GCS / fs | same, plus `response` = `NextResponse.json`, `redirect()`, `notFound()` |
+| — | **response** as a step: every `res.status(404).json({ error })`, `throw new NotFoundException(…)`, `reply.code(201).send(…)`, `return c.json(…)`, `raise HTTPException(…)` is a scenario row with its **WHEN** and its **arguments** (the body). Together they are the endpoint's contract *as the code has it* — the single most valuable reading for an API, and it falls out of the existing scenario rows once `response` is an effect category | `redirect('/login')` is both a response and a navigation (draw as the navigation) |
+| **WHEN** (guards, words, scenario rows) | same — plus the guard/middleware chain is the *shared prefix* said once (`FIRES FROM POST /users after authenticate`) | same |
+| **with what** (arguments) | same; especially `res.status(404).json({ error })`, `prisma.user.create({ data: { email, name } })`, `fetch(\`/api/users/${id}\`, { method: 'POST' })` | same |
+| **via** (folded plumbing) | controller → service → repository chains fold into `via` as hooks do today; the panel promotes it (`--ink-2`) | same |
+
+Words in the legend and the panel switch on the anchor: when the anchor's route name leads with an HTTP
+verb (`splitRouteName` in `api/routes.ts`), `screen` reads **endpoint**, `store` reads **data**,
+`bridge` reads **crosses a tier**, `event` reads **arrives from a queue / bus / the server**. Keep
+`kindWord()` in `steps-model.ts` as the one place that decides.
+
+---
+
+## 5. Work plan, in order
+
+Each item: what, where, the evidence rule (never guess — a wrong edge is worse than none), the test,
+and what "done" looks like on the picture. Do them in this order; P0 unblocks everything, P1–P3 make an
+API picture worth looking at, P4 makes a web app a Screens app, P5 widens the languages, P6 is words,
+P7 is the proof.
+
+### P0 — The root of an API route (small, unblocking)
+
+*Built* — `src/ui-server/api/route-roots.ts` (`routeRoots`, shared by `steps.ts` and `screens.ts`), the
+chooser lists endpoints by router file, `WireStep.screen` gained `endpoint` / `inline`; test
+`__tests__/ui-steps-api-servers.test.ts` (Express named + inline, Nest, FastAPI, Spring in one fixture).
+
+*Where:* `src/ui-server/api/steps.ts` (`buildSteps`, the `componentOf` map), and the same map in
+`screens.ts` for consistency.
+
+*Rule:* the root of a route anchor is, in order: (1) the target of the route's `references` edge whose
+target is a function/method (Express named handler, Nest method, React Router component); (2) the
+route's `calls`/`instantiates` target **only when it is a component** (`looksLikeComponent`, Expo/React
+pages); (3) the route node itself when it carries `calls` edges and nothing else (Express inline arrow —
+walk its callees as if the route were the handler; label the anchor `POST /users` and say "inline
+handler" in the sub line). Cross-check with the routing manifest (`cg.getRoutingManifest`,
+`api/routes.ts` resolves `handlerId` by file+line+name) and prefer it when both exist.
+
+*Also:* the Steps chooser (`StepsView.svelte`, the `!asked` branch) lists **routes** from `/api/routes`
+when `/api/screens` is not routed — grouped by router file, `METHOD path`, most-connected first.
+
+*Test:* extend `ui-steps-api.test.ts` with an Express fixture (one named-handler route with middleware
+args, one inline-arrow route) and a Nest fixture (controller with `@Controller('users')` +
+`@Get(':id')` + `@Post()`; a service injected via constructor; a repository). Assert the root, the first
+row, and that the walk reaches the service and the repository call.
+
+*Done when:* `#/steps?symbol=POST%20/users` on the fixture draws the handler's steps, not the anchor alone.
+
+### P1 — Effects for servers: `database`, `response`, `queue`, `email`, `payments`, `cache`, `auth`
+
+*Built* — `src/ui-server/api/effects.ts` (`classifyEffect`, `responseStatus`; rules per language family,
+`process` and Android rows added beyond the plan; `effect.model` / `access`, `site.status`, a response box
+per status since 2026-08-29 — the outcome is the box's identity, the line's pill its condition); tests `__tests__/ui-effects.test.ts`. Matching is on the call as written and on the
+receiver's declared type when the call leaves the index through it — see the status note at the top.
+
+*Where:* `EFFECTS` in `steps.ts` (make it a module of its own, `api/effects.ts`, with a table per
+category and unit tests — it is about to grow); `stepSub`/legend words in `steps-model.ts` / `StepsView.svelte`.
+
+*Rules:*
+- `database`: the receiver is a **known ORM client** — decide by the reference text *and* the import
+  origin of the receiver's binding when the graph has it (`prisma.*` where `prisma` is imported from
+  `@prisma/client` or a project file that constructs `new PrismaClient()`; `this.repo`/`this.*Repository`
+  typed `Repository<T>` (TypeORM); `Model.find*/create/update*/delete*` on a Mongoose model; `knex(…)`,
+  `db.select/insert/update/delete` (Drizzle), `pool.query`/`client.query` (pg), `sequelize`/`Model.*`,
+  `kysely`). The step's label is the call with its arguments as today; add `effect.model` = the model /
+  table when it can be read (`prisma.user` → `user`; `Repository<User>` → `User`; `knex('users')` →
+  `users`; raw SQL: first table after `FROM|INTO|UPDATE|JOIN`), and `effect.access = 'read' | 'write'`
+  from the method name (`find*/get*/count/aggregate/select` vs `create/update/upsert/delete/save/insert/remove`).
+  Box: `prisma.user.create({ data })` / sub `data · write · user · createUser`.
+- `response`: `res.status(…).json|send|end`, `res.json|send|sendStatus|redirect|render`,
+  `reply.code|send`, `c.json|text|redirect` (Hono), `NextResponse.json|redirect`, `throw new
+  *Exception(…)` / `throw new HttpError(…)` / `next(err)`, Python `raise HTTPException`, Spring
+  `ResponseEntity.*`, Go `c.JSON(…)`/`http.Error`. One box per (function, `response`) with `apis[]` as
+  today — **but the panel's scenario rows are the contract**, so keep every site with its WHEN and
+  arguments. Read the status code out of the arguments when literal (`status(404)`) and put it on the
+  site (`site.status`) so a row can say `404 · { error }`.
+- `queue`, `email`, `payments`, `cache`, `auth`: receiver/method tables like `network` today. Keep the
+  table curated and documented; false positives here are visible noise.
+
+*Test:* `api/effects.test.ts` over the table; extend the P0 fixtures with a Prisma create, a
+`res.status(404).json`, a `throw new NotFoundException`, a `queue.add('email', {…})`.
+
+*Done when:* `POST /users` shows `prisma.user.create({ data })`, `queue.add('email', {…})`, and the
+`response` box whose rows read `WHEN NOT user → 404 · { error: 'not found' }` / `always → 201 · user`.
+
+### P2 — Triggers for servers: the request, the guard chain, jobs, events, cron
+
+*Built* — `request` / `decorator` trigger kinds with `after` (the chain); Express-family middleware from the
+registration's arguments, guard decorators from `decoratorsForFile` (the index keeps no decorators), FastAPI
+`dependencies=[…]`; consumer decorators on a function anchored by name. The queue-consumer *reachability*
+(producer → `@Process`) is P3's.
+
+*Where:* `triggerInTree` in `graph/branch-guards.ts` gains a `decorator` form; `steps.ts` sets the
+anchor's / handler's trigger from the **route registration**, not from a JSX prop.
+
+*Rules:*
+- The trigger of a route's handler is the route itself: `{ kind: 'request', name: 'POST', of: '/users' }`
+  → `FIRES FROM POST /users`. The middleware / guard chain is read at the **registration site**: Express —
+  every argument before the handler in `app.post('/users', authenticate, validate(schema), createUser)`
+  (the resolver already knows the site line; read the arguments with `callArgumentsForFile` and drop the
+  last); Nest — `@UseGuards(...)`, `@UseInterceptors(...)`, `@UsePipes(...)` on the method **and** on the
+  class (class-level applies to every method); Fastify `{ preHandler: [...] }`; Koa `router.post(path,
+  mw, handler)`; Hono `app.post(path, mw, handler)`. Render as `FIRES FROM POST /users · after
+  authenticate, validate(…)` and put the chain on the link (`trigger.after: string[]`). Global
+  `app.use(mw)` before the route is a chain element too — read in file order, announce it as "global".
+- Queue consumers, event listeners, cron, message patterns, websocket handlers as triggers: Nest
+  decorators `@Process('x')`, `@OnEvent('x')`, `@Cron(expr)`, `@MessagePattern('x')`,
+  `@SubscribeMessage('x')`; Bull/BullMQ `queue.process('x', fn)` / `new Worker('q', fn)`; node-cron
+  `cron.schedule(expr, fn)`; socket.io `socket.on('x', fn)`; Kafka/SQS consumers. The `option` and
+  `callback` forms already cover several of these (`process('x', fn)` = callback of `process` with first
+  literal `'x'` → add the names to `LATER_CALLEES`); decorators need the new form: climb from the site to
+  the decorated method/class and read `decorator` nodes (`@Name(args)`).
+
+*Test:* `branch-guards.test.ts` `triggers` block: Express registration with middleware, Nest guards on
+class and method, `@Process`, `@Cron`, `queue.process`, `socket.on`.
+
+*Done when:* the handler box's sub line reads `POST /users · after authenticate, validate(…)` and a
+consumer reads `FIRES FROM @Process('email')`.
+
+### P3 — Cross-tier channels (the RN bridge, for the web)
+
+*Built* (2026-08-28, later session) — `src/resolution/tier-synthesizer.ts` (one pass, three channels: `http-client` with
+`tier: 'client→server'`, `queue-job`, `event-bus` for a bus and for sockets both ways; registered before the emitter pass), Next
+server actions marked at request time from the `'use server'` directive (`api/when.ts` `directive`), `crossing()` in `steps.ts`
+reading `tier` / `channel`, an endpoint reached across a tier drawn as a bridge box that is a boundary like a screen, a channel's
+call never also an effect, a top-level `new Worker` landing on its constant with the file-scope calls lent to it; Express mounts
+(`app.use('/api', router)`, nested, by import or `require`) composed onto route names in `postExtract`, and the chained
+`router.route('/x').get(h).put(h2)` form extracted; `e2e/` counts as a test directory. Test: `__tests__/ui-steps-cross-tier.test.ts`.
+Later the same day: FastAPI `APIRouter(prefix=)` + literal `include_router(prefix=)` composed (`python.ts` `postExtract`;
+`fastapi/full-stack-fastapi-template` 23 routes now read `GET /items/{id}` instead of `GET /`; its `settings.API_V1_STR`
+mount is skipped, not guessed) and the ASP.NET endpoint-group form (`csharp.ts`: `groupBuilder.MapPost(Handler[, "path"])`
+under the class, the app's `$"/api/{groupName}"` head read in `postExtract`, `RoutePrefix` honoured — the
+`jasontaylordev/CleanArchitecture` shape). Verified on `bradtraversy/proshop_mern` (30 routes, 23 client→route edges, every one correct on inspection; `login` reads
+`login → ⇢ POST /api/users/login (authUser) → User.findOne({ email }) → 401 rows → jwt.sign via generateToken`, which needed
+`routeRoots` to accept a function-valued constant — `const authUser = asyncHandler(async (req, res) => …)` — and the walk to lend
+such a value the file-scope calls and unresolved refs within its lines; and framework detection to read a workspace's
+`package.json` (`frameworks/package-deps.ts`), proshop keeping `react` in `frontend/`) and `nestjs/nest`
+(`sample/26-queues` `transcode` → `@Process('transcode')`, `sample/30-event-emitter` → `@OnEvent`; the `integration/*/e2e`
+helpers no longer count). **Not built:** tRPC (a procedure's inline handler is not a node — extractor work with the kernel twin).
+**Gap found:** a nested `const handleSubmit = async (e) => …` inside a component is not a node (only `function` declarations and
+`useCallback`-bound arrows are — `tree-sitter.ts` `reactHookBoundName`), so such a handler's `fetch` attributes to the component and
+the link carries no FIRES FROM; the fix is an extractor rule for a nested arrow bound by a declarator, in TS and in the Rust kernel.
+
+*Where:* new synthesizers in `resolution/callback-synthesizer.ts` (register in the channel list with a
+language gate), or a resolver for the resolvable ones; each tagged `provenance: 'heuristic'`,
+`synthesizedBy`, `registeredAt`. **Close both directions before shipping any of them** (playbook).
+
+1. **HTTP call → own route.** A JS/TS call `fetch('/api/users/…')`, `axios.post('/api/users')`,
+   `api.get('/users')` (a project axios instance with a literal `baseURL`) whose path literal (or
+   template with `${…}` segments as `:param`) matches a route node `METHOD path` in the same index
+   (method from the call: `fetch(url, { method: 'POST' })`, `axios.post`, else GET). Prefix-aware:
+   Express `router.use('/api', usersRouter)` mounts (P0's resolver gap — fix the label there too),
+   Next `app/api/**/route.ts` (P4). Edge: caller → route, kind `calls`, `metadata.tier: 'client→server'`,
+   confidence by how much of the path was literal. **Steps then draws the route as a `bridge` box
+   (`⇢ POST /api/users`) and, with `through=1`, walks on into the handler** — the capture→upload story
+   for a web app. Evidence bar: the path must be literal enough to match exactly one route; a bare
+   `fetch(url)` with a variable url produces nothing.
+2. **Next server actions** (P4 prerequisite): a function in a `'use server'` file, or marked with the
+   directive, called from a client component / passed as `action={fn}` → the call edge exists already
+   (it is a normal import); mark it `tier: 'client→server'` at resolution (the callee's file has the
+   directive) so Steps classifies it as `bridge` with evidence.
+3. **tRPC**: `trpc.users.create.useMutation()` / `.mutate(…)` ↔ `router({ users: router({ create:
+   procedure.mutation(…) }) })`: match the dotted path against the router object keys (object-literal
+   member resolution exists: `resolveObjectLiteralMember`). Client → procedure handler, `tier`.
+4. **Queues / buses**: BullMQ `queue.add('job', …)` ↔ `@Process('job')` / `worker = new Worker('q',
+   fn)`; Nest `EventEmitter2.emit('x')` ↔ `@OnEvent('x')`; socket.io `server.emit('x')` ↔ `socket.on('x')`
+   and Nest `@SubscribeMessage('x')`. Same shape as `rnEventEdges` (literal on both sides, fan-out cap,
+   `event` metadata); Steps classifies the landing as `event` ⇠ when the edge is synthesized and crosses
+   into a handler — extend `crossing()` to accept a `tier`/`channel` marker, since both sides are TS.
+
+*Test:* a monorepo fixture (`apps/web` Next page with a `fetch('/api/users')` + `apps/api` Express
+`app.post('/api/users')`), a BullMQ producer/consumer, a Nest `emit`/`@OnEvent` pair. Assert the edges
+(source, target, metadata) and that `buildSteps` from the page reaches the database effect **through**
+the route with `through=1`.
+
+*Done when:* from the web app's page, the picture reads `page → handler → ⇢ POST /api/users … →
+prisma.user.create → response`, dashed where synthesized, with `registeredAt` in the panel.
+
+### P4 — Next.js as a Screens app
+
+*Built* (2026-08-28, later session) — `frameworks/nextjs.ts` split out of `react.ts` (App Router pages and `route.ts` handlers,
+Pages Router pages and `pages/api`, `(group)` stripped, `[slug]` → `:slug`, `[...all]` → `:all*`, parallel / intercepting
+routes skipped; a page's `calls` ref to its default export via `defaultExportName`; `resolve()` claiming `router.push|replace|
+prefetch`, `redirect`, `permanentRedirect`, `NextResponse.redirect(new URL(…))` through the Expo href readers — now exported —
+against a Next-only route table gated on the app's root), `next-router-synthesizer.ts` (`<Link href>`, internal `<a href>` →
+dashed `navigates` from the component), `scoreMatch` accepting `:param` / `:all*`, the `load` trigger and `project: 'web'` for a
+Next page in `steps.ts`, `{ status: 201 }` read off the call site for response rows. Test `__tests__/nextjs.test.ts`.
+Route-handler references resolve by name with the same-file preference (`GET` / `POST` are common names). **Not built:** `revalidatePath`
+as a refresh, `middleware.ts` `config.matcher` as a global guard, a helper's return value as a destination (Expo has it).
+**Verified on `leerob/next-saas-starter`:** 8 pages + 4 endpoints, Screens routed (12 screens, 15 links), `<Link>`s in the
+layout, `redirect()` in the actions, `NextResponse.redirect` in the middleware and the checkout handler all bound. Two
+gaps it showed, both the wrapped-arrow idiom: `export const signIn = validatedAction(schema, async (data) => { … })` holds
+its `redirect` on the FILE node (Screens now re-attributes a file-scope navigation to the value spanning it), and
+`useActionState(signIn, …)` leaves no function-as-value edge (a plain call argument — Screens now falls back to the
+functions that MENTION the value in the files importing it, read from the source; the principled fix is an extractor
+fnRef rule for `useActionState` / `useFormState` / `startTransition` arguments, with the Rust kernel twin).
+With both: 12 screens, 17 links, 8 origins — `/sign-in → /dashboard via Login > signIn WHEN userWithTeam.length !== 0 &&
+isPasswordValid && redirectTo !== 'checkout'`, `/dashboard/security → /sign-in via deleteAccount WHEN isPasswordValid`.
+
+*Where:* `resolution/frameworks/react.ts` (split a `nextjs.ts` out of it — the pages/app routing is
+already there), a `next-router-synthesizer.ts` modelled on `expo-router-synthesizer.ts`.
+
+*Rules:*
+- Routes: App Router `app/**/page.{tsx,jsx,js}` → page route named by path (`(group)` stripped,
+  `[slug]` → `:slug`, `[...all]`, parallel/intercepting routes announced not modelled);
+  `app/**/route.ts` exports `GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS` → one route node each, `METHOD
+  /api/…`, with a `references` edge to the exported function; Pages Router `pages/**` and `pages/api/**`
+  (default export = handler; method from `req.method` switches — announce as `ANY`). `layout.tsx`,
+  `loading.tsx`, `error.tsx` are not routes; `middleware.ts` with `config.matcher` is a global guard (P2 chain).
+- Route → component: the page file's default export (`defaultExportName` exists in expo-router's
+  resolver — reuse) → `calls` edge, exactly as Expo Router.
+- `navigates`: `<Link href="/x">` (JSX attribute literal / template / object `{ pathname }`),
+  `router.push|replace('/x')` from `next/navigation` and `next/router`, `redirect('/x')` /
+  `permanentRedirect` (server), `NextResponse.redirect(new URL('/x', req.url))` in middleware and route
+  handlers, `<a href="/x">` to an internal path, `revalidatePath('/x')` (announce as a *refresh*, not a
+  navigation). Helper return values through the existing return-value synthesizer pattern.
+- Triggers on a page: the page's server work FIRES FROM **page load** (`{ kind: 'load', name: 'GET',
+  of: '/blog/[slug]' }`) — the RSC body, `getServerSideProps`, `generateMetadata`; client handlers as today.
+
+*Test:* an `expo-router.test.ts`-shaped Next fixture: two pages, a `<Link>`, a `router.push` behind a
+condition, a `redirect()` in a server action, a `route.ts` `POST`; assert routes, `navigates` metadata
+(`href`, `navMethod`), `buildScreens` (`routed: true`, the transition with its `when` and `via`), and
+`buildSteps` from a page reaching the server action (`⇢`) and the Prisma call.
+
+*Done when:* a Next app lands on the Screens tab like the mobile app does, and a page's Steps picture
+shows load-time data, handlers, server actions and route handlers as boundaries.
+
+### P5 — WHEN / arguments / triggers for Python, Java, Go (then Ruby, PHP, C#)
+
+*Built* for Python, Java, Kotlin, C#, Go, C / C++ / Objective-C (guards, arguments, the call as written,
+decorators, member types); Ruby and PHP still yield nothing. Test `__tests__/branch-guards-languages.test.ts`.
+
+*Where:* `graph/branch-guards.ts` — `Rules` per language for guards (`if`/`elif`/`else`, early
+`return`/`raise`/`continue`, `try`/`except`, `match`; Java `if`/`switch`/`throw`; Go `if err != nil {
+return }` as the idiomatic early exit, `switch`/`select`); argument containers (`argument_list`,
+`keyword_argument` → `name=value`); triggers (FastAPI `@router.post('/x', dependencies=[Depends(auth)])`,
+Flask `@app.route`, Django URLconf + `@login_required`; Spring `@PreAuthorize`, `@Transactional`;
+Gin middleware chain — `ginMiddlewareChainEdges` already knows it). Request-time only — no kernel work.
+`supportsBranchGuards` widens per language as rules land; a language without rules must still yield
+*nothing*, never a wrong label (§1 principle 6).
+
+*Test:* `branch-guards.test.ts` blocks per language, mirroring the JS ones.
+
+*Done when:* a FastAPI route's Steps picture carries the same four readings as an Express one.
+
+### P6 — Words and the chooser
+
+*Built* — `project` on the wire, `kindWord` / `kindWords` / `countWords`, the legend per project kind, the
+endpoint chooser. The Screens tab stays hidden for an API (no `navigates`).
+
+*Where:* `ui/src/lib/steps-model.ts` (`kindWord`, `stepSub`, `stepLabel`), `StepsView.svelte` legend
+and summary, `api/steps.ts` (`WireStepsPayload.project: 'app' | 'api' | 'web'` decided from the route
+names and the frameworks detected, so the viewer does not guess).
+
+- Endpoint boxes: `POST /users` mono, sub = handler name · file (like a screen's component). Response
+  boxes: dashed like other effects, label the status codes when literal (`404 · 201`).
+- Kind words per project kind (§4 table). The legend re-words itself from the same table. Keep the
+  sentence-case, no-tracking rule (spec §2) — capitals are only the condition keywords.
+- The chooser: routes grouped by router file, then pages; "most connected" by fan-out of the handler.
+- The Screens tab for an API: hide it (as today when `routed: false`) unless P3's inter-endpoint links
+  produce a picture with more than a handful of arrows — measure on the validation repos first; the
+  Entry points view is the honest list until then.
+
+### P7 — Validation set and the numbers
+
+*First numbers* (2026-08-28, later session) — the deterministic half is in the playbook §6 rows (Next.js, MERN, Nest
+channels, FastAPI prefixed routers, ASP.NET endpoint groups: node counts, edge precision spot-checks, pictures). One agent
+A/B so far, `bradtraversy/proshop_mern` (small, Express + React), `scripts/agent-eval/run-all.sh`, Sonnet/high, 2 runs per
+arm, a daemon pre-warmed before each with-run, the CLI shim on (0 leaks): *"How does submitting the login form reach the
+database, and what does the API respond with when the password is wrong?"* — with codegraph 14s / 14s, 2 tool calls, 0 Read,
+0 Grep, 1–2 explores, the full path named (`submitHandler → login → POST /api/users/login → authUser → User.findOne →
+matchPassword → 401`); without 18s / 37s, 14 / 8 tool calls, 7 / 1 Read plus Bash `cat`s and a subagent. Tokens 153k/165k vs
+237k/369k. The pass bar (§4 of the playbook) holds on the small repo. **Still open:** the medium / large rows (Ghost,
+immich, cal.com / twenty), ≥3 prompts per framework, and a control repo. The driver lives in the session scratchpad
+(`ab-proshop.sh`: pre-warm `serve --mcp` with `CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS` high and `CODEGRAPH_WASM_RELAUNCHED=1`,
+then `run-all.sh` per run with its own `AGENT_EVAL_OUT`); re-create it from the memory note.
+
+Small fixtures live in the tests. For the real bar, index these and record the results in
+`docs/design/dynamic-dispatch-coverage-playbook.md` (new rows) exactly as the playbook asks
+(**≥3 flow prompts × small/medium/large, node count stable, synthesized-edge precision spot-check,
+agent A/B with `--model sonnet`, ≥2 runs per arm**):
+
+| Framework | Small | Medium / large | Canonical flow to draw |
+|---|---|---|---|
+| Express | `gothinkster/node-express-realworld-example-app` | `TryGhost/Ghost` | `POST /api/articles` → `auth` → handler → service → DB → 201 / 422 |
+| NestJS | `nestjs/nest/sample/01-cats-app` (and `sample/*`) | `immich-app/immich`, `amplication/amplication` | `POST /assets` → `@UseGuards(Auth)` → controller → service → repository → queue job → `@Process` consumer |
+| Next.js | `vercel/next.js/examples/*` (app-dir + prisma) | `calcom/cal.com` (Next + Prisma + tRPC), `twentyhq/twenty` (Nest + Next monorepo — the cross-tier story) | page load → data → form action → server action / route handler → DB → `redirect` |
+| FastAPI | `tiangolo/full-stack-fastapi-template` | — | `POST /users` → `Depends(get_current_user)` → CRUD → session commit → `HTTPException` rows |
+| Spring | `spring-projects/spring-petclinic` | — | controller → service → JPA repository → view / `ResponseEntity` |
+
+Acceptance per framework: the canonical flow drawn end to end with all four readings on every link;
+boundaries where they should be; no picture over the caps at depth 8 on the small repo; every
+synthesized edge in the picture spot-checked against the source; the agent A/B not regressing the
+control repos.
+
+---
+
+## 6. Conventions and gotchas (learned the hard way this session)
+
+- **Extractor changes need the Rust twin** (§2.4). A TS-only extractor patch silently does nothing on
+  a machine with the kernel binary staged — tests pass under `CODEGRAPH_KERNEL=0` and fail by default.
+- **Function-as-value capture is what makes handlers visible**: JSX attribute values, `on*` options,
+  object shorthand members (`return { handleX }`) are capture sites (`TS_JS_SPEC.dispatch` in
+  `extraction/function-ref.ts`, mirrored in `codegraph-kernel/src/tsjs/fnref.rs`). The gate is
+  "defined in this file or imported" — a handler that comes out of a hook destructure in another file is
+  found through `contains`, and P0's route handlers through `references`. When a handler folds that
+  should be a box, check which of these it fell through.
+- **Evidence over inference.** A cross-family edge without `resolvedBy: 'framework'`, `bridge`, or
+  `provenance: 'heuristic'` is name-matcher noise (`arr.flat()` landing on a Swift `flat`) and Steps
+  drops it. Keep that rule for tiers: a `fetch` with a variable URL is nothing, not a guess.
+- **Two passes per fold** (classify arrivals, then fold the rest) so a node that is a step is never also
+  folded through its `contains` edge; keep `defines …` sites out of the rows when a call site exists.
+- **Readings are per site.** `WireStepSite.when`, `.args`, `.trigger` — the link's `when`/`trigger` is
+  only the summary. Scenario rows and the common-prefix factoring are in `ui/src/lib/conditions.ts`.
+- **Caps are announced, never silent** (`cut`, `truncated`). A new cap must say so on the step it hits.
+- **Docs to update with every change**: the design spec section, `CHANGELOG.md` `[Unreleased]` in the
+  user-facing style the file prescribes (no paths / symbol names / numbers), `CLAUDE.md` if a module or a
+  rule is added. The coverage playbook gets a row per validated framework.
+- **The UI package seam**: `ui/src/lib/adapter.ts` (`steps` optional), `navigation.ts`
+  (`stepsHref` required — the Pro app's driver must add it), `check-ui-package.mjs` prunes the app shell;
+  nothing outside `adapter.ts` may reach the network.
+- **Known flake**: `__tests__/mcp-daemon.test.ts` "daemon idle-times-out" fails under full-suite load
+  (~1 in 3 runs) and passes alone. Not related to any of this.
+- **Do not commit or push** unless asked; the session's work is uncommitted on `main`'s working tree of
+  `~/Development/CodeGraph/codegraph` (27 modified, 12 new files as of this writing) — branch first
+  (`feature/…`) when you do.
+
+---
+
+## 7. Open questions for the maintainer
+
+1. **A "Routes" picture for pure APIs, or Entry points as the list?** Recommendation: measure
+   inter-endpoint links on the validation repos after P3; build the picture only if it has arrows.
+2. **`response` as steps** (recommended: yes — the contract-as-code reading) vs. folded into the handler.
+3. **How much schema on `database` boxes**: model + read/write from the call (cheap, proposed) vs.
+   fields from the ORM schema (Prisma `schema.prisma`, TypeORM entities) — a later, separate reading.
+4. **Project kind on the wire** (`app | api | web`) decided server-side from routes + frameworks, or a
+   viewer toggle? Recommended: server-side, with the viewer allowed to override in the URL.

+ 341 - 0
docs/plans/2026-08-29-steps-in-code-order.md

@@ -0,0 +1,341 @@
+# Steps, in the code's order — plan
+
+**Status: BUILT** 2026-08-29 on `feature/steps-servers`, P0–P6. Written the same day (at tip `5797d7e`) as a plan for a
+fresh session; what follows is that plan, kept as written, with the notes below on where the build differs from it.
+The reading is `&view=order` on the Steps tab, `src/ui-server/api/program.ts` + `ui/src/lib/program-model.ts`, and
+spec §3.13.1 is the description of what was built.
+
+**Where the build differs from the plan:**
+- **A guard carries the decision it belongs to** (`BranchGuard.branch`), not just its text and line — §4.1's
+  "same `line`, same `text`" pairing does not tell one `switch` case from another, nor two `try`/`catch` blocks apart.
+  It also carries how the arm it is in leaves (`armExit`) and, for an early exit, how the arm not taken leaves (`exit`),
+  which is where `WireArm.ends` comes from.
+- **A function is read ONCE per rail** (`again` on the item), not redrawn at every call: expanding per path turned an
+  87-step screen into 3,849 items and 618 KB. Once-only is 476 items and 94 KB (+3% wall clock on that picture).
+- **A step the walk entered reads on into its own body** under its box, so the rail holds the same steps the tree does
+  (§4.1.7 only said this for boundaries under `through`).
+- **`WireItem`'s blocks are one kind with a discriminator** (`block: 'inline' | 'loop' | 'later' | 'together'`) rather
+  than four item kinds, and they carry facts (`by`, `via`, `loop`) rather than words — the viewer says them.
+- **The reading is drawn on the CANVAS, not as a nested document.** §4.2's rail — a column of boxes with forks as rows
+  of arm columns — was built first and rejected on sight by the maintainer: *"this is very hard to read… go back to the
+  way it looked, but since the 200 and 401 come after the signing of the jwt those should branch out of it."* The right
+  picture is the same Svelte Flow canvas with the same boxes, laid out by WHEN things happen: a line means **and then**,
+  a row down is one more thing already done, and the fork's condition rides on the line (drawn at rest — on this
+  picture the conditions are the content). `ui/src/lib/program-model.ts` turns the block tree into that graph;
+  `StepsRail`/`RailBlock` are gone.
+- **Loops needed their own reading** (`loopsForFile`), and loops and forks nest by which construct BEGINS first, since
+  neither reading knows about the other.
+- The open questions of §7 were answered: order for functions/endpoints and the tree for screens (1); read each
+  function once rather than capping the fold depth (2); a loop's body once, marked (3); the fork carries its condition
+  and the arms say WHEN / WHEN NOT (4).
+
+The Steps tab as it stood (spec §3.13, `src/ui-server/api/steps.ts`, `ui/src/views/StepsView.svelte`) is the base;
+everything here is a second *reading* of the same walk, not a new walk.
+
+**The ask, in the maintainer's words:** on proshop's `POST /api/users/login` the picture draws `User.findOne · jwt.sign ·
+200 · 401` in one row under the handler. That is true — all four are one step from `authUser` — and it is not what a
+person wants from a handler. They want *the flow of the code*: first the lookup, then **if** the password matches, sign a
+token and answer 200, **else** answer 401. "I think this should show the flow/order of the code. That would be way more
+useful to me." Build that.
+
+Read first, in this order: spec §3.13 and §3.14 (`docs/design/codegraph-ui-design-spec.md`), then
+`docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md` §2 and §6 (how the pictures work and the gotchas), then the
+auto-memory note `steps-for-apis-2026-08-28` (the two facts that made Steps work, the screenshot harness), then the files in
+§3 below. `CLAUDE.md` for tests, docs and the no-kernel-work rule (this plan is all request-time: no extractor changes).
+
+---
+
+## 1. The reading to build
+
+For `authUser` in `bradtraversy/proshop_mern` (`backend/controllers/userController.js`):
+
+```js
+const user = await User.findOne({ email })
+if (user && (await user.matchPassword(password))) {
+  res.json({ _id: user._id, …, token: generateToken(user._id) })
+} else {
+  res.status(401)
+  throw new Error('Invalid email or password')
+}
+```
+
+the picture reads top to bottom, as a rail that forks where the code forks:
+
+```
+● POST /api/users/login · authUser          FIRES FROM POST /api/users/login
+│
+├─ User.findOne({ email })                  database · User · read
+│
+╞═ user && (await user.matchPassword(password))
+│    ┌─ WHEN ──────────────────────────┐   ┌─ WHEN NOT ─────────────────────────┐
+│    │ jwt.sign({ id }, …)  auth        │   │ 401 · res.status(401)   response    │
+│    │   via generateToken · inside     │   │   then throw new Error('Invalid …') │
+│    │   res.json(…)                    │   └─────────────────────────────────────┘
+│    │ 200 · res.json({ _id, …, token })│
+│    └─────────────────────────────────┘
+```
+
+Every reading the Steps tab has today survives on each box — kind, `FIRES FROM`, `via`, `WHEN`, what it passes, the status,
+`inside res.json(…)` — but the *arrangement* is the code's: **sequence down the rail, branches as forks, an arm that
+replies (or returns / throws) ends there, arms that fall through rejoin.** A helper the walk folded (`generateToken`) is
+drawn *in place*: its own steps appear where it is called, marked `via generateToken`. A boundary (another endpoint the
+handler calls, another screen) sits in the sequence where the call is, still not entered unless `through` asks.
+
+The reading is the **outline of a function**, not a control-flow graph: statements in source order, `if` / `else` /
+`switch` / `try` / early exits as forks, loops as a marked block (the body once, "for each …"), callbacks that run later
+(`.then`, `setTimeout`, `useEffect`) as a marked block ("later"), `Promise.all([a(), b()])` as siblings marked "together".
+Source order is execution order for straight-line code and for arguments before their call; where it is not (callbacks,
+concurrency) the block says so rather than pretending.
+
+**What stays:** the existing tree ("what it sets in motion", rows = distance) remains as the other reading, one toggle
+away — it is the right picture for a screen, where handlers fire on events and have no order. **Default:** in order when
+the anchor's root is a function / method (a handler, an endpoint, any symbol); the tree when the anchor is a screen.
+Both read the same URL (`&view=order` / `&view=tree`), the same panel, the same double-click and `Start here →`.
+
+---
+
+## 2. Why the current picture cannot say it
+
+`buildSteps` is breadth-first from the anchor's root: a node is a *step* when it is a screen, a handler, a bridge, an
+event, a store action or an effect; everything else folds into the link's `via`. Rows are distance from the anchor.
+Since 2026-08-29 a row is ordered by the position of the hop that first reached each step, with a hop written inside
+another call's arguments before that call (`WireStep.order`, `hopCompare` in `steps.ts`) — that is how `jwt.sign` sits
+left of `200`. The link carries the whole guard chain as one `when` string and the enclosing call as `within`.
+
+So the walk already knows, per step, **where** it is reached (line, column, span) and **under what** (the guard chain).
+What it throws away is the *structure* between siblings: which guards are the same `if`, which arm is the `else` of
+which, what ends an arm. The tree cannot draw `authUser → 200 under jwt.sign` because that would be a lie (a `jwt.sign →
+200` edge); the rail can, because a rail is not a graph of causes but the order of statements.
+
+---
+
+## 3. Facts to build on (verified 2026-08-29; re-verify, these files move)
+
+| Need | Where it is today | Notes |
+|---|---|---|
+| The steps, links, sites, `via`, `when`, `trigger`, `status`, `order`, `within` | `src/ui-server/api/steps.ts` `buildSteps` → `WireStepsPayload` (mirrored in `ui/src/lib/wire.ts`) | one box per (function, category), per status for replies; boundaries `cut: 'screen'`; `through=1` |
+| Per-site position + span + enclosing call | `graph/branch-guards.ts` `callSitesForFile` → `CallSiteText { callee, args, argList, span, within, status }` | `span` = the call's start/end; `within` = the call whose arguments contain it (stops at a function / block) |
+| Per-site guard chain, structured | `guardsForFile` → `BranchGuard[]` (`text`, `negated`, `form`, `line`), joined by `guardLabel` into the `when` string | **the plan needs the array, not the string** — `SiteReader.when` returns the string; add `guards(caller, site): BranchGuard[]` beside it (`api/when.ts`) |
+| The rules that produce guards, per language | `RULES_BY_LANGUAGE` / `interface Rules` in `branch-guards.ts`: `boundaries`, `inlineFunctions`, `bindingParents`, `blocks`, `enclosing`, `earlyExits` | JS/TS, Swift, Python, Java, Kotlin, C#, Go, C; an early `return` / `throw` / `raise` before a site is already a negated guard in that site's chain (`earlyExits`) |
+| What fires a site (later-running callbacks) | `triggersForFile` → `SiteTrigger { kind: 'prop' \| 'option' \| 'callback', name, of }`; `LATER_CALLEES` | `kind: 'callback'` of `then` / `setTimeout` / `useEffect` / `addListener` = runs later |
+| The hop that first reached a step | `HopSite` in `steps.ts` (`file`, `line`, `column`, `end`, `within`), carried on `Fold.first`, sorted by `hopCompare` / `hopInside` | this is the position the rail places a step at |
+| Words for conditions | `ui/src/lib/conditions.ts`: `clauses`, `splitTop`, `conditionTokens`, `whenTokens`, `commonTokens`, `scenarios` | WHEN / AND / OR / NOT as words; scenario rows per site |
+| Box words and looks | `ui/src/lib/steps-model.ts` (`kindWord`, `stepLabel`, `stepSub`, `triggerWords`), `ui/src/components/steps/StepNode.svelte` | reuse the box verbatim; only the arrangement is new |
+| The view, URL, panel | `ui/src/views/StepsView.svelte` (`rewrite`, `navigate`, the panel with `Start here →`, the summary's `through` checkbox), `ui/src/lib/navigation.ts` `stepsHref` / `StepsHrefOptions`, `ui/src/lib/router.svelte.ts`, `App.svelte` | add `view` to the options and the route |
+| Tests to copy the shape of | `__tests__/ui-steps-api-servers.test.ts` (four frameworks in one fixture), `ui-steps-cross-tier.test.ts`, `nextjs.test.ts`, `ui-steps-model.test.ts` (pure model), `ui-conditions.test.ts` | |
+| Headless pictures | the auto-memory note: `shoot.mjs` (Playwright over `codegraph ui --no-open`, one shot per hash URL; **never `npm run build` while a `codegraph ui` runs**); `scripts/try-repo.sh <preset>` to clone + index + open | proshop, express-realworld, nest-boilerplate, next-saas-starter, fastapi-template are the presets |
+
+The walk's caps and budgets (`MAX_CALL_SITES`, `MAX_WHEN_SITES`, fold depth, fan-out, steps per picture) apply unchanged;
+the rail draws the same steps the tree does, so it costs one more structured read per site, nothing more.
+
+---
+
+## 4. Design
+
+### 4.1 The program: a block tree derived from the walk
+
+Add to the payload (same request, `view=order` asks for it; the tree's `steps` / `links` stay so the panel works):
+
+```ts
+/** The anchor's body as the code reads: items in order, forks where it forks. */
+interface WireProgram { root: WireBlock; truncated: number }
+type WireBlock = WireItem[];
+type WireItem =
+  | { kind: 'step'; step: string /* WireStep.id */; link: string /* WireStepLink.id */; site: WireStepSite; via: WireNodeRef[]; within?: string }
+  | { kind: 'fork'; on: string /* the condition as written */; form: 'if' | 'switch' | 'ternary' | 'try' | 'exit' | 'guard'; arms: WireArm[] }
+  | { kind: 'loop'; on: string /* `item of items` */; body: WireBlock }
+  | { kind: 'later'; by: string /* `then` / `useEffect` */; body: WireBlock }
+  | { kind: 'together'; body: WireBlock }            // Promise.all / Promise.allSettled arguments
+  | { kind: 'inline'; via: WireNodeRef; when: string; body: WireBlock }   // a folded helper, drawn where it is called
+  | { kind: 'cut'; why: WireStep['cut'] };           // a cap or a boundary, where the walk stopped
+interface WireArm { when: string /* this arm's own clause: `…` or `NOT …`, or `case 'x'` */; ends: 'reply' | 'return' | 'throw' | null; body: WireBlock }
+```
+
+**How it is built (server, `src/ui-server/api/program.ts`, called from `buildSteps` when asked):**
+
+1. **Collect the sites per function.** For the anchor's root and every function the walk folded into (the `via` chain
+   members) or entered (a step with a root), take every hop and effect site the walk made *from that function*: the
+   step it reached (or the helper it folded into), the site's position (`span.start`, column-exact), and its structured
+   guard chain (`guards(caller, site)` — the new `SiteReader` method — outermost first). One structure per function,
+   keyed by the function's node id; the walk already visits each function once, so record while walking rather than
+   re-walking.
+2. **Order** each function's sites by `hopCompare` (source position; a site inside another site's arguments first).
+3. **Fold the guard chains into forks.** Walk the ordered sites keeping a stack of open forks. For a site with guard
+   chain `g1…gn`: the longest prefix shared with the open stack stays open; deeper open forks close; each remaining
+   guard opens a fork (for an `if` — `form: 'if'`, `on: g.text`) with the arm this site is in (`when: g.negated ? NOT g.text
+   : g.text`). A later site whose chain has the *same* guard (same `line`, same `text`) with the opposite `negated`
+   joins the **same fork as its other arm** — that is how `else` and the `||`-arm are found without a CFG; a `switch`
+   guard (`form: 'switch'`) opens one arm per `case` text. A negated guard that `earlyExits` produced (a `return` / `throw`
+   before the site) is a fork whose *other* arm is the exit: `arms: [{ when: g.text, ends: 'return' | 'throw', body: [] },
+   { when: NOT g.text, body: […] }]` — draw the exit as a terminal, not a box.
+4. **End an arm.** An arm whose last item is a reply step (`effect.category === 'response'`) or whose guard came from a
+   `throw` / `return` ends (`ends`); the rail does not rejoin below it.
+5. **Inline the folds.** A hop that folded into a helper becomes `{ kind: 'inline', via: helper, body: program(helper) }` at
+   the hop's position, recursively, to the walk's fold depth; the helper's own guards are relative to its body (the
+   fold's outer `whens` are already on the position in the caller). A step reached through a helper appears **only** in
+   the inline block (never also at the top level).
+6. **Later and together.** A site whose trigger is `kind: 'callback'` of a `LATER_CALLEES` callee, or that sits inside an
+   inline function (the `Rules.inlineFunctions` climb) passed to `.then` / `setTimeout` / `useEffect`, goes under a
+   `later` item at the position of the registering call; sites inside `Promise.all([...])` arguments go under `together`.
+   Loops: a site inside a `for` / `while` / `forEach` body goes under `loop` (the guard reader knows the loop node types
+   per language — `Rules.blocks` includes loop bodies; add the loop header text as `on`).
+7. **Boundaries and cuts** are `step` items (a screen / endpoint step, `cut: 'screen'`) — the rail shows them where the
+   call is; `through` enters them by appending their program as an `inline` block, as the tree enters them today.
+
+This is a derivation from data the walk has; the only new reading is the structured guard array. **No CFG, no
+extractor, no kernel work.** It is honest by construction: a fork exists only where a guard was read; a site with an
+unreadable chain (a language without rules, a drifted file) sits in sequence with no fork, which is the same silence
+the tree shows today.
+
+### 4.2 The view
+
+`ui/src/views/StepsView.svelte` gains the mode; the rail is a new component `ui/src/components/steps/StepsRail.svelte`
+(or `SequenceView`) rendered instead of the Svelte Flow canvas when `view === 'order'`. No layout engine: the rail is
+flex / grid — a column of boxes; a fork is a row of arm columns under a condition pill, each arm a column; a rejoin is a
+hairline back to the rail; an arm that `ends` stops at its terminal (the reply box, or a small `return` / `throw` mark
+in `--ink-3`). `loop`, `later`, `together`, `inline` are bracketed blocks with a left rule and a one-word label in
+`--ink-3` (`for each item`, `later · then`, `together`, `via generateToken`). Boxes are `StepNode`'s markup and styles
+verbatim (kind classes, accent rule for bridge / event, dashed effect, `⇢` / `⇠`), sized to content — reuse the
+component with a `rail` prop or extract its inner markup; the click / double-click contract is identical (select →
+panel; double-click → `Start here`). Keep the **pill** words: the fork's condition in the conditions vocabulary
+(`conditionTokens`), `WHEN` / `WHEN NOT` on the arms, ≤ 36 chars with `…` and the full text on hover, as §3.12 pills.
+
+Words (`kindWord` stays): the toggle reads **"Read as: in order · what it sets in motion"**; the legend for the rail:
+"Boxes read top to bottom in the code's order; a fork is an `if`, `switch`, `try` or an early exit, its arms side by
+side; an arm that replies, returns or throws ends there; `via x` is a helper drawn where it is called; `later` runs
+after the handler returns." Sentence case, no tracking (spec §2).
+
+The panel is unchanged — the same `WireStep` / `WireStepLink` shapes, `Start here →`, `Open as a flow →`, the scenario
+rows. The summary keeps `depth`, `through`, the counts; `Continue through` enters boundaries in the rail too.
+
+### 4.3 URL and defaults
+
+`StepsHrefOptions.view?: 'order' | 'tree'`; the router parses `view`; `rewrite()` carries it; the default when absent:
+`order` for a function / method / endpoint anchor, `tree` for a screen anchor (the anchor's root kind decides, server
+side, in the payload as `defaultView` so the viewer never guesses). The Screens tab's double-click and the chooser open
+the default.
+
+---
+
+## 5. Work plan, in order
+
+**P0 — structured guards on the reader.** `api/when.ts` `SiteReader.guards(caller, site): Promise<BranchGuard[]>` (the
+array `guardsForFile` already computes; `when` keeps returning the joined string). Test: `branch-guards.test.ts` already
+covers the chains; add one assertion on the array for an `if` / `else` pair sharing `line` and `text` with `negated`
+flipped, and for an early `return`.
+
+**P1 — record per-function sites during the walk.** In `buildSteps`, alongside `link()`, push `{ fn, stepId, linkId,
+hop: HopSite, guards: BranchGuard[], trigger }` into a per-function list (`programs: Map<fnId, SiteRecord[]>`), for hops
+out of every fold node (steps *and* folds — a fold into a helper is a record in the caller and the helper's own
+records are the helper's). Cost: one `guards` read per hop, within `MAX_WHEN_SITES`.
+
+**P2 — the block builder.** `src/ui-server/api/program.ts`: `buildProgram(records, roots) → WireProgram`, pure over the
+records (no graph access), unit-tested on hand-made records: straight line; `if` / `else`; early return; nested ifs;
+`switch`; `try` / `catch`; a helper inlined; a site inside arguments; `later`; `together`; a loop; a cap. Then wire it
+into the payload behind `view=order`.
+
+**P3 — the rail.** `StepsRail.svelte`, the toggle, `view` in the URL, the legend and summary words, the default per
+anchor kind. Snapshot-free tests: the pure layout of arms is trivial, so test the *model* (`ui/src/lib/program-model.ts`:
+the block → rows/columns with the pills' words) in `__tests__/ui-program-model.test.ts`, and drive one real picture with
+the harness (P5).
+
+**P4 — languages beyond JS.** Nothing to add for guards (P5 of the previous plan built them for Python, Java, Kotlin,
+C#, Go, C); check `later` / `loop` / `together` node types per language in `Rules` and add the loop-header text. Test:
+extend `ui-steps-api-servers.test.ts` with an `in order` assertion per framework (FastAPI's `raise HTTPException` is an
+early exit; Spring's `if (…) return ResponseEntity.badRequest()` too).
+
+**P5 — validation pictures.** With the harness: proshop `POST /api/users/login` (the §1 reading), `POST
+/api/products/:id/reviews` (three outcomes: `!product` → 404 early exit, `alreadyReviewed` → 400, else create + 201),
+express-realworld `POST /api/users/login`, nest-boilerplate `POST /api/v1/auth/email/login` (guards, DI, thrown
+exceptions), next-saas-starter `signIn` (a server action: validation → lookup → `redirect`), fastapi-template `POST
+/login/access-token`. Read each against the source; a fork that is not in the code, or an order that is not the code's,
+is a bug, not a style choice. Re-shoot the mobile app (`/capture/review`) to confirm the tree mode is untouched.
+
+**P6 — docs.** Spec §3.13 gains "3.13.1 In order" with the §1 rendering and the rules of §4.1; `CHANGELOG.md`
+`[Unreleased]` gets one user-facing bullet ("The Steps tab reads a handler in the code's order …"); `CLAUDE.md`'s
+`src/ui-server/` note names `api/program.ts`; the previous plan's status header points here.
+
+*Done when:* proshop's login reads as §1 in the browser, the reviews endpoint shows its three arms with the 404 as an
+early exit, and the tree mode is byte-for-byte the picture it is today.
+
+---
+
+## 6. Gotchas, known from building the tree
+
+- **Source order is execution order only for straight-line code.** Arguments before their call (the nesting rule,
+  already in `hopCompare`); `await` does not reorder; a callback registered now runs later (`later`); `Promise.all` is
+  concurrent (`together`). Do not draw an order the code does not fix — say `later` / `together` instead.
+- **A nested `const handleX = async () => …` is not a node** (only `function` declarations and `useCallback`-bound
+  arrows are); its body's sites attribute to the enclosing component, and the guard climb treats the inline arrow as
+  transparent. The rail inherits that: such a handler's sites appear in the component's program. The fix is an
+  extractor rule with the Rust kernel twin — out of scope here; note it where it shows.
+- **The reply box is per (function, status)** since `ca9a7fd`; a status set by the statement before the reply
+  (`res.status(401); throw …`) is that reply's (`statusSetBefore`), and a bare `res.json` is a 200
+  (`implicitResponseStatus`). In the rail the 401 arm reads `401 · res.status(401)` then `throw new Error(…)` as its
+  terminal — the throw is the arm's `ends`, read from the early-exit guard on the *next* site, or from the site's own
+  statement's successor; either way it is a mark, not a step.
+- **Site keys.** A site is `(line, column)`; synthesized edges carry the call's column (the tier synthesizer sets it);
+  an edge with no column lands on the first non-blank column of the row, which for `const x = await fetch(…)` is
+  `const` — `callAt` climbs to the first call from there. Pass the callee name (`want`) when you know it.
+- **Budgets.** `MAX_WHEN_SITES` / `MAX_CALL_SITES` bound the tree reads per request; the rail adds one `guards` read per
+  hop — the same sites the `when` read already parsed, so it is cheap, but count it. A drifted file yields no guards
+  (`hasDriftedOnDisk`): the rail then shows sequence without forks, and should say "conditions not read: the file
+  changed since the index".
+- **Two passes per fold** (spec §3.13): a node that is a step is never also folded through its `contains` edge; the
+  program's records must come from the same pass that made the link, or a step appears twice.
+- **The index keeps only the last segment of a deep member call**; every call text in the rail must come from
+  `callSitesForFile` (as the tree's do), never from the edge's `refName`.
+- **Never `npm run build` while a `codegraph ui` server is running** (it loads modules lazily and hangs); the harness in
+  the memory note kills its server group on exit for that reason.
+
+---
+
+## 7. Open questions for the maintainer
+
+1. **Default reading per anchor** — the plan says *in order* for functions / endpoints, *tree* for screens. Or *in
+   order* everywhere, with a screen's program being its render body plus each handler's program under `later ·
+   onPress`?
+2. **How far to inline.** A helper drawn in place at every call is faithful but wide; the tree's fold depth (8) may be
+   too deep for a rail. Proposal: inline to depth 3, then a `cut` item with `Start here →`.
+3. **Loops.** Body once with `for each …`, or unrolled never. Proposal: once, marked.
+4. **Should the rail replace the pills' "→ …x" placement rule?** The rail's forks carry the condition once, on the
+   fork; the arm's `WHEN` / `WHEN NOT` is the pill. The scenario rows in the panel stay as they are.
+
+---
+
+## 8. What the validation pictures found (2026-08-29, P5)
+
+Read against the source, endpoint by endpoint. Every reading below is the one the rail draws today.
+
+| Repo | Anchor | Reads as | Verdict |
+|---|---|---|---|
+| `bradtraversy/proshop_mern` | `POST /api/users/login` | `User.findOne` · fork on the password check · [`via generateToken` → `jwt.sign`, then `200`] \| [`401`], both arms answering | §1 exactly |
+| " | `POST /api/products/:id/reviews` | `Product.findById` · fork on `product` · [fork on `alreadyReviewed` → `400` \| `201`] \| [`404`] | three outcomes, right |
+| `gothinkster/node-express-realworld-example-app` | `POST /users/login` | `via login` → two `422` guards, `prisma.user.findUnique`, `if user` → `bcrypt.compare` → `if match` → `via generateToken`, then `403`; then the handler's own `200` | right, after the span fix |
+| `brocoders/nestjs-boilerplate` | `POST /auth/email/login` | `via validateLogin` → `findByEmail`, `!user` → `422`, two throwing guards, `bcrypt.compare`, then `sessionRepository.create` and `via getTokensData` → **`together Promise.all`** of two `jwtService.signAsync` | right, after dropping the `update` name-match |
+| `leerob/next-saas-starter` | `signIn` (server action) | drizzle `select`, `length === 0` → return, `!isPasswordValid` → return, **`together Promise.all`** of `setSession` (→ `signToken` → `SignJWT`) and `logActivity` (→ `db.insert`), then `redirectTo === 'checkout'` → the whole checkout session \| `/dashboard` | right, after the lending fix |
+| `fastapi/full-stack-fastapi-template` | `POST /login/access-token` | `via authenticate` → `session.exec`, `not db_user` → return, `not verified` → return, `updated_password_hash` → `session.add`/`commit`/`refresh`; then `not user` → `400`, `elif not user.is_active` → `400`; then `via create_access_token` → `jwt.encode` | right, after the `elif` fix |
+| `amniservices-mobile-app` | `/capture/review` | unchanged: 87 steps, 160 links, the tree, `defaultView: 'tree'` | the regression check |
+
+Each of those was read first as the rejected rail and then as the canvas graph; the readings are the same, the picture
+is not. What the canvas gives that the rail could not: proshop's login fits the sketch the maintainer drew
+(`User.findOne` → the fork → `jwt.sign` → `200` | `401`), and nest-boilerplate's login reads down the page —
+`findByEmail` → `WHEN NOT user → 422` | `WHEN user AND user.provider === …` → `bcrypt.compare` → `WHEN
+isValidPassword` → `sessionRepository.create` → `together · Promise.all` → `jwtService.signAsync`.
+
+**Three defects the pictures caught, all in the walk and both readings** — a hop's span taken from a call that was not
+the one asked for (an inline Express handler's edges carry the route's line, so the registration's span swallowed the
+body); a name-match the call as written disproves (`crypto.createHash('sha256').update(…)` followed into the caller's
+own `AuthService.update`); and a value lent nothing because one plain `references` edge counted as a body of its own
+(`const signIn = validatedAction(schema, async (data) => { … })` drew one call out of nine). Plus an `elif` whose body
+raises being read as ending the arm it is written in.
+
+**Left open, deliberately:**
+- **A mongoose document's `product.save()` is not in the effects table** (`api/effects.ts`), so proshop's review
+  endpoint draws its `201` but not the write before it. A JS rule for `<lowercase receiver>.save` would catch it and
+  would also catch `canvas.save()` / `ctx.save()` / `sharp(...).toFile`-adjacent idioms in any web app — a call for
+  the maintainer, not a silent widening.
+- **A nested `const handleX = async () => …` is still not a node** (the plan's §6): its sites belong to the enclosing
+  component. Fixing it is an extractor change with a Rust kernel twin.
+- The **`via` name of an inlined helper is its bare name** (`via create`), which is ambiguous when two classes have a
+  `create`. The panel disambiguates; the rail could say the class.

Dosya farkı çok büyük olduğundan ihmal edildi
+ 840 - 3
package-lock.json


+ 8 - 1
package.json

@@ -16,8 +16,13 @@
     "scripts",
     "README.md"
   ],
+  "workspaces": [
+    "ui"
+  ],
   "scripts": {
-    "build": "tsc && npm run copy-assets && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"",
+    "build": "tsc && npm run copy-assets && npm run build:ui && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"",
+    "build:ui": "npm run build --workspace ui && node scripts/check-ui-build.mjs",
+    "build:lib": "npm run build:lib --workspace ui",
     "preuninstall": "node dist/bin/uninstall.js",
     "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"",
     "dev": "tsc --watch",
@@ -49,9 +54,11 @@
     "web-tree-sitter": "^0.25.3"
   },
   "devDependencies": {
+    "@sveltejs/vite-plugin-svelte": "^4.0.4",
     "@types/better-sqlite3": "^7.6.0",
     "@types/node": "^20.19.30",
     "@types/picomatch": "^4.0.2",
+    "jsdom": "^25.0.1",
     "typescript": "^5.0.0",
     "vitest": "^2.1.9"
   },

+ 9 - 0
scripts/build-bundle.sh

@@ -63,8 +63,17 @@ echo "[bundle] building app"
 STAGE="$WORK/codegraph-${TARGET}"
 mkdir -p "$STAGE/lib" "$STAGE/bin"
 cp -R "$ROOT/dist" "$STAGE/lib/dist"
+# The browser viewer rides along inside dist/viewer (built by `npm run build`
+# above). Fail here rather than shipping a bundle whose `codegraph ui` serves
+# a 404 — the copy is verified, not assumed.
+node "$ROOT/scripts/check-ui-build.mjs" --root "$STAGE/lib"
 cp "$ROOT/package.json" "$ROOT/package-lock.json" "$STAGE/lib/"
 echo "[bundle] installing production dependencies"
+# The staged package.json declares the `ui` workspace but the bundle carries
+# no ui/ source — only its build output. That is fine: ui/ has dev
+# dependencies only, so --omit=dev skips the workspace outright and no link
+# is created. (If a future npm starts erroring on the absent folder, stage a
+# stub ui/package.json before this line rather than editing the lock.)
 ( cd "$STAGE/lib" && npm ci --omit=dev --ignore-scripts >/dev/null 2>&1 )
 rm -f "$STAGE/lib/package-lock.json"
 

+ 157 - 0
scripts/check-ui-build.mjs

@@ -0,0 +1,157 @@
+#!/usr/bin/env node
+/**
+ * Assert that the browser viewer actually built.
+ *
+ * `codegraph ui` serves dist/viewer/ as static files. If that tree is missing
+ * or half-written, the CLI still starts and the browser gets a 404 — a failure
+ * that would otherwise surface after the release is published. So the build
+ * fails here instead: index.html must exist, be non-trivial, and every local
+ * asset it references must be on disk next to it.
+ *
+ * It also re-asserts that the compiled engine is still there. The viewer build
+ * empties its own output directory, and `dist/ui/` — the obvious name — is
+ * where tsc puts the TERMINAL ui, so a mis-pointed outDir silently deletes
+ * modules the CLI requires at startup.
+ *
+ * The tree-sitter grammars in dist/extraction/wasm/ are checked the same way
+ * and for the same reason. They are copied by `npm run copy-assets`, they are
+ * what both indexing and the viewer's syntax classification parse with, and
+ * their absence is survivable at runtime — source is served unhighlighted —
+ * which is exactly why it has to fail here: nothing downstream would complain.
+ *
+ * Usage: node scripts/check-ui-build.mjs [--root <dir>]
+ *   --root  directory holding dist/ (default: the repo root). The release
+ *           bundler points this at its staging dir to verify the copy.
+ */
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
+import { dirname, join, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const argv = process.argv.slice(2);
+const rootFlag = argv.indexOf('--root');
+const staged = rootFlag >= 0 && Boolean(argv[rootFlag + 1]);
+const root = staged
+  ? resolve(argv[rootFlag + 1])
+  : resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+const viewerDir = join(root, 'dist', 'viewer');
+const indexHtml = join(viewerDir, 'index.html');
+
+function fail(message, hint) {
+  console.error(`[check-ui-build] ${message}`);
+  if (hint) console.error(`[check-ui-build] ${hint}`);
+  process.exit(1);
+}
+
+if (!existsSync(indexHtml)) {
+  fail(
+    `missing ${indexHtml}`,
+    staged
+      ? 'this bundle predates the UI or was assembled from a stale archive — rebuild it with scripts/build-bundle.sh'
+      : 'the UI workspace did not build — run `npm run build:ui` (or `npm ci` if ui/ has no node_modules)'
+  );
+}
+
+const html = readFileSync(indexHtml, 'utf8');
+if (html.length < 200 || !/<div id="app">/.test(html)) {
+  fail(`${indexHtml} does not look like the built viewer (${html.length} bytes)`);
+}
+
+// Every local src=/href= in the document must resolve inside dist/ui. This is
+// what catches a partial write: index.html naming a hashed bundle that the
+// build never emitted.
+const referenced = [...html.matchAll(/\s(?:src|href)="([^"]+)"/g)].map((m) => m[1]);
+const local = referenced.filter(
+  (url) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url) && !url.startsWith('#')
+);
+
+const missing = [];
+let assets = 0;
+for (const url of local) {
+  const rel = url.replace(/^\.\//, '').replace(/[?#].*$/, '');
+  if (!rel) continue;
+  const onDisk = join(viewerDir, ...rel.split('/'));
+  if (!existsSync(onDisk) || !statSync(onDisk).isFile()) missing.push(rel);
+  else assets += 1;
+}
+
+if (missing.length > 0) {
+  fail(
+    `index.html references ${missing.length} file(s) that are not in dist/viewer: ${missing.join(', ')}`,
+    'the UI build was interrupted or dist/viewer was copied incompletely'
+  );
+}
+
+if (assets === 0) {
+  fail('index.html references no bundled assets — the UI build produced no JS/CSS');
+}
+
+// The viewer build must never have eaten the tsc output next door.
+for (const compiled of [join('bin', 'codegraph.js'), 'index.js', join('ui', 'shimmer-progress.js')]) {
+  if (!existsSync(join(root, 'dist', compiled))) {
+    fail(
+      `dist/${compiled.split(sep).join('/')} is missing — the compiled engine is incomplete`,
+      "if this appeared with a UI change, check ui/vite.config.ts: build.outDir must stay dist/viewer, and emptyOutDir must never point at a directory tsc writes (dist/ui is the TERMINAL ui)"
+    );
+  }
+}
+
+// The vendored tree-sitter grammars (`npm run copy-assets`). The viewer reads
+// every file with the same grammar the engine indexed it with, so a missing
+// wasm is both an extraction gap and a silently unhighlighted screen.
+const wasmDir = join(root, 'dist', 'extraction', 'wasm');
+
+/**
+ * The grammars the syntax classification is gated on — the eight languages
+ * CG-57 measured parity against, plus the two the TS family needs. Every one is
+ * vendored (see VENDORED_WASM_LANGS), so all of them must be in this directory
+ * rather than resolved out of node_modules.
+ */
+const GATE_GRAMMARS = [
+  'tree-sitter-typescript.wasm',
+  'tree-sitter-tsx.wasm',
+  'tree-sitter-javascript.wasm',
+  'tree-sitter-go.wasm',
+  'tree-sitter-python.wasm',
+  'tree-sitter-rust.wasm',
+  'tree-sitter-swift.wasm',
+  'tree-sitter-c_sharp.wasm',
+  'tree-sitter-ruby.wasm',
+  'tree-sitter-php.wasm',
+];
+
+if (!existsSync(wasmDir)) {
+  fail(
+    `missing ${wasmDir}`,
+    staged
+      ? 'dist/extraction/wasm was not copied into the bundle — re-run scripts/build-bundle.sh'
+      : 'run `npm run copy-assets` (it copies src/extraction/wasm/*.wasm into dist/)'
+  );
+}
+
+// Against the source tree, the source directory IS the list — nothing to drift.
+// Inside a staged bundle there is no src/, so the gate list carries it.
+const expectedGrammars = new Set(GATE_GRAMMARS);
+const srcWasmDir = join(root, 'src', 'extraction', 'wasm');
+if (!staged && existsSync(srcWasmDir)) {
+  for (const name of readdirSync(srcWasmDir)) {
+    if (name.endsWith('.wasm')) expectedGrammars.add(name);
+  }
+}
+
+const missingGrammars = [...expectedGrammars].filter(
+  (name) => !existsSync(join(wasmDir, name))
+);
+if (missingGrammars.length > 0) {
+  fail(
+    `dist/extraction/wasm is missing ${missingGrammars.length} grammar(s): ${missingGrammars.join(', ')}`,
+    'the copy-assets step was interrupted or dist/extraction/wasm was copied incompletely'
+  );
+}
+
+const grammarCount = readdirSync(wasmDir).filter((n) => n.endsWith('.wasm')).length;
+
+console.log(
+  `[check-ui-build] dist/viewer ok (index.html + ${assets} referenced asset(s)); ` +
+    `dist/extraction/wasm ok (${grammarCount} grammars); dist/ engine intact`
+);

+ 192 - 0
scripts/check-ui-package.mjs

@@ -0,0 +1,192 @@
+#!/usr/bin/env node
+/**
+ * Finish and verify the `@colbymchenry/codegraph-ui` build (task CG-61).
+ *
+ * `svelte-package` compiles the whole of `ui/src`, which is the right input —
+ * the components a host imports and the ones `codegraph ui` renders are the
+ * same files, and splitting them into two trees is how the two screens start
+ * to drift. But it means the emitted `dist/` also carries the standalone app's
+ * shell, and one of those files is a hazard rather than dead weight:
+ * `lib/router.svelte.js` attaches `hashchange`/`popstate` listeners at module
+ * scope. A host must never inherit a hash router just by rendering a Symbol
+ * view. So this script does three jobs, in order:
+ *
+ *   1. PRUNE the app-only files from the package.
+ *   2. RESOLVE the extensionless relative specifiers `svelte-package` leaves
+ *      behind, so the package works under Node's own ESM resolution and under
+ *      a consumer on `moduleResolution: node16`, not only inside a bundler.
+ *   3. ASSERT the result: the entry, the theme, every path in `exports`, the
+ *      five named components, and — the one that matters most — that nothing
+ *      outside `lib/adapter.js` talks to the network. The whole point of the
+ *      package is that a host's own adapter is the only way data arrives; a
+ *      stray `fetch` anywhere else is a screen that ignores it.
+ *
+ * Run by `npm run build:lib -w ui`. Exits non-zero on any failure.
+ */
+
+import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const UI = fileURLToPath(new URL('../ui', import.meta.url));
+const DIST = join(UI, 'dist');
+
+/**
+ * The standalone viewer's shell — everything that is only reachable from
+ * `main.ts`. Listed by hand rather than derived, because getting it wrong in
+ * the derived direction (pruning something a component needs) is silent until
+ * a host imports it.
+ */
+const APP_ONLY = [
+  'main.js',
+  'main.d.ts',
+  'App.svelte',
+  'App.svelte.d.ts',
+  'app.css',
+  'components/TopBar.svelte',
+  'components/TopBar.svelte.d.ts',
+  'lib/router.svelte.js',
+  'lib/router.svelte.d.ts',
+];
+
+/** Extensions that already resolve; anything else is rewritten to `<spec>.js`. */
+const RESOLVES = ['.js', '.mjs', '.cjs', '.json', '.css', '.svg', '.png'];
+
+const fail = (message) => {
+  console.error(`[check-ui-package] ${message}`);
+  process.exitCode = 1;
+};
+
+if (!existsSync(DIST)) {
+  fail(`no ${relative(UI, DIST)} — run \`npm run build:lib -w ui\``);
+  process.exit(1);
+}
+
+/* ------------------------------------------------------------------ 1. prune */
+
+for (const entry of APP_ONLY) {
+  const path = join(DIST, entry);
+  if (existsSync(path)) rmSync(path, { recursive: true });
+}
+
+/* ------------------------------------------------------------------ walk it */
+
+function* files(dir) {
+  for (const name of readdirSync(dir)) {
+    const path = join(dir, name);
+    if (statSync(path).isDirectory()) yield* files(path);
+    else yield path;
+  }
+}
+
+const all = [...files(DIST)];
+
+/* ---------------------------------------------------------------- 2. resolve */
+
+/**
+ * `from './lib/adapter'` -> `from './lib/adapter.js'`, and
+ * `from './lib/trail.svelte'` -> `from './lib/trail.svelte.js'` (the emitted
+ * file for a `.svelte.ts` rune module).
+ *
+ * Driven by the filesystem rather than by the extension alone: `.svelte` is a
+ * real file for a component and a compiled `.js` for a rune module, and only
+ * looking is right for both.
+ */
+function resolveSpecifiers(source, fromFile) {
+  return source.replace(
+    /(\bfrom\s*|\bimport\s*\(\s*)(['"])(\.[^'"]*)\2/g,
+    (match, head, quote, spec) => {
+      if (RESOLVES.some((ext) => spec.endsWith(ext))) return match;
+      const target = resolve(dirname(fromFile), spec);
+      if (existsSync(target) && statSync(target).isFile()) return match;
+      if (!existsSync(`${target}.js`)) return match;
+      return `${head}${quote}${spec}.js${quote}`;
+    }
+  );
+}
+
+let rewritten = 0;
+for (const path of all) {
+  if (!/\.(js|d\.ts|svelte)$/.test(path)) continue;
+  const before = readFileSync(path, 'utf8');
+  const after = resolveSpecifiers(before, path);
+  if (after !== before) {
+    writeFileSync(path, after);
+    rewritten += 1;
+  }
+}
+
+/* ----------------------------------------------------------------- 3. assert */
+
+const manifest = JSON.parse(readFileSync(join(UI, 'package.json'), 'utf8'));
+
+// Every path the exports map promises has to be there. A missing one is a
+// package that installs cleanly and then fails at the consumer's first import.
+for (const [name, entry] of Object.entries(manifest.exports ?? {})) {
+  const targets = typeof entry === 'string' ? [entry] : Object.values(entry);
+  for (const target of targets) {
+    if (!target.startsWith('./')) continue;
+    if (!existsSync(join(UI, target))) fail(`exports["${name}"] -> ${target} is missing`);
+  }
+}
+
+// The exported screens, plus the two seams they are useless
+// without. Checked in the emitted JS, so a rename in index.ts that misses a
+// component fails here rather than in the Pro app.
+const entry = existsSync(join(DIST, 'index.js'))
+  ? readFileSync(join(DIST, 'index.js'), 'utf8')
+  : '';
+for (const name of [
+  'SymbolView',
+  'TypeHierarchy',
+  'FlowStrip',
+  'ArchitectureMap',
+  'DeadCodeView',
+  'TrailBar',
+  'SavedTrails',
+  'SearchPalette',
+  'CodegraphUi',
+  'setGraphAdapter',
+  'createHttpAdapter',
+  'setNavigationDriver',
+]) {
+  if (!new RegExp(`\\b${name}\\b`).test(entry)) fail(`dist/index.js does not export ${name}`);
+}
+
+// Nothing the app dragged in survives. A component still importing one of the
+// pruned modules would resolve to nothing in a host.
+for (const path of all) {
+  if (!existsSync(path)) continue;
+  const text = readFileSync(path, 'utf8');
+  for (const pruned of ['router.svelte', 'TopBar.svelte', 'app.css']) {
+    const importing = new RegExp(`(from|import\\()\\s*['"][^'"]*${pruned}`);
+    if (importing.test(text)) {
+      fail(`${relative(DIST, path)} still imports ${pruned}, which is app-only`);
+    }
+  }
+}
+
+// The data seam. `lib/adapter.js` is the ONE place that may reach the network;
+// anywhere else means a screen that ignores the host's adapter.
+for (const path of all) {
+  if (!existsSync(path) || !path.endsWith('.js')) continue;
+  if (path.endsWith(join('lib', 'adapter.js'))) continue;
+  const text = readFileSync(path, 'utf8')
+    // Comments talk about `fetch` and `EventSource` on purpose; only code counts.
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+    .replace(/(^|\s)\/\/[^\n]*/g, '');
+  if (/\bnew EventSource\b|\bfetch\s*\(/.test(text)) {
+    fail(`${relative(DIST, path)} reaches the network directly — it must go through the adapter`);
+  }
+}
+
+if (process.exitCode) {
+  console.error('[check-ui-package] FAILED');
+  process.exit(1);
+}
+
+const count = [...files(DIST)].length;
+console.log(
+  `[check-ui-package] ok — ${count} files, ${rewritten} rewritten, ` +
+    `${APP_ONLY.length} app-only pruned (v${manifest.version})`
+);

+ 30 - 0
scripts/pack-npm.sh

@@ -55,6 +55,10 @@ for archive in "${archives[@]}"; do
       nodefile="node"
       ;;
   esac
+  # The browser viewer must survive the archive round-trip too: a tar/zip that
+  # dropped dist/viewer would publish a platform package whose `codegraph ui`
+  # serves a 404.
+  node "$ROOT/scripts/check-ui-build.mjs" --root "$pkgdir/lib"
   VERSION="$VERSION" SCOPE="$SCOPE" TARGET="$target" OSV="$os" ARCHV="$arch" NODEFILE="$nodefile" \
     node -e '
       const fs=require("fs");
@@ -121,3 +125,29 @@ VERSION="$VERSION" SCOPE="$SCOPE" TARGETS="${targets[*]}" \
 
 echo "[pack-npm] ${SCOPE}/codegraph@${VERSION} (${#targets[@]} platform packages in optionalDependencies)"
 echo "[pack-npm] output: $NPM"
+
+# ---------------------------------------------------------------------------
+# @colbymchenry/codegraph-ui — the viewer's components as a Svelte library.
+#
+# Staged into release/npm-ui/, NOT release/npm/: the workflow publishes
+# `release/npm/codegraph-*` by glob, and a directory named codegraph-ui in
+# there would be swept into that loop the moment it existed.
+#
+# OFF by default. The package is prepared, versioned with the engine and
+# tested (CG-61), but publishing it is a decision the maintainer has not
+# made — and `ui/package.json` still carries `"private": true`, which is what
+# actually stops an accidental `npm publish`. Set CODEGRAPH_PACK_UI=1 to build
+# the tarball; publishing it additionally means removing that flag.
+# ---------------------------------------------------------------------------
+if [ "${CODEGRAPH_PACK_UI:-0}" = "1" ]; then
+  UIREL="$REL/npm-ui"
+  rm -rf "$UIREL"
+  mkdir -p "$UIREL"
+  ( cd "$ROOT" && npm run build:lib --workspace ui )
+  # `npm pack` honours "files" and works on a private package; `npm publish`
+  # does not, which is exactly the guard we want to keep for now.
+  ( cd "$ROOT/ui" && npm pack --pack-destination "$UIREL" >/dev/null )
+  echo "[pack-npm] ${SCOPE}/codegraph-ui@${VERSION} packed (not published) -> $UIREL"
+else
+  echo "[pack-npm] skipping ${SCOPE}/codegraph-ui (set CODEGRAPH_PACK_UI=1 to pack it)"
+fi

+ 47 - 0
scripts/sync-ui-version.mjs

@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+/**
+ * Keep `@colbymchenry/codegraph-ui` on the engine's version number.
+ *
+ * The component package draws its screens from the engine's own JSON API, and
+ * that API is versioned with the binary that serves it — a payload field can
+ * appear or change shape in any engine release. So the two ship as one number:
+ * `@colbymchenry/codegraph-ui@1.6.0` is the reader for `codegraph@1.6.0`, and a
+ * host can pin them together without a compatibility table.
+ *
+ * This SYNCS rather than asserts, deliberately. The documented release flow is
+ * "edit the version in package.json, run the Release workflow" — often as a
+ * single-file edit in the GitHub web UI — and a check that failed the build
+ * because a second file had not been edited would turn that into a two-step
+ * dance for no gain. The same reasoning the workflow's package-lock sync step
+ * already runs on.
+ *
+ * Idempotent: a re-run with the versions already equal writes nothing.
+ */
+
+import { readFileSync, writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const root = fileURLToPath(new URL('../package.json', import.meta.url));
+const ui = fileURLToPath(new URL('../ui/package.json', import.meta.url));
+
+const engineVersion = JSON.parse(readFileSync(root, 'utf8')).version;
+const raw = readFileSync(ui, 'utf8');
+const manifest = JSON.parse(raw);
+
+if (manifest.version === engineVersion) {
+  console.log(`[sync-ui-version] ui already at ${engineVersion}`);
+  process.exit(0);
+}
+
+// A targeted replacement, not a re-serialise: rewriting the whole file would
+// reformat a manifest a human maintains and bury the one-line change in noise.
+const next = raw.replace(
+  /("version"\s*:\s*)"[^"]*"/,
+  (_match, prefix) => `${prefix}"${engineVersion}"`
+);
+if (next === raw) {
+  console.error('[sync-ui-version] could not find a "version" field in ui/package.json');
+  process.exit(1);
+}
+writeFileSync(ui, next);
+console.log(`[sync-ui-version] ui ${manifest.version} -> ${engineVersion}`);

+ 112 - 0
scripts/try-repo.sh

@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+# Try the Steps / Screens / Entry-points pictures on a real project with the
+# CURRENT build: clone (shallow) a preset or any git URL, index it with
+# dist/bin/codegraph.js (a full rebuild, so new synthesizers and resolvers
+# apply), print what the index holds — routes, cross-tier edges, navigations —
+# and open the viewer on it.
+#
+#   npm run build
+#   scripts/try-repo.sh                      # list the presets and what each shows
+#   scripts/try-repo.sh proshop              # clone + index + open the viewer
+#   scripts/try-repo.sh proshop 4750         # on a fixed port
+#   scripts/try-repo.sh https://github.com/x/y.git
+#   scripts/try-repo.sh ~/code/my-app        # a local project (re-indexed with this build)
+#
+# Env:  CODEGRAPH_TRY_DIR   where clones live (default ~/.cache/codegraph-try)
+#       CODEGRAPH_TRY_NO_OPEN=1   print the URL instead of opening a browser
+#       CODEGRAPH_TRY_REINDEX=0   reuse an existing index instead of rebuilding
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")/.." && pwd)"
+CLI="$HERE/dist/bin/codegraph.js"
+DIR="${CODEGRAPH_TRY_DIR:-$HOME/.cache/codegraph-try}"
+export CODEGRAPH_TELEMETRY=0 DO_NOT_TRACK=1
+
+# name|url|what to look at (hash URLs relative to the viewer)
+PRESETS='
+proshop|https://github.com/bradtraversy/proshop_mern.git|Express + React Router (MERN). Steps: #/steps?symbol=login&through=1 — login → ⇢ POST /api/users/login → User.findOne → 401 rows; #/steps?symbol=/payment — the bounce to /shipping WHEN !shippingAddress.address, the push to /placeorder, and the checkout nav tabs, each under the prop that enables it. Screens: #/screens — 19 pages wired by history.push and <LinkContainer to>. Entry points: 30 endpoints + 19 pages; the project reads as a web app.
+express-realworld|https://github.com/gothinkster/node-express-realworld-example-app.git|Express + Prisma (TypeScript). Steps: #/steps?symbol=POST%20/api/users/login — request → handler → prisma → response rows with their status codes.
+nest-samples|https://github.com/nestjs/nest.git|NestJS samples. Steps: #/steps?symbol=POST%20/audio/transcode (sample/26-queues: the job lands on @Process("transcode") as ⇠ transcode); the event emitter sample (30) pairs emit("order.created") with its @OnEvent listener; sample/02-gateways for @SubscribeMessage.
+nest-boilerplate|https://github.com/brocoders/nestjs-boilerplate.git|NestJS + TypeORM. Steps: #/steps?symbol=POST%20/api/v1/auth/email/login — guards on the class and method (FIRES FROM … after UseGuards), DI followed by declared type into the service, repository saves as data calls, thrown exceptions as response rows.
+tanstack|https://github.com/TanStack/router.git|TanStack Router: 477 example and e2e apps in ONE index — the app-root gating under load, where a link resolves within its own app and never into another. Look at examples/react/kitchen-sink-file-based (file-based: /profile from _auth.profile.tsx, /route-group from a (group) folder) and examples/react/basic (code-based: /posts/:postId composed through getParentRoute).
+next-saas-starter|https://github.com/leerob/next-saas-starter.git|Next.js App Router + server actions. Screens: #/screens — /sign-in → /dashboard via Login > signIn WHEN …, <Link>s, redirect(), NextResponse.redirect; Steps: #/steps?symbol=/dashboard&through=1 — FIRES FROM page load, handlers, useSWR("/api/team") → ⇢ GET /api/team.
+spring-petclinic|https://github.com/spring-projects/spring-petclinic.git|Spring (Java). Steps: #/steps?symbol=POST%20/owners/new — PreAuthorize-style guards, OwnerRepository owners → owners.save as the database, ResponseEntity / view replies with WHEN rows. Kotlin twin: spring-petclinic-kotlin.
+spring-petclinic-kotlin|https://github.com/spring-petclinic/spring-petclinic-kotlin.git|Spring (Kotlin). Same picture as spring-petclinic with Kotlin guards (if expressions, when).
+fastapi-template|https://github.com/fastapi/full-stack-fastapi-template.git|FastAPI + a TanStack Router frontend. Screens: #/screens — 8 frontend pages with their guards (/login and /signup bounce to / WHEN isLoggedIn(), /admin WHEN NOT user.is_superuser). Entry points: 23 routes named by path (APIRouter prefixes composed); Steps: #/steps?symbol=POST%20/items — Depends(...) as the chain, session.add/commit as the database, HTTPException rows with status_code. (settings.API_V1_STR is a computed prefix and is left off, on purpose.)
+dispatch|https://github.com/Netflix/dispatch.git|FastAPI, large. Steps on any router endpoint; expect guards and arguments for Python.
+clean-architecture|https://github.com/jasontaylordev/CleanArchitecture.git|ASP.NET Minimal API endpoint groups (C#). Entry points: 10 routes (POST /api/TodoItems, PUT /api/TodoItems/{id} …); Steps: #/steps?symbol=PUT%20/api/TodoItems/{id} — TypedResults replies as 204 · 400 rows with WHEN.
+eshoponweb|https://github.com/dotnet-architecture/eShopOnWeb.git|ASP.NET MVC + Minimal API (C#). Steps on a controller action or a MapGet endpoint; C# guards and arguments.
+bookstack|https://github.com/BookStackApp/BookStack.git|Laravel (PHP). Entry points: routes/web.php → controller methods; Steps draws handlers and effects (Eloquent, responses) but PHP has no WHEN / arguments / trigger rules yet — expect boxes without conditions.
+sveltekit-realworld|https://github.com/sveltejs/realworld.git|SvelteKit. Screens: #/screens — 10 pages wired by <a href>, goto() and redirect(status, path); /settings and /editor guard themselves in their +page.server.js loaders, drawn WHEN !locals.user. Entry points: file routes with load() edges; Steps on a load or an action.
+vue-realworld|https://github.com/gothinkster/vue-realworld-example-app.git|Vue Router. Screens: #/screens — 10 routes read from src/router/index.js, wired by router.push({ name }) and <router-link :to>, which navigate by route NAME rather than by path. Steps on a handler: template @click bindings, Pinia/Vuex channels.
+icecubes|https://github.com/Dimillian/IceCubesApp.git|SwiftUI (Swift). Steps on a view model method: Swift guards, network / storage effects; SwiftUI navigation is not a Screens picture yet.
+nowinandroid|https://github.com/android/nowinandroid.git|Jetpack Compose (Kotlin). Steps on a ViewModel method: Kotlin guards, DataStore / network effects; Compose navigation is not a Screens picture yet.
+'
+
+usage() {
+  echo "usage: scripts/try-repo.sh <preset | git-url | local-path> [port]"
+  echo
+  echo "presets:"
+  echo "$PRESETS" | awk -F'|' 'NF>=3 { printf "  %-24s %s\n", $1, $3 }'
+}
+
+[ -f "$CLI" ] || { echo "no build at $CLI — run: npm run build"; exit 1; }
+[ $# -ge 1 ] || { usage; exit 0; }
+
+TARGET="$1"
+PORT="${2:-}"
+HINT=""
+if [ -d "$TARGET" ]; then
+  REPO="$(cd "$TARGET" && pwd)"
+  NAME="$(basename "$REPO")"
+else
+  LINE="$(echo "$PRESETS" | awk -F'|' -v n="$TARGET" '$1==n { print; exit }')"
+  if [ -n "$LINE" ]; then
+    NAME="$TARGET"
+    URL="$(echo "$LINE" | cut -d'|' -f2)"
+    HINT="$(echo "$LINE" | cut -d'|' -f3-)"
+  else
+    URL="$TARGET"
+    NAME="$(basename "${URL%.git}")"
+  fi
+  REPO="$DIR/$NAME"
+  if [ ! -d "$REPO/.git" ]; then
+    mkdir -p "$DIR"
+    echo "cloning $URL → $REPO"
+    git clone -q --depth 1 "$URL" "$REPO"
+  fi
+fi
+
+cd "$REPO"
+if [ ! -d .codegraph ]; then
+  echo "indexing $REPO (first time)"
+  node "$CLI" init . 2>&1 | tail -2
+elif [ "${CODEGRAPH_TRY_REINDEX:-1}" != "0" ]; then
+  echo "re-indexing $REPO with the current build"
+  node "$CLI" index . 2>&1 | tail -2
+fi
+
+# What the index holds for the pictures: routes, cross-tier edges, navigations.
+node --disable-warning=ExperimentalWarning - <<'JS'
+const { DatabaseSync } = require('node:sqlite');
+const db = new DatabaseSync('.codegraph/codegraph.db', { readOnly: true });
+const routes = db.prepare("select name from nodes where kind='route' order by name").all().map((r) => r.name);
+const verbs = routes.filter((n) => /^[A-Z]+ /.test(n));
+const pages = routes.filter((n) => !/^[A-Z]+ /.test(n));
+console.log(`routes: ${routes.length} (${verbs.length} endpoints, ${pages.length} pages)`);
+if (routes.length) console.log('  ' + routes.slice(0, 24).join(' | ') + (routes.length > 24 ? ' | …' : ''));
+const navs = db.prepare("select count(*) as n from edges where kind='navigates'").get().n;
+const synth = db.prepare("select json_extract(metadata, '$.synthesizedBy') as by, count(*) as n from edges where provenance='heuristic' group by 1 order by 2 desc").all();
+console.log(`navigations: ${navs}${navs ? ' — the Screens tab is on' : ' — no screen navigation: the Screens tab stays hidden, Entry points is the list'}`);
+console.log('synthesized edges: ' + (synth.map((s) => `${s.by}=${s.n}`).join(', ') || 'none'));
+const tier = db.prepare("select s.name as s, t.name as t, e.metadata as m from edges e join nodes s on s.id=e.source join nodes t on t.id=e.target where json_extract(e.metadata, '$.synthesizedBy') in ('http-client', 'queue-job', 'event-bus') limit 12").all();
+for (const r of tier) { const m = JSON.parse(r.m); console.log(`  ${m.synthesizedBy}: ${r.s} → ${r.t}${m.href ? ' (' + m.method + ' ' + m.href + ')' : m.event ? ' (' + m.event + ')' : ''}`); }
+JS
+
+echo
+[ -n "$HINT" ] && { echo "look at: $HINT"; echo; }
+ARGS=(ui)
+[ -n "$PORT" ] && ARGS+=(--port "$PORT")
+[ "${CODEGRAPH_TRY_NO_OPEN:-0}" = "1" ] && ARGS+=(--no-open)
+echo "tabs: #/entry (Entry points)  #/screens  #/steps (chooser)  #/steps?symbol=<route or function name>&through=1"
+exec node "$CLI" "${ARGS[@]}" .

+ 1 - 0
site/astro.config.mjs

@@ -74,6 +74,7 @@ export default defineConfig({
 					label: 'Guides',
 					items: [
 						{ label: 'Indexing a Project', slug: 'guides/indexing' },
+						{ label: 'Reading Your Graph in the Browser', slug: 'guides/viewer' },
 						{ label: 'Framework Routes', slug: 'guides/framework-routes' },
 						{ label: 'Affected Tests in CI', slug: 'guides/affected-tests' },
 					],

+ 1 - 0
site/src/content/docs/getting-started/next-steps.md

@@ -14,6 +14,7 @@ You've got CodeGraph installed and a graph built. Here's where to go next.
 ## Put it to work
 
 - [Indexing a Project](/codegraph/guides/indexing/) — full index, incremental sync, and the file watcher.
+- [Reading Your Graph in the Browser](/codegraph/guides/viewer/) — `codegraph ui`: callers, source and callees on one screen.
 - [Framework Routes](/codegraph/guides/framework-routes/) — link URL patterns to their handlers.
 - [Affected Tests in CI](/codegraph/guides/affected-tests/) — run only the tests a change touches.
 

+ 156 - 0
site/src/content/docs/guides/viewer.md

@@ -0,0 +1,156 @@
+---
+title: Reading Your Graph in the Browser
+description: codegraph ui opens a local viewer for an indexed project — callers, source, and callees on one screen.
+---
+
+`codegraph ui` opens a viewer for a project you have already indexed. It is the same graph your agent reads, on screen.
+
+```bash
+codegraph init          # once per project, if you haven't already
+codegraph ui            # opens http://127.0.0.1:4747 in your browser
+```
+
+![The CodeGraph viewer: callers on the left, the symbol's source in the middle with a marker on every calling line, and the symbols it calls on the right, each level with its call site](https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/codegraph-ui-symbol-view.png?v=1)
+
+## The symbol screen
+
+Pick a symbol and you get three columns that all describe the same thing:
+
+- **Called by**, on the left, grouped by file, each caller carrying the exact line it calls from. Click a line number to open that caller scrolled to the call. Test callers fold into a single line so real callers stay in view.
+- **The source**, in the middle, verbatim from disk and syntax-highlighted, with a marker in the gutter on every line that calls something and a link on every call CodeGraph resolved. A long body shows its opening plus a window around every call site, with the skipped runs counted rather than hidden.
+- **Calls**, on the right, one row per symbol this one calls, drawn level with the line that calls it and joined to that line by a hairline. Hover either end and the line, the gutter marker and the connector all light up. A symbol called from several lines says so; `creates` marks a constructor.
+
+A class, interface, struct or enum shows its members in source order instead of a body, each with how many things call it and how many things it calls.
+
+Under the source, a **blast radius** strip counts what a change here would reach: direct dependents, everything within three hops, and how many files, test files and routes that touches.
+
+## Honesty on screen
+
+The viewer never presents a guess as a fact:
+
+- Edges CodeGraph resolved by name alone, below its confidence threshold, fold into an "uncertain" line rather than sitting among the resolved ones. Nothing is silently dropped — the count is always there.
+- A symbol that no test reaches within three caller hops wears a badge saying exactly that.
+- Calls into symbols that aren't in the index are counted and marked, not omitted.
+- A file that changed on disk since it was indexed wears a banner and switches to the file's **current** source, with everything the graph anchors to a line number — the gutter markers, the call arcs, the right-hand list — switched off. The bytes on disk are right by construction; the line numbers the index recorded are the part that stopped being true.
+
+## It keeps up with your project
+
+The viewer follows the project while it is open, and it does it by watching, never by asking on a timer.
+
+- **Save a file and the banner appears** — about a third of a second later, before any sync has run. That is the honest state: the file on disk and the index have parted company, and the screen says so rather than showing you a body sliced at the wrong lines.
+- **When something re-indexes** — your agent's background sync, `codegraph sync`, a git hook — whatever is on screen refetches itself and a small "Index updated · reloaded" note appears at the bottom. The symbol, the file, the map and the flow are all answers about the graph as a whole, so all of them re-read it.
+- **A symbol that moved is followed, not lost.** Adding two lines above a function changes its identity in the graph; the viewer finds it again in its file and carries your trail across, rather than telling you the thing you were reading no longer exists.
+
+If the viewer ever loses touch with the server, it retries a handful of times with a growing delay and then stops and says **"Not live"** in the top bar — it never falls back to polling. Focus the tab to reconnect.
+
+## Getting around
+
+- **Search** with `/` or Cmd-K: every symbol and file, grouped by kind, with signature and `file:line`. Arrow keys and Enter, no mouse needed.
+- **Entry points** on the opening screen, and in full on the **Entry points** tab (`e`) — see below.
+- **Typing a name also finds entry points.** They come back under their own heading below the symbol matches, so searching `payroll` returns the URL *with* the symbol that serves it, not just the URL.
+- **A trail** records the path you walked, with an arrow per hop showing whether you stepped into a call or up to a caller. Click any hop to jump back to it. The trail lives in the URL, so you can send someone the exact route you took — or press **Save trail** to keep it (see below).
+- **Keyboard:** arrow keys move within a column, left/right switch columns, Enter follows, Backspace steps back.
+
+Clicking any file path opens the **file view**: everything that file depends on, its outline in source order, and everything that depends on it.
+
+## Saved trails
+
+A trail you want to come back to is worth a name. Press **Save trail** on the trail bar, type one, and it is kept — listed on the opening screen and on the **Entry points** tab, above the derived suggestions. Opening one puts you back at the symbol you left with the whole walk restored in the bar. Explaining "how a request is served" to a new teammate becomes a name and a link.
+
+**A saved trail survives your project changing.** Each step is remembered by what it *is* — its qualified name, its kind, the file it was in — rather than by where it sat, so editing the file above a function does not lose it. When something does move, the row says so rather than quietly showing you something else:
+
+- a step that moved to another file still opens, and the row names both files;
+- a step that was renamed or deleted is called out by name, and the row says how much of the walk still opens (`Opens hops 2–4 of 6`);
+- a name now carried by several symbols is marked as a guess.
+
+A gap is never stitched over. The trail is a *path*, so a row opens the longest run of consecutive steps that still resolve — joining step 2 to step 4 would draw a call that does not exist.
+
+**Where they live.** One JSON file per trail under `.codegraph/ui/trails/`, which git already ignores, so trails are yours by default. **Export** on any row hands you the same file if you would rather commit one for the team; drop it back into that directory in another checkout and it re-resolves against *that* index.
+
+This is the only thing the viewer writes. Start it with `codegraph ui --read-only` and it will not write even this — saved trails can still be opened, just not saved or deleted.
+
+## Entry points
+
+The first screen worth opening on a codebase you have never seen. Four lists, all read out of the graph rather than guessed from filenames:
+
+- **Routes** — every URL with the symbol that serves it and the `file:line` you will find it at, grouped by the file the route is *registered* in (your router, not your handlers) and headed with the framework CodeGraph detected. A project with fewer than three routes is not a routed app, so this section is simply absent rather than empty.
+- **Top-level files with calls** — the files that *do* something when they load: a CLI, a worker entry, a build script. That is a fact about the graph (a statement outside every definition is recorded as a call from the file itself), not a guess about a filename, which is why a library module correctly shows nothing.
+- **Tests** — the other direction: what already exercises this code, widest reach first.
+- **Most depended on** — not where the project starts, but where a change radiates furthest.
+
+Every row opens the code. Every row that names a symbol also carries a **Flow ›** chip: press it, then name a second symbol — type it, or press **→ here** on another row — and you get the path between them. "How does `POST /v1/payroll/cycles/{cycleID}/run` reach the database" is two clicks once both ends are on the screen.
+
+## The whole file
+
+The **Source** tab on that screen replaces the outline with the file itself, top to bottom, with the same gutter markers and the same right-hand list of what each line calls — a 6,800-line file scrolls as smoothly as a 60-line one, and the text pages in behind you.
+
+The margin on the left is the part you cannot get anywhere else: **an arc for every call that stays inside the file**, drawn from the calling line to the line the callee is defined on. Source order is the only layout — nothing is placed by an algorithm, because the author already placed it — so the shape of a file's internal call structure is legible at a glance. Hover a line to light the arcs the function under your cursor takes part in; click an arc to jump to the other end. On a file with more than forty of them the diagram narrows to the symbol you are reading rather than drawing a wash of overlapping sweeps, and the count stays in the header.
+
+A rail on the far left lists the file's symbols and follows you as you scroll, when the window is wide enough for it.
+
+## The flow
+
+Type **"how does execute reach getFile"** into the search box — or `execute -> getFile` — and the first result opens the **Flow** strip: the call path between the two symbols, left to right, one card per hop.
+
+Each card is opened at the line that makes the next call, not at the top of the function, so reading the strip is reading the six or eight lines that actually carry the request. The identifier being called is a link; click a card's header to open it in the symbol screen with the trail already set to the path you have read so far.
+
+- **The link between two cards carries the edge** — what kind it is and the line it was recorded at.
+- **A dashed link is a hop nobody can see in the source**: a callback, an interface dispatch, a React re-render, a JSX child. It names the mechanism and, where the resolver knows it, the exact line the handler was wired at. This is the part grep cannot do.
+- **When a name means several definitions**, the strip says so under the picture and names the one this path runs through — and offers the other paths in the picker at the top. Choosing "All paths" draws them as one diagram, branching where they differ and rejoining where they agree.
+- **"Not connected" is an answer**, not a failure: a flow that runs through a dispatch no static edge records genuinely has no path, and the screen says that rather than inventing one.
+
+### Where the graph stops
+
+A path that does not reach what you asked about ends in a dashed block headed **"Where the graph stops."** It is the honest end of the search rather than an error, and it carries what the resolver actually knows:
+
+- **The dispatch form** that ended the path — a computed member call, a `getattr`, a reflective invoke, a `#selector`, a typed message bus — and the line it sits on. The card beside it is opened at that line, so the source the block is describing is on screen.
+- **The key, when the source writes one down.** `handlers['save']` gives `save`, and the block shortlists the symbols that could be on the other side of it — `onSave`, `handleSave`, `SaveHandler` — marking any you already named. When the key is a runtime value it says so instead of shortlisting anything.
+- **What was not followed.** Name-only matches under 0.6 confidence are listed with their confidence, and the other calls the symbol makes are counted. A refused guess left invisible would read as "there is nothing here", which is the one thing it does not mean.
+
+Nothing on the block is invented: no edge is guessed, and none is written to your graph. A flow that reaches what it was asked for never shows one. It is the same finding `codegraph_explore` announces to an agent when a flow breaks, drawn from the same detector, so the screen and the agent's answer cannot disagree.
+
+The **"Read as flow"** button on the trail turns a walk you did by hand into the same strip. It is the same path finder `codegraph_explore` leads its answers with, so the picture and what your agent tells you cannot disagree.
+
+## The map
+
+The **Map** tab (`m`) draws the project at module granularity — one box per directory — with dependencies pointing down. Nothing is placed by hand: a module sits one layer above whatever it depends on, so the top of the picture is what runs first and the bottom is what everything else stands on, and the same project always draws the same picture.
+
+- **Line weight** is how many calls, imports and type references cross the link. Hover one for the breakdown by kind and the busiest symbol pairs behind it.
+- **Click a module** to isolate its links and see its dependencies and dependents with counts, plus its files — click one to open the file view.
+- **Cycles are listed, not straightened away**: mutual dependencies between two modules, loops of three or more, and circular imports between individual files.
+
+It is honest about what it leaves out. Links carrying only a handful of references stay hidden until you select a module they touch, and references CodeGraph isn't confident about are excluded from every count on the screen — the panel prints how many. The vertical order rests on the dependencies your code writes down (imports, qualified names, inheritance, typed receivers), because a method name shared by two unrelated folders should not be able to move a box; when a project has too few of those to go on, the panel says the order came from raw reference counts instead.
+
+The map opens on your project's source directory. The picker switches to any other top-level folder or the whole repository, the checkbox brings test modules in, and `?depth=2` in the address splits a large folder into its sub-folders — the useful setting on a monorepo. What you are looking at lives in the URL, so the view is shareable.
+
+## Take the picture with you
+
+The flow strip and the map both carry **Copy image** and **Download SVG**.
+
+Copy image puts a PNG on the clipboard, ready to paste into a pull-request comment or a chat — the fastest way to say "this is what your change touches" without asking anyone to install something. Download SVG saves a file for a README: it is real text rather than a bitmap, so it stays sharp at any size and the symbol names in it are selectable and searchable.
+
+Both render the **light** theme whatever you are reading in, because the image is going to be read on somebody else's screen. Both carry a caption saying what the picture is — the path, or the root and how many modules — and a small CodeGraph mark in the corner. What you export is exactly what is on screen: the same hops, the same dashed dynamic-dispatch links, the same modules dimmed or brought forward by your selection, the same links hidden for being thin.
+
+An eight-hop strip comes out around half a megabyte, well inside what GitHub accepts inline.
+
+## Options
+
+| | |
+|---|---|
+| `codegraph ui [path]` | Read a specific indexed project instead of the current directory |
+| `--port <n>` | Pin a port. Without it the viewer takes 4747, or the next free one |
+| `--no-open` | Print the URL instead of opening a browser (headless boxes, SSH) |
+| `--read-only` | Refuse every write — saved trails can be opened, but not saved or deleted |
+| `CODEGRAPH_BROWSER=<command>` | Choose which browser opens. `CODEGRAPH_BROWSER=none` never opens one |
+
+`codegraph web` is an alias for the same command.
+
+## Privacy
+
+The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It opens an index that already exists, never creates one, and never changes your graph or a line of your code.
+
+The one thing it writes is a trail you asked it to save, as JSON under `.codegraph/ui/trails/`. Nothing else it serves has a side effect, no other endpoint accepts a write, and `codegraph ui --read-only` refuses that one too.
+
+It sends nothing anywhere — no code, no paths, no analytics. The page in your browser talks only to the server on your own machine, and that server makes no outbound connections at all. See [Telemetry](https://github.com/colbymchenry/codegraph/blob/main/TELEMETRY.md) for the complete picture.
+
+The viewer reads an index that already exists, so run [`codegraph init`](/codegraph/guides/indexing/) in the project first.

+ 17 - 0
site/src/content/docs/reference/cli.md

@@ -12,6 +12,7 @@ codegraph uninit [path]           # Remove CodeGraph from a project (--force to
 codegraph index [path]            # Full re-index from scratch (--force, --quiet, --verbose)
 codegraph sync [path]             # Incremental update (--quiet)
 codegraph status [path]           # Show statistics (--json)
+codegraph ui [path]               # Open the browser viewer for an indexed project (alias: web; --port, --no-open)
 codegraph unlock [path]           # Remove a stale lock file that's blocking indexing
 codegraph query <search>          # Search symbols (--kind, --limit, --json)
 codegraph explore <query>         # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)
@@ -49,3 +50,19 @@ codegraph impact AuthMiddleware --depth 3
 ## affected
 
 Traces import dependencies transitively to find which test files are affected by changed source files. See [Affected Tests in CI](/codegraph/guides/affected-tests/) for options and a CI example.
+
+## ui
+
+`codegraph ui` opens the [browser viewer](/codegraph/guides/viewer/) for a project you have already indexed: callers on the left, the symbol's source in the middle, and what it calls on the right at the height of the line that calls it.
+
+```bash
+codegraph ui                     # the project you're standing in
+codegraph ui ~/code/my-app       # a project indexed elsewhere
+codegraph ui --port 8080         # pin a port (fails if it's taken)
+codegraph ui --no-open           # just print the URL (headless boxes, SSH)
+codegraph ui --read-only         # refuse every write, including saved trails
+```
+
+Without `--port` it takes 4747, or the next free port. `CODEGRAPH_BROWSER=<command>` chooses which browser opens; `CODEGRAPH_BROWSER=none` never opens one. `codegraph web` is an alias.
+
+The viewer listens on `127.0.0.1` only: it opens an index that already exists, never creates one, never changes your graph or a line of your code, and sends nothing anywhere. The one thing it writes is a trail you asked it to save, under `.codegraph/ui/trails/`; `--read-only` refuses even that.

+ 191 - 0
src/bin/codegraph.ts

@@ -20,6 +20,7 @@
  *   codegraph callees <symbol>   Find what a function/method calls
  *   codegraph impact <symbol>    Analyze what code is affected by changing a symbol
  *   codegraph affected [files]   Find test files affected by changes
+ *   codegraph ui [path]          Open the browser viewer for an indexed project (alias: web)
  *   codegraph upgrade [version]  Update CodeGraph to the latest release
  */
 
@@ -53,6 +54,11 @@ import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime
 import { installCommandSupervision } from './command-supervision';
 import { EXTRACTION_VERSION } from '../extraction/extraction-version';
 import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
+// Value import, but dependency-free by design so `--help` text can name the
+// default port without dragging node:http into every other subcommand; the
+// server itself is loaded lazily inside the `ui` action. See ui-server/constants.
+import { BROWSER_ENV, DEFAULT_UI_PORT } from '../ui-server/constants';
+import type { UiServerHandle } from '../ui-server';
 
 // Decided once, before `--color`/`--no-color` are stripped from argv below
 // (#1281). Piped/redirected stdout, NO_COLOR, or --no-color -> plain output.
@@ -1822,6 +1828,191 @@ program
     });
   });
 
+/**
+ * Print the "no index here" guidance.
+ *
+ * The viewer READS an index; it never builds one — indexing stays the user's
+ * decision, exactly as it is for the MCP tools. So a missing index is normal
+ * input, not a failure to apologize for: say what is missing, say the one
+ * command that fixes it, and never print a stack trace.
+ */
+function printNoIndexGuidance(projectPath: string): void {
+  error(`No CodeGraph index found for ${projectPath}`);
+  console.error('');
+  // getGlyphs() (not a literal em dash): a legacy Windows console decodes raw
+  // UTF-8 with its OEM codepage and renders one as mojibake (#168).
+  console.error(`  The viewer reads an index that already exists ${getGlyphs().dash} it never creates one.`);
+  console.error('  To index this project:');
+  console.error('');
+  console.error(`    ${chalk.cyan('codegraph init')}`);
+  console.error('');
+  console.error('  Already indexed somewhere else? Point the viewer at it:');
+  console.error('');
+  console.error(`    ${chalk.cyan('codegraph ui /path/to/indexed/project')}`);
+  console.error('');
+}
+
+/**
+ * codegraph ui [path]  (alias: web)
+ *
+ * The browser reader: serves the built viewer (`dist/viewer/`) over loopback
+ * and opens it. It opens the index for reading and never writes to it, never
+ * indexes, and never changes a line of the project's code. The single thing it
+ * writes is a trail the reader saved, as JSON under `.codegraph/ui/trails/`;
+ * `--read-only` turns even that off.
+ *
+ * Deliberately absent from TELEMETRY_FLUSH_COMMANDS above: the command's own
+ * banner tells the user nothing leaves their machine, so it must not be the
+ * thing that triggers a telemetry send. The usage count still buffers locally
+ * like every other quick command.
+ */
+program
+  .command('ui [path]')
+  .alias('web')
+  .description('Open the CodeGraph viewer in your browser — read your indexed project as a graph')
+  .option('--port <number>', `Port to listen on (default: ${DEFAULT_UI_PORT}, or the next free one)`)
+  .option('--no-open', 'Print the URL instead of opening a browser')
+  .option('--read-only', 'Refuse every write — saved trails can be opened but not saved or deleted')
+  .addHelpText(
+    'after',
+    `
+Examples:
+  $ codegraph ui                    Read the project you're standing in
+  $ codegraph ui ~/code/my-app      Read a specific indexed project
+  $ codegraph ui --port 8080        Use one specific port (fails if it's taken)
+  $ codegraph ui --no-open          Just print the URL (headless boxes, SSH)
+  $ codegraph web                   Same command under its alias
+
+Pick a symbol and you see who calls it on the left, its source in the middle,
+and what it calls on the right at the height of the line that calls it. Search
+with / (or Cmd-K), click a file path for the file's outline and its imports.
+
+Ask "how does execute reach getFile" (or "execute -> getFile") in the search
+box for the flow between two symbols: one card per hop, opened at the line that
+makes the next call, with dynamic-dispatch hops drawn dashed and named. The Map
+tab draws the whole project by module, with dependencies pointing down.
+
+Never opened this codebase before? The Entry points tab lists the routes with
+the symbols that serve them, the files that run something when they load, the
+tests, and what the most code depends on — and starts a flow from any of them.
+
+The page keeps up with the project while it is open: save a file and it says so
+within about a third of a second, and whatever is on screen re-reads the graph
+when something re-indexes it. It watches for that; it never polls.
+
+Save a walk you want to keep: name the trail and it is written to
+.codegraph/ui/trails/ (already gitignored) as plain JSON, listed on the empty
+screen, and reopened at the symbol you left. Hops are remembered by name rather
+than by position, so a saved trail survives re-indexing and says which hop moved
+when one does. Pass --read-only to refuse every write.
+
+The viewer listens on 127.0.0.1 only, so nothing on your network can reach it.
+It opens an index that already exists, never indexes, and never changes a line
+of your code — the one thing it writes is a trail you asked it to save.
+Requests from any other host are refused, and nothing is sent anywhere: no code,
+no paths, no analytics.
+
+Without --port it takes ${DEFAULT_UI_PORT}, or the next free port if that one is busy.
+
+Set ${BROWSER_ENV}=<command> to choose which browser opens, or
+${BROWSER_ENV}=none to never open one.
+`
+  )
+  .action(async (pathArg: string | undefined, options: { port?: string; open?: boolean; readOnly?: boolean }) => {
+    // An explicit --port stays explicit: a scripted `--port 8080` that quietly
+    // lands on 8081 is worse than one that says the port is busy. The default
+    // port is the only one we're free to walk away from.
+    let requestedPort: number | undefined;
+    if (options.port !== undefined) {
+      requestedPort = Number(options.port);
+      if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
+        error(`--port must be a whole number between 0 and 65535 (got "${options.port}").`);
+        process.exit(1);
+      }
+    }
+
+    const projectPath = resolveProjectPath(pathArg);
+
+    // Sensitive-directory refusal before anything opens: the same guard the MCP
+    // entry points use, so `codegraph ui /etc` is turned away here rather than
+    // becoming a browsable view of the system.
+    const { validateProjectPath } = await import('../utils');
+    const rootError = validateProjectPath(projectPath);
+    if (rootError) {
+      error(rootError);
+      process.exit(1);
+    }
+
+    if (!isInitialized(projectPath)) {
+      printNoIndexGuidance(projectPath);
+      process.exit(1);
+    }
+
+    const { startUiServer, openBrowser, createGraphApi, ViewerMissingError } = await import(
+      '../ui-server'
+    );
+
+    // The JSON API the viewer reads its screens from. It opens the index lazily
+    // on the first request, so a slow first paint is the only cost of mounting
+    // it here rather than after the browser connects.
+    const readOnly = options.readOnly === true;
+    const api = createGraphApi({
+      projectRoot: projectPath,
+      readOnly,
+      readOnlyReason: readOnly
+        ? 'This viewer was started with --read-only, so trails cannot be saved.'
+        : undefined,
+    });
+
+    let handle: UiServerHandle;
+    try {
+      handle = await startUiServer({
+        projectRoot: projectPath,
+        port: requestedPort,
+        portFallback: requestedPort === undefined,
+        api: api.handler,
+      });
+    } catch (err) {
+      api.close();
+      // Both failure modes here (viewer assets missing, no port available) carry
+      // their own remediation — print it plainly, never a stack trace.
+      error(err instanceof ViewerMissingError || err instanceof Error ? err.message : String(err));
+      process.exit(1);
+    }
+
+    console.log('');
+    console.log(chalk.bold('CodeGraph viewer'));
+    console.log('');
+    console.log(`  ${chalk.dim('Reading')}  ${projectPath}`);
+    console.log(`  ${chalk.dim('URL')}      ${chalk.cyan(handle.url)}`);
+    console.log(
+      `  ${chalk.dim('Access')}   this machine only ${getGlyphs().dash} ` +
+        (readOnly
+          ? 'read-only, nothing leaves your computer'
+          : 'nothing leaves your computer; saved trails are the only thing written')
+    );
+    console.log('');
+
+    const opened = options.open === false ? false : openBrowser(handle.url);
+    console.log(
+      opened
+        ? chalk.dim('  Opening your browser... press Ctrl+C to stop.')
+        : chalk.dim('  Open that URL in a browser. Press Ctrl+C to stop.')
+    );
+    console.log('');
+
+    // The http server keeps the event loop alive on its own; these just make
+    // Ctrl-C hang up live sockets instead of waiting on browser keep-alives.
+    const shutdown = (): void => {
+      // Release the SQLite handle before the socket: the process should never
+      // exit with a live connection to the user's index.
+      api.close();
+      void handle.close().then(() => process.exit(0));
+    };
+    process.once('SIGINT', shutdown);
+    process.once('SIGTERM', shutdown);
+  });
+
 /**
  * codegraph serve
  */

+ 7 - 1
src/context/index.ts

@@ -412,6 +412,12 @@ export class ContextBuilder {
         ? `renders <${String(m.via || 'child')}>`
         : m.synthesizedBy === 'vue-handler'
         ? `Vue @${String(m.event || 'event')} handler`
+        : m.synthesizedBy === 'http-client'
+        ? `HTTP ${String(m.method || 'GET')} ${String(m.href || '')} — the client's call onto its own route${at}`
+        : m.synthesizedBy === 'queue-job'
+        ? `queue job ${m.event ? `\`${String(m.event)}\`` : ''}${m.queue ? ` on \`${String(m.queue)}\`` : ''}${at}`
+        : m.synthesizedBy === 'event-bus' && m.channel === 'socket'
+        ? `socket message ${m.event ? `\`${String(m.event)}\`` : ''}${m.tier === 'client→server' ? ' → server' : m.tier === 'server→client' ? ' → client' : ''}${at}`
         : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
       synthByPair.set(`${e.source}>${e.target}`, label);
     }
@@ -1208,7 +1214,7 @@ export class ContextBuilder {
 
     // Edge recovery: BFS with many entry points leaves most nodes disconnected.
     // Discover edges between already-selected nodes to recover connectivity.
-    const recoveryKinds: EdgeKind[] = ['calls', 'extends', 'implements', 'references', 'overrides'];
+    const recoveryKinds: EdgeKind[] = ['calls', 'extends', 'implements', 'references', 'overrides', 'navigates'];
     const recoveredEdges = this.queries.findEdgesBetweenNodes(
       [...finalNodes.keys()],
       recoveryKinds,

+ 822 - 3
src/db/queries.ts

@@ -245,6 +245,8 @@ export class QueryBuilder {
     deleteEdgesByTarget?: SqliteStatement;
     getEdgesBySource?: SqliteStatement;
     getEdgesByTarget?: SqliteStatement;
+    getUnresolvedFromNode?: SqliteStatement;
+    getUnresolvedInFile?: SqliteStatement;
     insertFile?: SqliteStatement;
     updateFile?: SqliteStatement;
     deleteFile?: SqliteStatement;
@@ -1022,7 +1024,17 @@ export class QueryBuilder {
    * mapping AND the handler implementations.
    */
   getRoutingManifest(limit: number = 40): {
-    entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>;
+    entries: Array<{
+      url: string;
+      handler: string;
+      handlerFile: string;
+      handlerLine: number;
+      handlerKind: string;
+      /** The route node itself: where the URL is REGISTERED, not where it is served. */
+      routeId: string;
+      routeFile: string;
+      routeLine: number;
+    }>;
     topHandlerFile: string | null;
     topHandlerFileCount: number;
     totalRoutes: number;
@@ -1034,6 +1046,9 @@ export class QueryBuilder {
       this.stmts.getRoutingManifest = this.db.prepare(`
         SELECT
           r.name AS url,
+          r.id AS route_id,
+          r.file_path AS route_file,
+          r.start_line AS route_line,
           h.name AS handler,
           h.file_path AS handler_file,
           h.start_line AS handler_line,
@@ -1043,13 +1058,14 @@ export class QueryBuilder {
         JOIN nodes h ON e.target = h.id
         WHERE r.kind = 'route'
           AND e.kind IN ('references', 'calls')
-          AND h.kind IN ('function', 'method', 'class')
+          AND h.kind IN ('function', 'method', 'class', 'constant', 'variable')
         ORDER BY r.file_path, r.start_line
         LIMIT ?
       `);
     }
     const rows = this.stmts.getRoutingManifest.all(limit) as Array<{
-      url: string; handler: string; handler_file: string; handler_line: number; handler_kind: string;
+      url: string; route_id: string; route_file: string; route_line: number;
+      handler: string; handler_file: string; handler_line: number; handler_kind: string;
     }>;
     // Drop test/generated handlers — same hygiene as elsewhere.
     const generated = this.getGeneratedPathsAmong(rows.map(r => r.handler_file));
@@ -1075,6 +1091,9 @@ export class QueryBuilder {
         handlerFile: r.handler_file,
         handlerLine: r.handler_line,
         handlerKind: r.handler_kind,
+        routeId: r.route_id,
+        routeFile: r.route_file,
+        routeLine: r.route_line,
       })),
       topHandlerFile,
       topHandlerFileCount,
@@ -1862,6 +1881,655 @@ export class QueryBuilder {
     return rows.map(rowToEdge);
   }
 
+  /**
+   * Outgoing edges for MANY source nodes in one query.
+   *
+   * The batch form of {@link getOutgoingEdges}. Building a nested outline needs
+   * the `contains` edges of every container in a file at once; doing that one
+   * source at a time is a query per symbol on files that have hundreds.
+   */
+  getOutgoingEdgesFrom(sourceIds: readonly string[], kinds?: EdgeKind[]): Edge[] {
+    if (sourceIds.length === 0) return [];
+    const unique = [...new Set(sourceIds)];
+    const out: Edge[] = [];
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      let sql = `SELECT * FROM edges WHERE source IN (${placeholders})`;
+      const params: string[] = [...chunk];
+      if (kinds && kinds.length > 0) {
+        sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
+        params.push(...kinds);
+      }
+      const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
+      for (const row of rows) out.push(rowToEdge(row));
+    }
+    return out;
+  }
+
+  /**
+   * Fan-in (total incoming edge count) for MANY nodes in one query.
+   *
+   * The per-node alternative — `getIncomingEdges(id).length` — is an indexed
+   * lookup each, but a symbol screen rendering a couple of hundred callees
+   * would issue a couple of hundred of them. Ids with no incoming edges are
+   * absent from the map rather than present as 0, so callers can tell "no
+   * edges" from "not asked about".
+   */
+  countIncomingEdges(ids: readonly string[]): Map<string, number> {
+    const out = new Map<string, number>();
+    if (ids.length === 0) return out;
+    const unique = [...new Set(ids)];
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      const rows = this.db
+        .prepare(
+          `SELECT target, COUNT(*) AS count FROM edges WHERE target IN (${placeholders}) GROUP BY target`
+        )
+        .all(...chunk) as Array<{ target: string; count: number }>;
+      for (const row of rows) out.set(row.target, row.count);
+    }
+    return out;
+  }
+
+  /**
+   * Incoming edges for MANY target nodes in one query — the mirror of
+   * {@link getOutgoingEdgesFrom}. Needed wherever a whole file's inbound edges
+   * are wanted at once ("which files import anything in this one?").
+   */
+  getIncomingEdgesTo(targetIds: readonly string[], kinds?: EdgeKind[]): Edge[] {
+    if (targetIds.length === 0) return [];
+    const unique = [...new Set(targetIds)];
+    const out: Edge[] = [];
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      let sql = `SELECT * FROM edges WHERE target IN (${placeholders})`;
+      const params: string[] = [...chunk];
+      if (kinds && kinds.length > 0) {
+        sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
+        params.push(...kinds);
+      }
+      const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
+      for (const row of rows) out.push(rowToEdge(row));
+    }
+    return out;
+  }
+
+  /**
+   * Fan-out (total outgoing edge count) for MANY nodes in one query — the
+   * mirror of {@link countIncomingEdges}. Ids with no outgoing edges are absent
+   * from the map rather than present as 0.
+   */
+  countOutgoingEdges(ids: readonly string[]): Map<string, number> {
+    const out = new Map<string, number>();
+    if (ids.length === 0) return out;
+    const unique = [...new Set(ids)];
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      const rows = this.db
+        .prepare(
+          `SELECT source, COUNT(*) AS count FROM edges WHERE source IN (${placeholders}) GROUP BY source`
+        )
+        .all(...chunk) as Array<{ source: string; count: number }>;
+      for (const row of rows) out.set(row.source, row.count);
+    }
+    return out;
+  }
+
+  /**
+   * Symbols nothing in the index points at — the candidate set behind the dead
+   * code list (`src/graph/dead-code.ts`).
+   *
+   * "Points at" is every edge kind EXCEPT `contains`: a class containing a
+   * method is structure, not use, and counting it would make every member look
+   * reached by its own container. A self-edge is excluded for the same reason
+   * a recursive function is not its own caller.
+   *
+   * One scan, one index probe per candidate. `NOT EXISTS` over
+   * `idx_edges_target_kind` is what keeps it that way — the alternative
+   * (`LEFT JOIN edges … GROUP BY`) builds a row per edge for the whole table
+   * before discarding all but the empty groups. Ordered by position so the
+   * answer is stable across runs and groups by file without a second sort.
+   *
+   * The result is deliberately NOT called dead code: an unreferenced symbol is
+   * a symbol with no STATIC reference, and the caller applies the exclusions
+   * (tests, generated files, overrides, unresolved names) that turn the
+   * candidate set into a claim worth making.
+   */
+  getUnreferencedNodes(
+    kinds: readonly string[],
+    limit: number
+  ): Array<{ node: Node; generated: boolean }> {
+    if (kinds.length === 0 || limit <= 0) return [];
+    const placeholders = kinds.map(() => '?').join(',');
+    const rows = this.db
+      .prepare(
+        `SELECT n.*, COALESCE(f.generated, 0) AS file_generated
+           FROM nodes n
+           LEFT JOIN files f ON f.path = n.file_path
+          WHERE n.kind IN (${placeholders})
+            AND NOT EXISTS (
+                  SELECT 1 FROM edges e
+                   WHERE e.target = n.id
+                     AND e.kind != 'contains'
+                     AND e.source != n.id
+                )
+       ORDER BY n.file_path, n.start_line, n.name
+          LIMIT ?`
+      )
+      .all(...kinds, limit) as Array<NodeRow & { file_generated: number }>;
+    return rows.map((row) => ({ node: rowToNode(row), generated: row.file_generated === 1 }));
+  }
+
+  /**
+   * Which of `names` the index holds an UNRESOLVED reference to.
+   *
+   * The point is honesty about our own blind spots. A `failed` row in
+   * `unresolved_refs` records that some file referenced a name and the resolver
+   * could not decide what it meant — so a symbol with that name cannot be
+   * called unreferenced, whatever the edge table says. It is deliberately
+   * matched loosely, on the reference name AND on its tail (`util.greet` →
+   * `greet`), because the question being asked is "could this name be the one
+   * we failed to follow", and a maybe has to count as a yes.
+   *
+   * Bounded-lookup like {@link getGeneratedPathsAmong}: the caller holds a
+   * candidate list, so this is a chunked probe over `idx_unresolved_name`, not
+   * a scan of the table.
+   */
+  getUnresolvedNamesAmong(names: Iterable<string>): Set<string> {
+    const unique = [...new Set(names)].filter((name) => name.length > 0);
+    const found = new Set<string>();
+    if (unique.length === 0) return found;
+
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      const rows = this.db
+        .prepare(
+          `SELECT DISTINCT reference_name AS name FROM unresolved_refs
+            WHERE reference_name IN (${placeholders})
+            UNION
+           SELECT DISTINCT name_tail AS name FROM unresolved_refs
+            WHERE name_tail IN (${placeholders})`
+        )
+        .all(...chunk, ...chunk) as Array<{ name: string }>;
+      for (const row of rows) found.add(row.name);
+    }
+    return found;
+  }
+
+  /**
+   * Which of `names` are carried by MORE THAN ONE symbol, at least one of which
+   * something points at.
+   *
+   * The false positive this exists to kill: `CodeGraph.getTopRouteFile` calls
+   * `this.queries.getTopRouteFile()`, and the resolver — which prefers a
+   * same-name definition in the call site's own file — attaches that edge to
+   * the *calling* method. One of the two ends up with a self-edge and the other
+   * with nothing at all, and neither is unreferenced. From the edge table the
+   * mis-resolution and a genuinely unused twin are the same picture, so the
+   * claim is not made about either.
+   *
+   * Both halves of the condition are load-bearing. **More than one symbol**:
+   * a uniquely-named function that only calls itself is genuinely dead, and
+   * excluding every recursive function would gut the list. **Self-edges
+   * counted**: the self-edge IS the fingerprint of the mis-resolution above, so
+   * it has to count as evidence that this name resolves somewhere.
+   *
+   * Chunked probe over `idx_nodes_name`, bounded by the caller's candidate list.
+   */
+  getAmbiguousReferencedNames(names: Iterable<string>): Set<string> {
+    const unique = [...new Set(names)].filter((name) => name.length > 0);
+    const found = new Set<string>();
+    if (unique.length === 0) return found;
+
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      const rows = this.db
+        .prepare(
+          `SELECT name FROM (
+             SELECT n.name AS name,
+                    EXISTS (
+                      SELECT 1 FROM edges e
+                       WHERE e.target = n.id AND e.kind != 'contains'
+                    ) AS referenced
+               FROM nodes n
+              WHERE n.name IN (${placeholders})
+           )
+         GROUP BY name
+           HAVING COUNT(*) > 1 AND SUM(referenced) > 0`
+        )
+        .all(...chunk) as Array<{ name: string }>;
+      for (const row of rows) found.add(row.name);
+    }
+    return found;
+  }
+
+  /**
+   * Which of the given languages the index records an EXPORT marker for.
+   *
+   * A self-measurement, and the honest basis for a whole class of exclusion.
+   * The dead code report's strongest filter is "exported symbols may be reached
+   * from outside this repository" — and that filter silently does nothing for a
+   * language whose exports are not recorded, either because the extractor does
+   * not record them (Rust `pub`) or because the language has no such concept at
+   * all (Python, C, Ruby: the header or the module IS the surface). Rather than
+   * carry a table of which is which, ask the index: if nothing in this language
+   * is marked exported, the filter did not run, and no claim about outside
+   * reachability can be made for it.
+   *
+   * `idx_nodes_language` covers the grouping; the caller passes the handful of
+   * languages its candidates are actually in.
+   */
+  getLanguagesWithExports(languages: Iterable<string>): Set<string> {
+    const unique = [...new Set(languages)].filter((language) => language.length > 0);
+    const found = new Set<string>();
+    if (unique.length === 0) return found;
+
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      const rows = this.db
+        .prepare(
+          `SELECT language, MAX(is_exported) AS any_exported
+             FROM nodes
+            WHERE language IN (${placeholders})
+         GROUP BY language`
+        )
+        .all(...chunk) as Array<{ language: string; any_exported: number }>;
+      for (const row of rows) if (row.any_exported === 1) found.add(row.language);
+    }
+    return found;
+  }
+
+  /**
+   * The nodes with the most DISTINCT dependents, most first.
+   *
+   * "Distinct" is the difference that matters: a helper called forty times from
+   * one function has a fan-in of 40 but exactly one dependent. This counts the
+   * second thing — the number a reader means by "N callers" — so the top of
+   * this list is the set of symbols a change actually radiates furthest from.
+   *
+   * `contains` is excluded because it is structure, not dependency: counting it
+   * would rank every file and class above the code they hold.
+   */
+  getTopDependedOn(limit: number): Array<{ nodeId: string; dependents: number }> {
+    if (limit <= 0) return [];
+    const rows = this.db
+      .prepare(
+        `SELECT target AS nodeId, COUNT(DISTINCT source) AS dependents
+           FROM edges
+          WHERE kind != 'contains' AND source != target
+       GROUP BY target
+       ORDER BY dependents DESC
+          LIMIT ?`
+      )
+      .all(limit) as Array<{ nodeId: string; dependents: number }>;
+    return rows;
+  }
+
+  /**
+   * The graph's executable roots — files that RUN something at module level,
+   * ranked by how much of the project they set in motion.
+   *
+   * The engine records a statement at the top level of a file as an edge from
+   * the *file* node, so `src/bin/codegraph.ts` calling `program.parse()` at
+   * module scope is a `calls` edge out of a `file`. That set is what makes the
+   * roots of a dependency graph visible: a library module holds definitions and
+   * runs nothing until someone imports it, while a CLI, a worker entry or a
+   * build script does its work on the way down the file. `instantiates` counts
+   * the same way — `new Server(...)` at module scope is the same act.
+   *
+   * Ranking multiplies the two things an entry point does: it runs (calls), and
+   * it wires the project together (distinct other files its symbols reach). One
+   * alone is misleading — a registration table makes hundreds of module-level
+   * calls into itself, and a barrel file imports everything and runs nothing.
+   * The product puts the file that does both at the top.
+   */
+  getTopCallingFiles(
+    limit: number
+  ): Array<{ nodeId: string; filePath: string; calls: number; reaches: number; score: number }> {
+    if (limit <= 0) return [];
+    return this.db
+      .prepare(
+        `WITH runs AS (
+             SELECT e.source AS id, COUNT(*) AS calls
+               FROM edges e
+               JOIN nodes n ON n.id = e.source
+              WHERE n.kind = 'file' AND e.kind IN ('calls', 'instantiates')
+           GROUP BY e.source
+         ),
+         cand AS (
+             SELECT r.id AS id, n.file_path AS fp, r.calls AS calls
+               FROM runs r JOIN nodes n ON n.id = r.id
+         ),
+         wires AS (
+             SELECT sn.file_path AS fp, COUNT(DISTINCT tn.file_path) AS reaches
+               FROM edges e
+               JOIN nodes sn ON sn.id = e.source
+               JOIN nodes tn ON tn.id = e.target
+              WHERE e.kind != 'contains'
+                AND sn.file_path <> tn.file_path
+                AND sn.file_path IN (SELECT fp FROM cand)
+           GROUP BY sn.file_path
+         )
+         SELECT c.id AS nodeId,
+                c.fp AS filePath,
+                c.calls AS calls,
+                COALESCE(w.reaches, 0) AS reaches,
+                c.calls * (1 + COALESCE(w.reaches, 0)) AS score
+           FROM cand c LEFT JOIN wires w ON w.fp = c.fp
+       ORDER BY score DESC, calls DESC, filePath
+          LIMIT ?`
+      )
+      .all(limit) as Array<{
+      nodeId: string;
+      filePath: string;
+      calls: number;
+      reaches: number;
+      score: number;
+    }>;
+  }
+
+  /**
+   * How many OTHER files depend on each of the given files.
+   *
+   * Counted through the symbols, not the file nodes: an `imports` edge points
+   * at the imported symbol, so a file node almost never receives one and
+   * counting edges into it would report every file as depended on by nobody.
+   * Same-file edges are excluded, which is what makes zero mean "nothing else
+   * in the index reaches into this file" — the honest reading of a root.
+   */
+  getFileDependentCounts(filePaths: string[]): Array<{ filePath: string; dependents: number }> {
+    if (filePaths.length === 0) return [];
+    return this.db
+      .prepare(
+        `SELECT tn.file_path AS filePath, COUNT(DISTINCT sn.file_path) AS dependents
+           FROM edges e
+           JOIN nodes tn ON tn.id = e.target
+           JOIN nodes sn ON sn.id = e.source
+          WHERE e.kind != 'contains'
+            AND tn.file_path IN (SELECT value FROM json_each(?))
+            AND sn.file_path <> tn.file_path
+       GROUP BY tn.file_path`
+      )
+      .all(JSON.stringify(filePaths)) as Array<{ filePath: string; dependents: number }>;
+  }
+
+  /**
+   * How far each of the given files reaches OUT: distinct other files its
+   * symbols touch, and how many references that is.
+   *
+   * The mirror of {@link getFileDependentCounts}, and the same reasoning about
+   * `contains` and same-file edges applies. It is driven from `nodes` rather
+   * than from `edges` so the work is proportional to the files asked about —
+   * the entry-points endpoint asks it about every test file in the index, and
+   * an edge-first plan would scan the whole table to answer a question about a
+   * tenth of it.
+   */
+  getFileReachCounts(filePaths: string[]): Array<{ filePath: string; reaches: number; refs: number }> {
+    if (filePaths.length === 0) return [];
+    return this.db
+      .prepare(
+        `SELECT sn.file_path AS filePath,
+                COUNT(DISTINCT tn.file_path) AS reaches,
+                COUNT(*) AS refs
+           FROM nodes sn
+           JOIN edges e ON e.source = sn.id
+           JOIN nodes tn ON tn.id = e.target
+          WHERE sn.file_path IN (SELECT value FROM json_each(?))
+            AND e.kind != 'contains'
+            AND tn.file_path <> sn.file_path
+       GROUP BY sn.file_path`
+      )
+      .all(JSON.stringify(filePaths)) as Array<{
+      filePath: string;
+      reaches: number;
+      refs: number;
+    }>;
+  }
+
+  /**
+   * The `file` nodes for the given paths, in one query.
+   *
+   * A file's own node is what makes a file row navigable, and looking it up
+   * with {@link getNodesInFile} means materialising every symbol in the file to
+   * throw all but one away.
+   */
+  getFileNodes(filePaths: string[]): Node[] {
+    if (filePaths.length === 0) return [];
+    const rows = this.db
+      .prepare(
+        `SELECT * FROM nodes
+          WHERE kind = 'file'
+            AND file_path IN (SELECT value FROM json_each(?))`
+      )
+      .all(JSON.stringify(filePaths)) as NodeRow[];
+    return rows.map(rowToNode);
+  }
+
+  /**
+   * Roll the whole edge table up to module granularity in one pass.
+   *
+   * The caller decides what a module IS — it hands in a file → module
+   * assignment and gets back the cross-module traffic. That split is
+   * deliberate: naming modules is a *policy* (top-level directories, a façade
+   * file kept separate, a monorepo root) that belongs where the reader lives,
+   * while grouping a million edges by it is *mechanics* that must happen in
+   * SQLite. Doing the fold in JavaScript instead means materialising every
+   * cross-file edge in memory; doing the naming in SQL means a tower of
+   * `instr`/`substr` no one can read.
+   *
+   * The assignment lands in a TEMP table with a primary key, so the join is
+   * indexed and the result set is bounded by modules², not by edges. Temp
+   * tables live in SQLite's own temp database, so this stays valid against a
+   * read-only main.
+   *
+   * Two result sets, because they need two different groupings over the same
+   * join: `links` counts edges per (module, module, kind), and `pairs` names
+   * the busiest symbol pairs behind each link (the map's tooltip). `pairs` is
+   * ranked and cut inside SQLite — the un-cut grouping is the one thing here
+   * that scales with distinct symbol names rather than with modules. Pairs are
+   * ranked by `declared` before raw count, so a link's tooltip names the
+   * symbols the source actually points at rather than whichever `has`/`get`
+   * happened to name-match most often.
+   *
+   * `declared` is the subset of a link's edges that came from something the
+   * source *writes down*: an import, a qualified name, an inheritance clause,
+   * or a call through a typed receiver. It exists because bare name matching
+   * (`resolvedBy: 'exact-match'`) is what invents cross-module links out of
+   * common method names — `run`, `push`, `finish` — and a map that lets those
+   * decide the layering puts the storage layer above the CLI.
+   */
+  aggregateModuleGraph(
+    assignments: ReadonlyArray<{ filePath: string; module: string }>,
+    options: {
+      kinds: readonly EdgeKind[];
+      minConfidence: number;
+      topPairsPerLink: number;
+      pairKinds: readonly EdgeKind[];
+    }
+  ): {
+    links: Array<{
+      source: string;
+      target: string;
+      kind: EdgeKind;
+      count: number;
+      declared: number;
+      uncertain: number;
+    }>;
+    pairs: Array<{
+      source: string;
+      target: string;
+      from: string;
+      to: string;
+      count: number;
+      declared: number;
+    }>;
+  } {
+    if (assignments.length === 0 || options.kinds.length === 0) return { links: [], pairs: [] };
+
+    const CONFIDENCE = `COALESCE(json_extract(e.metadata, '$.confidence'), 1)`;
+    const DECLARED = `(json_extract(e.metadata, '$.resolvedBy') IN ('import', 'qualified-name')
+                       OR e.kind IN ('extends', 'implements')
+                       OR (json_extract(e.metadata, '$.resolvedBy') = 'instance-method'
+                           AND ${CONFIDENCE} >= 0.9))`;
+
+    this.db.exec('DROP TABLE IF EXISTS temp.cg_module_map');
+    this.db.exec('CREATE TEMP TABLE cg_module_map (path TEXT PRIMARY KEY, mod TEXT NOT NULL)');
+    try {
+      const insert = this.db.prepare(
+        'INSERT OR REPLACE INTO cg_module_map (path, mod) VALUES (?, ?)'
+      );
+      this.db.exec('BEGIN');
+      try {
+        for (const row of assignments) insert.run(row.filePath, row.module);
+        this.db.exec('COMMIT');
+      } catch (err) {
+        this.db.exec('ROLLBACK');
+        throw err;
+      }
+
+      // ONE pass over the edge table. Grouping by the symbol names as well as
+      // the modules costs nothing extra in scan time — the join is what is
+      // expensive — and it buys both results from a single scan. Measured on
+      // this index inflated to 1.6M edges: 1.66s for this query against 3.0s
+      // for the module-level and name-level queries run separately, which is
+      // the difference between meeting and missing the map's cold budget on a
+      // ten-thousand-file repository.
+      const rows = this.db
+        .prepare(
+          `SELECT ms.mod AS source, mt.mod AS target, e.kind AS kind,
+                  sn.name AS "from", tn.name AS "to",
+                  SUM(CASE WHEN ${CONFIDENCE} >= ? THEN 1 ELSE 0 END) AS count,
+                  SUM(CASE WHEN ${CONFIDENCE} >= ? AND ${DECLARED} THEN 1 ELSE 0 END) AS declared,
+                  SUM(CASE WHEN ${CONFIDENCE} <  ? THEN 1 ELSE 0 END) AS uncertain
+             FROM edges e
+             JOIN nodes sn ON sn.id = e.source
+             JOIN nodes tn ON tn.id = e.target
+             JOIN cg_module_map ms ON ms.path = sn.file_path
+             JOIN cg_module_map mt ON mt.path = tn.file_path
+            WHERE e.kind IN (SELECT value FROM json_each(?))
+              AND ms.mod <> mt.mod
+         GROUP BY ms.mod, mt.mod, e.kind, sn.name, tn.name`
+        )
+        .all(
+          options.minConfidence,
+          options.minConfidence,
+          options.minConfidence,
+          JSON.stringify(options.kinds)
+        ) as Array<{
+        source: string;
+        target: string;
+        kind: EdgeKind;
+        from: string;
+        to: string;
+        count: number;
+        declared: number;
+        uncertain: number;
+      }>;
+
+      return foldModuleRows(rows, options);
+    } finally {
+      this.db.exec('DROP TABLE IF EXISTS temp.cg_module_map');
+    }
+  }
+
+  /**
+   * Every ordered pair of files where one reaches into the other, once each.
+   *
+   * The input a cycle finder wants: file-level circular dependencies are the
+   * strongly connected components of this graph. One query instead of the
+   * dependency lookup per file that {@link GraphQueryManager.findCircularDependencies}
+   * does — which matters because a cycle report is only interesting on a large
+   * repo, and that is exactly where a query per file stops being affordable.
+   *
+   * `contains` is excluded (a file "contains" its own symbols, which is not a
+   * dependency), and so are same-file edges and low-confidence name matches:
+   * a cycle conjured by a common method name is a false alarm a reader cannot
+   * check.
+   */
+  getCrossFileDependencyPairs(minConfidence: number): Array<{ source: string; target: string }> {
+    return this.db
+      .prepare(
+        `SELECT DISTINCT sn.file_path AS source, tn.file_path AS target
+           FROM edges e
+           JOIN nodes sn ON sn.id = e.source
+           JOIN nodes tn ON tn.id = e.target
+          WHERE e.kind <> 'contains'
+            AND sn.file_path <> tn.file_path
+            AND COALESCE(json_extract(e.metadata, '$.confidence'), 1) >= ?`
+      )
+      .all(minConfidence) as Array<{ source: string; target: string }>;
+  }
+
+  /**
+   * Every unresolved reference recorded in one FILE, ordered by line.
+   *
+   * The per-symbol form above answers "what does this body reach that the
+   * index does not hold". A whole-file reader asks the same question of every
+   * line at once, and asking it one symbol at a time is a query per symbol —
+   * 153 of them on this repo's largest file. `unresolved_refs.file_path` is
+   * indexed, so this is one lookup whatever the file holds.
+   *
+   * `limit` bounds the answer rather than the work: the caller draws a marker
+   * per row, and a generated file with fifty thousand of them would ship
+   * megabytes to say something a count already says. Rows come back in line
+   * order, so a cap trims the END of the file, which is at least legible.
+   */
+  getUnresolvedReferencesInFile(filePath: string, limit = 5000): UnresolvedReference[] {
+    if (!this.stmts.getUnresolvedInFile) {
+      this.stmts.getUnresolvedInFile = this.db.prepare(
+        'SELECT * FROM unresolved_refs WHERE file_path = ? ORDER BY line, col LIMIT ?'
+      );
+    }
+    const rows = this.stmts.getUnresolvedInFile.all(filePath, limit) as UnresolvedRefRow[];
+    return rows.map((row) => ({
+      fromNodeId: row.from_node_id,
+      referenceName: row.reference_name,
+      referenceKind: row.reference_kind as EdgeKind,
+      line: row.line,
+      column: row.col,
+      candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
+      filePath: row.file_path,
+      language: row.language as Language,
+      rowId: row.id,
+    }));
+  }
+
+  /**
+   * References recorded against a symbol that never resolved to a node — the
+   * calls and type mentions that leave the index (a third-party package, a
+   * runtime builtin, a language construct extraction doesn't model).
+   *
+   * Read-only. It exists so a reader can say "N calls into symbols outside the
+   * index" instead of silently showing a callee list shorter than the body's
+   * call sites, which reads as "nothing else happens here".
+   */
+  getUnresolvedReferencesFrom(fromNodeId: string): UnresolvedReference[] {
+    if (!this.stmts.getUnresolvedFromNode) {
+      this.stmts.getUnresolvedFromNode = this.db.prepare(
+        'SELECT * FROM unresolved_refs WHERE from_node_id = ?'
+      );
+    }
+    const rows = this.stmts.getUnresolvedFromNode.all(fromNodeId) as UnresolvedRefRow[];
+    return rows.map((row) => ({
+      fromNodeId: row.from_node_id,
+      referenceName: row.reference_name,
+      referenceKind: row.reference_kind as EdgeKind,
+      line: row.line,
+      column: row.col,
+      candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
+      filePath: row.file_path,
+      language: row.language as Language,
+      rowId: row.id,
+    }));
+  }
+
   /**
    * Find all edges where both source and target are in the given node set.
    * Useful for recovering inter-node connectivity after BFS.
@@ -2188,6 +2856,42 @@ export class QueryBuilder {
     return row?.last ?? null;
   }
 
+  /**
+   * The index's revision marker: how far the last sync got, and how many files
+   * it left behind — one query, both numbers.
+   *
+   * This is the cheapest honest answer to "has the index moved since I last
+   * looked". `MAX(indexed_at)` alone is not enough: a sync that only DELETES
+   * files (a branch checkout that removed a directory) advances nothing, and
+   * the graph the viewer is showing has still changed underneath it. The row
+   * count catches exactly that case.
+   */
+  getIndexRevision(): { lastIndexedAt: number | null; fileCount: number } {
+    const row = this.db
+      .prepare('SELECT MAX(indexed_at) AS last, COUNT(*) AS files FROM files')
+      .get() as { last: number | null; files: number } | undefined;
+    return { lastIndexedAt: row?.last ?? null, fileCount: row?.files ?? 0 };
+  }
+
+  /**
+   * Files re-indexed strictly after `since` (ms since epoch), newest first.
+   *
+   * `total` is the real count; `paths` is capped at `limit`. Used by the
+   * viewer's live channel to name what a sync just picked up. A file the same
+   * sync DELETED cannot appear here — it has no row left — which is why the
+   * caller compares {@link getIndexRevision} as well rather than treating an
+   * empty list as "nothing happened".
+   */
+  getFilesIndexedSince(since: number, limit: number): { paths: string[]; total: number } {
+    const count = this.db
+      .prepare('SELECT COUNT(*) AS n FROM files WHERE indexed_at > ?')
+      .get(since) as { n: number } | undefined;
+    const rows = this.db
+      .prepare('SELECT path FROM files WHERE indexed_at > ? ORDER BY indexed_at DESC, path LIMIT ?')
+      .all(since, Math.max(0, limit)) as Array<{ path: string }>;
+    return { paths: rows.map((r) => r.path), total: count?.n ?? rows.length };
+  }
+
   /**
    * Get files that need re-indexing (hash changed)
    */
@@ -2893,3 +3597,118 @@ export class QueryBuilder {
     })();
   }
 }
+
+/**
+ * Turn the module aggregation's one result set into its two answers.
+ *
+ * The query groups by module pair AND kind AND symbol names, because the join
+ * is what costs and a finer grouping rides along free. That leaves two folds:
+ * counts per (module, module, kind) for the map's link weights, and the busiest
+ * symbol pairs per link for its tooltip.
+ *
+ * Pairs are ranked `declared` first and only then by raw count, so a link's
+ * tooltip names the symbols the source actually points at rather than whichever
+ * `has`/`get`/`run` happened to name-match most often. Only `pairKinds` are
+ * eligible: "Config to Config" is real traffic but not an interesting row.
+ */
+interface ModuleGroupRow {
+  source: string;
+  target: string;
+  kind: EdgeKind;
+  from: string;
+  to: string;
+  count: number;
+  declared: number;
+  uncertain: number;
+}
+
+interface ModuleLinkTotal {
+  source: string;
+  target: string;
+  kind: EdgeKind;
+  count: number;
+  declared: number;
+  uncertain: number;
+}
+
+interface ModulePairTotal {
+  source: string;
+  target: string;
+  from: string;
+  to: string;
+  count: number;
+  declared: number;
+}
+
+function foldModuleRows(
+  rows: ReadonlyArray<ModuleGroupRow>,
+  options: { topPairsPerLink: number; pairKinds: readonly EdgeKind[] }
+): { links: ModuleLinkTotal[]; pairs: ModulePairTotal[] } {
+  // A module id is a path and may contain anything printable, so the key
+  // separator has to be something a path cannot hold.
+  const SEP = '\u0000';
+  const links = new Map<string, ModuleLinkTotal>();
+  const pairKinds = new Set(options.pairKinds);
+  const wantPairs = options.topPairsPerLink > 0 && pairKinds.size > 0;
+  const pairTotals = new Map<string, ModulePairTotal>();
+
+  for (const row of rows) {
+    const linkKey = `${row.source}${SEP}${row.target}${SEP}${row.kind}`;
+    const link = links.get(linkKey);
+    if (link) {
+      link.count += row.count;
+      link.declared += row.declared;
+      link.uncertain += row.uncertain;
+    } else {
+      links.set(linkKey, {
+        source: row.source,
+        target: row.target,
+        kind: row.kind,
+        count: row.count,
+        declared: row.declared,
+        uncertain: row.uncertain,
+      });
+    }
+
+    // Only the confident half of a row can be named: an uncertain edge is a
+    // guess, and printing "a to b, 12" for twelve guesses is the map claiming
+    // something it does not know.
+    if (!wantPairs || row.count === 0 || !pairKinds.has(row.kind)) continue;
+    const pairKey = `${row.source}${SEP}${row.target}${SEP}${row.from}${SEP}${row.to}`;
+    const pair = pairTotals.get(pairKey);
+    if (pair) {
+      pair.count += row.count;
+      pair.declared += row.declared;
+    } else {
+      pairTotals.set(pairKey, {
+        source: row.source,
+        target: row.target,
+        from: row.from,
+        to: row.to,
+        count: row.count,
+        declared: row.declared,
+      });
+    }
+  }
+
+  const byLink = new Map<string, ModulePairTotal[]>();
+  for (const pair of pairTotals.values()) {
+    const key = `${pair.source}${SEP}${pair.target}`;
+    let list = byLink.get(key);
+    if (!list) byLink.set(key, (list = []));
+    list.push(pair);
+  }
+  const pairs: ModulePairTotal[] = [];
+  for (const list of byLink.values()) {
+    list.sort(
+      (a, b) =>
+        b.declared - a.declared ||
+        b.count - a.count ||
+        a.from.localeCompare(b.from) ||
+        a.to.localeCompare(b.to)
+    );
+    for (const pair of list.slice(0, options.topPairsPerLink)) pairs.push(pair);
+  }
+
+  return { links: [...links.values()], pairs };
+}

+ 14 - 0
src/errors.ts

@@ -161,6 +161,20 @@ export class ConfigError extends CodeGraphError {
   }
 }
 
+/**
+ * A refused path — the caller asked for something outside the project root, or
+ * for a sensitive system directory. Deliberately a plain `Error` and NOT a
+ * {@link CodeGraphError}: it is a security marker every read sink tests with
+ * `instanceof`, not a categorized operational failure, and the MCP layer treats
+ * it as one of the only two "stop trying" conditions (see `mcp/tools.ts`).
+ *
+ * It lives here — in the dependency-free error module — rather than next to its
+ * first caller so that a consumer can enforce the refusal WITHOUT importing the
+ * MCP tool graph. `mcp/tools.ts` re-exports it, so the class identity stays
+ * single and every existing `instanceof` check keeps working.
+ */
+export class PathRefusalError extends Error {}
+
 /**
  * Simple logger for CodeGraph operations
  *

+ 1 - 1
src/extraction/extraction-version.ts

@@ -21,4 +21,4 @@
  * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
  * in the product is load-bearing").
  */
-export const EXTRACTION_VERSION = 25;
+export const EXTRACTION_VERSION = 26;

+ 14 - 1
src/extraction/function-ref.ts

@@ -175,13 +175,26 @@ function cFamilySpec(extra?: { special?: string[]; addressOfOnly?: boolean }): F
 // resolve precisely. Bare identifiers stay function-kind-only (a bare id can
 // never be a method value in JS).
 const TS_JS_SPEC: FnRefSpec = {
-  idTypes: new Set(['identifier']),
+  // `shorthand_property_identifier`: `{ handleSubmit }` — the object a hook
+  // returns its handlers in, and a namespace object's members.
+  idTypes: new Set(['identifier', 'shorthand_property_identifier']),
   dispatch: new Map<string, CaptureRule>([
     ['arguments', { mode: 'args' }],
     ['assignment_expression', { mode: 'rhs', field: 'right' }],
     ['variable_declarator', { mode: 'varinit', field: 'value' }],
     ['pair', { mode: 'value', field: 'value' }],
     ['array', { mode: 'list' }],
+    // A JSX attribute value or child: `onPress={handleSubmit}`, `renderItem={renderRow}`,
+    // `<Route component={Home}/>`. The expression's one named child is the value; a
+    // spread or a call normalizes to nothing. This is THE handler-binding idiom of
+    // React, and without it a tap's handler had no edge from the component that
+    // renders it — the Screens and Steps views could not see what a tap does.
+    ['jsx_expression', { mode: 'list' }],
+    // An object literal's shorthand members — `return { handleApprove,
+    // handleRetake }` from a hook, `const Api = { upload, createFolder }`.
+    // Every named child is offered; only a shorthand identifier normalizes
+    // (a `pair` is its own container above, a spread or a method is nothing).
+    ['object', { mode: 'list' }],
   ]),
   special: new Set(['member_expression']),
 };

+ 13 - 0
src/extraction/grammars.ts

@@ -561,6 +561,19 @@ function looksLikeObjc(source: string): boolean {
   return /@(?:interface|implementation|protocol|synthesize)\b/.test(sample);
 }
 
+/**
+ * Whether a language has a tree-sitter grammar of its own.
+ *
+ * Narrower than {@link isLanguageSupported}, which also answers true for the
+ * formats handled by custom extractors (SFCs, Liquid, Razor, YAML, XML,
+ * properties) — those have extraction but no grammar, so anything that needs to
+ * PARSE the file (the viewer's syntax classification, for one) has to ask this
+ * instead.
+ */
+export function hasTreeSitterGrammar(language: string | undefined | null): boolean {
+  return !!language && language in WASM_GRAMMAR_FILES;
+}
+
 /**
  * Check if a language is supported (has a grammar defined).
  * Returns true if the grammar exists, even if not yet loaded.

+ 465 - 0
src/extraction/syntax-tokens.ts

@@ -0,0 +1,465 @@
+/**
+ * Syntax classification from the engine's own tree-sitter parse (CG-57).
+ *
+ * The viewer used to run a second highlighter (Shiki + 56 pruned TextMate
+ * grammars) over source the engine had already parsed with a real grammar. This
+ * takes the classification off the tree instead, which removes the second
+ * dependency, the second grammar set, and — the part that actually mattered —
+ * the second opinion: a `.ts` file is now read by exactly the grammar that
+ * decided what its symbols are.
+ *
+ * ## What comes out
+ *
+ * A flat, ordered, non-overlapping list of {@link SyntaxSpan}s over the source
+ * string. Gaps between spans are whitespace and are the caller's to fill. The
+ * classes are deliberately few, because the design's code colouring is
+ * near-monochrome: comments recede, strings and numbers recede one step less,
+ * keywords carry weight rather than hue, and the only colour in the body is a
+ * call site the graph resolved.
+ *
+ * ## How a node becomes a class
+ *
+ * The rules are language-agnostic on purpose — the engine indexes 40-odd
+ * languages and a per-grammar scope table would be 40 tables to keep true:
+ *
+ * * a node whose type mentions `comment` is a comment, whole, undescended;
+ * * inside a string node every leaf is string, *except* below an interpolation,
+ *   where the code starts again (so `${user.name()}` still links);
+ * * a numeric literal node is a number;
+ * * an **anonymous** leaf is a keyword when its text is a bare word and
+ *   punctuation otherwise — this is what makes `func`, `fn`, `def`, `END-IF`
+ *   and `Sub` all land as keywords without naming any of them;
+ * * a **named** leaf whose text is identifier-shaped is an identifier, unless
+ *   the grammar called it a type name, or the extractor's own definition tables
+ *   say it is the name of a definition.
+ *
+ * The last of those is the one place per-language knowledge is used, and it is
+ * reused rather than restated: {@link EXTRACTORS} already names every node type
+ * that declares something in each language, plus the field its name hangs on.
+ */
+
+import type { Node as SyntaxNode } from 'web-tree-sitter';
+import { Language } from '../types';
+import { EXTRACTORS } from './languages';
+import { getParser, loadGrammarsForLanguages } from './grammars';
+import type { LanguageExtractor } from './tree-sitter-types';
+
+/* ------------------------------------------------------------- the classes -- */
+
+/**
+ * Every class a token can carry, in wire order.
+ *
+ * `other` is punctuation and whitespace both. The design spec lists them apart
+ * (`punct` vs the gaps) but they paint identically — plain ink — and splitting
+ * them would roughly double the token count on a dense line to express a
+ * difference nothing draws.
+ */
+export const SYNTAX_TOKEN_CLASSES = [
+  'other',
+  'ident',
+  'comment',
+  'string',
+  'keyword',
+  'number',
+  'type',
+  'def',
+] as const;
+
+export type SyntaxTokenClass = (typeof SYNTAX_TOKEN_CLASSES)[number];
+
+/** A classified run of the source, by JS string index. Half-open. */
+export interface SyntaxSpan {
+  start: number;
+  end: number;
+  cls: SyntaxTokenClass;
+}
+
+/* ---------------------------------------------------------- node-type tests -- */
+
+/**
+ * Anything a grammar calls a comment.
+ *
+ * Substring rather than equality because the spelling is per-grammar:
+ * `comment`, `line_comment`, `block_comment`, `doc_comment`, `html_comment`,
+ * `comment_directive`, `preproc_comment`.
+ */
+function isCommentType(type: string): boolean {
+  return type.includes('comment');
+}
+
+/**
+ * A node whose leaves are string content unless an interpolation interrupts.
+ *
+ * `string` covers the bulk (`string_literal`, `interpreted_string_literal`,
+ * `raw_string_literal`, `encapsed_string`, `string_content`); the rest are the
+ * spellings that avoid the word — Rust/Go/C character literals, shell and PHP
+ * heredocs, and regular expressions, which recede for the same reason a string
+ * does.
+ */
+function isStringType(type: string): boolean {
+  return (
+    type.includes('string') ||
+    type.includes('heredoc') ||
+    type.includes('regex') ||
+    type === 'char_literal' ||
+    type === 'character' ||
+    type === 'character_literal' ||
+    type === 'rune_literal' ||
+    type === 'quoted_attribute_value'
+  );
+}
+
+/**
+ * Where code resumes inside a string.
+ *
+ * A template literal's `${…}` and an f-string's `{…}` hold real expressions,
+ * and the graph records call sites inside them. Swallowing the whole literal as
+ * one string token would drop those links — the overlay refuses to claim a
+ * token classed `string`, deliberately, so that a word inside a message never
+ * gets underlined.
+ */
+function isInterpolationType(type: string): boolean {
+  return (
+    type.includes('interpolation') ||
+    type.includes('substitution') ||
+    type === 'template_substitution' ||
+    type === 'string_interpolation' ||
+    type === 'format_expression'
+  );
+}
+
+/** A numeric literal, plus the language constants a theme groups with them. */
+function isNumberType(type: string): boolean {
+  return (
+    type === 'number' ||
+    type === 'integer' ||
+    type === 'float' ||
+    type === 'number_literal' ||
+    type === 'integer_literal' ||
+    type === 'float_literal' ||
+    type === 'decimal_integer_literal' ||
+    type === 'decimal_floating_point_literal' ||
+    type === 'hex_integer_literal' ||
+    type === 'real_literal' ||
+    type === 'numeric_literal' ||
+    type === 'int_literal' ||
+    type === 'imaginary_literal'
+  );
+}
+
+/** A named type reference — `type_identifier` and the equivalents. */
+function isTypeNameType(type: string): boolean {
+  return type.includes('type_identifier') || type === 'type_name' || type === 'class_type';
+}
+
+/**
+ * Built-in type words — `string`, `int`, `u32`, `void`.
+ *
+ * These are emitted WHOLE and undescended, and they carry the same `type` class
+ * a user-defined type name gets. Both halves of that matter, because the
+ * grammars disagree with each other about what a built-in type even is:
+ * tree-sitter-go calls `string` a `type_identifier` (so it would be a type),
+ * tree-sitter-typescript wraps it in a `predefined_type` whose child is an
+ * anonymous token spelled `string` (so it would be a keyword). Reading the
+ * wrapper rather than its children is what stops the same word from painting
+ * two different ways in two languages on the same screen.
+ */
+const BUILTIN_TYPE_TYPES: ReadonlySet<string> = new Set([
+  'primitive_type',
+  'predefined_type',
+  'builtin_type',
+  'sized_type_specifier',
+]);
+
+/** Literal constants a theme groups with numbers (`constant.language`). */
+const CONSTANT_TYPES: ReadonlySet<string> = new Set([
+  'true',
+  'false',
+  'null',
+  'nil',
+  'none',
+  'undefined',
+  'null_literal',
+  'nil_literal',
+  'boolean_literal',
+  'true_literal',
+  'false_literal',
+]);
+
+/**
+ * Identifier-shaped text, in the loosest sense every indexed language agrees on.
+ *
+ * The high range is there because `\w` is ASCII-only in JavaScript and a symbol
+ * name can be Chinese, Japanese or Cyrillic; a call site in those repositories
+ * has to be linkable too. Hyphens are in because COBOL and Erlang spell words
+ * with them (`END-IF`, `is_record`).
+ */
+const IDENT_SHAPE = /^[A-Za-z_$À-￿][\w$À-￿-]*$/;
+
+/** A bare word — what separates a keyword from punctuation among anonymous nodes. */
+const WORD_SHAPE = /^[A-Za-z_][A-Za-z_0-9-]*$/;
+
+/* ------------------------------------------------------- definition names -- */
+
+/**
+ * Every node type that declares something, per language, from the extractors.
+ *
+ * This is the single piece of per-language knowledge the classifier uses, and
+ * it is borrowed rather than restated: the same lists drive extraction, so a
+ * language that learns a new declaration form gets its name bolded here for
+ * free — and cannot drift, because there is only one list.
+ */
+function definitionTypesFor(extractor: LanguageExtractor): ReadonlySet<string> {
+  return new Set([
+    ...extractor.functionTypes,
+    ...extractor.classTypes,
+    ...extractor.methodTypes,
+    ...extractor.interfaceTypes,
+    ...extractor.structTypes,
+    ...extractor.enumTypes,
+    ...extractor.typeAliasTypes,
+    ...(extractor.unionTypes ?? []),
+    ...(extractor.extraClassNodeTypes ?? []),
+  ]);
+}
+
+/* ------------------------------------------------------------- the walker -- */
+
+interface WalkContext {
+  source: string;
+  out: SyntaxSpan[];
+  defTypes: ReadonlySet<string>;
+  nameField: string;
+  /** Start indices of nodes that are a definition's own name. */
+  defStarts: Set<number>;
+  offset: number;
+}
+
+/**
+ * Classify one parsed tree into spans.
+ *
+ * Exported for tests and for anything that already holds a tree; the usual
+ * entry point is {@link tokenizeSource}, which parses first.
+ */
+export function classifyTree(
+  root: SyntaxNode,
+  source: string,
+  language: Language,
+  offset = 0
+): SyntaxSpan[] {
+  const extractor = EXTRACTORS[language];
+  const ctx: WalkContext = {
+    source,
+    out: [],
+    defTypes: extractor ? definitionTypesFor(extractor) : new Set<string>(),
+    nameField: extractor?.nameField ?? 'name',
+    defStarts: new Set<number>(),
+    offset,
+  };
+  visit(root, ctx, false);
+  return ctx.out;
+}
+
+function visit(node: SyntaxNode, ctx: WalkContext, inString: boolean): void {
+  const type = node.type;
+
+  if (node.isNamed && isCommentType(type)) {
+    emit(ctx, node.startIndex, node.endIndex, 'comment');
+    return;
+  }
+
+  if (node.isNamed && BUILTIN_TYPE_TYPES.has(type)) {
+    emit(ctx, node.startIndex, node.endIndex, 'type');
+    return;
+  }
+
+  // Record the definition's own name BEFORE descending — the name node is a
+  // descendant, so the mark has to be in place by the time the walk reaches it.
+  if (ctx.defTypes.has(type)) {
+    const name = node.childForFieldName(ctx.nameField);
+    if (name) ctx.defStarts.add(name.startIndex);
+  }
+
+  const childCount = node.childCount;
+  if (childCount === 0) {
+    emit(ctx, node.startIndex, node.endIndex, leafClass(node, ctx, inString));
+    return;
+  }
+
+  const nested = isInterpolationType(type) ? false : inString || isStringType(type);
+
+  for (let i = 0; i < childCount; i++) {
+    const child = node.child(i);
+    if (child) visit(child, ctx, nested);
+  }
+}
+
+function leafClass(node: SyntaxNode, ctx: WalkContext, inString: boolean): SyntaxTokenClass {
+  const type = node.type;
+
+  // An ANONYMOUS node's `type` is its own literal text, so none of the
+  // type-name tests below may be applied to one: `key: string` in TypeScript or
+  // PHP is a token whose type is the word `string`, and reading that as a
+  // string literal greys out half of every signature. Anonymous means keyword
+  // or punctuation, decided on shape alone — which is also what makes `func`,
+  // `fn`, `def`, `Sub` and `END-IF` all land right without naming any of them.
+  if (!node.isNamed) {
+    if (inString) return 'string';
+    if (CONSTANT_TYPES.has(type)) return 'number';
+    return WORD_SHAPE.test(type) ? 'keyword' : 'other';
+  }
+
+  if (inString || isStringType(type)) return 'string';
+  if (isNumberType(type) || CONSTANT_TYPES.has(type)) return 'number';
+
+  const text = ctx.source.slice(node.startIndex, node.endIndex);
+  // Ahead of the type tests: a class name is a `type_identifier` in half these
+  // grammars and a plain `identifier` in the other half, and the design bolds
+  // the thing being DECLARED either way.
+  if (ctx.defStarts.has(node.startIndex) && IDENT_SHAPE.test(text)) return 'def';
+  if (isTypeNameType(type)) return 'type';
+  return IDENT_SHAPE.test(text) ? 'ident' : 'other';
+}
+
+/**
+ * Append a span, skipping empties and merging a run of the same class.
+ *
+ * Zero-width nodes are real: every grammar with a layout-sensitive scanner
+ * (Python's `_newline`, Erlang's, Swift's) emits them, and a zero-width span
+ * would put an empty token on the wire for nothing.
+ */
+function emit(ctx: WalkContext, start: number, end: number, cls: SyntaxTokenClass): void {
+  if (end <= start) return;
+  const last = ctx.out[ctx.out.length - 1];
+  const from = start + ctx.offset;
+  if (last && last.cls === cls && last.end === from) {
+    last.end = end + ctx.offset;
+    return;
+  }
+  ctx.out.push({ start: from, end: end + ctx.offset, cls });
+}
+
+/* --------------------------------------------------------------- regions -- */
+
+/**
+ * A stretch of a file written in a different language from the file itself.
+ *
+ * Single-file components are the only case: a `.svelte`, `.vue` or `.astro`
+ * file has no tree-sitter grammar of its own here, but its `<script>` block —
+ * where every symbol the engine indexed in that file lives — is ordinary
+ * TypeScript or JavaScript. The extractors already delegate exactly this way,
+ * so the viewer reads a component's code with the same grammar the graph was
+ * built from. The surrounding markup stays unclassified, which under a
+ * near-monochrome theme costs the recession on tag names and attribute strings
+ * and nothing else.
+ */
+export interface SyntaxRegion {
+  start: number;
+  end: number;
+  language: Language;
+}
+
+const SCRIPT_BLOCK = /<script(\s[^>]*)?>([\s\S]*?)<\/script>/gi;
+const TS_LANG_ATTR = /lang\s*=\s*["'](ts|typescript)["']/i;
+/** Astro's frontmatter: a `---` fence at the very top of the file. */
+const ASTRO_FRONTMATTER = /^(---\r?\n)([\s\S]*?)\r?\n---/;
+
+/**
+ * The sub-language regions of a file, or null when the file is one language.
+ *
+ * Null and an empty array mean different things: null is "parse the whole file
+ * as `language`", empty is "this file has a grammar for none of it".
+ */
+export function syntaxRegionsFor(source: string, language: Language): SyntaxRegion[] | null {
+  if (language !== 'svelte' && language !== 'vue' && language !== 'astro') return null;
+
+  const regions: SyntaxRegion[] = [];
+  if (language === 'astro') {
+    const front = ASTRO_FRONTMATTER.exec(source);
+    if (front && front[2]) {
+      const start = (front[1] as string).length;
+      regions.push({ start, end: start + (front[2] as string).length, language: 'typescript' });
+    }
+  }
+
+  SCRIPT_BLOCK.lastIndex = 0;
+  let match: RegExpExecArray | null;
+  while ((match = SCRIPT_BLOCK.exec(source)) !== null) {
+    const body = match[2] ?? '';
+    if (body.trim() === '') continue;
+    const start = match.index + match[0].length - body.length - '</script>'.length;
+    regions.push({
+      start,
+      end: start + body.length,
+      language: TS_LANG_ATTR.test(match[1] ?? '') ? 'typescript' : 'javascript',
+    });
+  }
+  return regions;
+}
+
+/* --------------------------------------------------------------- the API -- */
+
+export interface TokenizeResult {
+  spans: SyntaxSpan[];
+  /** The grammar(s) that produced them, for the payload's `grammar` field. */
+  grammars: string[];
+}
+
+/**
+ * Parse `source` and classify it.
+ *
+ * Returns null when nothing in the file has a grammar — a plain answer, which
+ * every caller here already knows how to serve. Never throws: a grammar that
+ * fails to load or a parse that comes back empty is the same outcome as not
+ * having one.
+ */
+export async function tokenizeSource(
+  source: string,
+  language: Language
+): Promise<TokenizeResult | null> {
+  const regions = syntaxRegionsFor(source, language);
+  if (regions === null) {
+    const spans = await tokenizeRegion(source, language, 0);
+    return spans ? { spans, grammars: [language] } : null;
+  }
+  if (regions.length === 0) return null;
+
+  const spans: SyntaxSpan[] = [];
+  const grammars = new Set<string>();
+  for (const region of regions) {
+    const part = await tokenizeRegion(
+      source.slice(region.start, region.end),
+      region.language,
+      region.start
+    );
+    if (!part) continue;
+    grammars.add(region.language);
+    spans.push(...part);
+  }
+  if (spans.length === 0) return null;
+  spans.sort((a, b) => a.start - b.start);
+  return { spans, grammars: [...grammars] };
+}
+
+async function tokenizeRegion(
+  source: string,
+  language: Language,
+  offset: number
+): Promise<SyntaxSpan[] | null> {
+  try {
+    await loadGrammarsForLanguages([language]);
+    const parser = getParser(language);
+    if (!parser) return null;
+    const tree = parser.parse(source);
+    if (!tree?.rootNode) return null;
+    try {
+      return classifyTree(tree.rootNode, source, language, offset);
+    } finally {
+      tree.delete();
+    }
+  } catch {
+    // A grammar that will not load, or a parse that threw: the caller serves
+    // the source unclassified, which is the whole point of the plain path.
+    return null;
+  }
+}

+ 76 - 2
src/extraction/tree-sitter.ts

@@ -388,6 +388,13 @@ const LITERAL_RECEIVER_TYPES = new Set([
   'dictionary', 'dict_literal', 'object', 'tuple', 'set',
 ]);
 
+/**
+ * React hooks that bind a NAME to a handler function (`const onPress =
+ * useCallback(() => {…}, [])`). The arrow inside is extracted as a function
+ * node named by the declarator — see `reactHookBoundName`.
+ */
+const REACT_HANDLER_HOOKS = /^(?:React\.)?use(?:Callback|EffectEvent|Event)$/;
+
 export class TreeSitterExtractor {
   private filePath: string;
   private language: Language;
@@ -2205,6 +2212,24 @@ export class TreeSitterExtractor {
     }
   }
 
+  /**
+   * A top-level binding exported by a LATER statement rather than at its
+   * declaration: `export default NAME`, `export { NAME }`, `export { NAME as
+   * default }`. The declaration's own `isExported` (an `export_statement`
+   * ancestor) cannot see these, so a store written as `const useStore =
+   * create(…)` + `export default useStore` read as unexported and its actions
+   * were never extracted. One anchored regex over the file source; JS-family
+   * callers only.
+   */
+  private isExportedLater(name: string): boolean {
+    if (!/^[A-Za-z_$][\w$]*$/.test(name)) return false;
+    const re = new RegExp(
+      `^[ \\t]*export\\s+(?:default\\s+${name}\\s*;?[ \\t]*$|\\{[^}]*\\b${name}\\b[^}]*\\})`,
+      'm'
+    );
+    return re.test(this.source);
+  }
+
   /** Property-key text with surrounding quotes stripped (`'foo'` → `foo`). */
   private objectKeyName(key: SyntaxNode): string {
     return getNodeText(key, this.source).replace(/^['"`]|['"`]$/g, '');
@@ -2653,7 +2678,10 @@ export class TreeSitterExtractor {
             //     never nodes — so `node`/`callers` on `fetchUser` return "not
             //     found" and the agent Reads the store to reconstruct the flow.
             // Scoped to EXPORTED consts to exclude inline-object noise
-            // (`ctx.set({...})`) the object-method skip deliberately avoids.
+            // (`ctx.set({...})`) the object-method skip deliberately avoids —
+            // where "exported" includes the two-statement form `const useStore
+            // = create(…)` … `export default useStore` (see isExportedLater),
+            // the shape most React Native stores are written in.
             const objectOfFns =
               valueNode && (valueNode.type === 'object' || valueNode.type === 'object_expression')
                 ? valueNode
@@ -2666,7 +2694,8 @@ export class TreeSitterExtractor {
             // whose functions are body-local consts — it must fall through to a
             // normal body walk (extracting those consts), not be skipped here.
             const hasInlineFns = !!objectOfFns && this.objectHasInlineFunctions(objectOfFns);
-            const extractObjectMethods = isExported && !!objectOfFns && hasInlineFns;
+            const extractObjectMethods =
+              (isExported || this.isExportedLater(name)) && !!objectOfFns && hasInlineFns;
 
             // RTK Query: `createApi`/`injectEndpoints` define endpoints as
             // object-literal properties whose values are `build.query/mutation(...)`
@@ -5193,6 +5222,36 @@ export class TreeSitterExtractor {
     targets.add(target);
   }
 
+  /**
+   * The declarator name a React handler hook binds an anonymous function to —
+   * `const NAME = useCallback(<node>, [...])` — or null for any other shape.
+   * JS-family only; the node must be the hook call's FIRST argument, and the
+   * call's value must be bound directly by a `variable_declarator`.
+   */
+  private reactHookBoundName(node: SyntaxNode): string | null {
+    if (
+      this.language !== 'typescript' &&
+      this.language !== 'javascript' &&
+      this.language !== 'tsx' &&
+      this.language !== 'jsx'
+    ) {
+      return null;
+    }
+    if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
+    const args = node.parent;
+    if (!args || args.type !== 'arguments') return null;
+    const first = args.namedChild(0);
+    if (!first || first.startIndex !== node.startIndex || first.endIndex !== node.endIndex) return null;
+    const call = args.parent;
+    if (!call || call.type !== 'call_expression') return null;
+    const callee = getChildByField(call, 'function');
+    if (!callee || !REACT_HANDLER_HOOKS.test(getNodeText(callee, this.source))) return null;
+    const declarator = call.parent;
+    if (!declarator || declarator.type !== 'variable_declarator') return null;
+    const nameNode = getChildByField(declarator, 'name');
+    return nameNode?.type === 'identifier' ? getNodeText(nameNode, this.source) : null;
+  }
+
   private visitFunctionBody(body: SyntaxNode, _functionId: string): void {
     if (!this.extractor) return;
 
@@ -5315,6 +5374,21 @@ export class TreeSitterExtractor {
           this.extractFunction(node);
           return;
         }
+        // `const handleSubmit = useCallback(() => {…}, [deps])` — React's
+        // memoised handler. The function is anonymous only syntactically: the
+        // arrow is the first argument of a call whose result the declarator
+        // binds, and that binding is the name every `onPress={handleSubmit}`
+        // and `addListener('x', handleSubmit)` uses. Without a node of its own
+        // the handler's calls attribute to the component and the JSX prop or
+        // event registration has nothing to resolve to — the trigger of a flow
+        // is invisible. Bounded to the hooks React documents for handlers
+        // (`useCallback`, `useEffectEvent`, the experimental `useEvent`):
+        // `useMemo` / `useEffect` callbacks are computations, not handlers.
+        const hookBound = this.reactHookBoundName(node);
+        if (hookBound) {
+          this.extractFunction(node, hookBound);
+          return;
+        }
       }
 
       // Extract structural nodes found inside function bodies.

+ 2300 - 0
src/graph/branch-guards.ts

@@ -0,0 +1,2300 @@
+/**
+ * Branch guards — the conditions under which a call site runs.
+ *
+ * An edge says `handlePress → openObjectDetail`. What a reader wants to know
+ * is that it happens **when `isCollected`** and **not while `isUploading`**:
+ *
+ *   if (isUploading) return            ← early-return guard: !isUploading
+ *   if (isCollected) {                 ← if: isCollected
+ *     openObjectDetail(item)           ← the call site
+ *
+ * This module derives that from the AST at query time. Given a file, its
+ * language and a call site (line, column), it walks from the innermost node at
+ * that position up to the enclosing function boundary and records every
+ * branch it passes through: `if` / `else` / `else if`, the arms of a ternary,
+ * `switch` cases, the right side of `&&` / `||`, a `catch`, and — at each
+ * statement block on the way — the early exits that precede the site
+ * (`if (x) return`, Swift `guard x else { return }`).
+ *
+ * Nothing is stored in the index. The viewer and `codegraph_explore` already
+ * re-read source per request (drift checks, source windows, highlighting), the
+ * grammars are loaded in both processes, and a file parses in about a
+ * millisecond — so labels are computed where they are shown, from the source
+ * as it is now, and the index schema and the native kernel are untouched. A
+ * small LRU keeps the last few parsed trees so a Symbol view that asks about
+ * forty call sites in one file parses it once.
+ *
+ * Only what the AST states is reported. Loops are not conditions and are not
+ * listed; a condition that cannot be read (a language without rules here, a
+ * file that will not parse) yields no label rather than a wrong one.
+ */
+
+import * as fs from 'fs';
+import type { Node as SyntaxNode, Tree } from 'web-tree-sitter';
+import type { Language } from '../types';
+import { getParser, loadGrammarsForLanguages } from '../extraction/grammars';
+
+// =============================================================================
+// Public shape
+// =============================================================================
+
+export type GuardForm = 'if' | 'else' | 'ternary' | 'case' | 'guard' | 'and' | 'or' | 'catch';
+
+/** How an arm leaves the flow: back to the caller, or by an error. */
+export type GuardExit = 'return' | 'throw' | 'exit';
+
+export interface BranchGuard {
+  /** The condition's source, whitespace-collapsed, outer parens dropped, capped in length. */
+  text: string;
+  /** The site runs when the condition is FALSE (an else arm, an early-return guard, `||`). */
+  negated: boolean;
+  form: GuardForm;
+  /** Line of the condition (1-based). */
+  line: number;
+  /**
+   * Where the branching construct starts, `line:column` (1-based line, 0-based
+   * column) — the identity of the FORK rather than of the arm: the `if` an
+   * `if` guard and its `else` guard both come from, the `switch` every one of
+   * its cases comes from, the `try` a `catch` belongs to. Two guards with the
+   * same `branch` are arms of one decision, which is what a reader of the code
+   * in its own order needs and a joined condition string cannot say.
+   * '' when the walk could not place it.
+   */
+  branch: string;
+  /** How the arm the site is IN leaves, when it always does — `return`, `throw`. */
+  armExit?: GuardExit;
+  /** For an early exit (`form: 'guard'`), how the arm that was NOT taken leaves. */
+  exit?: GuardExit;
+}
+
+/** Longest condition text kept before it is cut with an ellipsis. */
+const MAX_TEXT = 80;
+
+const JS_FAMILY: ReadonlySet<Language> = new Set(['typescript', 'javascript', 'tsx', 'jsx']);
+
+/** Languages with walk rules below. Others yield no guards (never a wrong one). */
+export function supportsBranchGuards(language: Language | string | undefined | null): boolean {
+  return !!language && RULES_BY_LANGUAGE.has(language as Language);
+}
+
+/**
+ * The label a rail or a flow connector prints: the conditions in execution
+ * order, joined with `&&`, each negated one written as `!x`. Empty when the
+ * site is unconditional.
+ */
+export function guardLabel(guards: readonly BranchGuard[]): string {
+  return guards.map(renderGuard).join(' && ');
+}
+
+function renderGuard(g: BranchGuard): string {
+  if (g.form === 'catch') return g.text;
+  // `if (!object?.id || !object?.name)` joined to the guard before it with
+  // `&&` would read as two conditions: it keeps its parentheses.
+  if (!g.negated) return hasTopLevelOr(g.text) ? `(${g.text})` : g.text;
+  // `!x` negated reads back as `x`; a simple operand takes a bare `!`;
+  // anything with operators is parenthesised so the negation is unambiguous.
+  if (/^!(?![=])/.test(g.text) && isSimpleOperand(g.text.slice(1))) return g.text.slice(1);
+  if (/^not\s+/.test(g.text) && isSimpleOperand(g.text.slice(4).trim())) return g.text.slice(4).trim();
+  // One comparison flips instead of wrapping: the reader of a Go `if err !=
+  // nil { return }` wants `err == nil`, not `!(err != nil)`.
+  const flipped = flipComparison(g.text);
+  if (flipped !== null) return flipped;
+  return isSimpleOperand(g.text) ? `!${g.text}` : `!(${g.text})`;
+}
+
+/** `a != b` → `a == b`, `x is None` → `x is not None`; null when the text is not one plain comparison. */
+function flipComparison(text: string): string | null {
+  if (/&&|\|\||\band\b|\bor\b|\?/.test(text)) return null;
+  if (hasTopLevelOr(text)) return null;
+  const m = /^([^=!<>]+?)\s*(===|!==|==|!=|\bis not\b|\bis\b)\s*([^=!<>]+)$/.exec(text);
+  if (!m) return null;
+  const flip: Record<string, string> = { '===': '!==', '!==': '===', '==': '!=', '!=': '==', is: 'is not', 'is not': 'is' };
+  const op = flip[m[2]!];
+  return op ? `${m[1]!.trim()} ${op} ${m[3]!.trim()}` : null;
+}
+
+/** A `||` outside every bracket and string — the condition is a disjunction as written. */
+function hasTopLevelOr(text: string): boolean {
+  let depth = 0;
+  let quote: string | null = null;
+  for (let i = 0; i < text.length; i++) {
+    const ch = text[i]!;
+    if (quote !== null) {
+      if (ch === '\\') i++;
+      else if (ch === quote) quote = null;
+      continue;
+    }
+    if (ch === "'" || ch === '"' || ch === '`') quote = ch;
+    else if (ch === '(' || ch === '[' || ch === '{') depth++;
+    else if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
+    else if (depth === 0 && ch === '|' && text[i + 1] === '|') return true;
+  }
+  return false;
+}
+
+function isSimpleOperand(text: string): boolean {
+  // A name, a member chain, or one call on it — `Objects.equals(owner.getId(), id)`
+  // included: the parens must balance and nothing may sit outside them.
+  if (/[=<>]/.test(text) || /\s(?:&&|\|\||and|or)\s/.test(text)) return false;
+  const m = /^([\w$.?!]+)(\(.*\))?$/s.exec(text);
+  if (!m) return false;
+  if (!m[2]) return true;
+  let depth = 0;
+  for (let i = 0; i < m[2].length; i++) {
+    const ch = m[2][i]!;
+    if (ch === '(') depth++;
+    else if (ch === ')') {
+      depth--;
+      if (depth === 0 && i < m[2].length - 1) return false;
+    }
+  }
+  return depth === 0;
+}
+
+// =============================================================================
+// Trees, cached per file version
+// =============================================================================
+
+interface CachedTree {
+  key: string;
+  tree: Tree;
+  source: string;
+}
+
+const TREE_CACHE_SIZE = 8;
+const treeCache = new Map<string, CachedTree>();
+
+/**
+ * Files above this size are not parsed for labels. A 300 KB source file costs
+ * tens of milliseconds to parse, and a Symbol view is budgeted at 100 ms end
+ * to end; a call site in such a file simply shows no `when`.
+ */
+export const MAX_PARSE_BYTES = 256 * 1024;
+
+/** The `web-tree-sitter` trees held above are native memory: evict explicitly. */
+function remember(path: string, entry: CachedTree): void {
+  const old = treeCache.get(path);
+  if (old) old.tree.delete();
+  treeCache.delete(path);
+  treeCache.set(path, entry);
+  if (treeCache.size > TREE_CACHE_SIZE) {
+    const oldest = treeCache.keys().next().value as string;
+    treeCache.get(oldest)?.tree.delete();
+    treeCache.delete(oldest);
+  }
+}
+
+async function treeFor(absPath: string, language: Language): Promise<CachedTree | null> {
+  let stat: fs.Stats;
+  try {
+    stat = fs.statSync(absPath);
+  } catch {
+    return null;
+  }
+  const key = `${language}:${stat.mtimeMs}:${stat.size}`;
+  const hit = treeCache.get(absPath);
+  if (hit && hit.key === key) return hit;
+  if (stat.size > MAX_PARSE_BYTES) return null;
+  let source: string;
+  try {
+    source = fs.readFileSync(absPath, 'utf8');
+  } catch {
+    return null;
+  }
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  const entry = { key, tree, source };
+  remember(absPath, entry);
+  return entry;
+}
+
+async function parse(source: string, language: Language): Promise<Tree | null> {
+  try {
+    await loadGrammarsForLanguages([language]);
+    const parser = getParser(language);
+    if (!parser) return null;
+    return parser.parse(source) ?? null;
+  } catch {
+    return null;
+  }
+}
+
+// =============================================================================
+// Entry points
+// =============================================================================
+
+export interface CallSite {
+  line: number;
+  /** 0-based; null/undefined = the first non-blank column of the line. */
+  column?: number | null;
+  /**
+   * The callee's last segment, when known (`json` for `res.status(201).json(…)`):
+   * a position at the start of a chain sits on the innermost call, and the
+   * climb continues to the call that is actually this one.
+   */
+  callee?: string;
+}
+
+export function siteKey(site: CallSite): string {
+  return `${site.line}:${typeof site.column === 'number' ? site.column : ''}${site.callee ? `:${site.callee}` : ''}`;
+}
+
+/**
+ * Guards for many call sites in one file, keyed by {@link siteKey}. The file
+ * is parsed once (and cached across requests until it changes on disk). A
+ * language without rules, or a file that cannot be read or parsed, yields an
+ * empty map.
+ */
+export async function guardsForFile(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Promise<Map<string, BranchGuard[]>> {
+  const out = new Map<string, BranchGuard[]>();
+  if (!supportsBranchGuards(language) || sites.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const site of sites) {
+    const key = siteKey(site);
+    if (out.has(key)) continue;
+    out.set(key, guardsInTree(cached.tree.rootNode, cached.source, language, site.line, site.column ?? null));
+  }
+  return out;
+}
+
+/**
+ * Synchronous twin of {@link guardsForFile} for callers that cannot await
+ * (the explore text builder). It only serves languages whose grammar is
+ * ALREADY loaded — see {@link warmBranchGuardGrammars} — and yields an empty
+ * map otherwise, never a wrong label.
+ */
+export function guardsForFileSync(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Map<string, BranchGuard[]> {
+  const out = new Map<string, BranchGuard[]>();
+  if (!supportsBranchGuards(language) || sites.length === 0) return out;
+  let stat: fs.Stats;
+  try {
+    stat = fs.statSync(absPath);
+  } catch {
+    return out;
+  }
+  const key = `${language}:${stat.mtimeMs}:${stat.size}`;
+  let cached = treeCache.get(absPath);
+  if (!cached || cached.key !== key) {
+    if (stat.size > MAX_PARSE_BYTES) return out;
+    const parser = getParser(language);
+    if (!parser) return out;
+    let source: string;
+    try {
+      source = fs.readFileSync(absPath, 'utf8');
+    } catch {
+      return out;
+    }
+    const tree = parser.parse(source);
+    if (!tree) return out;
+    cached = { key, tree, source };
+    remember(absPath, cached);
+  }
+  for (const site of sites) {
+    const k = siteKey(site);
+    if (!out.has(k)) out.set(k, guardsInTree(cached.tree.rootNode, cached.source, language, site.line, site.column ?? null));
+  }
+  return out;
+}
+
+/** The languages with rules here — what {@link warmBranchGuardGrammars} loads. */
+export const BRANCH_GUARD_LANGUAGES: readonly Language[] = [
+  'typescript',
+  'tsx',
+  'javascript',
+  'jsx',
+  'swift',
+  'python',
+  'java',
+  'kotlin',
+  'csharp',
+  'go',
+  'c',
+  'cpp',
+  'objc',
+];
+
+// =============================================================================
+// Call arguments — what a site passes
+// =============================================================================
+
+/** Longest argument list kept before it is cut with an ellipsis. */
+const MAX_ARGS_TEXT = 96;
+/** Longest single argument (a string literal, a name) kept whole. */
+const MAX_ARG_TEXT = 40;
+/** Object keys listed before `…` stands for the rest. */
+const MAX_OBJECT_KEYS = 4;
+/** Call nodes, across the grammars with rules here: JS, Swift, Python, Java, Kotlin, C#, Go, C. */
+const CALL_TYPES: ReadonlySet<string> = new Set([
+  'call_expression',
+  'new_expression',
+  'call',
+  'method_invocation',
+  'object_creation_expression',
+  'invocation_expression',
+  'constructor_invocation',
+]);
+const ARGUMENT_CONTAINERS: ReadonlySet<string> = new Set(['arguments', 'value_arguments', 'argument_list']);
+const STRING_TYPES: ReadonlySet<string> = new Set([
+  'string',
+  'template_string',
+  'line_string_literal',
+  'multi_line_string_literal',
+  'raw_string_literal',
+  'string_literal',
+  'interpreted_string_literal',
+  'concatenated_string',
+  'verbatim_string_literal',
+  'interpolated_string_expression',
+  'char_literal',
+]);
+const OBJECT_TYPES: ReadonlySet<string> = new Set(['object', 'object_expression', 'dictionary', 'anonymous_object_creation_expression']);
+const ARRAY_TYPES: ReadonlySet<string> = new Set([
+  'array',
+  'array_literal',
+  'dictionary_literal',
+  'list',
+  'tuple',
+  'set',
+  'list_comprehension',
+  'array_creation_expression',
+  'array_initializer',
+  'initializer_list',
+  'collection_expression',
+  'collection_literal',
+]);
+const FUNCTION_TYPES: ReadonlySet<string> = new Set([
+  'arrow_function',
+  'function_expression',
+  'function',
+  'lambda',
+  'lambda_expression',
+  'func_literal',
+  'anonymous_function',
+  'anonymous_method_expression',
+]);
+
+/**
+ * The arguments a call site passes, as written, abbreviated to what a reader
+ * scans for: a string literal whole (a storage key, a URL, a message), a name
+ * whole, an object as its keys (`{ email, password }`), an array as `[…]`, a
+ * function as `() => …`, a nested call as `f(…)`. The conditions say WHEN a
+ * step runs; this says WITH WHAT — `SecureStore.setItemAsync('userEmail',
+ * values.email)` is a different fact from `SecureStore.setItemAsync`.
+ *
+ * Keyed by {@link siteKey} like the guards, read from the same cached tree.
+ * A site that is not inside a call, or a language without rules, is absent.
+ */
+export async function callArgumentsForFile(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Promise<Map<string, string>> {
+  const out = new Map<string, string>();
+  if (!supportsBranchGuards(language) || sites.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const site of sites) {
+    const key = siteKey(site);
+    if (out.has(key)) continue;
+    const text = callArgumentsInTree(cached.tree.rootNode, cached.source, site.line, site.column ?? null);
+    if (text !== null) out.set(key, text);
+  }
+  return out;
+}
+
+/** {@link callArgumentsForFile} over source text — the test surface. */
+export async function callArgumentsInSource(
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null
+): Promise<string | null> {
+  if (!supportsBranchGuards(language)) return null;
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  try {
+    return callArgumentsInTree(tree.rootNode, source, line, column);
+  } finally {
+    tree.delete();
+  }
+}
+
+export function callArgumentsInTree(
+  root: SyntaxNode,
+  source: string,
+  line: number,
+  column: number | null
+): string | null {
+  return callSiteInTree(root, source, line, column)?.args ?? null;
+}
+
+/** One call site, both halves: what is called, as written, and what it is passed. */
+export interface CallSiteText {
+  /**
+   * The callee as written, normalised: `prisma.article.findFirst`,
+   * `this.owners.findById().orElseThrow`, `res.status().json` — member
+   * chains kept whole (the index keeps only the last segment of a deep
+   * chain), argument lists emptied, `await`/`new` dropped, `?.` as `.`.
+   */
+  callee: string;
+  /** The argument list, abbreviated as {@link callArgumentsForFile} says. */
+  args: string;
+  /** The same arguments one by one — a registration site's middleware chain is `argList.slice(1, -1)`. */
+  argList: string[];
+  /** A status code written as an object property in the arguments (`{ status: 201 }`), which the abbreviation to keys would hide. */
+  status?: number;
+  /** Where the call starts and ends in the source (1-based lines) — the span another site may be written inside. */
+  span?: { start: { line: number; column: number }; end: { line: number; column: number } };
+  /**
+   * The call this one is written inside the arguments of — `res.json` for the
+   * `generateToken(…)` in `res.json({ token: generateToken(…) })` — normalised
+   * like `callee`. Absent at the top of a statement, and never reaching out of
+   * the function or block the call is in.
+   */
+  within?: string;
+}
+
+/** A node the climb to an enclosing call must not cross: the call is then a statement of its own inside a callback. */
+const CALL_BOUNDARY = /function|lambda|closure|block|statement|body|declaration/;
+
+/** The nearest call whose ARGUMENTS contain `call`, as its callee chain; null when there is none this side of a function or block. */
+function enclosingCallText(call: SyntaxNode): string | null {
+  for (let node = call.parent, up = 0; node && up < 12; node = node.parent, up++) {
+    if (CALL_BOUNDARY.test(node.type)) return null;
+    if (!CALL_TYPES.has(node.type)) continue;
+    const container = argumentsOf(node);
+    if (container && container.startIndex <= call.startIndex && call.endIndex <= container.endIndex) return calleeChainText(node, container);
+    return null;
+  }
+  return null;
+}
+
+const STATUS_KEY = /^(?:status|statusCode|status_code|code)$/;
+
+/** `{ status: 201 }` inside an argument — the literal an abbreviated object hides. */
+function statusPropertyIn(node: SyntaxNode, depth = 0): number | null {
+  if (depth > 2) return null;
+  for (let i = 0; i < node.namedChildCount; i++) {
+    const c = node.namedChild(i)!;
+    if (c.type === 'pair' || c.type === 'keyword_argument' || c.type === 'named_argument' || c.type === 'property_assignment' || c.type === 'object_property') {
+      const key = c.childForFieldName('key') ?? c.childForFieldName('name') ?? c.namedChild(0);
+      const value = c.childForFieldName('value') ?? c.namedChild(c.namedChildCount - 1);
+      if (key && value && STATUS_KEY.test(key.text.replace(/['"]/g, '')) && /^[1-5]\d{2}$/.test(value.text)) return Number(value.text);
+    }
+    const inner = statusPropertyIn(c, depth + 1);
+    if (inner !== null) return inner;
+  }
+  return null;
+}
+
+/** Longest callee text kept before it is cut. */
+const MAX_CALLEE_TEXT = 96;
+
+/** The call node a site belongs to: climb from the callee to the call. A few levels cover a member chain. */
+function callAt(root: SyntaxNode, source: string, line: number, column: number | null, callee?: string): SyntaxNode | null {
+  const row = line - 1;
+  // The recorded column may sit a character off the callee (a 1-based
+  // column, the space before `prisma`): a near miss is tried before giving up.
+  const columns = column === null ? [firstNonBlankColumn(source, row)] : [column, column + 1, Math.max(0, column - 1), firstNonBlankColumn(source, row)];
+  const want = callee ? callee.split(/[.:]/).pop() ?? callee : null;
+  for (const col of columns) {
+    const start = innermostAt(root, row, col);
+    if (!start) continue;
+    let node: SyntaxNode | null = start;
+    let first: SyntaxNode | null = null;
+    for (let up = 0; node && up < 10; up++, node = node.parent) {
+      if (!CALL_TYPES.has(node.type)) continue;
+      if (!first) first = node;
+      if (want === null) return node;
+      // A chain's position is its start: `res.status(201).json(…)` at `res`
+      // meets `res.status(…)` first; the call that is THIS one names `json`.
+      const container = argumentsOf(node);
+      const text = container ? calleeChainText(node, container) : '';
+      if ((text.replace(/\([^()]*\)/g, '').split(/[.:]/).pop() ?? '') === want) return node;
+    }
+    if (first) return first;
+  }
+  return null;
+}
+
+export function callSiteInTree(root: SyntaxNode, source: string, line: number, column: number | null, want?: string): CallSiteText | null {
+  const call = callAt(root, source, line, column, want);
+  if (!call) return null;
+  const container = argumentsOf(call);
+  if (!container) return null;
+  const callee = calleeChainText(call, container);
+  if (container.type === 'lambda_literal') return { callee, args: '{ … }', argList: ['{ … }'] };
+  const parts: string[] = [];
+  let status: number | null = null;
+  for (let i = 0; i < container.namedChildCount; i++) {
+    const c = container.namedChild(i);
+    if (!c || c.type === 'comment') continue;
+    parts.push(abbreviateArgument(c, source));
+    if (status === null && c.type !== 'comment') status = statusPropertyIn(c);
+  }
+  const text = parts.join(', ');
+  if (status === null) status = statusSetBefore(call, callee);
+  const span = {
+    start: { line: call.startPosition.row + 1, column: call.startPosition.column },
+    end: { line: call.endPosition.row + 1, column: call.endPosition.column },
+  };
+  const within = enclosingCallText(call);
+  return {
+    callee,
+    args: text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text,
+    argList: parts,
+    span,
+    ...(within ? { within } : {}),
+    ...(status !== null ? { status } : {}),
+  };
+}
+
+const BODY_REPLY = /^(?:res|response|reply|rep|ctx|c|context)\.(?:json|jsonp|send|render|sendFile|download|end|text|html|body)$/;
+const STATEMENT_BLOCKS: ReadonlySet<string> = new Set(['statement_block', 'program', 'block', 'class_body', 'module']);
+/** Statements looked back through for a status the reply's own chain does not carry. */
+const STATUS_LOOKBACK = 6;
+
+/**
+ * `res.status(202); res.json(user)` — the status set by an earlier statement
+ * in the same block, when the reply's own chain sets none. Only a statement
+ * that IS the status call counts (`res.status(404)` inside an `if` before it
+ * is another path, not this reply's); the first one found walking back wins.
+ */
+function statusSetBefore(call: SyntaxNode, callee: string): number | null {
+  const bare = callee.replace(/\([^()]*\)/g, '');
+  if (!BODY_REPLY.test(bare) || /\b(?:status|code|sendStatus|writeHead)\(/.test(callee)) return null;
+  const receiver = bare.split('.')[0]!;
+  let statement: SyntaxNode | null = call;
+  while (statement.parent && !STATEMENT_BLOCKS.has(statement.parent.type)) statement = statement.parent;
+  const re = new RegExp(`^\\s*(?:await\\s+)?${receiver}\\s*\\.\\s*(?:status|code)\\s*\\(\\s*([1-5]\\d{2})\\s*\\)\\s*;?\\s*$|^\\s*${receiver}\\s*\\.\\s*statusCode\\s*=\\s*([1-5]\\d{2})\\s*;?\\s*$`);
+  let prev: SyntaxNode | null = statement.previousNamedSibling;
+  for (let i = 0; prev && i < STATUS_LOOKBACK; i++, prev = prev.previousNamedSibling) {
+    if (prev.type === 'comment') continue;
+    const m = re.exec(prev.text);
+    if (m) return Number(m[1] ?? m[2]);
+  }
+  return null;
+}
+
+/** The text of a call before its arguments, normalised to a member chain. */
+function calleeChainText(call: SyntaxNode, container: SyntaxNode): string {
+  // Kotlin and Swift wrap the arguments in a `call_suffix`; the callee is
+  // everything before that suffix.
+  let end = container.startIndex;
+  const suffix = container.parent && container.parent.type === 'call_suffix' ? container.parent : null;
+  if (suffix) end = suffix.startIndex;
+  let text = collapse(call.text.slice(0, Math.max(0, end - call.startIndex)));
+  text = text.replace(/^(?:await|new|yield|return)\s+/, '').replace(/^(?:await|new)\s+/, '');
+  // Empty every nested argument list, innermost first: `a(b(c)).d` → `a().d`
+  // — keeping one short literal or name (`res.status(404).json`,
+  // `ResponseEntity.status(HttpStatus.NOT_FOUND).body`), which is the fact a
+  // reader of the chain wants.
+  for (let i = 0; i < 6 && /\([^()]*\)/.test(text); i++) {
+    text = text.replace(/\(([^()]*)\)/g, (_m, inner: string) => (/^\s*[\w.]{1,28}\s*$/.test(inner) ? `(${inner.trim()})` : '()'));
+  }
+  text = text
+    .replace(/\?\./g, '.')
+    .replace(/!\./g, '.')
+    .replace(/\s+/g, '')
+    .replace(/<[^<>]*>/g, '')
+    .replace(/\([^()]*\)$/, '');
+  return text.length > MAX_CALLEE_TEXT ? `${text.slice(0, MAX_CALLEE_TEXT - 1)}…` : text;
+}
+
+/** Both halves of every site, keyed by {@link siteKey}, from one cached tree. */
+export async function callSitesForFile(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Promise<Map<string, CallSiteText>> {
+  const out = new Map<string, CallSiteText>();
+  if (!supportsBranchGuards(language) || sites.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const site of sites) {
+    const key = siteKey(site);
+    if (out.has(key)) continue;
+    const found = callSiteInTree(cached.tree.rootNode, cached.source, site.line, site.column ?? null, site.callee);
+    if (found !== null) out.set(key, found);
+  }
+  return out;
+}
+
+/** {@link callSitesForFile} over source text — the test surface. */
+export async function callSiteInSource(
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null
+): Promise<CallSiteText | null> {
+  if (!supportsBranchGuards(language)) return null;
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  try {
+    return callSiteInTree(tree.rootNode, source, line, column);
+  } finally {
+    tree.delete();
+  }
+}
+
+/** The node holding a call's arguments: the `arguments` field, a container child, or Swift's `call_suffix` contents. */
+function argumentsOf(call: SyntaxNode): SyntaxNode | null {
+  const field = call.childForFieldName('arguments');
+  if (field) return field;
+  for (let i = 0; i < call.namedChildCount; i++) {
+    const c = call.namedChild(i);
+    if (!c) continue;
+    if (ARGUMENT_CONTAINERS.has(c.type)) return c;
+    if (c.type === 'call_suffix') {
+      for (let j = 0; j < c.namedChildCount; j++) {
+        const inner = c.namedChild(j);
+        if (inner && (ARGUMENT_CONTAINERS.has(inner.type) || inner.type === 'lambda_literal')) return inner;
+      }
+      return c;
+    }
+  }
+  return null;
+}
+
+function abbreviateArgument(node: SyntaxNode, source: string): string {
+  const type = node.type;
+  // Python `name=value`, C# `name: value` — the name is half the meaning.
+  if (type === 'keyword_argument') {
+    const name = node.childForFieldName('name');
+    const value = node.childForFieldName('value');
+    return `${name?.text ?? ''}=${value ? abbreviateArgument(value, source) : ''}`;
+  }
+  if (type === 'argument') {
+    // C#: an `argument` wraps the expression, optionally with a name.
+    const name = node.childForFieldName('name');
+    const inner = lastNamed(node);
+    const value = inner ? abbreviateArgument(inner, source) : cut(collapse(node.text), MAX_ARG_TEXT);
+    return name && inner && name.id !== inner.id ? `${name.text}: ${value}` : value;
+  }
+  // Go `gin.H{"error": err}` / `User{Name: n}`: the type, then the braces.
+  if (type === 'composite_literal') {
+    const t = node.childForFieldName('type');
+    return `${t ? cut(collapse(t.text), 24) : ''}{…}`;
+  }
+  if (STRING_TYPES.has(type)) return cut(collapse(node.text), MAX_ARG_TEXT);
+  if (OBJECT_TYPES.has(type)) return objectKeys(node, source);
+  if (ARRAY_TYPES.has(type)) return '[…]';
+  if (FUNCTION_TYPES.has(type)) return '() => …';
+  if (type === 'lambda_literal') return '{ … }';
+  if (type === 'spread_element') return cut(collapse(node.text), MAX_ARG_TEXT);
+  if (type === 'await_expression') {
+    const inner = node.namedChild(0);
+    return inner ? `await ${abbreviateArgument(inner, source)}` : 'await …';
+  }
+  if (CALL_TYPES.has(type)) {
+    const callee = node.childForFieldName('function') ?? node.childForFieldName('constructor') ?? node.namedChild(0);
+    const name = callee ? cut(collapse(callee.text), 28) : '';
+    return `${type === 'new_expression' ? 'new ' : ''}${name}(…)`;
+  }
+  // Swift `label: value` — the label is half the meaning (`withName:`).
+  if (type === 'value_argument') {
+    const named: SyntaxNode[] = [];
+    for (let i = 0; i < node.namedChildCount; i++) {
+      const c = node.namedChild(i);
+      if (c) named.push(c);
+    }
+    if (named.length >= 2 && (named[0]!.type === 'simple_identifier' || named[0]!.type === 'value_argument_label')) {
+      return `${named[0]!.text}: ${abbreviateArgument(named[named.length - 1]!, source)}`;
+    }
+    return named.length > 0 ? abbreviateArgument(named[named.length - 1]!, source) : cut(collapse(node.text), MAX_ARG_TEXT);
+  }
+  if (type === 'lambda_argument' || type === 'trailing_closure' || type === 'annotated_lambda') return '{ … }';
+  return cut(collapse(node.text), MAX_ARG_TEXT);
+}
+
+/** `{ email, password, …}` — the keys an object literal passes, not its bulk. */
+function objectKeys(node: SyntaxNode, source: string): string {
+  const keys: string[] = [];
+  let more = 0;
+  for (let i = 0; i < node.namedChildCount; i++) {
+    const c = node.namedChild(i);
+    if (!c || c.type === 'comment') continue;
+    let key: string | null = null;
+    if (c.type === 'pair') key = c.childForFieldName('key')?.text ?? null;
+    else if (c.type === 'shorthand_property_identifier' || c.type === 'shorthand_property_identifier_pattern') key = c.text;
+    else if (c.type === 'spread_element') key = collapse(c.text);
+    else if (c.type === 'method_definition') key = c.childForFieldName('name')?.text ?? null;
+    if (key === null) continue;
+    if (keys.length >= MAX_OBJECT_KEYS) {
+      more++;
+      continue;
+    }
+    keys.push(cut(key, 24));
+  }
+  void source;
+  if (keys.length === 0) return '{…}';
+  return `{ ${keys.join(', ')}${more > 0 ? ', …' : ''} }`;
+}
+
+// =============================================================================
+// Triggers — what fires a site
+// =============================================================================
+
+/**
+ * What binds a call site to an event, when something does — the answer to
+ * "at what point does this run": the JSX attribute the site sits under
+ * (`onPress` of `<Button>`), the `on*` option it is written in (`onSubmit`
+ * of `useFormik({…})`), or the runs-later call it is an argument of
+ * (`useEffect`, `setTimeout`, `addListener('x')`, `.then`).
+ */
+export interface SiteTrigger {
+  /**
+   * `prop` / `option` / `callback` are read at the site (below). `request`
+   * (a route: `name` the verb, `of` the path), `decorator` (`@Process('email')`:
+   * `name` the decorator, `of` its literal argument) and `load` (a page's own
+   * load-time work) are set by the Steps endpoint from the registration
+   * site, not read from the tree.
+   */
+  kind: 'prop' | 'option' | 'callback' | 'request' | 'decorator' | 'load';
+  /** `onPress`, `onSubmit`, `useEffect`, `addListener`, `POST`, `Process`. */
+  name: string;
+  /** `Button` for a prop, `useFormik` for an option, the first string argument for a callback; null when unknown. */
+  of: string | null;
+  /** What runs before it fires — the middleware / guard chain at the registration site, in order. */
+  after?: string[];
+}
+
+/** Callees whose function argument runs LATER — a callback, not a call. Matched on the last segment. */
+const LATER_CALLEES: ReadonlySet<string> = new Set([
+  'useEffect',
+  'useLayoutEffect',
+  'useFocusEffect',
+  'useImperativeHandle',
+  'setTimeout',
+  'setInterval',
+  'requestAnimationFrame',
+  'requestIdleCallback',
+  'runAfterInteractions',
+  'addListener',
+  'addEventListener',
+  'on',
+  'once',
+  'subscribe',
+  'then',
+  'catch',
+  'finally',
+  'runOnJS',
+  'runOnUI',
+  'scheduleOnRN',
+]);
+/** The walk up never leaves the function the site belongs to — unless that function is inline. */
+const TRIGGER_BOUNDARIES: ReadonlySet<string> = new Set(['function_declaration', 'method_definition', 'class_declaration', 'class_body', 'program']);
+const MAX_TRIGGER_CLIMB = 24;
+
+export async function triggersForFile(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Promise<Map<string, SiteTrigger>> {
+  const out = new Map<string, SiteTrigger>();
+  if (!JS_FAMILY.has(language) || sites.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const site of sites) {
+    const key = siteKey(site);
+    if (out.has(key)) continue;
+    const t = triggerInTree(cached.tree.rootNode, cached.source, site.line, site.column ?? null);
+    if (t !== null) out.set(key, t);
+  }
+  return out;
+}
+
+/** {@link triggersForFile} over source text — the test surface. */
+export async function triggerInSource(
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null
+): Promise<SiteTrigger | null> {
+  if (!JS_FAMILY.has(language)) return null;
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  try {
+    return triggerInTree(tree.rootNode, source, line, column);
+  } finally {
+    tree.delete();
+  }
+}
+
+export function triggerInTree(root: SyntaxNode, source: string, line: number, column: number | null): SiteTrigger | null {
+  const row = line - 1;
+  const col = column ?? firstNonBlankColumn(source, row);
+  let node: SyntaxNode | null = innermostAt(root, row, col);
+  let prev: SyntaxNode | null = null;
+  // Whether the climb crossed an inline function: `onPress={() => go()}`
+  // fires later, `behavior={isAndroid() ? 'a' : 'b'}` runs at render.
+  let deferred = false;
+  for (let up = 0; node && up < MAX_TRIGGER_CLIMB; up++, prev = node, node = node.parent) {
+    const type = node.type;
+    if (TRIGGER_BOUNDARIES.has(type)) return null;
+    // A named handler is its own story: `const handleX = useCallback(() => …)`
+    // binds a name, and whoever uses the name is the trigger of what is inside.
+    if (type === 'arrow_function' || type === 'function_expression') {
+      const p = node.parent;
+      if (p?.type === 'variable_declarator') return null;
+      if (p?.type === 'arguments' && p.parent) {
+        const callee = lastSegment(calleeText(p.parent));
+        if (callee === 'useCallback' || callee === 'useMemo' || callee === 'useEffectEvent' || callee === 'useEvent') return null;
+      }
+      deferred = true;
+    }
+    if (type === 'jsx_attribute') {
+      const name = node.namedChild(0);
+      const propName = name ? name.text : 'prop';
+      // An event prop, or any prop given a function: fired later. A value
+      // computed in the attribute (`behavior={isAndroid() ? …}`) is not.
+      if (!deferred && !/^on[A-Z]/.test(propName)) return null;
+      const element = node.parent;
+      const tag = element ? element.childForFieldName('name') : null;
+      return { kind: 'prop', name: propName, of: tag ? collapseText(tag.text) : null };
+    }
+    if (type === 'pair') {
+      const key = node.childForFieldName('key');
+      const keyText = key ? key.text.replace(/^['"`]|['"`]$/g, '') : '';
+      if (/^on[A-Z]\w*$/.test(keyText)) {
+        // `useFormik({ onSubmit: … })`, `Alert.alert(t, m, [{ onPress: … }])`:
+        // the object — possibly inside an array — is an argument of a call.
+        let holder: SyntaxNode | null = node.parent;
+        for (let hop = 0; holder && hop < 4 && (holder.type === 'object' || holder.type === 'array' || holder.type === 'pair'); hop++) {
+          holder = holder.parent;
+        }
+        const call = holder?.type === 'arguments' ? holder.parent : null;
+        return { kind: 'option', name: keyText, of: call && CALL_TYPES.has(call.type) ? calleeText(call) : null };
+      }
+    }
+    if (type === 'arguments' && node.parent && CALL_TYPES.has(node.parent.type) && prev !== null) {
+      const callee = lastSegment(calleeText(node.parent));
+      if (callee !== null && LATER_CALLEES.has(callee)) {
+        const first = node.namedChild(0);
+        const of = first && STRING_TYPES.has(first.type) ? cut(collapseText(first.text), MAX_ARG_TEXT) : null;
+        return { kind: 'callback', name: callee, of };
+      }
+    }
+  }
+  return null;
+}
+
+/** A call's callee as written: `nativeEmitter.addListener`, `Alert.alert`, `useFormik`. */
+function calleeText(call: SyntaxNode): string | null {
+  const callee = call.childForFieldName('function') ?? call.childForFieldName('constructor');
+  return callee ? cut(collapseText(callee.text), 40) : null;
+}
+
+/** The last segment of a callee: `nativeEmitter.addListener` → `addListener`. */
+function lastSegment(text: string | null): string | null {
+  if (text === null) return null;
+  const m = text.match(/([A-Za-z_$][\w$]*)\s*$/);
+  return m ? m[1]! : text;
+}
+
+function collapseText(text: string): string {
+  return text.replace(/\s+/g, ' ').trim();
+}
+
+function collapse(text: string): string {
+  return collapseText(text);
+}
+
+function cut(text: string, max: number): string {
+  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
+}
+
+function firstNonBlankColumn(source: string, row: number): number {
+  const line = source.split('\n')[row] ?? '';
+  const m = line.match(/\S/);
+  return m ? (m.index ?? 0) : 0;
+}
+
+/** Load the grammars {@link guardsForFileSync} needs; a no-op once loaded, never throws. */
+export async function warmBranchGuardGrammars(only?: readonly Language[]): Promise<void> {
+  const wanted = BRANCH_GUARD_LANGUAGES.filter((l) => !only || only.includes(l));
+  if (wanted.length === 0) return;
+  try {
+    await loadGrammarsForLanguages(wanted);
+  } catch {
+    // Explore prints no `when` for that language; nothing else changes.
+  }
+}
+
+/** Guards for one site in source text — the test seam; production reads files. */
+export async function guardsInSource(
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null = null
+): Promise<BranchGuard[]> {
+  if (!supportsBranchGuards(language)) return [];
+  const tree = await parse(source, language);
+  if (!tree) return [];
+  try {
+    return guardsInTree(tree.rootNode, source, language, line, column);
+  } finally {
+    tree.delete();
+  }
+}
+
+/** Loops for one site in source text — the test seam; production reads files. */
+export async function loopsInSource(source: string, language: Language, line: number, column: number | null = null): Promise<SiteLoop[]> {
+  if (!supportsBranchGuards(language)) return [];
+  const tree = await parse(source, language);
+  if (!tree) return [];
+  try {
+    return loopsInTree(tree.rootNode, source, language, line, column);
+  } finally {
+    tree.delete();
+  }
+}
+
+/** A loop a site is written inside: its header as written, and where the loop starts. */
+export interface SiteLoop {
+  /** `const item of items`, `i = 0; i < n; i++`, `queue.length > 0` — the header, without its keyword. */
+  text: string;
+  /** `each` for a `for` / `foreach` / `for … in`, `while` for a `while` / `do` / `repeat`. */
+  kind: 'each' | 'while';
+  /** Where the loop starts, `line:column` — the same identity a guard's `branch` carries. */
+  branch: string;
+}
+
+/** Loop node types across the grammars with rules here. A type absent yields nothing, never a wrong label. */
+const LOOP_TYPES: ReadonlyMap<string, 'each' | 'while'> = new Map([
+  ['for_statement', 'each'],
+  ['for_in_statement', 'each'],
+  ['for_of_statement', 'each'],
+  ['for_each_statement', 'each'],
+  ['enhanced_for_statement', 'each'],
+  ['foreach_statement', 'each'],
+  ['for_range_loop', 'each'],
+  ['for_expression', 'each'],
+  ['while_statement', 'while'],
+  ['while_expression', 'while'],
+  ['do_statement', 'while'],
+  ['do_while_statement', 'while'],
+  ['repeat_while_statement', 'while'],
+]);
+
+/**
+ * The loops a site is written inside, outermost first — what tells a reading in
+ * the code's order that a run of calls happens once PER ITEM rather than once.
+ * The climb is the guards' climb (the same boundaries, the same transparent
+ * inline functions), so a callback's body is read in its own function and a
+ * `.forEach` body under the loop it is written in.
+ */
+export function loopsInTree(root: SyntaxNode, source: string, language: Language, line: number, column: number | null): SiteLoop[] {
+  const rules = RULES_BY_LANGUAGE.get(language);
+  if (!rules) return [];
+  const row = line - 1;
+  if (row < 0) return [];
+  let col = column ?? 0;
+  if (column === null) {
+    const text = source.split('\n')[row] ?? '';
+    const first = text.search(/\S/);
+    col = first < 0 ? 0 : first;
+  }
+  let node: SyntaxNode | null = innermostAt(root, row, col);
+  if (!node) return [];
+  const found: SiteLoop[] = [];
+  while (node) {
+    const parent: SyntaxNode | null = node.parent;
+    if (!parent || rules.boundaries.has(parent.type)) break;
+    if (rules.inlineFunctions.has(parent.type)) {
+      const holder = parent.parent?.type ?? '';
+      if (rules.bindingParents.has(holder)) break;
+      node = parent;
+      continue;
+    }
+    const kind = LOOP_TYPES.get(parent.type);
+    // The header, not the body: a site is in the loop only when it is under it.
+    if (kind && !isField(parent, 'condition', node) && !isField(parent, 'value', node)) {
+      const text = loopHeader(parent);
+      if (text) found.push({ text, kind, branch: branchKey(parent) });
+    }
+    node = parent;
+  }
+  found.reverse();
+  return found;
+}
+
+/** Loops for many sites in one file, keyed by {@link siteKey}. */
+export async function loopsForFile(absPath: string, language: Language, sites: readonly CallSite[]): Promise<Map<string, SiteLoop[]>> {
+  const out = new Map<string, SiteLoop[]>();
+  if (!supportsBranchGuards(language) || sites.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const site of sites) {
+    const key = siteKey(site);
+    if (!out.has(key)) out.set(key, loopsInTree(cached.tree.rootNode, cached.source, language, site.line, site.column ?? null));
+  }
+  return out;
+}
+
+/** A loop's header as written, keyword and braces dropped: `item of items`, `queue.length > 0`. */
+function loopHeader(loop: SyntaxNode): string {
+  const body = loop.childForFieldName('body') ?? namedChildren(loop).find((c) => BLOCKISH.has(c.type)) ?? null;
+  const raw = body && body.startIndex > loop.startIndex ? loop.text.slice(0, body.startIndex - loop.startIndex) : loop.text;
+  let text = raw.replace(/\s+/g, ' ').trim();
+  text = text.replace(/^(?:for|foreach|while|do|repeat)\b\s*/i, '');
+  text = text.replace(/[{:]\s*$/, '').trim();
+  const inner = /^\((.*)\)$/s.exec(text);
+  if (inner) text = inner[1]!.trim();
+  // `const item of items` reads as `item of items`; the binding word is noise here.
+  text = text.replace(/^(?:const|let|var|val|final)\s+/, '');
+  return cut(text, 60);
+}
+
+/**
+ * The walk. `line` is 1-based, `column` 0-based (null → first non-blank).
+ * Returns the guards outermost first — execution order, the way a reader
+ * would list them.
+ */
+export function guardsInTree(
+  root: SyntaxNode,
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null
+): BranchGuard[] {
+  const row = line - 1;
+  if (row < 0) return [];
+  let col = column ?? 0;
+  if (column === null) {
+    const text = source.split('\n')[row] ?? '';
+    const first = text.search(/\S/);
+    col = first < 0 ? 0 : first;
+  }
+  let node: SyntaxNode | null = innermostAt(root, row, col);
+  if (!node) return [];
+  const rules = RULES_BY_LANGUAGE.get(language);
+  if (!rules) return [];
+  const found: BranchGuard[] = [];
+
+  // Innermost → outermost. `found` is reversed at the end, so within one level
+  // anything meant to read as OUTER must be pushed LATER.
+  while (node) {
+    const parent: SyntaxNode | null = node.parent;
+    if (!parent || rules.boundaries.has(parent.type)) break;
+    if (rules.inlineFunctions.has(parent.type)) {
+      const holder = parent.parent?.type ?? '';
+      if (rules.bindingParents.has(holder)) break;
+      node = parent;
+      continue;
+    }
+    // Anything `enclosing` pushed came from THIS construct, and the arm the
+    // site is in is the child the walk came up through: stamp both, unless the
+    // rule already named a different branch (a `switch` case's is the switch).
+    const before = found.length;
+    rules.enclosing(parent, node, found);
+    for (let i = before; i < found.length; i++) {
+      const g = found[i]!;
+      if (!g.branch) g.branch = branchKey(parent);
+      const arm = exitKind(node);
+      if (arm && !g.armExit) g.armExit = arm;
+    }
+    if (rules.blocks.has(parent.type)) rules.earlyExits(parent, node, found);
+    node = parent;
+  }
+  found.reverse();
+  return found;
+}
+
+/**
+ * The innermost named node containing (row, col). `descendantForPosition` is
+ * the fast path, but some grammars (Swift's `statements`) answer with the
+ * container, so the result is refined by descending while a named child still
+ * contains the point.
+ */
+function innermostAt(root: SyntaxNode, row: number, col: number): SyntaxNode | null {
+  let node: SyntaxNode | null = root.descendantForPosition({ row, column: col });
+  if (!node) return null;
+  for (;;) {
+    let next: SyntaxNode | null = null;
+    const here: SyntaxNode = node;
+    for (let i = 0; i < here.namedChildCount; i++) {
+      const c: SyntaxNode = here.namedChild(i)!;
+      const s = c.startPosition;
+      const e = c.endPosition;
+      const afterStart = s.row < row || (s.row === row && s.column <= col);
+      const beforeEnd = e.row > row || (e.row === row && e.column > col);
+      if (afterStart && beforeEnd) {
+        next = c;
+        break;
+      }
+    }
+    if (!next) return node;
+    node = next;
+  }
+}
+
+// =============================================================================
+// Language rules
+// =============================================================================
+
+interface Rules {
+  /**
+   * Node types the walk never climbs past: the function the site belongs to.
+   * An INLINE function — an arrow passed as an argument, a closure in an
+   * object literal, a trailing closure — is not a boundary: the conditions
+   * around its definition are the conditions under which it exists at all,
+   * which is what a reader asking "when does this run" wants. A function that
+   * is declared, or assigned to a name, starts its own story.
+   */
+  boundaries: ReadonlySet<string>;
+  /** Function-expression types that are boundaries only when named/assigned. */
+  inlineFunctions: ReadonlySet<string>;
+  /** Parent types under which an inline function counts as named/assigned. */
+  bindingParents: ReadonlySet<string>;
+  /** Statement containers whose earlier children may be early exits. */
+  blocks: ReadonlySet<string>;
+  /** `parent` encloses `child` (the node the walk came up through): record any branch. */
+  enclosing(parent: SyntaxNode, child: SyntaxNode, out: BranchGuard[]): void;
+  /** `child` is a statement of block `parent`: record the exits before it. */
+  earlyExits(parent: SyntaxNode, child: SyntaxNode, out: BranchGuard[]): void;
+}
+
+function condText(node: SyntaxNode | null | undefined): string {
+  if (!node) return '';
+  let n: SyntaxNode = node;
+  // `(x)` — the parens are the statement's, not the condition's.
+  while (n.type === 'parenthesized_expression' && n.namedChildCount === 1) n = n.namedChild(0)!;
+  const text = n.text.replace(/\s+/g, ' ').trim();
+  // A bare keyword is a grammar's error recovery (`let` from an `if let` it
+  // could not parse), never a condition a reader can use.
+  if (/^(?:let|var|case|try|await|guard|if|else|some|any)$/.test(text)) return '';
+  return text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text;
+}
+
+/**
+ * One guard. `branch` names the construct the arm belongs to: by default the
+ * node the walk is climbing THROUGH (an `if`, a ternary, a `catch`), which is
+ * right whenever the construct is the site's parent — and overridden where it
+ * is not, by a `switch` case (whose parent is the case) and by an early exit
+ * (whose branch is the `if` that returned, several statements back).
+ */
+function guard(
+  form: GuardForm,
+  cond: SyntaxNode | null | undefined,
+  negated: boolean,
+  text?: string,
+  branch?: SyntaxNode | null,
+  exit?: GuardExit | null
+): BranchGuard | null {
+  const t = text ?? condText(cond);
+  if (!t) return null;
+  return {
+    text: t,
+    negated,
+    form,
+    line: (cond ?? null) ? cond!.startPosition.row + 1 : 0,
+    branch: branch ? branchKey(branch) : '',
+    ...(exit ? { exit } : {}),
+  };
+}
+
+/** A branching construct's identity: where it starts, `line:column`. */
+function branchKey(node: SyntaxNode): string {
+  return `${node.startPosition.row + 1}:${node.startPosition.column}`;
+}
+
+/**
+ * Containers whose LAST statement decides how the whole thing leaves: a block,
+ * and the clause wrappers a grammar puts an arm's block inside (`else_clause`,
+ * `except_clause`) — the walk climbs through those, so the node it hands back
+ * is the wrapper, not the block. A CONDITIONAL wrapper is not one of them: an
+ * `elif` whose body raises does not mean the arm it is in always raises.
+ */
+const BLOCKISH: ReadonlySet<string> = new Set([
+  'statement_block',
+  'block',
+  'statements',
+  'function_body',
+  'compound_statement',
+  'control_structure_body',
+  'else_clause',
+  'else_statement',
+  'catch_clause',
+  'catch_block',
+  'except_clause',
+  'finally_clause',
+]);
+
+/**
+ * How a statement — or a block, by its last statement — leaves: `throw` for a
+ * raised error, `return` for a return / break / continue, `exit` for a
+ * language's other way out (Go's `panic`, C's `exit`) that the language rules
+ * count as an exit but no keyword names. Null when it does not always leave.
+ */
+function exitKind(node: SyntaxNode | null | undefined): GuardExit | null {
+  if (!node) return null;
+  const t = node.type;
+  if (t === 'throw_statement' || t === 'raise_statement' || t === 'throw_expression') return 'throw';
+  if (/^(?:return|break|continue|goto|yield)_statement$/.test(t)) return 'return';
+  // Swift's `control_transfer_statement` and Kotlin's `jump_expression` say
+  // which in their first word.
+  if (t === 'control_transfer_statement' || t === 'jump_expression') return /^\s*throw\b/.test(node.text) ? 'throw' : 'return';
+  if (BLOCKISH.has(t)) return exitKind(lastNamed(node));
+  return null;
+}
+
+/** {@link exitKind}, or `exit` when the language's rules call it an exit and no keyword names it. */
+function exitKindOr(node: SyntaxNode | null | undefined, exits: boolean): GuardExit | null {
+  if (!exits) return null;
+  return exitKind(node) ?? 'exit';
+}
+
+function push(out: BranchGuard[], g: BranchGuard | null): void {
+  if (g) out.push(g);
+}
+
+function isField(parent: SyntaxNode, field: string, child: SyntaxNode): boolean {
+  const f = parent.childForFieldName(field);
+  return !!f && f.id === child.id;
+}
+
+function lastNamed(node: SyntaxNode): SyntaxNode | null {
+  return node.namedChildCount > 0 ? node.namedChild(node.namedChildCount - 1) : null;
+}
+
+/** The named children of `parent` that come before `child`, in source order. */
+function precedingSiblings(parent: SyntaxNode, child: SyntaxNode): SyntaxNode[] {
+  const out: SyntaxNode[] = [];
+  for (let i = 0; i < parent.namedChildCount; i++) {
+    const s = parent.namedChild(i)!;
+    if (s.id === child.id) break;
+    out.push(s);
+  }
+  return out;
+}
+
+// ----------------------------------------------------------------------- JS --
+
+const JS_EXITS = new Set(['return_statement', 'throw_statement', 'break_statement', 'continue_statement']);
+
+/** A statement that always leaves the block: an exit, or a block ending in one. */
+function jsAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (JS_EXITS.has(node.type)) return true;
+  if (node.type === 'statement_block') return jsAlwaysExits(lastNamed(node));
+  return false;
+}
+
+const JS: Rules = {
+  boundaries: new Set([
+    'function_declaration',
+    'method_definition',
+    'generator_function_declaration',
+    'class_declaration',
+    'class_body',
+    'class',
+    'program',
+  ]),
+  inlineFunctions: new Set(['arrow_function', 'function_expression', 'function', 'generator_function']),
+  bindingParents: new Set([
+    'variable_declarator',
+    'assignment_expression',
+    'export_statement',
+    'public_field_definition',
+    'field_definition',
+    'lexical_declaration',
+  ]),
+  blocks: new Set(['statement_block', 'program', 'switch_case', 'switch_default']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'ternary_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'switch_case':
+      case 'switch_default': {
+        // `child` is one of the case's body statements (not its value).
+        if (parent.type === 'switch_case' && isField(parent, 'value', child)) return;
+        const body = parent.parent; // switch_body
+        const stmt = body?.parent; // switch_statement
+        const subject = condText(stmt?.childForFieldName('value'));
+        if (parent.type === 'switch_default') push(out, guard('case', stmt?.childForFieldName('value'), false, subject ? `${subject}: default` : 'default', stmt));
+        else {
+          const value = condText(parent.childForFieldName('value'));
+          push(out, guard('case', parent.childForFieldName('value'), false, subject ? `${subject} === ${value}` : value, stmt));
+        }
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_clause':
+        if (!isField(parent, 'parameter', child)) push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    // Outer-most last (the list is reversed once at the end): walk the
+    // preceding statements backwards so the FIRST guard in the source ends up
+    // first in the final order.
+    const before = precedingSiblings(parent, child);
+    for (let i = before.length - 1; i >= 0; i--) {
+      const s = before[i]!;
+      if (s.type !== 'if_statement' || s.childForFieldName('alternative')) continue;
+      const body = s.childForFieldName('consequence');
+      if (!jsAlwaysExits(body)) continue;
+      push(out, guard('guard', s.childForFieldName('condition'), true, undefined, s, exitKindOr(body, true)));
+    }
+  },
+};
+
+// -------------------------------------------------------------------- Swift --
+
+function swiftAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (node.type === 'control_transfer_statement') return true;
+  if (node.type === 'statements') return swiftAlwaysExits(lastNamed(node));
+  return false;
+}
+
+/** For a Swift `if`: is `child` after the `else` keyword? */
+function afterElse(parent: SyntaxNode, child: SyntaxNode): boolean {
+  let seenElse = false;
+  for (let i = 0; i < parent.childCount; i++) {
+    const c = parent.child(i)!;
+    if (c.id === child.id) return seenElse;
+    if (c.type === 'else') seenElse = true;
+  }
+  return false;
+}
+
+/** All `condition` fields of a Swift `if`/`guard`, joined — `if let x, y > 0`. */
+function swiftConditions(node: SyntaxNode): { node: SyntaxNode | null; text: string } {
+  // The grammar labels several tokens of `if let x = y, z > 0` as `condition`
+  // (the binding's own pieces included), so the readable text is the SPAN from
+  // the first to the last of them, not the pieces joined.
+  const parts: SyntaxNode[] = [];
+  for (let i = 0; i < node.childCount; i++) {
+    if (node.fieldNameForChild(i) === 'condition') parts.push(node.child(i)!);
+  }
+  if (parts.length === 0) return { node: null, text: '' };
+  const first = parts[0]!;
+  const last = parts[parts.length - 1]!;
+  const raw = node.text.slice(first.startIndex - node.startIndex, last.endIndex - node.startIndex);
+  const text = raw.replace(/\s+/g, ' ').trim();
+  if (/^(?:let|var|case|try|await)$/.test(text)) return { node: null, text: '' };
+  return { node: first, text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text };
+}
+
+const SWIFT: Rules = {
+  boundaries: new Set([
+    'function_declaration',
+    'init_declaration',
+    'deinit_declaration',
+    'class_declaration',
+    'protocol_declaration',
+    'computed_property',
+    'source_file',
+  ]),
+  inlineFunctions: new Set(['lambda_literal']),
+  bindingParents: new Set(['property_declaration', 'assignment']),
+  blocks: new Set(['statements', 'function_body']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const c = swiftConditions(parent);
+        if (parent.fieldNameForChild(indexOf(parent, child)) === 'condition') return;
+        push(out, guard(afterElse(parent, child) ? 'else' : 'if', c.node, afterElse(parent, child), c.text));
+        return;
+      }
+      case 'guard_statement': {
+        // Inside the guard's body the condition FAILED.
+        if (parent.fieldNameForChild(indexOf(parent, child)) === 'condition') return;
+        const c = swiftConditions(parent);
+        push(out, guard('else', c.node, true, c.text));
+        return;
+      }
+      case 'ternary_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'if_true', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'if_false', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'switch_entry': {
+        const stmt = parent.parent;
+        const subject = condText(stmt?.childForFieldName('expr'));
+        const pattern = parent.namedChildren.find((n) => n.type === 'switch_pattern');
+        if (pattern && pattern.id === child.id) return;
+        const isDefault = parent.children.some((n) => n.type === 'default_keyword');
+        const value = pattern ? condText(pattern) : '';
+        const text = isDefault ? (subject ? `${subject}: default` : 'default') : subject ? `${subject} == ${value}` : value;
+        push(out, guard('case', pattern ?? stmt?.childForFieldName('expr'), false, text, stmt));
+        return;
+      }
+      case 'catch_block':
+        push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    const before = precedingSiblings(parent, child);
+    for (let i = before.length - 1; i >= 0; i--) {
+      const s = before[i]!;
+      if (s.type === 'guard_statement') {
+        const c = swiftConditions(s);
+        const body = s.namedChildren.find((n) => n.type === 'statements') ?? null;
+        push(out, guard('guard', c.node, false, c.text, s, exitKindOr(body, true)));
+      } else if (s.type === 'if_statement' && !s.children.some((n) => n.type === 'else')) {
+        const body = s.namedChildren.find((n) => n.type === 'statements') ?? null;
+        if (!swiftAlwaysExits(body)) continue;
+        const c = swiftConditions(s);
+        push(out, guard('guard', c.node, true, c.text, s, exitKindOr(body, true)));
+      }
+    }
+  },
+};
+
+function indexOf(parent: SyntaxNode, child: SyntaxNode): number {
+  for (let i = 0; i < parent.childCount; i++) if (parent.child(i)!.id === child.id) return i;
+  return -1;
+}
+
+/** The named children of a node, in order. */
+function namedChildren(node: SyntaxNode): SyntaxNode[] {
+  const out: SyntaxNode[] = [];
+  for (let i = 0; i < node.namedChildCount; i++) out.push(node.namedChild(i)!);
+  return out;
+}
+
+/**
+ * Shared shape of "an early exit before the site": the preceding statements
+ * of the block that are an `if` with no else whose body always leaves.
+ */
+function guardsBefore(
+  parent: SyntaxNode,
+  child: SyntaxNode,
+  out: BranchGuard[],
+  isIf: (s: SyntaxNode) => boolean,
+  hasElse: (s: SyntaxNode) => boolean,
+  body: (s: SyntaxNode) => SyntaxNode | null,
+  alwaysExits: (b: SyntaxNode | null) => boolean,
+  condition: (s: SyntaxNode) => { node: SyntaxNode | null; text: string }
+): void {
+  const before = precedingSiblings(parent, child);
+  for (let i = before.length - 1; i >= 0; i--) {
+    const s = before[i]!;
+    if (!isIf(s) || hasElse(s)) continue;
+    const arm = body(s);
+    if (!alwaysExits(arm)) continue;
+    const c = condition(s);
+    push(out, guard('guard', c.node, true, c.text, s, exitKindOr(arm, true)));
+  }
+}
+
+// ------------------------------------------------------------------- Python --
+
+const PY_EXITS = new Set(['return_statement', 'raise_statement', 'break_statement', 'continue_statement']);
+
+function pyAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (PY_EXITS.has(node.type)) return true;
+  if (node.type === 'block') return pyAlwaysExits(lastNamed(node));
+  return false;
+}
+
+function pyOperator(node: SyntaxNode): string {
+  const field = node.childForFieldName('operator');
+  if (field) return field.text;
+  for (let i = 0; i < node.childCount; i++) {
+    const c = node.child(i)!;
+    if (!c.isNamed && (c.text === 'and' || c.text === 'or')) return c.text;
+  }
+  return '';
+}
+
+const PYTHON: Rules = {
+  boundaries: new Set(['function_definition', 'class_definition', 'module']),
+  inlineFunctions: new Set(['lambda']),
+  bindingParents: new Set(['assignment', 'augmented_assignment']),
+  blocks: new Set(['block', 'module']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (child.type === 'elif_clause' || child.type === 'else_clause') {
+          // An elif/else arm runs when the `if` and every earlier elif failed.
+          push(out, guard('else', cond, true));
+          for (const s of precedingSiblings(parent, child)) {
+            if (s.type === 'elif_clause') push(out, guard('else', s.childForFieldName('condition'), true));
+          }
+        }
+        return;
+      }
+      case 'elif_clause': {
+        if (isField(parent, 'consequence', child)) push(out, guard('if', parent.childForFieldName('condition'), false));
+        return;
+      }
+      case 'conditional_expression': {
+        // `a if cond else b`: children are [a, cond, b].
+        const kids = namedChildren(parent);
+        if (kids.length < 3) return;
+        if (child.id === kids[0]!.id) push(out, guard('ternary', kids[1], false));
+        else if (child.id === kids[2]!.id) push(out, guard('ternary', kids[1], true));
+        return;
+      }
+      case 'case_clause': {
+        if (isField(parent, 'consequence', child)) {
+          const stmt = parent.parent?.parent;
+          const subject = condText(stmt?.childForFieldName('subject'));
+          const pattern = namedChildren(parent).find((n) => n.type === 'case_pattern');
+          const value = pattern ? condText(pattern) : '';
+          const isDefault = value === '_' || value === '';
+          const text = isDefault ? (subject ? `${subject}: default` : 'default') : subject ? `${subject} == ${value}` : value;
+          push(out, guard('case', pattern ?? null, false, text, stmt));
+        }
+        return;
+      }
+      case 'boolean_operator': {
+        if (!isField(parent, 'right', child)) return;
+        const op = pyOperator(parent);
+        const left = parent.childForFieldName('left');
+        if (op === 'and') push(out, guard('and', left, false));
+        else if (op === 'or') push(out, guard('or', left, true));
+        return;
+      }
+      case 'except_clause':
+      case 'except_group_clause':
+        if (child.type === 'block') push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => namedChildren(s).some((n) => n.type === 'elif_clause' || n.type === 'else_clause'),
+      (s) => s.childForFieldName('consequence'),
+      pyAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+// --------------------------------------------------------------------- Java --
+
+const JAVA_EXITS = new Set(['return_statement', 'throw_statement', 'break_statement', 'continue_statement', 'yield_statement']);
+
+function javaAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (JAVA_EXITS.has(node.type)) return true;
+  if (node.type === 'block') return javaAlwaysExits(lastNamed(node));
+  return false;
+}
+
+/** `case A:` / `case A ->` / `default` labels of a Java switch group or rule, as one condition text. */
+function javaCaseText(labels: SyntaxNode[], subject: string): string {
+  const values = labels.map((l) => namedChildren(l).map((n) => condText(n)).filter(Boolean).join(', ')).filter(Boolean);
+  if (values.length === 0) return subject ? `${subject}: default` : 'default';
+  const value = values.join(', ');
+  return subject ? `${subject} == ${value}` : value;
+}
+
+const JAVA: Rules = {
+  boundaries: new Set([
+    'method_declaration',
+    'constructor_declaration',
+    'class_declaration',
+    'class_body',
+    'interface_declaration',
+    'enum_declaration',
+    'record_declaration',
+    'program',
+  ]),
+  inlineFunctions: new Set(['lambda_expression']),
+  bindingParents: new Set(['variable_declarator', 'assignment_expression', 'field_declaration']),
+  blocks: new Set(['block', 'switch_block_statement_group', 'program', 'constructor_body']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'ternary_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'switch_block_statement_group':
+      case 'switch_rule': {
+        if (child.type === 'switch_label') return;
+        const stmt = parent.parent?.parent;
+        const subject = condText(stmt?.childForFieldName('condition'));
+        const labels = namedChildren(parent).filter((n) => n.type === 'switch_label');
+        push(out, guard('case', labels[0] ?? null, false, javaCaseText(labels, subject), stmt));
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_clause':
+        if (isField(parent, 'body', child)) push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => !!s.childForFieldName('alternative'),
+      (s) => s.childForFieldName('consequence'),
+      javaAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+// ------------------------------------------------------------------- Kotlin --
+
+function ktAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (node.type === 'jump_expression') return true;
+  if (node.type === 'control_structure_body' || node.type === 'statements') return ktAlwaysExits(lastNamed(node));
+  return false;
+}
+
+/** The two arms of a Kotlin `if`: the bodies, in order (then, else). */
+function ktArms(ifExpr: SyntaxNode): SyntaxNode[] {
+  return namedChildren(ifExpr).filter((n) => n.type === 'control_structure_body');
+}
+
+const KOTLIN: Rules = {
+  boundaries: new Set([
+    'function_declaration',
+    'secondary_constructor',
+    'class_declaration',
+    'class_body',
+    'object_declaration',
+    'getter',
+    'setter',
+    'source_file',
+  ]),
+  inlineFunctions: new Set(['lambda_literal', 'anonymous_function']),
+  bindingParents: new Set(['property_declaration', 'assignment']),
+  blocks: new Set(['statements', 'function_body', 'source_file']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_expression': {
+        const kids = namedChildren(parent);
+        const cond = kids[0] ?? null;
+        if (cond && child.id === cond.id) return;
+        const arms = ktArms(parent);
+        if (arms[0] && child.id === arms[0].id) push(out, guard('if', cond, false));
+        else if (arms[1] && child.id === arms[1].id) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'when_entry': {
+        if (child.type !== 'control_structure_body') return;
+        const when = parent.parent;
+        const subject = condText(namedChildren(when!).find((n) => n.type === 'when_subject')).replace(/^\((.*)\)$/, '$1');
+        const conds = namedChildren(parent).filter((n) => n.type === 'when_condition');
+        if (conds.length === 0) push(out, guard('case', null, false, subject ? `${subject}: else` : 'else', when));
+        else {
+          const value = conds.map((c) => condText(c)).join(', ');
+          push(out, guard('case', conds[0]!, false, subject ? `${subject} == ${value}` : value, when));
+        }
+        return;
+      }
+      case 'conjunction_expression':
+      case 'disjunction_expression': {
+        const kids = namedChildren(parent);
+        if (kids.length < 2 || child.id !== kids[kids.length - 1]!.id) return;
+        const left = kids[0]!;
+        if (parent.type === 'conjunction_expression') push(out, guard('and', left, false));
+        else push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_block':
+        if (child.type === 'statements') push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_expression',
+      (s) => ktArms(s).length > 1,
+      (s) => ktArms(s)[0] ?? null,
+      ktAlwaysExits,
+      (s) => {
+        const cond = namedChildren(s)[0] ?? null;
+        return { node: cond, text: condText(cond) };
+      }
+    );
+  },
+};
+
+// ----------------------------------------------------------------------- C# --
+
+const CS_EXITS = new Set(['return_statement', 'throw_statement', 'break_statement', 'continue_statement']);
+
+function csAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (CS_EXITS.has(node.type)) return true;
+  if (node.type === 'block') return csAlwaysExits(lastNamed(node));
+  return false;
+}
+
+const CSHARP: Rules = {
+  boundaries: new Set([
+    'method_declaration',
+    'constructor_declaration',
+    'local_function_statement',
+    'class_declaration',
+    'struct_declaration',
+    'record_declaration',
+    'interface_declaration',
+    'declaration_list',
+    'property_declaration',
+    'accessor_declaration',
+    'compilation_unit',
+  ]),
+  inlineFunctions: new Set(['lambda_expression', 'anonymous_method_expression']),
+  bindingParents: new Set(['variable_declarator', 'assignment_expression', 'equals_value_clause']),
+  blocks: new Set(['block', 'switch_section', 'compilation_unit']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'conditional_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'switch_section': {
+        const isLabel = (n: SyntaxNode) => /pattern$|switch_label$/.test(n.type);
+        if (isLabel(child)) return;
+        const stmt = parent.parent?.parent;
+        const subject = condText(stmt?.childForFieldName('value'));
+        const labels = namedChildren(parent).filter(isLabel);
+        const value = labels.map((l) => condText(l)).filter(Boolean).join(', ');
+        const text = value === '' ? (subject ? `${subject}: default` : 'default') : subject ? `${subject} == ${value}` : value;
+        push(out, guard('case', labels[0] ?? null, false, text, stmt));
+        return;
+      }
+      case 'switch_expression_arm': {
+        if (!isField(parent, 'expression', child)) return;
+        const stmt = parent.parent;
+        const subject = condText(stmt?.childForFieldName('value'));
+        const pattern = parent.childForFieldName('pattern');
+        const value = condText(pattern);
+        const text = value === '_' || value === '' ? (subject ? `${subject}: default` : 'default') : subject ? `${subject} == ${value}` : value;
+        push(out, guard('case', pattern, false, text, stmt));
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_clause':
+        if (isField(parent, 'body', child)) push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => !!s.childForFieldName('alternative'),
+      (s) => s.childForFieldName('consequence'),
+      csAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+// ----------------------------------------------------------------------- Go --
+
+const GO_EXITS = new Set(['return_statement', 'break_statement', 'continue_statement', 'goto_statement']);
+
+function goAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (GO_EXITS.has(node.type)) return true;
+  if (node.type === 'block') return goAlwaysExits(lastNamed(node));
+  if (node.type === 'expression_statement') {
+    const call = node.namedChild(0);
+    const fn = call?.type === 'call_expression' ? call.childForFieldName('function')?.text : '';
+    return fn === 'panic' || fn === 'os.Exit' || fn === 'log.Fatal' || fn === 'log.Fatalf' || fn === 'log.Fatalln';
+  }
+  return false;
+}
+
+const GO: Rules = {
+  boundaries: new Set(['function_declaration', 'method_declaration', 'source_file']),
+  inlineFunctions: new Set(['func_literal']),
+  bindingParents: new Set(['short_var_declaration', 'var_spec', 'assignment_statement', 'const_spec']),
+  blocks: new Set(['block', 'expression_case', 'default_case', 'type_case', 'communication_case', 'source_file']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'expression_case':
+      case 'type_case':
+      case 'communication_case': {
+        const value = parent.childForFieldName('value') ?? parent.childForFieldName('type') ?? parent.childForFieldName('communication');
+        if (value && child.id === value.id) return;
+        const stmt = parent.parent;
+        const subject = condText(stmt?.childForFieldName('value'));
+        const v = condText(value);
+        if (parent.type === 'communication_case') push(out, guard('case', value, false, v, stmt));
+        else push(out, guard('case', value, false, subject ? `${subject} == ${v}` : v, stmt));
+        return;
+      }
+      case 'default_case': {
+        const stmt = parent.parent;
+        const subject = condText(stmt?.childForFieldName('value'));
+        push(out, guard('case', stmt?.childForFieldName('value'), false, subject ? `${subject}: default` : 'default', stmt));
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => !!s.childForFieldName('alternative'),
+      (s) => s.childForFieldName('consequence'),
+      goAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+// -------------------------------------------------------------------- C/C++ --
+
+const C_EXITS = new Set(['return_statement', 'break_statement', 'continue_statement', 'goto_statement', 'throw_statement']);
+
+function cAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (C_EXITS.has(node.type)) return true;
+  if (node.type === 'compound_statement') return cAlwaysExits(lastNamed(node));
+  if (node.type === 'expression_statement') {
+    const call = node.namedChild(0);
+    const fn = call?.type === 'call_expression' ? call.childForFieldName('function')?.text : '';
+    return fn === 'exit' || fn === '_exit' || fn === 'abort' || fn === 'longjmp';
+  }
+  return false;
+}
+
+const C: Rules = {
+  boundaries: new Set([
+    'function_definition',
+    'class_specifier',
+    'struct_specifier',
+    'namespace_definition',
+    'translation_unit',
+    'field_declaration_list',
+  ]),
+  inlineFunctions: new Set(['lambda_expression']),
+  bindingParents: new Set(['init_declarator', 'assignment_expression']),
+  blocks: new Set(['compound_statement', 'case_statement', 'translation_unit']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child) || child.type === 'else_clause') push(out, guard('else', cond, true));
+        return;
+      }
+      case 'conditional_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'case_statement': {
+        const value = parent.childForFieldName('value');
+        if (value && child.id === value.id) return;
+        const stmt = parent.parent?.parent;
+        const subject = condText(stmt?.childForFieldName('condition'));
+        if (!value) push(out, guard('case', stmt?.childForFieldName('condition'), false, subject ? `${subject}: default` : 'default', stmt));
+        else {
+          const v = condText(value);
+          push(out, guard('case', value, false, subject ? `${subject} == ${v}` : v, stmt));
+        }
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_clause':
+        if (isField(parent, 'body', child)) push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => !!s.childForFieldName('alternative') || namedChildren(s).some((n) => n.type === 'else_clause'),
+      (s) => s.childForFieldName('consequence'),
+      cAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+/** The rules per language. A language absent here yields no guards — never a wrong one. */
+const RULES_BY_LANGUAGE: ReadonlyMap<Language, Rules> = new Map<Language, Rules>([
+  ['typescript', JS],
+  ['tsx', JS],
+  ['javascript', JS],
+  ['jsx', JS],
+  ['swift', SWIFT],
+  ['python', PYTHON],
+  ['java', JAVA],
+  ['kotlin', KOTLIN],
+  ['csharp', CSHARP],
+  ['go', GO],
+  ['c', C],
+  ['cpp', C],
+  ['objc', C],
+]);
+
+// =============================================================================
+// Decorators — what is written on a definition
+// =============================================================================
+
+/**
+ * The decorators / annotations / attributes on the definition at a line, and
+ * on the class that holds it: `UseGuards(AuthGuard('jwt'))`,
+ * `PreAuthorize("hasRole('ADMIN')")`, `HttpPost("items")`, `Process('email')`.
+ * Text as written, without the `@` or the brackets, whitespace collapsed,
+ * capped. The index keeps no decorators, so they are read here at request
+ * time like the guards.
+ */
+export interface DefinitionDecorators {
+  own: string[];
+  /** The enclosing class's, when the definition is a member. */
+  class: string[];
+}
+
+const DEFINITION_TYPES: ReadonlySet<string> = new Set([
+  'function_declaration',
+  'function_definition',
+  'method_definition',
+  'method_declaration',
+  'constructor_declaration',
+  'class_declaration',
+  'class_definition',
+  'decorated_definition',
+  'local_function_statement',
+  'lexical_declaration',
+  'variable_declaration',
+  'public_field_definition',
+]);
+const CLASS_TYPES: ReadonlySet<string> = new Set(['class_declaration', 'class_definition', 'class', 'object_declaration', 'struct_declaration', 'record_declaration']);
+const DECORATOR_TYPES: ReadonlySet<string> = new Set(['decorator', 'annotation', 'marker_annotation', 'attribute']);
+const MAX_DECORATOR_TEXT = 80;
+
+export async function decoratorsForFile(
+  absPath: string,
+  language: Language,
+  lines: readonly number[]
+): Promise<Map<number, DefinitionDecorators>> {
+  const out = new Map<number, DefinitionDecorators>();
+  if (!supportsBranchGuards(language) || lines.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const line of lines) {
+    if (out.has(line)) continue;
+    const found = decoratorsInTree(cached.tree.rootNode, cached.source, line);
+    if (found !== null) out.set(line, found);
+  }
+  return out;
+}
+
+/** {@link decoratorsForFile} over source text — the test surface. */
+export async function decoratorsInSource(source: string, language: Language, line: number): Promise<DefinitionDecorators | null> {
+  if (!supportsBranchGuards(language)) return null;
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  try {
+    return decoratorsInTree(tree.rootNode, source, line);
+  } finally {
+    tree.delete();
+  }
+}
+
+export function decoratorsInTree(root: SyntaxNode, source: string, line: number): DefinitionDecorators | null {
+  const row = line - 1;
+  const col = firstNonBlankColumn(source, row);
+  let node: SyntaxNode | null = innermostAt(root, row, col);
+  if (!node) return null;
+  // Up to the definition the line belongs to.
+  let definition: SyntaxNode | null = null;
+  for (let up = 0; node && up < 12; up++, node = node.parent) {
+    if (DEFINITION_TYPES.has(node.type)) {
+      definition = node;
+      break;
+    }
+  }
+  if (!definition) return null;
+  // A Python decorated function is the child of the node that holds the decorators.
+  const holder = definition.parent && definition.parent.type === 'decorated_definition' ? definition.parent : definition;
+  const own = decoratorsOn(holder);
+  let cls: SyntaxNode | null = holder.parent;
+  for (let up = 0; cls && up < 6 && !CLASS_TYPES.has(cls.type); up++) cls = cls.parent;
+  const clsHolder = cls && cls.parent && cls.parent.type === 'decorated_definition' ? cls.parent : cls;
+  return { own, class: clsHolder ? decoratorsOn(clsHolder) : [] };
+}
+
+/** Decorator texts on one definition node: its own leading decorator children, its modifiers/attribute lists, or the siblings before it. */
+function decoratorsOn(definition: SyntaxNode): string[] {
+  const out: string[] = [];
+  const add = (n: SyntaxNode) => {
+    if (n.type === 'attribute_list') {
+      for (const a of namedChildren(n)) if (a.type === 'attribute') out.push(decoratorText(a));
+      return;
+    }
+    if (DECORATOR_TYPES.has(n.type)) out.push(decoratorText(n));
+  };
+  for (const c of namedChildren(definition)) {
+    if (c.type === 'modifiers') for (const m of namedChildren(c)) add(m);
+    else add(c);
+  }
+  // JS: decorators are siblings that precede the member in the class body.
+  if (out.length === 0 && definition.parent) {
+    const before = precedingSiblings(definition.parent, definition);
+    for (let i = before.length - 1; i >= 0; i--) {
+      const s = before[i]!;
+      if (s.type !== 'decorator') break;
+      out.unshift(decoratorText(s));
+    }
+  }
+  return out;
+}
+
+function decoratorText(node: SyntaxNode): string {
+  let text = collapse(node.text).replace(/^@\s*/, '');
+  if (node.type === 'attribute_list') text = text.replace(/^\[|\]$/g, '');
+  return cut(text, MAX_DECORATOR_TEXT);
+}
+
+
+// =============================================================================
+// Member types — what a class declares its members to be
+// =============================================================================
+
+/**
+ * The declared types of a class's members, read from the tree: the
+ * constructor's parameter properties (`private readonly usersService:
+ * UsersService`), its fields (`private final OwnerRepository owners`,
+ * `val owners: OwnerRepository`, `private readonly IRepo _repo`), its typed
+ * properties. The index keeps no type for these, and a member call the
+ * extractor kept only the last segment of (`this.usersService.findByEmail`
+ * → `findByEmail`) resolves by name alone; the declared type is what says
+ * where it really goes, and whether it leaves the index.
+ *
+ * Keyed by member name, the type as written without generics'
+ * arguments (`Repository<Cat>` → `Repository<Cat>` is kept whole; callers
+ * strip what they need).
+ */
+export async function memberTypesForFile(absPath: string, language: Language, line: number): Promise<Map<string, string>> {
+  const out = new Map<string, string>();
+  if (!supportsBranchGuards(language)) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  return memberTypesInTree(cached.tree.rootNode, cached.source, line);
+}
+
+/** {@link memberTypesForFile} over source text — the test surface. */
+export async function memberTypesInSource(source: string, language: Language, line: number): Promise<Map<string, string>> {
+  if (!supportsBranchGuards(language)) return new Map();
+  const tree = await parse(source, language);
+  if (!tree) return new Map();
+  try {
+    return memberTypesInTree(tree.rootNode, source, line);
+  } finally {
+    tree.delete();
+  }
+}
+
+const CLASS_BODY_TYPES: ReadonlySet<string> = new Set(['class_body', 'declaration_list', 'field_declaration_list']);
+
+export function memberTypesInTree(root: SyntaxNode, source: string, line: number): Map<string, string> {
+  const out = new Map<string, string>();
+  const row = line - 1;
+  let node: SyntaxNode | null = innermostAt(root, row, firstNonBlankColumn(source, row));
+  let cls: SyntaxNode | null = null;
+  for (let up = 0; node && up < 16; up++, node = node.parent) {
+    if (CLASS_TYPES.has(node.type)) {
+      cls = node;
+      break;
+    }
+  }
+  if (!cls) return out;
+  const typeText = (n: SyntaxNode | null | undefined): string => (n ? collapse(n.text).replace(/^:\s*/, '').trim() : '');
+  const put = (name: string | null | undefined, type: string) => {
+    if (name && type && !out.has(name)) out.set(name, type);
+  };
+  const visitParams = (params: SyntaxNode | null) => {
+    if (!params) return;
+    for (const p of namedChildren(params)) {
+      // TS: `private readonly x: T` (a parameter property); Kotlin: `val x: T`; C#/Java: `T x` — a field of the same name may follow.
+      if (p.type === 'required_parameter' || p.type === 'optional_parameter') {
+        if (!namedChildren(p).some((c) => c.type === 'accessibility_modifier' || c.type === 'override_modifier') && !/^\s*(?:public|private|protected|readonly)\b/.test(p.text)) continue;
+        put(p.childForFieldName('pattern')?.text, typeText(p.childForFieldName('type')));
+      } else if (p.type === 'class_parameter') {
+        const kids = namedChildren(p);
+        const name = kids.find((c) => c.type === 'simple_identifier');
+        const type = kids.find((c) => c.type === 'user_type' || c.type === 'nullable_type');
+        if (kids.some((c) => c.type === 'binding_pattern_kind')) put(name?.text, typeText(type));
+      } else if (p.type === 'parameter' || p.type === 'formal_parameter') {
+        put(p.childForFieldName('name')?.text, typeText(p.childForFieldName('type')));
+      }
+    }
+  };
+  // Kotlin's primary constructor sits on the class node itself.
+  for (const c of namedChildren(cls)) if (c.type === 'primary_constructor') visitParams(namedChildren(c).find((n) => n.type === 'class_parameters') ?? c);
+  const body = namedChildren(cls).find((c) => CLASS_BODY_TYPES.has(c.type)) ?? cls.childForFieldName('body');
+  if (!body) return out;
+  for (const m of namedChildren(body)) {
+    switch (m.type) {
+      case 'public_field_definition':
+      case 'field_definition':
+        put(m.childForFieldName('name')?.text, typeText(m.childForFieldName('type')));
+        break;
+      case 'method_definition':
+        if (m.childForFieldName('name')?.text === 'constructor') visitParams(m.childForFieldName('parameters'));
+        break;
+      case 'field_declaration': {
+        // Java: `type` + `declarator`; C#: a `variable_declaration` inside.
+        const type = m.childForFieldName('type');
+        if (type) {
+          for (const d of namedChildren(m)) if (d.type === 'variable_declarator') put(d.childForFieldName('name')?.text, typeText(type));
+        } else {
+          const decl = namedChildren(m).find((c) => c.type === 'variable_declaration');
+          const t = decl?.childForFieldName('type');
+          for (const d of decl ? namedChildren(decl) : []) if (d.type === 'variable_declarator') put(d.childForFieldName('name')?.text, typeText(t));
+        }
+        break;
+      }
+      case 'property_declaration': {
+        // C#: `type` + `name`; Kotlin: `variable_declaration (name) (type)`.
+        const csType = m.childForFieldName('type');
+        if (csType) put(m.childForFieldName('name')?.text, typeText(csType));
+        else {
+          const decl = namedChildren(m).find((c) => c.type === 'variable_declaration');
+          const kids = decl ? namedChildren(decl) : [];
+          const name = kids.find((c) => c.type === 'simple_identifier');
+          const type = kids.find((c) => c.type === 'user_type' || c.type === 'nullable_type');
+          put(name?.text, typeText(type));
+        }
+        break;
+      }
+      case 'constructor_declaration':
+        visitParams(m.childForFieldName('parameters'));
+        break;
+      default:
+        break;
+    }
+  }
+  return out;
+}

+ 886 - 0
src/graph/dead-code.ts

@@ -0,0 +1,886 @@
+/**
+ * Dead code and islands — one derivation of "nothing in this repository
+ * reaches here".
+ *
+ * The graph can answer that question exactly, and that is the problem: the
+ * exact answer is *no incoming edge*, and a symbol with no incoming edge is not
+ * the same thing as a symbol nobody uses. Reflection calls it. A framework
+ * registers it by name. A test file that was never indexed imports it. The
+ * resolver saw the name and could not follow it. So the honest product of this
+ * module is two things at once — a list, and everything the list could not see.
+ *
+ * ## The shape of the claim
+ *
+ * `unreferenced` is a fact: no edge in the index, other than the `contains`
+ * edge from whatever holds it, points at this symbol. `dead` is an inference on
+ * top of that fact, and every step of the inference is subtractive — a
+ * candidate is dropped from the list the moment there is any reason to believe
+ * something outside the graph reaches it:
+ *
+ * - it is **exported** (something outside this repository may import it);
+ * - it lives in a **test** or a **generated** file (not code anyone deletes by
+ *   hand);
+ * - it is **abstract** or declared on an interface (a declaration is dispatched
+ *   to, never called);
+ * - it is **decorated** (`@app.route`, `@Component`, `@EventHandler`) — a
+ *   decorator is a registration, and the framework that reads it is not in the
+ *   graph. Seen as the symbol's own outgoing `decorates` edge, which is where
+ *   the engine records it; `node.decorators` is only populated by a couple of
+ *   languages and is checked as well rather than instead;
+ * - it **overrides** a member an ancestor declares (calls land on the ancestor;
+ *   see {@link overrideCandidates} for why an ancestor we cannot read counts
+ *   the same way);
+ * - it has a name the language calls by itself (`constructor`, `__enter__`,
+ *   `main`);
+ * - it sits in a **vendored** directory (`vendor/`, `third_party/`,
+ *   `node_modules/` — code the repository carries but does not own);
+ * - it is in a **test scope** the path does not reveal — a Rust
+ *   `#[cfg(test)] mod tests`, a nested `Tests` namespace;
+ * - it is in a **component file** whose markup the index reads for calls but
+ *   not for references, so a handler passed as `{onkeydown}` is invisible;
+ * - it is in a file **nothing in the index reaches**. Then "nothing references
+ *   this symbol" is a restatement of "we cannot see how this file is wired",
+ *   not a finding about the symbol — and it is the map, not this list, that
+ *   says so: a file no one reaches is an island, and islands are drawn there;
+ * - the index holds an **unresolved reference** to its name. A `failed` row in
+ *   `unresolved_refs` is the resolver's own record of a reference it could not
+ *   follow, and a symbol whose name we failed to follow cannot be called
+ *   unreferenced.
+ * - **another symbol of the same name IS referenced.** This is the one that
+ *   matters most and the one nothing else would catch. `CodeGraph.getTopRouteFile`
+ *   calls `this.queries.getTopRouteFile()`; the resolver prefers a same-name
+ *   definition in the call site's own file, so the edge lands on the caller
+ *   itself and the real target is left with nothing. From the edge table,
+ *   "nobody calls this" and "the resolver picked the twin" are the same
+ *   picture — so the claim is not made about either;
+ * - it is declared in a **header** (`.h`, `.hpp`, `.d.ts`, `.pyi`): a header IS
+ *   the export surface, and the reference to it is an `#include` the resolver
+ *   does not follow to the declaration;
+ * - it is in a language this index records **no export marker** for. The
+ *   exported filter is the strongest one here, and for Rust (`pub` is not
+ *   recorded) or Python and C (no such concept at all) it silently does
+ *   nothing — so the index is asked, per language, whether it ran;
+ * - **its own file writes the name more than once.** The last rule, and the
+ *   only one that is not a graph query. Everything above assumes the edge
+ *   table is complete; it is not, and the gaps do not announce themselves —
+ *   `this.handleMessage.bind(this)` is a value reference the extractor does not
+ *   record, and a call inside an object-literal initialiser is another. Both
+ *   leave the name written twice in one file and no edge at all. So before the
+ *   claim is made, the identifier is counted in the file itself AND in every
+ *   file the index says depends on it — written once, in its own declaration,
+ *   nothing that can reach it writes it down; written twice, we simply did not
+ *   see the second one.
+ *
+ * Every subtraction is counted. {@link DeadCodeReport.excluded} is not
+ * diagnostics — it is the sentence under the list ("47 exported, 12 overriding
+ * an ancestor…"), because a list of eight rows drawn from four thousand
+ * candidates means something different from a list of eight drawn from nine.
+ *
+ * ## Islands
+ *
+ * The other half of the task, a *module* nothing depends on, is not computed
+ * here: it falls straight out of the map's own link set (a module with no
+ * incoming link), and the map's layout is already a pure function in the
+ * viewer. Computing it a second time on this side would be a second answer to
+ * a question the map has already answered. See `ui/src/lib/map-model.ts`.
+ *
+ * Everything here is query-time and read-only.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import type CodeGraph from '../index';
+import type { Node, NodeKind } from '../types';
+import { isTestFile } from '../search/query-utils';
+
+// =============================================================================
+// Caps and defaults
+// =============================================================================
+
+/**
+ * Kinds asked about by default.
+ *
+ * Callables and types, and nothing else. `variable`/`constant`/`field` are out
+ * deliberately: a value's uses are recorded as `references` edges, and that
+ * coverage is the most language-dependent thing in the resolver — a default
+ * that included them would produce a list whose truthfulness varied by which
+ * language the reader happened to be looking at.
+ */
+export const DEAD_CODE_KINDS: readonly NodeKind[] = [
+  'function',
+  'method',
+  'class',
+  'component',
+  'interface',
+  'struct',
+  'trait',
+  'protocol',
+  'enum',
+  'union',
+  'type_alias',
+];
+
+/** Kinds a `kinds=` request may ask for. Anything else is a caller bug. */
+export const DEAD_CODE_ALLOWED_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  ...DEAD_CODE_KINDS,
+  'variable',
+  'constant',
+  'property',
+  'field',
+  'enum_member',
+  'namespace',
+  'module',
+]);
+
+/**
+ * Candidates pulled out of SQL before any exclusion runs.
+ *
+ * High enough that no real repository reaches it with the default kinds (this
+ * index produces ~1 400), and bounded so that a half-indexed monorepo cannot
+ * turn one screen into a scan of a million rows. When it bites,
+ * {@link DeadCodeReport.bounded} says so.
+ */
+export const MAX_DEAD_CODE_CANDIDATES = 20000;
+
+/** Levels walked up looking for an ancestor that declares the same member. */
+export const MAX_OVERRIDE_ANCESTOR_DEPTH = 8;
+
+/**
+ * Files read for the corroboration pass, and the biggest one read.
+ *
+ * The pass runs over the survivors only — everything cheap has already fired —
+ * so on this index it reads a few dozen files. The caps are a backstop against
+ * a repository whose survivors span a thousand files or include a generated
+ * megabyte. A file skipped for either reason counts as NOT corroborated, which
+ * drops the row: the safe direction is always the one that says less.
+ */
+export const MAX_CORROBORATION_FILES = 600;
+export const MAX_CORROBORATION_BYTES = 2_000_000;
+
+/** Kinds that can carry members, i.e. whose ancestors are worth walking. */
+const CONTAINER_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'class',
+  'interface',
+  'struct',
+  'trait',
+  'protocol',
+  'enum',
+  'union',
+  'type_alias',
+]);
+
+/** Container kinds whose members are declarations, never call targets. */
+const DECLARATION_CONTAINER_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'interface',
+  'trait',
+  'protocol',
+]);
+
+/** Member kinds an override can be declared on. */
+const OVERRIDABLE_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'method',
+  'function',
+  'property',
+  'field',
+]);
+
+/**
+ * Names a language or a runtime calls without anything in the source naming
+ * them.
+ *
+ * Kept short on purpose. The temptation is a per-language table of every
+ * lifecycle hook ever written, which would be wrong twice over — it would go
+ * stale, and it would hide real dead code behind a name coincidence. The
+ * entries below are the ones where the *language itself* does the calling, so
+ * no source file could name them even in principle. Framework hooks are caught
+ * by the decorator and override rules instead, which are structural.
+ */
+const IMPLICIT_ENTRY_NAMES: ReadonlySet<string> = new Set([
+  'constructor',
+  'main',
+  'init',
+  'deinit',
+  'finalize',
+  'destructor',
+  'dispose',
+  'drop',
+  'default',
+  'tostring',
+  'equals',
+  'gethashcode',
+  'hashcode',
+]);
+
+/**
+ * Qualified-name segments that mean "inside a test scope the file path does not
+ * reveal" — a Rust `#[cfg(test)] mod tests`, a nested `Tests` class in C#, a
+ * Go `TestMain` helper block. `isTestFile` only reads paths, and an in-file test
+ * module is invisible to it.
+ */
+const TEST_SCOPE_SEGMENTS: ReadonlySet<string> = new Set([
+  'test',
+  'tests',
+  '__tests__',
+  'spec',
+  'specs',
+  'testing',
+]);
+
+/**
+ * Languages whose files are markup with a script block inside them.
+ *
+ * The extractors for these read the `<script>` region properly and scan the
+ * template for CALLS — but a handler passed by reference (`{onkeydown}`,
+ * `@click="submit"`) is a reference, not a call, and it is not extracted. Every
+ * event handler in every component would therefore head this list. The index
+ * cannot tell a handler wired in markup from one nobody uses, so it does not
+ * guess.
+ */
+const MARKUP_HOST_LANGUAGES: ReadonlySet<string> = new Set([
+  'svelte',
+  'vue',
+  'astro',
+  'liquid',
+  'html',
+  'razor',
+  'twig',
+  'blade',
+  'erb',
+  'handlebars',
+]);
+
+/**
+ * Extensions whose contents are declarations for somebody else.
+ *
+ * A C header is the translation unit's export surface: everything in it exists
+ * to be `#include`d, and the resolver does not follow an include to the
+ * declaration it lands on. `.d.ts` and `.pyi` are the same idea in TypeScript
+ * and Python. Treating these as exported is not a heuristic — it is what the
+ * file is for.
+ */
+const HEADER_EXTENSIONS: ReadonlyArray<string> = [
+  '.h',
+  '.hh',
+  '.hpp',
+  '.hxx',
+  '.h++',
+  '.inc',
+  '.d.ts',
+  '.d.mts',
+  '.d.cts',
+  '.pyi',
+  '.pxd',
+];
+
+/** Python and Ruby call these by protocol: `__enter__`, `__iter__`, `__init__`. */
+const DUNDER = /^__[a-z0-9_]+__$/i;
+
+/**
+ * Directory names that mean "this code is carried, not written here".
+ *
+ * Vendored third-party source is the second-largest source of noise after name
+ * ambiguity, and it is noise of a particular kind: the code IS reached, by a
+ * build system or a runtime that is not in the index at all (a tree-sitter
+ * scanner is called through a generated symbol table; a vendored library is
+ * called by whatever links it). Matched as a whole path segment, so
+ * `src/vendored-parser.ts` is not caught by `vendor`.
+ */
+const VENDOR_SEGMENTS: ReadonlySet<string> = new Set([
+  'vendor',
+  'vendored',
+  'third_party',
+  'third-party',
+  'thirdparty',
+  'external',
+  'externals',
+  'node_modules',
+  'bower_components',
+  'site-packages',
+  'godeps',
+  'pods',
+  '.venv',
+  'venv',
+]);
+
+// =============================================================================
+// Shapes
+// =============================================================================
+
+/** One symbol nothing reaches, and what it takes with it. */
+export interface DeadCodeEntry {
+  node: Node;
+  /**
+   * Members that are themselves unreferenced and live inside {@link node}.
+   *
+   * A class nobody instantiates takes its methods with it, and listing all
+   * eleven of them as siblings would turn one finding into eleven. They are
+   * folded in here instead and reported as a count.
+   */
+  members: Node[];
+  /** Source lines the entry spans, members included (they are inside it). */
+  lines: number;
+  /**
+   * The symbol is exported. Only ever true when the caller asked for exported
+   * symbols — and then it is the row's own caveat, because an exported symbol
+   * is reachable from outside the index by definition.
+   */
+  exported: boolean;
+}
+
+/** How many candidates each rule removed, in the order the rules ran. */
+export interface DeadCodeExclusions {
+  /** In a file that looks like test or fixture code. */
+  tests: number;
+  /** In a tool-generated file. */
+  generated: number;
+  /** Exported, or declared in a header — reachable from outside this index. */
+  exported: number;
+  /**
+   * In a language this index records no export marker for, so nothing here can
+   * be told apart from that language's public surface.
+   */
+  exportsUnknown: number;
+  /** Abstract, or a member of an interface / trait / protocol. */
+  declarations: number;
+  /** Carries a decorator, so a framework registers it. */
+  decorated: number;
+  /** Overrides a member an ancestor declares, or an ancestor we cannot read. */
+  overriding: number;
+  /** Named something the language calls by itself. */
+  implicit: number;
+  /** In a vendored directory — carried code, reached by something outside the index. */
+  vendored: number;
+  /** In a test scope the file path does not reveal (a Rust `mod tests`). */
+  testScope: number;
+  /** In a component file whose markup can reference a symbol invisibly. */
+  markup: number;
+  /** In a file nothing in the index reaches — an island, drawn on the map. */
+  unreachableFile: number;
+  /** The index holds an unresolved reference to this name. */
+  unresolvedName: number;
+  /** Another symbol of the same name IS referenced, so the resolver may have picked it. */
+  ambiguousName: number;
+  /** Its own file writes the name more than once, so something uses it there. */
+  mentioned: number;
+  /** Its file could not be read, so the mention count could not be checked. */
+  unreadable: number;
+  /** Folded into a container that is itself on the list. */
+  nested: number;
+}
+
+export interface DeadCodeReport {
+  /** Ranked, capped. */
+  entries: DeadCodeEntry[];
+  /** Entries before {@link DeadCodeQuery.limit} — always the real number. */
+  total: number;
+  /** Symbols with no incoming reference at all, before any exclusion ran. */
+  candidates: number;
+  excluded: DeadCodeExclusions;
+  /** The kinds actually asked about. */
+  kinds: NodeKind[];
+  /** Exported symbols were included, so every row carries the outside-reach caveat. */
+  includeExported: boolean;
+  /** The candidate scan stopped at {@link MAX_DEAD_CODE_CANDIDATES}. */
+  bounded: boolean;
+  /**
+   * Every surviving row was checked against its own file's text — the rule that
+   * covers the edges the extractor never recorded. False when no reader was
+   * available, and then the list is weaker than it looks.
+   */
+  corroborated: boolean;
+}
+
+export interface DeadCodeQuery {
+  kinds?: readonly NodeKind[];
+  /** Include symbols something outside the index could import. Default false. */
+  includeExported?: boolean;
+  /** Include symbols in test files. Default false. */
+  includeTests?: boolean;
+  /** Include symbols in tool-generated files. Default false. */
+  includeGenerated?: boolean;
+  /** Entries returned. `total` stays the real count. */
+  limit?: number;
+  /**
+   * How to read a project-relative source file, for the corroboration pass.
+   *
+   * Injected rather than assumed so that a caller with a read chokepoint — the
+   * viewer's API refuses any path outside the project before opening it — keeps
+   * its own rule. Return `null` for anything unreadable. Omitted entirely means
+   * the default reader, which resolves against the project root; passing `null`
+   * turns the pass off, and {@link DeadCodeReport.corroborated} then says so.
+   */
+  readSource?: ((filePath: string) => string | null) | null;
+}
+
+// =============================================================================
+// The report
+// =============================================================================
+
+/**
+ * The dead code report: unreferenced symbols, minus every reason to doubt it,
+ * plus a count of every doubt.
+ */
+export function buildDeadCodeReport(cg: CodeGraph, query: DeadCodeQuery = {}): DeadCodeReport {
+  const kinds = normalizeKinds(query.kinds);
+  const includeExported = query.includeExported === true;
+  const includeTests = query.includeTests === true;
+  const includeGenerated = query.includeGenerated === true;
+  const limit = Math.max(1, query.limit ?? 200);
+  const readSource =
+    query.readSource === undefined ? defaultSourceReader(cg) : query.readSource;
+
+  const excluded: DeadCodeExclusions = {
+    tests: 0,
+    generated: 0,
+    exported: 0,
+    exportsUnknown: 0,
+    declarations: 0,
+    decorated: 0,
+    overriding: 0,
+    implicit: 0,
+    vendored: 0,
+    testScope: 0,
+    markup: 0,
+    unreachableFile: 0,
+    unresolvedName: 0,
+    ambiguousName: 0,
+    mentioned: 0,
+    unreadable: 0,
+    nested: 0,
+  };
+
+  const raw = cg.getUnreferencedNodes(kinds, MAX_DEAD_CODE_CANDIDATES + 1);
+  const bounded = raw.length > MAX_DEAD_CODE_CANDIDATES;
+  const candidates = bounded ? raw.slice(0, MAX_DEAD_CODE_CANDIDATES) : raw;
+
+  // Asking the index whether the exported filter can run at all, per language,
+  // over the handful of languages the candidates are actually in. Skipped when
+  // the caller has already accepted outside-reachability by asking for exported
+  // symbols.
+  const languagesWithExports = includeExported
+    ? new Set<string>()
+    : cg.getLanguagesWithExports(candidates.map((row) => row.node.language));
+
+  // ---- the cheap, per-row rules -------------------------------------------
+  const surviving: Array<{ node: Node; generated: boolean }> = [];
+  for (const row of candidates) {
+    const { node } = row;
+    if (!includeTests && isTestFile(node.filePath)) {
+      excluded.tests += 1;
+      continue;
+    }
+    if (!includeGenerated && row.generated) {
+      excluded.generated += 1;
+      continue;
+    }
+    if (!includeExported && (node.isExported || isHeaderFile(node.filePath))) {
+      excluded.exported += 1;
+      continue;
+    }
+    if (!includeExported && !languagesWithExports.has(node.language)) {
+      excluded.exportsUnknown += 1;
+      continue;
+    }
+    if (node.isAbstract) {
+      excluded.declarations += 1;
+      continue;
+    }
+    if (isImplicitEntryName(node.name)) {
+      excluded.implicit += 1;
+      continue;
+    }
+    if (isVendoredPath(node.filePath)) {
+      excluded.vendored += 1;
+      continue;
+    }
+    if (!includeTests && isTestScope(node.qualifiedName)) {
+      excluded.testScope += 1;
+      continue;
+    }
+    if (MARKUP_HOST_LANGUAGES.has(node.language)) {
+      excluded.markup += 1;
+      continue;
+    }
+    surviving.push(row);
+  }
+
+  // ---- the rules that need the graph --------------------------------------
+  // A `decorates` edge runs FROM the decorated symbol to the decorator, so
+  // this is an outgoing-edge question, not something the candidate query could
+  // have answered.
+  const decorated = new Set(
+    cg
+      .getOutgoingEdgesFrom(
+        surviving.map((row) => row.node.id),
+        ['decorates']
+      )
+      .map((edge) => edge.source)
+  );
+  const containers = containersOf(cg, surviving.map((row) => row.node));
+  const overriding = overrideCandidates(cg, surviving.map((row) => row.node), containers);
+  // One batched count for every file still in play. A file nothing reaches is
+  // an island: its symbols' zero fan-in describes the file, not the symbol.
+  const unreachableFiles = filesNothingReaches(cg, surviving.map((row) => row.node.filePath));
+  const reachable = surviving.filter((row) => {
+    if (!unreachableFiles.has(row.node.filePath)) return true;
+    excluded.unreachableFile += 1;
+    return false;
+  });
+  surviving.length = 0;
+  surviving.push(...reachable);
+
+  const names = surviving.map((row) => row.node.name);
+  const unresolved = cg.getUnresolvedNamesAmong(names);
+  const ambiguousNames = cg.getAmbiguousReferencedNames(names);
+
+  const kept: Array<{ node: Node; generated: boolean }> = [];
+  for (const row of surviving) {
+    if (decorated.has(row.node.id) || (row.node.decorators?.length ?? 0) > 0) {
+      excluded.decorated += 1;
+      continue;
+    }
+    const container = containers.get(row.node.id);
+    if (container && DECLARATION_CONTAINER_KINDS.has(container.kind)) {
+      excluded.declarations += 1;
+      continue;
+    }
+    if (overriding.has(row.node.id)) {
+      excluded.overriding += 1;
+      continue;
+    }
+    if (unresolved.has(row.node.name)) {
+      excluded.unresolvedName += 1;
+      continue;
+    }
+    if (ambiguousNames.has(row.node.name)) {
+      excluded.ambiguousName += 1;
+      continue;
+    }
+    kept.push(row);
+  }
+
+  // ---- the file's own text has the last word -------------------------------
+  // Everything above is a graph query, and the graph is what has the gaps. This
+  // is the only rule that can see a reference the extractor never recorded.
+  const confirmed: Array<{ node: Node; generated: boolean }> = [];
+  if (readSource) {
+    const sources = new Map<string, string | null>();
+    const read = (file: string): string | null => {
+      if (!sources.has(file)) {
+        sources.set(file, sources.size >= MAX_CORROBORATION_FILES ? null : readSource(file));
+      }
+      return sources.get(file) ?? null;
+    };
+    // The set to search is the declaring file plus everything the index says
+    // reaches into it — the same set a call could have come from. Computed once
+    // per file, not once per candidate.
+    const scopes = new Map<string, string[]>();
+    for (const row of kept) {
+      const file = row.node.filePath;
+      if (!scopes.has(file)) scopes.set(file, [file, ...cg.getFileDependents(file)]);
+    }
+
+    for (const row of kept) {
+      const scope = scopes.get(row.node.filePath) ?? [row.node.filePath];
+      let own: string | null = null;
+      let mentions = 0;
+      for (const file of scope) {
+        const source = read(file);
+        if (file === row.node.filePath) own = source;
+        if (source === null) continue;
+        mentions += mentionCount(source, row.node.name, 2 - mentions);
+        if (mentions >= 2) break;
+      }
+      // Its OWN file has to be readable: the declaration itself is one of the
+      // two mentions, so an unreadable declaring file makes the count meaningless.
+      if (own === null) {
+        excluded.unreadable += 1;
+        continue;
+      }
+      if (mentions >= 2) {
+        excluded.mentioned += 1;
+        continue;
+      }
+      confirmed.push(row);
+    }
+  } else {
+    confirmed.push(...kept);
+  }
+
+  // ---- fold members into a container that is itself dead -------------------
+  const keptIds = new Set(confirmed.map((row) => row.node.id));
+  const entries = new Map<string, DeadCodeEntry>();
+  const pending: Array<{ node: Node; containerId: string }> = [];
+  for (const row of confirmed) {
+    const container = containers.get(row.node.id);
+    if (container && keptIds.has(container.id)) {
+      pending.push({ node: row.node, containerId: container.id });
+      excluded.nested += 1;
+      continue;
+    }
+    entries.set(row.node.id, {
+      node: row.node,
+      members: [],
+      lines: Math.max(1, row.node.endLine - row.node.startLine + 1),
+      exported: row.node.isExported === true,
+    });
+  }
+  for (const member of pending) {
+    // A member whose container was itself folded away (a dead class inside a
+    // dead class) has no entry to hang off; it was still counted as nested, so
+    // it is not silently missing from the totals.
+    entries.get(member.containerId)?.members.push(member.node);
+  }
+  for (const entry of entries.values()) {
+    entry.members.sort((a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name));
+  }
+
+  // Biggest first: the list is read to decide what to delete, and a 200-line
+  // unreachable class is a different finding from a three-line helper. File and
+  // line break the tie so the order is stable across runs.
+  const ranked = [...entries.values()].sort(
+    (a, b) =>
+      b.lines - a.lines ||
+      a.node.filePath.localeCompare(b.node.filePath) ||
+      a.node.startLine - b.node.startLine
+  );
+
+  return {
+    entries: ranked.slice(0, limit),
+    total: ranked.length,
+    candidates: candidates.length,
+    excluded,
+    kinds,
+    includeExported,
+    bounded,
+    corroborated: readSource !== null,
+  };
+}
+
+/**
+ * Reads a project-relative file off disk, for callers with no chokepoint of
+ * their own (the CLI, a library user). Refuses anything that escapes the
+ * project root — a `filePath` comes out of the index, but the index is a file
+ * on disk and this module should not be the thing that trusts it.
+ */
+function defaultSourceReader(cg: CodeGraph): (filePath: string) => string | null {
+  const root = path.resolve(cg.getProjectRoot());
+  return (filePath: string): string | null => {
+    try {
+      const absolute = path.resolve(root, filePath);
+      if (absolute !== root && !absolute.startsWith(root + path.sep)) return null;
+      const stat = fs.statSync(absolute);
+      if (!stat.isFile() || stat.size > MAX_CORROBORATION_BYTES) return null;
+      return fs.readFileSync(absolute, 'utf8');
+    } catch {
+      return null;
+    }
+  };
+}
+
+/**
+ * How many times `name` is written in `source` as a whole identifier, counting
+ * no further than `stopAt`.
+ *
+ * Deliberately dumb: no parsing, no comment or string stripping. A mention in a
+ * comment or in a string is exactly the kind of thing that turns out to be a
+ * reflective call or a registration key, and the rule this serves only ever
+ * uses the count to say LESS. `\b` is not used because it is ASCII-only in
+ * JavaScript and an identifier may not be.
+ */
+export function mentionCount(source: string, name: string, stopAt = Number.MAX_SAFE_INTEGER): number {
+  if (name.length === 0) return 0;
+  let count = 0;
+  let from = 0;
+  for (;;) {
+    const at = source.indexOf(name, from);
+    if (at < 0) return count;
+    from = at + name.length;
+    if (!isIdentifierChar(source[at - 1]) && !isIdentifierChar(source[from])) {
+      count += 1;
+      if (count >= stopAt) return count;
+    }
+  }
+}
+
+function isIdentifierChar(char: string | undefined): boolean {
+  if (char === undefined) return false;
+  return char === '_' || char === '$' || /[\p{L}\p{N}]/u.test(char);
+}
+
+/**
+ * Files in the candidate set that nothing else in the index reaches.
+ *
+ * One batched query for the whole set (`getFileDependentCounts` counts through
+ * the symbols, because an `imports` edge points at the imported symbol and a
+ * file node almost never receives one). Zero means nothing else in the index
+ * reaches into this file at all.
+ */
+function filesNothingReaches(cg: CodeGraph, filePaths: readonly string[]): Set<string> {
+  const unique = [...new Set(filePaths)];
+  if (unique.length === 0) return new Set();
+  const dependents = cg.getFileDependentCounts(unique);
+  return new Set(unique.filter((path) => (dependents.get(path) ?? 0) === 0));
+}
+
+/** A file whose contents are declarations for somebody else — see {@link HEADER_EXTENSIONS}. */
+export function isHeaderFile(filePath: string): boolean {
+  const lower = filePath.toLowerCase();
+  return HEADER_EXTENSIONS.some((ext) => lower.endsWith(ext));
+}
+
+/** A qualified name that runs through a test scope — see {@link TEST_SCOPE_SEGMENTS}. */
+export function isTestScope(qualifiedName: string): boolean {
+  for (const segment of qualifiedName.split(/[.:/\\#>]+/)) {
+    if (TEST_SCOPE_SEGMENTS.has(segment.toLowerCase())) return true;
+  }
+  return false;
+}
+
+/** Code the repository carries rather than owns — see {@link VENDOR_SEGMENTS}. */
+export function isVendoredPath(filePath: string): boolean {
+  for (const segment of filePath.replace(/\\/g, '/').split('/')) {
+    if (VENDOR_SEGMENTS.has(segment.toLowerCase())) return true;
+  }
+  return false;
+}
+
+/** A name the language calls by itself, so no source file could name it. */
+export function isImplicitEntryName(name: string): boolean {
+  return DUNDER.test(name) || IMPLICIT_ENTRY_NAMES.has(name.toLowerCase());
+}
+
+function normalizeKinds(requested: readonly NodeKind[] | undefined): NodeKind[] {
+  if (!requested || requested.length === 0) return [...DEAD_CODE_KINDS];
+  const kinds = requested.filter((kind) => DEAD_CODE_ALLOWED_KINDS.has(kind));
+  return kinds.length > 0 ? [...new Set(kinds)] : [...DEAD_CODE_KINDS];
+}
+
+/**
+ * The type each candidate is declared in, for the candidates that are members.
+ *
+ * One batched query for the whole candidate set, then one for the containers
+ * themselves — never a lookup per row. Only type-ish containers are returned: a
+ * function's container is the file, which tells us nothing.
+ */
+function containersOf(cg: CodeGraph, nodes: readonly Node[]): Map<string, Node> {
+  const memberIds = nodes.filter((node) => OVERRIDABLE_KINDS.has(node.kind)).map((n) => n.id);
+  const out = new Map<string, Node>();
+  if (memberIds.length === 0) return out;
+
+  const edges = cg.getIncomingEdgesTo(memberIds, ['contains']);
+  const byMember = new Map<string, string>();
+  for (const edge of edges) if (!byMember.has(edge.target)) byMember.set(edge.target, edge.source);
+
+  const containerNodes = cg.getNodesByIds([...new Set(byMember.values())]);
+  for (const [memberId, containerId] of byMember) {
+    const container = containerNodes.get(containerId);
+    if (container && CONTAINER_KINDS.has(container.kind)) out.set(memberId, container);
+  }
+  return out;
+}
+
+/**
+ * Which candidates override something — the ids to drop.
+ *
+ * A method that overrides `Base.run` is reached through `Base.run`; the call
+ * site names the base, so the override carries no incoming edge of its own and
+ * would otherwise head the list. It is matched by NAME within a chain the graph
+ * already links (nothing in the engine emits an `overrides` edge), exactly as
+ * the type-hierarchy block does.
+ *
+ * The second rule is the one that looks wrong and is not: **an ancestor with no
+ * extracted members counts as a match.** A TypeScript interface of pure method
+ * signatures produces no `contains` edges at all, so `class X implements Y`
+ * with every member of `Y` implemented reads, structurally, as a class whose
+ * members override nothing. Answering "cannot tell" with an exclusion is the
+ * only choice that keeps the list's promise; the alternative puts every
+ * implementation of every signature-only interface at the top of a screen that
+ * says "nothing reaches this".
+ */
+function overrideCandidates(
+  cg: CodeGraph,
+  nodes: readonly Node[],
+  containers: ReadonlyMap<string, Node>
+): Set<string> {
+  const dropped = new Set<string>();
+  const containerIds = [...new Set([...containers.values()].map((node) => node.id))];
+  if (containerIds.length === 0) return dropped;
+
+  // Level-by-level upward walk over EVERY container at once: one query per
+  // level rather than one per container. `reach` maps an ancestor back to the
+  // containers it is an ancestor of.
+  const reach = new Map<string, Set<string>>();
+  const seen = new Set<string>(containerIds);
+  let frontier = containerIds.map((id) => ({ id, roots: new Set<string>([id]) }));
+
+  for (let depth = 0; depth < MAX_OVERRIDE_ANCESTOR_DEPTH && frontier.length > 0; depth++) {
+    const rootsOf = new Map(frontier.map((item) => [item.id, item.roots]));
+    const edges = cg.getOutgoingEdgesFrom(
+      frontier.map((item) => item.id),
+      ['extends', 'implements']
+    );
+    const next = new Map<string, Set<string>>();
+    for (const edge of edges) {
+      if (edge.target === edge.source) continue;
+      const roots = rootsOf.get(edge.source);
+      if (!roots) continue;
+      const merged = next.get(edge.target) ?? new Set<string>();
+      for (const root of roots) merged.add(root);
+      next.set(edge.target, merged);
+      const known = reach.get(edge.target) ?? new Set<string>();
+      for (const root of roots) known.add(root);
+      reach.set(edge.target, known);
+    }
+    frontier = [];
+    for (const [id, roots] of next) {
+      if (seen.has(id)) continue;
+      seen.add(id);
+      frontier.push({ id, roots });
+    }
+  }
+
+  if (reach.size === 0) return dropped;
+
+  // What each ancestor declares, and whether it declares anything at all.
+  const ancestorIds = [...reach.keys()];
+  const memberEdges = cg.getOutgoingEdgesFrom(ancestorIds, ['contains']);
+  const memberIdsByAncestor = new Map<string, string[]>();
+  for (const edge of memberEdges) {
+    const bucket = memberIdsByAncestor.get(edge.source);
+    if (bucket) bucket.push(edge.target);
+    else memberIdsByAncestor.set(edge.source, [edge.target]);
+  }
+  const memberNodes = cg.getNodesByIds(memberEdges.map((edge) => edge.target));
+
+  /** Member names an ancestor declares, and whether it declares none we can read. */
+  const namesByContainer = new Map<string, Set<string>>();
+  const opaqueContainers = new Set<string>();
+  for (const [ancestorId, roots] of reach) {
+    const names: string[] = [];
+    for (const memberId of memberIdsByAncestor.get(ancestorId) ?? []) {
+      const member = memberNodes.get(memberId);
+      if (member && OVERRIDABLE_KINDS.has(member.kind)) names.push(member.name);
+    }
+    for (const root of roots) {
+      if (names.length === 0) {
+        opaqueContainers.add(root);
+        continue;
+      }
+      const bucket = namesByContainer.get(root) ?? new Set<string>();
+      for (const name of names) bucket.add(name);
+      namesByContainer.set(root, bucket);
+    }
+  }
+
+  for (const node of nodes) {
+    const container = containers.get(node.id);
+    if (!container) continue;
+    if (opaqueContainers.has(container.id)) {
+      dropped.add(node.id);
+      continue;
+    }
+    if (namesByContainer.get(container.id)?.has(node.name)) dropped.add(node.id);
+  }
+  return dropped;
+}

+ 359 - 0
src/graph/dynamic-boundary-report.ts

@@ -0,0 +1,359 @@
+/**
+ * Where the graph stops — the boundary report, as data.
+ *
+ * When a flow does not connect, the honest answer is not "no path": it is the
+ * dispatch site where the static path ends. `src/mcp/dynamic-boundaries.ts`
+ * finds those sites in a body with deterministic regex; this module is the
+ * graph-aware layer on top of it — it reads the bodies off disk, shortlists the
+ * candidate runtime targets for a statically-visible dispatch key, and collects
+ * the continuations out of the stopping symbol that the search did not follow.
+ *
+ * It exists for the same reason `named-symbol-flow.ts` does. `codegraph_explore`
+ * announces boundaries in prose ("**Dynamic boundaries** … candidates for key
+ * `save`: …") and the viewer's Flow strip draws the same verdict as an end cap
+ * (design spec §3.5). Two derivations of "where does this stop" would eventually
+ * disagree, and a reader who had both on screen would have no way to tell which
+ * one was lying. So the *verdict* lives here once, and each caller renders it:
+ * `ToolHandler.buildDynamicBoundaries` turns it into markdown, `/api/flow` turns
+ * it into `WireFlowBoundary`.
+ *
+ * Everything here is query-time and read-only. The graph is never mutated, no
+ * edge is ever guessed, and a fully connected flow never reaches this module —
+ * silence beats a wrong edge (#687).
+ */
+
+import type CodeGraph from '../index';
+import type { Edge, Node } from '../types';
+import { scanDynamicDispatch, type BoundaryMatch } from '../mcp/dynamic-boundaries';
+import { validatePathWithinRoot } from '../utils';
+import { existsSync, readFileSync } from 'fs';
+
+/** Below this resolution confidence an edge is a name-only guess, not a call. */
+export const UNCERTAIN_BELOW = 0.6;
+
+/** Dispatch sites reported across one scan. Matches explore's bullet budget. */
+export const MAX_BOUNDARY_SITES = 4;
+
+/** Bodies read off disk per scan, however many symbols were handed in. */
+const MAX_SCAN = 8;
+
+/** Total body characters read per scan — a god-function tail must not stall a request. */
+const MAX_TOTAL_CHARS = 200_000;
+
+/** Candidate runtime targets shortlisted for one dispatch key. */
+const MAX_CANDIDATES = 4;
+
+/** FTS rows inspected while shortlisting; also the "too generic" threshold. */
+const CANDIDATE_SEARCH_LIMIT = 12;
+
+/** Kinds that can be the runtime target of a dispatch. */
+const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor', 'class']);
+
+/**
+ * A conventional handler method on a typed-bus target class — MediatR's
+ * `Handle`, a consumer's `Consume`, PHP's `__invoke`.
+ */
+const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
+
+// =============================================================================
+// Shapes
+// =============================================================================
+
+/** One plausible runtime target of a keyed dispatch. */
+export interface BoundaryCandidate {
+  node: Node;
+  /**
+   * How the candidate should be named. Usually `qualifiedName`, but a typed-bus
+   * key resolves to a CLASS whose real target is its handler method, so the
+   * display names that method (`CreateTodoCommandHandler.Handle`) and `node` is
+   * the method too — a row the reader clicks must open what it claims.
+   */
+  display: string;
+  /** The reader already named this symbol: "you were right, here's the wiring". */
+  named: boolean;
+}
+
+/** A dispatch site: the detector's verdict plus what the graph knows about it. */
+export interface BoundarySite extends BoundaryMatch {
+  /** Runtime targets for {@link BoundaryMatch.key}. Empty when the key is a runtime value. */
+  candidates: BoundaryCandidate[];
+  /**
+   * Why there is no shortlist, when a key was visible but nothing could be
+   * narrowed down: "key `id` is too generic to shortlist (12+ matches)".
+   */
+  candidateNote: string | null;
+}
+
+/** Every dispatch site found in one symbol's body. */
+export interface NodeBoundary {
+  node: Node;
+  sites: BoundarySite[];
+}
+
+/** One call out of the stopping symbol, and how sure the resolver was of it. */
+export interface BoundaryContinuation {
+  node: Node;
+  line: number | null;
+  confidence: number | null;
+}
+
+/**
+ * The calls recorded out of a symbol, split by whether the resolver believed
+ * them. `uncertain` is the part a flow search deliberately does not follow.
+ */
+export interface BoundaryContinuations {
+  resolved: BoundaryContinuation[];
+  uncertain: BoundaryContinuation[];
+}
+
+export interface BoundaryScanOptions {
+  /** Dispatch sites returned in total. Default {@link MAX_BOUNDARY_SITES}. */
+  maxSites?: number;
+  /** Symbols the reader named — candidates matching one are marked and sort first. */
+  named?: ReadonlyMap<string, Node>;
+}
+
+// =============================================================================
+// The scan
+// =============================================================================
+
+/**
+ * Scan the given symbols' bodies for dynamic-dispatch sites, in order.
+ *
+ * `scanList` is a priority order, not a set: the caller puts the place the flow
+ * actually stopped first (the chain's dead end), then the symbols that were
+ * asked for and never reached. Scanning stops at the first of three budgets —
+ * sites found, bodies read, characters read — so a question about a god
+ * function costs the same as any other.
+ *
+ * Returns one entry per symbol that yielded at least one site; a symbol with a
+ * clean body is simply absent, because "nothing dynamic here" is not a finding.
+ */
+export function findDynamicBoundaries(
+  cg: CodeGraph,
+  scanList: readonly Node[],
+  opts: BoundaryScanOptions = {}
+): NodeBoundary[] {
+  const maxSites = opts.maxSites ?? MAX_BOUNDARY_SITES;
+  const named = opts.named ?? new Map<string, Node>();
+  let projectRoot: string;
+  try {
+    projectRoot = cg.getProjectRoot();
+  } catch {
+    return [];
+  }
+
+  const out: NodeBoundary[] = [];
+  const seenNode = new Set<string>();
+  const seenSite = new Set<string>();
+  let sites = 0;
+  let scanned = 0;
+  let charsScanned = 0;
+
+  for (const node of scanList) {
+    if (sites >= maxSites || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS) break;
+    if (seenNode.has(node.id) || !node.startLine || !node.endLine) continue;
+    seenNode.add(node.id);
+    const absPath = validatePathWithinRoot(projectRoot, node.filePath);
+    if (!absPath || !existsSync(absPath)) continue;
+    let content: string;
+    try {
+      content = readFileSync(absPath, 'utf-8');
+    } catch {
+      continue;
+    }
+    const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
+    scanned++;
+    charsScanned += body.length;
+
+    const found: BoundarySite[] = [];
+    for (const match of scanDynamicDispatch(body, node.language || '', node.startLine)) {
+      if (sites >= maxSites) break;
+      const siteKey = `${node.filePath}:${match.line}:${match.form}`;
+      if (seenSite.has(siteKey)) continue;
+      seenSite.add(siteKey);
+      const shortlist = match.key
+        ? shortlistBoundaryCandidates(cg, match.key, !!match.keyIsType, named, node.id)
+        : { candidates: [], note: null };
+      found.push({ ...match, candidates: shortlist.candidates, candidateNote: shortlist.note });
+      sites++;
+    }
+    if (found.length > 0) out.push({ node, sites: found });
+  }
+  return out;
+}
+
+// =============================================================================
+// Candidates
+// =============================================================================
+
+const normalizeName = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, '');
+
+/**
+ * Shortlist the runtime targets a dispatch key could reach.
+ *
+ * Exact conventional names first (`save` → `onSave` / `handleSave`;
+ * `CreateCmd` → `CreateCmdHandler`), then FTS, with a normalized-containment
+ * post-filter — FTS camel-splitting is fuzzier than a candidate list should be,
+ * and a shortlist that is mostly wrong is worse than none. Symbols the caller
+ * already named sort first and are marked.
+ *
+ * A key too short or too common to narrow down returns no candidates and a
+ * `note` saying so, rather than four arbitrary rows.
+ */
+export function shortlistBoundaryCandidates(
+  cg: CodeGraph,
+  key: string,
+  keyIsType: boolean,
+  named: ReadonlyMap<string, Node>,
+  selfId: string
+): { candidates: BoundaryCandidate[]; note: string | null } {
+  const keyNorm = normalizeName(key);
+  if (keyNorm.length < 3) return { candidates: [], note: null };
+
+  const cands = new Map<string, Node>();
+  const consider = (n: Node | undefined | null): void => {
+    if (!n || n.id === selfId || !CALLABLE_KINDS.has(n.kind) || cands.has(n.id)) return;
+    const nameNorm = normalizeName(n.name || '');
+    if (nameNorm.length < 3) return;
+    if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm)) return;
+    cands.set(n.id, n);
+  };
+
+  const cap = key.charAt(0).toUpperCase() + key.slice(1);
+  const probes = keyIsType
+    ? [`${key}Handler`, key]
+    : [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
+  for (const probe of probes) {
+    try {
+      for (const n of cg.getNodesByName(probe)) consider(n);
+    } catch {
+      /* an exact probe that misses is the normal case */
+    }
+  }
+
+  let raw = 0;
+  try {
+    const results = cg.searchNodes(key, { limit: CANDIDATE_SEARCH_LIMIT });
+    raw = results.length;
+    for (const r of results) consider(r.node);
+  } catch {
+    /* FTS syntax edge — the exact probes already ran */
+  }
+
+  if (cands.size === 0) {
+    const generic = raw >= CANDIDATE_SEARCH_LIMIT && key.length < 5;
+    return {
+      candidates: [],
+      note: generic ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : null,
+    };
+  }
+
+  // A constructor candidate duplicates its class: extractors emit constructors
+  // as METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
+  const all = [...cands.values()];
+  const classKey = new Set(
+    all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`)
+  );
+  // The flow's named set holds callables only, so a class whose METHOD the
+  // reader named still counts as named — transfer the mark by name.
+  const namedNames = new Set([...named.values()].map((n) => n.name));
+  const isNamed = (n: Node): boolean => named.has(n.id) || namedNames.has(n.name);
+
+  const candidates = all
+    .filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
+    .sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
+    .slice(0, MAX_CANDIDATES)
+    .map((n): BoundaryCandidate => {
+      // Typed-bus convention: the runtime target is the candidate class's
+      // Handle/Execute/Consume method — name the exact node, not just the class.
+      if (keyIsType && n.kind === 'class') {
+        const method = handlerMethodOf(cg, n);
+        if (method) {
+          return { node: method, display: `${n.name}.${method.name}`, named: isNamed(n) };
+        }
+      }
+      return { node: n, display: n.qualifiedName || n.name, named: isNamed(n) };
+    });
+
+  return { candidates, note: null };
+}
+
+function handlerMethodOf(cg: CodeGraph, cls: Node): Node | null {
+  try {
+    return (
+      cg
+        .getOutgoingEdges(cls.id)
+        .filter((e) => e.kind === 'contains')
+        .map((e) => {
+          try {
+            return cg.getNode(e.target);
+          } catch {
+            return null;
+          }
+        })
+        .find((c): c is Node => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name)) ?? null
+    );
+  } catch {
+    return null; // a class whose members do not resolve — show the class itself
+  }
+}
+
+// =============================================================================
+// Continuations
+// =============================================================================
+
+const CONTINUATION_KINDS = new Set(['calls', 'instantiates', 'navigates']);
+
+/**
+ * The calls recorded out of a symbol, minus the ones already on the path.
+ *
+ * This is the other half of an honest end cap. A flow that stops somewhere has
+ * two kinds of unexplored exit: calls the resolver was sure of and the path
+ * simply did not need, and name-only matches under {@link UNCERTAIN_BELOW} that
+ * the search deliberately refused to follow. Listing the second kind is the
+ * point — an unfollowed guess that stays invisible reads as "there is nothing
+ * here", which is the one thing it does not mean.
+ *
+ * Deduped by target, keeping the first line each was recorded at.
+ */
+export function continuationsFrom(
+  cg: CodeGraph,
+  node: Node,
+  exclude: ReadonlySet<string> = new Set()
+): BoundaryContinuations {
+  const resolved = new Map<string, BoundaryContinuation>();
+  const uncertain = new Map<string, BoundaryContinuation>();
+  let edges: Edge[];
+  try {
+    edges = cg.getOutgoingEdges(node.id);
+  } catch {
+    return { resolved: [], uncertain: [] };
+  }
+  for (const edge of edges) {
+    if (!CONTINUATION_KINDS.has(edge.kind)) continue;
+    if (edge.target === node.id || exclude.has(edge.target)) continue;
+    const meta = (edge.metadata ?? {}) as Record<string, unknown>;
+    const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
+    const bucket = confidence !== null && confidence < UNCERTAIN_BELOW ? uncertain : resolved;
+    if (bucket.has(edge.target)) continue;
+    let target: Node | null;
+    try {
+      target = cg.getNode(edge.target);
+    } catch {
+      continue;
+    }
+    if (!target) continue;
+    bucket.set(edge.target, {
+      node: target,
+      line: typeof edge.line === 'number' ? edge.line : null,
+      confidence,
+    });
+  }
+  const byLine = (a: BoundaryContinuation, b: BoundaryContinuation): number =>
+    (a.line ?? 0) - (b.line ?? 0);
+  return {
+    resolved: [...resolved.values()].sort(byLine),
+    uncertain: [...uncertain.values()].sort(byLine),
+  };
+}

+ 29 - 0
src/graph/index.ts

@@ -6,3 +6,32 @@
 
 export { GraphTraverser } from './traversal';
 export { GraphQueryManager } from './queries';
+export {
+  buildTypeHierarchy,
+  canHaveHierarchy,
+  countImplementers,
+  DISPATCH_MIN_IMPLEMENTERS,
+  HIERARCHY_EDGE_KINDS,
+  HIERARCHY_KINDS,
+  MAX_DESCENDANTS,
+} from './type-hierarchy';
+export type {
+  HierarchyEntry,
+  HierarchyRelation,
+  OverrideMatch,
+  TypeHierarchy,
+} from './type-hierarchy';
+export {
+  buildDeadCodeReport,
+  isImplicitEntryName,
+  DEAD_CODE_ALLOWED_KINDS,
+  DEAD_CODE_KINDS,
+  MAX_DEAD_CODE_CANDIDATES,
+  MAX_OVERRIDE_ANCESTOR_DEPTH,
+} from './dead-code';
+export type {
+  DeadCodeEntry,
+  DeadCodeExclusions,
+  DeadCodeQuery,
+  DeadCodeReport,
+} from './dead-code';

+ 682 - 0
src/graph/named-symbol-flow.ts

@@ -0,0 +1,682 @@
+/**
+ * The call path among a bag of named symbols — the one path finder.
+ *
+ * `codegraph_explore` leads its answer with a "Flow" section: the longest call
+ * chain among the symbols an agent named, riding synthesized dynamic-dispatch
+ * edges so a controller reaches its implementation through the interface. The
+ * viewer's Flow strip (`/api/flow`, design spec §3.5) draws the same thing as
+ * cards. They must never disagree, so the search lives here once and both
+ * callers ride it: same token parsing, same overload disambiguation, same
+ * bridge budget, same edges.
+ *
+ * What differs between the two callers is expressed as OPTIONS, not as a second
+ * implementation:
+ *
+ * - **`mode: 'named'`** is exactly what explore does. Every resolved symbol is
+ *   both a possible start and a possible end, at most ONE unnamed symbol may
+ *   bridge two named ones ({@link DEFAULT_MAX_BRIDGE}), and the LONGEST chain
+ *   wins. The bridge cap is what stops the search wandering a god-function's
+ *   fan-out: the agent's own naming is the evidence that a hop is on-topic.
+ * - **`mode: 'directed'`** is "how does X reach Y", which the agent has no way
+ *   to ask and the viewer's search box does. Both ends are pinned, so the
+ *   evidence the bridge cap was standing in for is already there and the search
+ *   bridges freely — a two-token query under the named rules could never return
+ *   more than three cards. The SHORTEST path wins, because with both ends fixed
+ *   a longer route is a detour rather than a fuller answer.
+ *
+ * Overloads are handled differently for the same reason. A bare ambiguous name
+ * in `named` mode is filtered by CO-NAMING (keep `list` only where the agent
+ * also named its class); in `directed` mode every candidate for both endpoints
+ * is tried and the pair that actually connects is the answer — which is a
+ * better disambiguator than co-naming and the only one available when the
+ * whole query is two words.
+ */
+
+import type CodeGraph from '../index';
+import type { Node, Edge } from '../types';
+import { isTestFile } from '../search/query-utils';
+
+/**
+ * Rust path roots that have no file-system equivalent — `crate` is the
+ * current crate, `super` is the parent module, `self` is the current
+ * module. Used by `matchesSymbol` to strip these before file-path
+ * matching so `crate::configurator::stage_apply::run` resolves the
+ * same as `configurator::stage_apply::run`.
+ */
+export const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
+
+/**
+ * Last `::` / `.` / `/`-separated segment of a qualified symbol. An Erlang
+ * arity tail (`mod::fn/3`, `fn/3`) is stripped first — the useful last segment
+ * is the function name, never the digits (#1610).
+ */
+export function lastQualifierPart(symbol: string): string {
+  const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
+  const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
+  return parts[parts.length - 1] ?? symbol;
+}
+
+/**
+ * Check if a node matches a symbol query.
+ *
+ * Accepts simple names (`run`) and three flavors of qualifier:
+ *   - dotted     `Session.request`         (TS/JS/Python)
+ *   - colon-pair `stage_apply::run`        (Rust, C++, Ruby)
+ *   - slash      `configurator/stage_apply` (path-ish)
+ *
+ * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
+ * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
+ * the canonical `crate::module::symbol` form resolves.
+ *
+ * Resolution order, last part must always equal `node.name`:
+ *   1. Suffix-match against `qualifiedName` (handles class-scoped methods
+ *      where the extractor builds the qualified name from the AST stack)
+ *   2. File-path containment (handles file-derived modules in Rust/
+ *      Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
+ */
+export function matchesSymbol(node: Node, symbol: string): boolean {
+  // Erlang arity spelling (`fn/3`, `mod:fn/3` → normalized `mod.fn/3`): when
+  // the node's qualifiedName carries an arity (`mod::fn/3`, #1610), the
+  // written arity must match it exactly; the remaining comparison then runs
+  // on the arity-less spelling. A node with no arity in its qualifiedName
+  // keeps the original symbol (a `/` there means a path-ish name instead).
+  const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
+  if (aritySpelling) {
+    const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
+    if (nodeArity !== undefined) {
+      if (nodeArity !== aritySpelling[2]) return false;
+      symbol = aritySpelling[1]!;
+    }
+  }
+  // Simple name match
+  if (node.name === symbol) return true;
+  // File basename match (e.g., "product-card" matches "product-card.liquid")
+  if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
+
+  // Qualified-name lookups: split on any supported separator. `\w` keeps
+  // identifier chars (incl. `_`) intact; everything else is treated as
+  // a separator we tolerate.
+  if (!/[.\/]|::/.test(symbol)) return false;
+  const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
+  if (parts.length < 2) return false;
+
+  const lastPart = parts[parts.length - 1]!;
+  if (node.name !== lastPart) return false;
+
+  // Stage 1: qualified-name suffix match. The extractor joins the
+  // semantic hierarchy with `::`, so `Session.request` and
+  // `Session::request` both become `Session::request` here.
+  const colonSuffix = parts.join('::');
+  if (node.qualifiedName.includes(colonSuffix)) return true;
+
+  // Stage 2: file-path containment. Rust modules and Python packages
+  // are not in `qualifiedName` — they're encoded in the file path. So
+  // `stage_apply::run` matches a `run` in any file whose path
+  // contains a `stage_apply` segment (with or without an extension).
+  //
+  // Filter out Rust path prefixes that have no file-system equivalent.
+  const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
+  if (containerHints.length === 0) return false;
+
+  const segments = node.filePath.split('/').filter((s) => s.length > 0);
+  return containerHints.every((hint) =>
+    segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
+  );
+}
+
+/**
+ * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
+ * results across all matching symbols (e.g., multiple classes with an `execute` method).
+ */
+export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
+  // Nix option paths: the declaration is stored as `options.<path>` and
+  // config writes carry longer/quoted tails (`<path>."git/config".text`),
+  // so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
+  // no exact-name node and would degrade to bare-tail FTS soup — burying
+  // the declaration hub the nix-option-path edges hang off. Resolve the
+  // convention directly: declaration first, then the exact write, then a
+  // capped prefix scan of write sites. Three index hits; non-nix graphs
+  // fall straight through.
+  if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
+    const optionHits = [
+      ...cg.getNodesByName(`options.${symbol}`),
+      ...cg.getNodesByName(symbol),
+      ...cg.getNodesByNamePrefix(`${symbol}.`, 12),
+    ].filter((n) => n.language === 'nix');
+    if (optionHits.length > 0) {
+      const seen = new Set<string>();
+      const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
+      return { nodes, note: '' };
+    }
+  }
+  let results = cg.searchNodes(symbol, { limit: 50 });
+
+  // Mirror the fallback in `findSymbol` for qualified queries — FTS
+  // strips colons, so a module-qualified lookup needs a second pass
+  // by the bare last part.
+  if (results.length === 0 && /[.\/]|::/.test(symbol)) {
+    const tail = lastQualifierPart(symbol);
+    if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
+  }
+
+  if (results.length === 0) {
+    return { nodes: [], note: '' };
+  }
+
+  const exactMatches = results.filter(r => matchesSymbol(r.node, symbol));
+
+  if (exactMatches.length <= 1) {
+    const node = exactMatches[0]?.node ?? results[0]!.node;
+    return { nodes: [node], note: '' };
+  }
+
+  // Same generated-file down-rank as findSymbol — keeps callers/callees
+  // /impact aggregation aligned (a query against "Send" returns the
+  // hand-written implementations before the protobuf scaffold).
+  const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
+  const ranked = [...exactMatches].sort((a, b) => {
+    const aGen = isGen(a.node.filePath) ? 1 : 0;
+    const bGen = isGen(b.node.filePath) ? 1 : 0;
+    return aGen - bGen;
+  });
+
+  const locations = ranked.map(r =>
+    `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
+  );
+  const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
+  return { nodes: ranked.map(r => r.node), note };
+}
+
+/** Node kinds that can sit on a call chain. */
+export const FLOW_CALLABLE_KINDS: ReadonlySet<string> = new Set([
+  'method',
+  'function',
+  'component',
+  'constructor',
+  'route',
+]);
+
+/**
+ * Edge kinds a flow may ride. `navigates` is a screen transition (Expo Router
+ * `router.push('/x')` → the route node) — a hop in the user's flow exactly as
+ * a call is a hop in the program's.
+ */
+export const FLOW_EDGE_KINDS: ReadonlySet<string> = new Set(['calls', 'navigates']);
+
+/**
+ * Node kinds that can be an endpoint of a SYNTHESIZED edge without being
+ * callable. An RTK thunk is `const X = createAsyncThunk(...)`, so a thunk →
+ * thunk hop is constant → constant and the callable-only set cannot hold it.
+ */
+const DYN_KINDS: ReadonlySet<string> = new Set(['constant', 'variable', 'field', 'property']);
+
+/** Only a REAL file extension is stripped from a token — `Class.method` is kept. */
+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|astro|erl|hrl)$/i;
+
+/** Chain length ceiling, in NODES. Explore's Flow section has always used 7. */
+export const DEFAULT_MAX_HOPS = 7;
+
+/**
+ * Longer ceiling for a directed question.
+ *
+ * "How does X reach Y" is asked about two symbols that a reader believes are
+ * connected, and a real call path between a CLI entry point and a storage
+ * primitive runs deeper than seven frames. Explore's ceiling stays where it is:
+ * there, a longer chain is a bigger guess, because nothing pins the far end.
+ */
+export const DIRECTED_MAX_HOPS = 12;
+
+/** At most one consecutive UNNAMED hop may bridge two named symbols. */
+export const DEFAULT_MAX_BRIDGE = 1;
+
+/** Seeds a `named` search starts from, and candidates an ambiguous token keeps. */
+const MAX_SEEDS = 8;
+const MAX_CANDIDATES_PER_TOKEN = 6;
+
+/**
+ * Candidates a DIRECTED endpoint keeps, and the seeds it therefore walks from.
+ *
+ * Higher than the `named` cap, and the reason is a real failure: `main` has ten
+ * definitions in this repository — a Python asset script, a Rust build script,
+ * four `scripts/*.mjs` one-offs, a Go fixture — and the CLI's own `main`, the
+ * one anybody asking "how does main reach X" means, sorts SEVENTH. A cap of six
+ * silently answered "these two symbols are not connected". Both endpoints are
+ * pinned here, so an extra candidate costs one bounded walk that ends the
+ * moment it reaches the destination, and the pair that connects is the answer.
+ */
+const MAX_CANDIDATES_DIRECTED = 12;
+const MAX_TOKENS = 16;
+const MAX_NAMED = 40;
+
+export interface FlowStep {
+  node: Node;
+  /** The edge INTO this node from the previous step; null on the first. */
+  edge: Edge | null;
+}
+
+export interface FlowChain {
+  steps: FlowStep[];
+  /** For each node on the chain, the line where it calls the NEXT one. */
+  callSites: Map<string, number>;
+}
+
+export interface NamedSymbolFlowOptions {
+  /** `named` = explore's rules; `directed` = a pinned from → to question. */
+  mode?: 'named' | 'directed';
+  /** Required in `directed` mode: the token the path must start at. */
+  from?: string;
+  /** Required in `directed` mode: the token the path must end at. */
+  to?: string;
+  maxHops?: number;
+  /** Consecutive unnamed hops allowed. `Infinity` in directed mode. */
+  maxBridge?: number;
+  /** Distinct chains to return. Explore only ever looks at the first. */
+  maxChains?: number;
+}
+
+export interface NamedSymbolFlow {
+  /** The query's symbol tokens, in the order they were written. */
+  tokens: string[];
+  /** Every CALLABLE the tokens resolved to, by node id. */
+  named: Map<string, Node>;
+  /** Non-callable endpoints of synthesized edges (RTK thunks and friends). */
+  dynNamed: Map<string, Node>;
+  /** token → the node ids it resolved to. */
+  tokenNodes: Map<string, string[]>;
+  /** token → its whole same-name callable family, before the container filter. */
+  tokenFamily: Map<string, Node[]>;
+  /** Ids whose token was a (near-)unique callable name — at most 3 defs. */
+  uniqueNamedNodeIds: Set<string>;
+  /** Ids resolved from a shape-precise token (camelCase, dotted, PascalCase…). */
+  preciseNamedIds: Set<string>;
+  /** Chains found, best first. Empty when nothing connects. */
+  chains: FlowChain[];
+}
+
+const EMPTY_FLOW = (): NamedSymbolFlow => ({
+  tokens: [],
+  named: new Map(),
+  dynNamed: new Map(),
+  tokenNodes: new Map(),
+  tokenFamily: new Map(),
+  uniqueNamedNodeIds: new Set(),
+  preciseNamedIds: new Set(),
+  chains: [],
+});
+
+/**
+ * Production code before test and fixture code, otherwise the order the index
+ * ranked them in.
+ *
+ * Only used for a directed question, where the candidates are the two ends of
+ * "how does X reach Y" and a fixture's `main` is never what was meant. In
+ * `named` mode the agent's own co-naming does this job and re-ranking would
+ * change what `codegraph_explore` answers.
+ */
+function rankForDirected(nodes: readonly Node[]): Node[] {
+  return [...nodes].sort(
+    (a, b) => (isTestFile(a.filePath) ? 1 : 0) - (isTestFile(b.filePath) ? 1 : 0)
+  );
+}
+
+/**
+ * A token is shape-precise when it looks like a symbol reference rather than an
+ * English word that happened to exact-match a callable.
+ */
+function isPreciseToken(token: string): boolean {
+  return /[._$]|::|\//.test(token) || /[a-z][A-Z]/.test(token) || /^[A-Z]/.test(token);
+}
+
+/** The symbol-shaped tokens of a query, deduped and capped. */
+export function flowTokens(query: string): string[] {
+  return [
+    ...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, MAX_TOKENS);
+}
+
+/**
+ * Resolve a query's tokens to nodes, with the overload rules described in the
+ * module header. No graph traversal happens here.
+ */
+export function resolveNamedTokens(
+  cg: CodeGraph,
+  query: string,
+  opts: NamedSymbolFlowOptions = {}
+): NamedSymbolFlow {
+  const directed = opts.mode === 'directed';
+  const out = EMPTY_FLOW();
+  const tokens = flowTokens(query);
+  out.tokens = tokens;
+  if (tokens.length < 2) return out;
+
+  // Pool of name SEGMENTS (Class + method from every token), used to keep an
+  // ambiguous simple name only where its CONTAINER class is itself named.
+  const segPool = new Set<string>();
+  for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
+
+  const hasHeuristicEdge = (id: string): boolean =>
+    [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
+
+  for (const t of tokens) {
+    const hits = findAllSymbols(cg, t).nodes;
+    const cands = hits.filter((n) => FLOW_CALLABLE_KINDS.has(n.kind));
+    out.tokenFamily.set(t, cands);
+    // A qualified or otherwise-specific name (<=3 hits) keeps all of them.
+    const specific = cands.length <= 3;
+    // In directed mode every candidate is kept and the search decides: the pair
+    // of overloads that actually connects IS the disambiguation, and co-naming
+    // has nothing to work with when the whole query is two words.
+    const pick =
+      specific || directed
+        ? 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);
+          });
+    const kept = directed
+      ? rankForDirected(pick).slice(0, MAX_CANDIDATES_DIRECTED)
+      : pick.slice(0, MAX_CANDIDATES_PER_TOKEN);
+    out.tokenNodes.set(
+      t,
+      kept.map((n) => n.id)
+    );
+    const precise = isPreciseToken(t);
+    for (const n of kept) {
+      out.named.set(n.id, n);
+      if (specific) out.uniqueNamedNodeIds.add(n.id);
+      if (precise) out.preciseNamedIds.add(n.id);
+    }
+    // Same token, non-callable synthesized endpoints. Capped per token so one
+    // token's many endpoints cannot fill the pool before later tokens get a slot,
+    // and gated on an actual heuristic edge so plain constants never qualify.
+    if (out.dynNamed.size < 12) {
+      let tokenDyn = 0;
+      for (const n of hits) {
+        if (FLOW_CALLABLE_KINDS.has(n.kind) || !DYN_KINDS.has(n.kind) || out.dynNamed.has(n.id)) {
+          continue;
+        }
+        if (hasHeuristicEdge(n.id)) {
+          out.dynNamed.set(n.id, n);
+          if (precise) out.preciseNamedIds.add(n.id);
+          tokenDyn++;
+        }
+        if (out.dynNamed.size >= 12 || tokenDyn >= 4) break;
+      }
+    }
+    if (out.named.size > MAX_NAMED) break;
+  }
+  return out;
+}
+
+/** Where each node on a chain calls the next one. */
+function callSitesOf(steps: readonly FlowStep[]): Map<string, number> {
+  const sites = new Map<string, number>();
+  for (let i = 0; i < steps.length - 1; i++) {
+    const line = steps[i + 1]?.edge?.line;
+    const id = steps[i]?.node.id;
+    if (id && line && line > 0 && !sites.has(id)) sites.set(id, line);
+  }
+  return sites;
+}
+
+/**
+ * Nodes one side of a search may visit before it gives up.
+ *
+ * The `named` cap is explore's own, unchanged: with at most one unnamed bridge
+ * between named symbols the frontier cannot run away, so 1 500 is generous.
+ * A directed search bridges freely and needs far more room — but it spends it
+ * from two ends at once, so a side that blows past this has genuinely fanned
+ * out rather than merely gone deep.
+ */
+const NAMED_VISIT_CAP = 1500;
+const DIRECTED_VISIT_CAP = 12_000;
+
+/**
+ * Breadth-first over `calls` edges — synthesized ones included, which is what
+ * carries a flow across a callback, a re-render or a JSX child.
+ *
+ * This is the `named` walk: every named symbol is a possible destination, and
+ * at most `maxBridge` unnamed symbols may sit between two of them. That cap is
+ * what bounds the frontier, so {@link NAMED_VISIT_CAP} is generous.
+ *
+ * Returns the parent map, so a caller can reconstruct any reached node's path.
+ */
+function walkCalls(
+  cg: CodeGraph,
+  seed: Node,
+  named: ReadonlySet<string>,
+  maxHops: number,
+  maxBridge: number
+): { parent: Map<string, { prev: string | null; edge: Edge | null; node: Node }>; reached: string[] } {
+  const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
+  parent.set(seed.id, { prev: null, edge: null, node: seed });
+  const queue: Array<{ id: string; depth: number; streak: number }> = [
+    { id: seed.id, depth: 0, streak: 0 },
+  ];
+  const reached: string[] = [];
+  for (let head = 0; head < queue.length && parent.size < NAMED_VISIT_CAP; head++) {
+    const { id, depth, streak } = queue[head]!;
+    if (id !== seed.id && named.has(id)) reached.push(id);
+    if (depth >= maxHops - 1) continue;
+    for (const c of cg.getCallees(id)) {
+      if (!FLOW_EDGE_KINDS.has(c.edge.kind) || parent.has(c.node.id)) continue;
+      // A route node is a connector, not a symbol the reader would have named:
+      // crossing one costs no bridge budget.
+      const newStreak = named.has(c.node.id) ? 0 : c.node.kind === 'route' ? streak : streak + 1;
+      if (newStreak > maxBridge) continue;
+      parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
+      queue.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
+    }
+  }
+  return { parent, reached };
+}
+
+
+/**
+ * A short call path from `seed` to any of `sinks`, searched from BOTH ends.
+ *
+ * A directed question bridges freely — nothing in the middle is "named" to keep
+ * the frontier small — so a one-way walk from an entry point balloons: `main`
+ * on this repository touches hundreds of symbols within four hops of a
+ * twelve-hop budget. Coming in from both ends halves the depth each side has to
+ * cover, and the destination end is nearly always the cheap one: a leaf has a
+ * handful of callers where an entry point has an enormous fan-out.
+ *
+ * Measured against the one-way walk on twelve pairs from this repository's own
+ * index: **identical paths, 3–6× faster** (`main -> resolveOne` 40 ms → 11 ms,
+ * `main -> scanDynamicDispatch` 33 ms → 7 ms). The one-way search never
+ * actually exhausted its visit cap here, so the reachability headroom below is
+ * insurance for a graph much larger than this one, not a fix for a bug that was
+ * observed.
+ *
+ * It alternates a level at a time, always expanding the SMALLER frontier, and
+ * stops the moment the two sides share a node. Alternating levels this way can
+ * return a path one hop longer than the true shortest — which is why nothing in
+ * the payload claims to be shortest, only to be a path the graph records.
+ */
+function walkBidirectional(
+  cg: CodeGraph,
+  seed: Node,
+  sinks: ReadonlySet<string>,
+  maxHops: number
+): FlowStep[] | null {
+  if (sinks.has(seed.id)) return null;
+
+  const forward = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
+  /** id → the edge OUT of it towards the destination; null AT the destination. */
+  const backward = new Map<string, { next: string; edge: Edge } | null>();
+  const backNodes = new Map<string, Node>();
+
+  forward.set(seed.id, { prev: null, edge: null, node: seed });
+  let frontF: Node[] = [seed];
+  let frontB: Node[] = [];
+  for (const id of sinks) {
+    const node = cg.getNode(id);
+    if (!node) continue;
+    backward.set(id, null);
+    backNodes.set(id, node);
+    frontB.push(node);
+  }
+  if (frontB.length === 0) return null;
+
+  const meetAt = (): string | null => {
+    // The forward side is the one that is walked in full, so scanning it is the
+    // cheaper direction of the check.
+    for (const id of forward.keys()) if (backward.has(id)) return id;
+    return null;
+  };
+
+  const maxEdges = Math.max(1, maxHops - 1);
+  for (let laid = 0; laid < maxEdges; laid++) {
+    if (frontF.length <= frontB.length) {
+      if (forward.size > DIRECTED_VISIT_CAP) break;
+      const next: Node[] = [];
+      for (const node of frontF) {
+        for (const c of cg.getCallees(node.id)) {
+          if (!FLOW_EDGE_KINDS.has(c.edge.kind) || forward.has(c.node.id)) continue;
+          forward.set(c.node.id, { prev: node.id, edge: c.edge, node: c.node });
+          next.push(c.node);
+        }
+      }
+      if (next.length === 0) break;
+      frontF = next;
+    } else {
+      if (backward.size > DIRECTED_VISIT_CAP) break;
+      const next: Node[] = [];
+      for (const node of frontB) {
+        for (const c of cg.getCallers(node.id)) {
+          if (!FLOW_EDGE_KINDS.has(c.edge.kind) || backward.has(c.node.id)) continue;
+          backward.set(c.node.id, { next: node.id, edge: c.edge });
+          backNodes.set(c.node.id, c.node);
+          next.push(c.node);
+        }
+      }
+      if (next.length === 0) break;
+      frontB = next;
+    }
+
+    const meet = meetAt();
+    if (meet === null) continue;
+
+    // Forward half: seed → meet, walking the forward parents back.
+    const steps: FlowStep[] = [];
+    let cur: string | null = meet;
+    while (cur) {
+      const at = forward.get(cur);
+      if (!at) break;
+      steps.push({ node: at.node, edge: at.edge });
+      cur = at.prev;
+    }
+    steps.reverse();
+    // Backward half: meet → sink. An entry holds the edge OUT of its node, so
+    // it is the edge INTO the step after it, which is the shape a step wants.
+    let link = backward.get(meet);
+    while (link) {
+      const node = backNodes.get(link.next);
+      if (!node) break;
+      steps.push({ node, edge: link.edge });
+      link = backward.get(link.next);
+    }
+
+    const last = steps[steps.length - 1];
+    if (steps.length < 2 || !last || !sinks.has(last.node.id)) return null;
+    return steps.length <= maxHops ? steps : null;
+  }
+  return null;
+}
+
+function chainTo(
+  parent: Map<string, { prev: string | null; edge: Edge | null; node: Node }>,
+  target: string
+): FlowStep[] {
+  const steps: FlowStep[] = [];
+  let cur: string | null = target;
+  while (cur) {
+    const at = parent.get(cur);
+    if (!at) break;
+    steps.push({ node: at.node, edge: at.edge });
+    cur = at.prev;
+  }
+  steps.reverse();
+  return steps;
+}
+
+/**
+ * The call path among a query's named symbols. See the module header for what
+ * the two modes mean and why they differ.
+ */
+export function resolveNamedSymbolFlow(
+  cg: CodeGraph,
+  query: string,
+  opts: NamedSymbolFlowOptions = {}
+): NamedSymbolFlow {
+  try {
+    const directed = opts.mode === 'directed';
+    const flow = resolveNamedTokens(cg, query, opts);
+    if (flow.named.size < 2) return flow;
+
+    const maxHops = opts.maxHops ?? (directed ? DIRECTED_MAX_HOPS : DEFAULT_MAX_HOPS);
+    const maxBridge = opts.maxBridge ?? (directed ? Number.POSITIVE_INFINITY : DEFAULT_MAX_BRIDGE);
+    const maxChains = Math.max(1, opts.maxChains ?? 1);
+    const namedIds = new Set(flow.named.keys());
+
+    const found: FlowStep[][] = [];
+    if (directed) {
+      const fromIds = flow.tokenNodes.get(normalizeToken(opts.from ?? '')) ?? [];
+      const toIds = flow.tokenNodes.get(normalizeToken(opts.to ?? '')) ?? [];
+      if (fromIds.length === 0 || toIds.length === 0) return flow;
+      const sinks = new Set(toIds);
+      // Every candidate start is searched: each is a bounded two-ended walk that
+      // ends the moment the frontiers meet, and the start that actually connects
+      // IS the answer to which overload was meant.
+      for (const id of fromIds) {
+        const seed = flow.named.get(id);
+        if (!seed) continue;
+        const steps = walkBidirectional(cg, seed, sinks, maxHops);
+        if (steps) found.push(steps);
+      }
+    } else {
+      for (const seed of [...flow.named.values()].slice(0, MAX_SEEDS)) {
+        const { parent, reached } = walkCalls(cg, seed, namedIds, maxHops, maxBridge);
+        // Explore's rule: the DEEPEST named sink this seed can reach.
+        let deepest: FlowStep[] | null = null;
+        for (const id of reached) {
+          const steps = chainTo(parent, id);
+          if (!deepest || steps.length > deepest.length) deepest = steps;
+        }
+        if (deepest) found.push(deepest);
+      }
+    }
+
+    if (found.length === 0) return flow;
+    found.sort((a, b) => (directed ? a.length - b.length : b.length - a.length));
+
+    // Identical chains, and chains that are just a shorter run along one
+    // already kept, are the same answer twice: `a → b → c` and `b → c` differ
+    // only in where the seed happened to be. Alternatives are for genuinely
+    // different routes — a second overload, a different intermediate.
+    const kept: string[] = [];
+    for (const steps of found) {
+      const key = steps.map((s) => s.node.id).join('>');
+      if (kept.some((other) => other === key || other.includes(key))) continue;
+      kept.push(key);
+      flow.chains.push({ steps, callSites: callSitesOf(steps) });
+      if (flow.chains.length >= maxChains) break;
+    }
+    return flow;
+  } catch {
+    return EMPTY_FLOW();
+  }
+}
+
+/** The token spelling {@link flowTokens} would have produced for one word. */
+export function normalizeToken(token: string): string {
+  return token.replace(FILE_EXT, '').trim();
+}

+ 2 - 2
src/graph/traversal.ts

@@ -292,7 +292,7 @@ export class GraphTraverser {
     // caller of the class. Without it, `callers <Class>` surfaced only the
     // importing file (via `imports`) and missed every construction site —
     // the opposite of "what breaks if I change this class?" (#774).
-    const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates']);
+    const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates', 'navigates']);
     if (incomingEdges.length === 0) return;
 
     // Batch-fetch all caller nodes in one round-trip instead of one
@@ -347,7 +347,7 @@ export class GraphTraverser {
     // (`Foo(...)` / `new Foo()`) has that class as a callee, so callers and
     // callees stay inverses of each other and `trace` can cross the
     // instantiation boundary (function → class → its methods) (#774).
-    const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates']);
+    const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates', 'navigates']);
     if (outgoingEdges.length === 0) return;
 
     // Batch-fetch callee nodes (was N+1 — see getCallersRecursive note).

+ 482 - 0
src/graph/type-hierarchy.ts

@@ -0,0 +1,482 @@
+/**
+ * The type hierarchy — one derivation of "what is above this type, what is
+ * below it, and what a call through it can land on".
+ *
+ * Three surfaces ask that question. The viewer draws it as a tree above the
+ * members outline (design spec §3.10). `codegraph_explore` announces it as an
+ * interface-dispatch boundary ("`execute` → runtime dispatch to **611** types
+ * implementing `INodeType`"). `codegraph_node` shows the same relations as
+ * chips. Three derivations would eventually disagree about the ONE number that
+ * matters — how many implementations a call can reach — and a reader holding
+ * two of them has no way to tell which is lying. So the walk lives here once,
+ * and each caller renders it: `src/ui-server/api/node.ts` turns it into
+ * `WireHierarchy`, `ToolHandler.buildPolymorphicBoundaries` into prose.
+ *
+ * Everything here is query-time and read-only. No edge is invented: the tree is
+ * exactly the `extends`/`implements` edges the graph holds, and the one thing
+ * that is *derived* — which members override an ancestor's — is derived by name
+ * within a chain the graph already links, and is labelled as a match rather
+ * than as an `overrides` edge (nothing in the engine emits one).
+ *
+ * ## Why the fan is the interesting direction
+ *
+ * Ancestors are a fact about the code you are reading: `class X extends Y` is
+ * written on line 1. Descendants are a fact you cannot get from the file at
+ * all — the implementations of an interface live anywhere in the repo, and they
+ * are precisely what a call through that interface dispatches to. Go makes this
+ * sharpest: `System` and `Fixed` satisfy `Clock` without either file naming the
+ * other, and the `implements` edge that links them is synthesized by the
+ * resolver (`synthesizedBy: 'go-implements'`). So the fan carries its own
+ * provenance and the caller draws a synthesized hop differently — the same
+ * honesty rule the Flow strip's dashed connectors follow.
+ */
+
+import type CodeGraph from '../index';
+import type { Edge, EdgeKind, Node, NodeKind } from '../types';
+
+/** The two edge kinds that make a type hierarchy. Nothing else is a subtype. */
+export const HIERARCHY_EDGE_KINDS: readonly EdgeKind[] = ['extends', 'implements'];
+
+/**
+ * Kinds that can sit in a type hierarchy.
+ *
+ * `type_alias` is in deliberately — TypeScript's `interface A extends B` and
+ * Rust's associated types both land here, and an alias with subtypes is a real
+ * hierarchy however it was spelled. `enum` is in for Java/Kotlin/Swift, where an
+ * enum implements interfaces.
+ */
+export const HIERARCHY_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'class',
+  'interface',
+  'struct',
+  'trait',
+  'protocol',
+  'enum',
+  'type_alias',
+  'union',
+]);
+
+/** Member kinds an override can be declared on. */
+const OVERRIDABLE_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'method',
+  'function',
+  'property',
+  'field',
+]);
+
+/** Levels walked upward. A chain deeper than this is a generated-code artefact. */
+export const MAX_ANCESTOR_DEPTH = 8;
+
+/** Levels walked downward. Depth, not breadth — the fan itself is capped separately. */
+export const MAX_DESCENDANT_DEPTH = 6;
+
+/**
+ * Subtypes returned across the whole downward walk.
+ *
+ * A framework base class can have thousands, and the caller caps again for
+ * display; this bound is what stops the *query* from walking them. When it
+ * bites, {@link TypeHierarchy.bounded} says so — a fan that quietly stopped at
+ * 400 would read as a complete answer.
+ */
+export const MAX_DESCENDANTS = 400;
+
+/** Ancestors whose members are read when matching overrides. */
+const MAX_OVERRIDE_ANCESTORS = 12;
+
+/**
+ * Implementations at or above which a call through the type cannot be resolved
+ * statically at all — the same threshold `codegraph_explore` uses before it
+ * announces an interface-dispatch boundary.
+ */
+export const DISPATCH_MIN_IMPLEMENTERS = 8;
+
+// =============================================================================
+// Shapes
+// =============================================================================
+
+/** How a subtype is tied to the type above it. */
+export type HierarchyRelation = 'extends' | 'implements';
+
+/** One type in the tree, and the single edge that puts it there. */
+export interface HierarchyEntry {
+  node: Node;
+  /** Steps from the focus. 1 = declared directly on the focus (either way). */
+  depth: number;
+  /**
+   * The entry one step NEARER the focus — the row this one hangs off when the
+   * tree is drawn. The focus's own id for a depth-1 entry.
+   */
+  parentId: string;
+  relation: HierarchyRelation;
+  /** The edge itself, always oriented subtype → supertype as the code declares it. */
+  edge: Edge;
+  /**
+   * The edge was synthesized rather than parsed — Go's implicit interface
+   * satisfaction, a framework registry. Drawn dashed, with its wiring site.
+   */
+  synthesized: boolean;
+  /** Direct subtypes this entry has that are NOT in the returned set. */
+  hiddenSubtypes: number;
+}
+
+/** A member of the focus that redeclares a member of one of its ancestors. */
+export interface OverrideMatch {
+  /** The member on the focus. */
+  memberId: string;
+  /** The member it redeclares. */
+  baseId: string;
+  /** The ancestor type that declares {@link baseId}. */
+  baseTypeId: string;
+  baseTypeName: string;
+  /** How the focus reaches that ancestor — `implements` reads as "satisfies". */
+  relation: HierarchyRelation;
+}
+
+/** What is above a type, what is below it, and what a call through it reaches. */
+export interface TypeHierarchy {
+  focus: Node;
+  /** Supertypes, nearest first. Ordered so the focus's own parents lead. */
+  ancestors: HierarchyEntry[];
+  /** Subtypes, breadth-first, so depth 1 is complete before depth 2 begins. */
+  descendants: HierarchyEntry[];
+  /** True number of DIRECT subtypes, whatever `descendants` was capped to. */
+  directSubtypes: number;
+  /** Of {@link directSubtypes}, the ones tied by `implements`. */
+  directImplementers: number;
+  /**
+   * The downward walk hit {@link MAX_DESCENDANTS} or {@link MAX_DESCENDANT_DEPTH}
+   * — subtypes exist that are not in `descendants`.
+   */
+  bounded: boolean;
+  /**
+   * A call through this type dispatches at runtime rather than to one target.
+   * `directImplementers >= DISPATCH_MIN_IMPLEMENTERS`.
+   */
+  polymorphic: boolean;
+  /** Members of the focus that redeclare an ancestor's, keyed by member id. */
+  overrides: Map<string, OverrideMatch>;
+}
+
+// =============================================================================
+// The walk
+// =============================================================================
+
+/**
+ * Whether a node could have a hierarchy at all.
+ *
+ * Cheap enough to gate on before doing any work: a function never has one, and
+ * the overwhelming majority of symbols a reader opens are functions.
+ */
+export function canHaveHierarchy(node: Node): boolean {
+  return HIERARCHY_KINDS.has(node.kind);
+}
+
+/**
+ * The whole hierarchy of one type.
+ *
+ * Cost is one query per level in each direction plus one batched member read,
+ * never one per node — a base class with 400 subtypes is 2–3 queries, not 400.
+ *
+ * Returns `null` when the node cannot have a hierarchy or has no
+ * `extends`/`implements` edge in either direction, so a caller can gate on the
+ * return value rather than on the emptiness of three lists.
+ */
+export function buildTypeHierarchy(
+  cg: CodeGraph,
+  focus: Node,
+  options: { overrides?: boolean } = {}
+): TypeHierarchy | null {
+  if (!canHaveHierarchy(focus)) return null;
+
+  const ancestors = walkAncestors(cg, focus);
+  const down = walkDescendants(cg, focus);
+  if (ancestors.length === 0 && down.entries.length === 0) return null;
+
+  return {
+    focus,
+    ancestors,
+    descendants: down.entries,
+    directSubtypes: down.directTotal,
+    directImplementers: down.directImplementers,
+    bounded: down.bounded,
+    polymorphic: down.directImplementers >= DISPATCH_MIN_IMPLEMENTERS,
+    overrides: options.overrides === false ? new Map() : matchOverrides(cg, focus, ancestors),
+  };
+}
+
+/**
+ * Walk up. Multiple direct parents are normal (a class extends one and
+ * implements three), so this is a BFS rather than a chain, ordered nearest
+ * first and — within a level — `extends` before `implements`, because the one
+ * that carries the implementation is the one a reader wants adjacent.
+ */
+function walkAncestors(cg: CodeGraph, focus: Node): HierarchyEntry[] {
+  const out: HierarchyEntry[] = [];
+  const seen = new Set<string>([focus.id]);
+  let frontier = [focus.id];
+
+  for (let depth = 1; depth <= MAX_ANCESTOR_DEPTH && frontier.length > 0; depth++) {
+    const edges = hierarchyEdges(cg, frontier, 'up');
+    if (edges.length === 0) break;
+    const nodes = cg.getNodesByIds(edges.map((e) => e.target));
+
+    const level: HierarchyEntry[] = [];
+    for (const edge of edges) {
+      const node = nodes.get(edge.target);
+      if (!node || seen.has(node.id)) continue;
+      seen.add(node.id);
+      level.push(toEntry(node, depth, edge.source, edge));
+    }
+    sortLevel(level);
+    out.push(...level);
+    frontier = level.map((e) => e.node.id);
+  }
+
+  return out;
+}
+
+/**
+ * Walk down — the fan. Breadth-first so the cap always trims the deepest,
+ * least-relevant end: a reader looking at an interface wants its direct
+ * implementations complete before a subclass of a subclass appears at all.
+ */
+function walkDescendants(cg: CodeGraph, focus: Node): {
+  entries: HierarchyEntry[];
+  directTotal: number;
+  directImplementers: number;
+  bounded: boolean;
+} {
+  const entries: HierarchyEntry[] = [];
+  const byId = new Map<string, HierarchyEntry>();
+  const seen = new Set<string>([focus.id]);
+  let frontier = [focus.id];
+  let directTotal = 0;
+  let directImplementers = 0;
+  let bounded = false;
+
+  for (let depth = 1; depth <= MAX_DESCENDANT_DEPTH && frontier.length > 0; depth++) {
+    const edges = hierarchyEdges(cg, frontier, 'down');
+    if (edges.length === 0) break;
+    const nodes = cg.getNodesByIds(edges.map((e) => e.source));
+
+    // One row per subtype, not per edge: a class tied to its supertype by both
+    // a parsed `extends` and a synthesized `implements` is ONE implementation.
+    // `extends` wins the relation because it is the one written in the file.
+    const level: HierarchyEntry[] = [];
+    const overflow = new Map<string, number>();
+    const levelSeen = new Set<string>();
+    for (const edge of edges) {
+      const node = nodes.get(edge.source);
+      if (!node || seen.has(node.id)) continue;
+      const existing = levelSeen.has(node.id)
+        ? level.find((e) => e.node.id === node.id)
+        : undefined;
+      if (existing) {
+        if (existing.relation === 'implements' && edge.kind === 'extends') {
+          existing.relation = 'extends';
+          existing.edge = edge;
+          existing.synthesized = edge.provenance === 'heuristic';
+        }
+        continue;
+      }
+      if (depth === 1) {
+        directTotal++;
+        if (edge.kind === 'implements') directImplementers++;
+      }
+      if (entries.length + level.length >= MAX_DESCENDANTS) {
+        // Stop materialising rows, but keep counting depth 1 so
+        // `directSubtypes` stays the true number.
+        bounded = true;
+        overflow.set(edge.target, (overflow.get(edge.target) ?? 0) + 1);
+        levelSeen.add(node.id);
+        continue;
+      }
+      levelSeen.add(node.id);
+      level.push(toEntry(node, depth, edge.target, edge));
+    }
+    for (const entry of level) seen.add(entry.node.id);
+    sortLevel(level);
+    for (const entry of level) {
+      entries.push(entry);
+      byId.set(entry.node.id, entry);
+    }
+    for (const [parentId, count] of overflow) {
+      const parent = byId.get(parentId);
+      if (parent) parent.hiddenSubtypes += count;
+    }
+    if (bounded) break;
+
+    frontier = level.map((e) => e.node.id);
+    if (depth === MAX_DESCENDANT_DEPTH && frontier.length > 0) {
+      // A level exists below the one we are about to stop at. Say so rather
+      // than letting the deepest row read as a leaf.
+      for (const edge of hierarchyEdges(cg, frontier, 'down')) {
+        if (seen.has(edge.source)) continue;
+        bounded = true;
+        const parent = byId.get(edge.target);
+        if (parent) parent.hiddenSubtypes++;
+      }
+    }
+  }
+
+  return { entries, directTotal, directImplementers, bounded };
+}
+
+/** One batched edge read per level, filtered to the two hierarchy kinds. */
+function hierarchyEdges(cg: CodeGraph, ids: readonly string[], direction: 'up' | 'down'): Edge[] {
+  const kinds = [...HIERARCHY_EDGE_KINDS];
+  try {
+    const edges =
+      direction === 'up'
+        ? cg.getOutgoingEdgesFrom(ids, kinds)
+        : cg.getIncomingEdgesTo(ids, kinds);
+    // Belt and braces: the kind filter is applied in SQL, but a caller reading
+    // `entry.relation` must never see a third value.
+    return edges.filter((e) => e.kind === 'extends' || e.kind === 'implements');
+  } catch {
+    return [];
+  }
+}
+
+function toEntry(node: Node, depth: number, parentId: string, edge: Edge): HierarchyEntry {
+  return {
+    node,
+    depth,
+    parentId,
+    relation: edge.kind === 'implements' ? 'implements' : 'extends',
+    edge,
+    synthesized: edge.provenance === 'heuristic',
+    hiddenSubtypes: 0,
+  };
+}
+
+/**
+ * Deterministic order within one level: `extends` first, then by name, then by
+ * file. Never by insertion — two runs against the same index must draw the same
+ * tree, and SQLite's row order is not a promise.
+ */
+function sortLevel(level: HierarchyEntry[]): void {
+  level.sort(
+    (a, b) =>
+      (a.relation === b.relation ? 0 : a.relation === 'extends' ? -1 : 1) ||
+      a.node.name.localeCompare(b.node.name) ||
+      a.node.filePath.localeCompare(b.node.filePath) ||
+      a.node.startLine - b.node.startLine
+  );
+}
+
+// =============================================================================
+// Overrides
+// =============================================================================
+
+/**
+ * Which of the focus's members redeclare an ancestor's.
+ *
+ * Nothing in the engine emits an `overrides` edge (the kind exists in the
+ * schema and no extractor writes one), so this is a NAME match — but a name
+ * match inside a chain the graph already established, which is exactly what
+ * every language's dispatch rule is. It is reported as a match against a named
+ * base member the reader can open, never as an edge, and it is deliberately
+ * blind to signatures: an overload set would need type resolution the graph
+ * does not have, and claiming "overrides" for the wrong overload is worse than
+ * saying which type also declares this name.
+ *
+ * Two batched queries total, whatever the ancestor count.
+ */
+function matchOverrides(
+  cg: CodeGraph,
+  focus: Node,
+  ancestors: readonly HierarchyEntry[]
+): Map<string, OverrideMatch> {
+  const result = new Map<string, OverrideMatch>();
+  if (ancestors.length === 0) return result;
+
+  const ownMembers = membersOf(cg, [focus.id]);
+  if (ownMembers.length === 0) return result;
+
+  // Nearest ancestors win: a method redeclared two levels up is still reported
+  // against the type the reader would actually look in.
+  const chain = ancestors.slice(0, MAX_OVERRIDE_ANCESTORS);
+  const baseMembers = membersOf(
+    cg,
+    chain.map((a) => a.node.id)
+  );
+  if (baseMembers.length === 0) return result;
+
+  const ancestorById = new Map(chain.map((a) => [a.node.id, a] as const));
+  const byName = new Map<string, { member: Node; ownerId: string }>();
+  // `chain` is nearest-first and `membersOf` preserves the order of the ids it
+  // was given, so the first entry for a name is the nearest declaration.
+  for (const { member, ownerId } of baseMembers) {
+    if (!byName.has(member.name)) byName.set(member.name, { member, ownerId });
+  }
+
+  for (const { member } of ownMembers) {
+    if (!OVERRIDABLE_KINDS.has(member.kind)) continue;
+    const base = byName.get(member.name);
+    if (!base || base.member.id === member.id) continue;
+    const owner = ancestorById.get(base.ownerId);
+    if (!owner) continue;
+    result.set(member.id, {
+      memberId: member.id,
+      baseId: base.member.id,
+      baseTypeId: owner.node.id,
+      baseTypeName: owner.node.name,
+      relation: owner.relation,
+    });
+  }
+
+  return result;
+}
+
+/** Direct `contains` children of the given containers, in the containers' order. */
+function membersOf(
+  cg: CodeGraph,
+  containerIds: readonly string[]
+): Array<{ member: Node; ownerId: string }> {
+  if (containerIds.length === 0) return [];
+  let edges: Edge[];
+  try {
+    edges = cg.getOutgoingEdgesFrom(containerIds, ['contains']);
+  } catch {
+    return [];
+  }
+  if (edges.length === 0) return [];
+  const nodes = cg.getNodesByIds(edges.map((e) => e.target));
+
+  const rank = new Map(containerIds.map((id, i) => [id, i] as const));
+  const out: Array<{ member: Node; ownerId: string }> = [];
+  for (const edge of edges) {
+    const member = nodes.get(edge.target);
+    if (member) out.push({ member, ownerId: edge.source });
+  }
+  out.sort(
+    (a, b) =>
+      (rank.get(a.ownerId) ?? 0) - (rank.get(b.ownerId) ?? 0) ||
+      a.member.startLine - b.member.startLine
+  );
+  return out;
+}
+
+// =============================================================================
+// The fan, on its own
+// =============================================================================
+
+/**
+ * How many distinct types extend or implement this one — the number
+ * `codegraph_explore` prints when it announces an interface dispatch and the
+ * number the viewer's fan draws.
+ *
+ * DISTINCT types, not edges: a class tied to a supertype by both an `extends`
+ * and a synthesized `implements` edge is one implementation, and a count that
+ * disagrees with the length of the list beside it is the bug this function
+ * exists to prevent.
+ */
+export function countImplementers(cg: CodeGraph, typeId: string): number {
+  try {
+    const edges = cg.getIncomingEdgesTo([typeId], [...HIERARCHY_EDGE_KINDS]);
+    return new Set(edges.map((e) => e.source)).size;
+  } catch {
+    return 0;
+  }
+}

+ 258 - 2
src/index.ts

@@ -23,6 +23,7 @@ import {
   TaskContext,
   BuildContextOptions,
   FindRelevantContextOptions,
+  UnresolvedReference,
 } from './types';
 import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
 import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve';
@@ -1153,6 +1154,43 @@ export class CodeGraph {
     return this.queries.getLastIndexedAt();
   }
 
+  /**
+   * How far the last sync got and how many files it left behind — the cheapest
+   * marker of "has this index moved". One query; safe to call on every
+   * filesystem event a live viewer sees.
+   */
+  getIndexRevision(): { lastIndexedAt: number | null; fileCount: number } {
+    return this.queries.getIndexRevision();
+  }
+
+  /**
+   * Files re-indexed strictly after `since`, newest first — what a sync just
+   * picked up. `total` is the real count, `paths` is capped at `limit`.
+   */
+  getFilesIndexedSince(since: number, limit: number): { paths: string[]; total: number } {
+    return this.queries.getFilesIndexedSince(since, limit);
+  }
+
+  /**
+   * Forget everything held in memory about rows another process may have
+   * changed.
+   *
+   * The query layer keeps an LRU of nodes by id, invalidated by writes made
+   * through THIS instance — which is exactly right for a process that owns the
+   * index, and wrong for one that is only reading a database somebody else is
+   * writing. A long-lived reader (the `codegraph ui` server, a daemon holding a
+   * graph open across an agent's edits) will otherwise answer `getNode(id)`
+   * with a row a sync deleted minutes ago, while every SQL-backed query beside
+   * it reports the truth — a disagreement that reads as a bug in whichever
+   * screen shows both.
+   *
+   * Cheap (clearing a bounded Map) and safe to call whenever the database file
+   * looks like it moved.
+   */
+  dropReadCaches(): void {
+    this.queries.clearCache();
+  }
+
   /**
    * Completeness of the last full index run. `'complete'` is the only good
    * state. `'indexing'` after the fact means a run was killed mid-index (OOM,
@@ -1323,6 +1361,205 @@ export class CodeGraph {
     return this.queries.getNodeById(id);
   }
 
+  /**
+   * Get many nodes by id in ONE round-trip (LRU-cache aware).
+   *
+   * The batch form of {@link getNode}. Anything resolving a list of edges to
+   * their endpoints — a caller list, a callee rail, an impact set — must use
+   * this rather than a `getNode` per edge: a symbol with 500 callers is 500
+   * queries otherwise. Ids that name nothing are simply absent from the map.
+   */
+  getNodesByIds(ids: readonly string[]): Map<string, Node> {
+    return this.queries.getNodesByIds(ids);
+  }
+
+  /**
+   * Every symbol carrying an exact qualified name.
+   *
+   * The identity that survives a re-index. A node's id contains its start line,
+   * so any edit ABOVE a symbol gives it a different id — anything that has to
+   * name the same symbol across two indexes (a saved trail, a bookmark, a
+   * review comment) has to key on this instead, and then disambiguate the
+   * result by kind and file. Index-backed; unlike
+   * {@link GraphQueryManager.findByQualifiedName} it takes no pattern and scans
+   * nothing.
+   */
+  getNodesByQualifiedName(qualifiedName: string): Node[] {
+    return this.queries.getNodesByQualifiedNameExact(qualifiedName);
+  }
+
+  /**
+   * Outgoing edges for many source nodes at once — the batch form of
+   * {@link getOutgoingEdges}. See {@link QueryBuilder.getOutgoingEdgesFrom}.
+   */
+  getOutgoingEdgesFrom(nodeIds: readonly string[], kinds?: Edge['kind'][]): Edge[] {
+    return this.queries.getOutgoingEdgesFrom(nodeIds, kinds);
+  }
+
+  /**
+   * Fan-in (incoming edge count) for many nodes at once — the "hub" signal,
+   * without a query per node. See {@link QueryBuilder.countIncomingEdges}.
+   */
+  getFanIn(ids: readonly string[]): Map<string, number> {
+    return this.queries.countIncomingEdges(ids);
+  }
+
+  /**
+   * Incoming edges for many target nodes at once — the mirror of
+   * {@link getOutgoingEdgesFrom}. See {@link QueryBuilder.getIncomingEdgesTo}.
+   */
+  getIncomingEdgesTo(nodeIds: readonly string[], kinds?: Edge['kind'][]): Edge[] {
+    return this.queries.getIncomingEdgesTo(nodeIds, kinds);
+  }
+
+  /**
+   * Fan-out (outgoing edge count) for many nodes at once — the mirror of
+   * {@link getFanIn}. See {@link QueryBuilder.countOutgoingEdges}.
+   */
+  getFanOut(ids: readonly string[]): Map<string, number> {
+    return this.queries.countOutgoingEdges(ids);
+  }
+
+  /**
+   * Symbols nothing in the index points at, by kind — the candidate set the
+   * dead code report (`src/graph/dead-code.ts`) applies its exclusions to.
+   *
+   * Every edge kind except `contains` counts as a reference, so a method is
+   * not "reached" by the class that holds it. An unreferenced symbol is not
+   * yet a dead one: see {@link buildDeadCodeReport}.
+   */
+  getUnreferencedNodes(
+    kinds: readonly Node['kind'][],
+    limit: number
+  ): Array<{ node: Node; generated: boolean }> {
+    return this.queries.getUnreferencedNodes(kinds, limit);
+  }
+
+  /**
+   * Which of the given names are carried by more than one symbol, at least one
+   * of which something references — the names a "nothing reaches this" claim
+   * must not be made about, because the resolver may have picked the twin.
+   */
+  getAmbiguousReferencedNames(names: Iterable<string>): Set<string> {
+    return this.queries.getAmbiguousReferencedNames(names);
+  }
+
+  /**
+   * Which of the given languages this index records an export marker for. A
+   * language with none has no "reachable from outside" signal at all.
+   */
+  getLanguagesWithExports(languages: Iterable<string>): Set<string> {
+    return this.queries.getLanguagesWithExports(languages);
+  }
+
+  /**
+   * Which of the given names the index holds an unresolved reference to — the
+   * resolver saw the name and could not decide what it meant. A symbol with
+   * such a name can never be called unreferenced.
+   */
+  getUnresolvedNamesAmong(names: Iterable<string>): Set<string> {
+    return this.queries.getUnresolvedNamesAmong(names);
+  }
+
+  /**
+   * The symbols with the most distinct dependents, most first — the index's
+   * hubs. Distinct dependents, not edges: a helper called forty times from one
+   * function has one dependent, and it is dependents a blast radius grows from.
+   */
+  getTopDependedOn(limit: number): Array<{ nodeId: string; dependents: number }> {
+    return this.queries.getTopDependedOn(limit);
+  }
+
+  /**
+   * The graph's executable roots — files that run something at module level (a
+   * CLI, a worker entry, a script), ranked by calls x the number of other files
+   * they reach. A statement at the top level of a file is recorded as an edge
+   * out of the *file* node, which is what makes these visible at all.
+   */
+  getTopCallingFiles(
+    limit: number
+  ): Array<{ nodeId: string; filePath: string; calls: number; reaches: number; score: number }> {
+    return this.queries.getTopCallingFiles(limit);
+  }
+
+  /**
+   * How many other files depend on each of the given files, counted through
+   * their symbols (an `imports` edge points at the symbol, not the file).
+   * A zero means nothing else in the index reaches into that file.
+   */
+  getFileDependentCounts(filePaths: string[]): Map<string, number> {
+    return new Map(
+      this.queries.getFileDependentCounts(filePaths).map((row) => [row.filePath, row.dependents])
+    );
+  }
+
+  /**
+   * How far each of the given files reaches out: distinct other files their
+   * symbols touch, and how many references that is. The mirror of
+   * {@link getFileDependentCounts}; a test file's reach is what it exercises.
+   */
+  getFileReachCounts(filePaths: string[]): Map<string, { reaches: number; refs: number }> {
+    return new Map(
+      this.queries
+        .getFileReachCounts(filePaths)
+        .map((row) => [row.filePath, { reaches: row.reaches, refs: row.refs }])
+    );
+  }
+
+  /** The `file` nodes for the given paths, in one query. */
+  getFileNodes(filePaths: string[]): Node[] {
+    return this.queries.getFileNodes(filePaths);
+  }
+
+  /**
+   * Roll the edge table up to module granularity, for a file → module
+   * assignment the caller decides.
+   *
+   * The architecture map's single query: cross-module edge counts by kind,
+   * the `declared` subset of each (see {@link QueryBuilder.aggregateModuleGraph}),
+   * and the busiest symbol pairs behind each link. Read-only, and bounded by
+   * the number of modules rather than the number of edges.
+   */
+  getModuleAggregation(
+    assignments: ReadonlyArray<{ filePath: string; module: string }>,
+    options: {
+      kinds: readonly Edge['kind'][];
+      minConfidence: number;
+      topPairsPerLink: number;
+      pairKinds: readonly Edge['kind'][];
+    }
+  ): ReturnType<QueryBuilder['aggregateModuleGraph']> {
+    return this.queries.aggregateModuleGraph(assignments, options);
+  }
+
+  /**
+   * Every ordered pair of files where one reaches into the other — the edge
+   * list a cycle finder runs on. See {@link QueryBuilder.getCrossFileDependencyPairs}.
+   */
+  getFileDependencyPairs(minConfidence = 0): Array<{ source: string; target: string }> {
+    return this.queries.getCrossFileDependencyPairs(minConfidence);
+  }
+
+  /**
+   * References from a symbol that never resolved to an indexed node — the
+   * calls and type mentions that leave the index. Lets a reader account for
+   * the call sites that have no callee row instead of implying there are none.
+   */
+  getUnresolvedReferencesFrom(nodeId: string): UnresolvedReference[] {
+    return this.queries.getUnresolvedReferencesFrom(nodeId);
+  }
+
+  /**
+   * The same, for every symbol in a FILE at once, in line order.
+   *
+   * One indexed lookup instead of one per symbol — the whole-file reader needs
+   * it for every line it draws. See
+   * {@link QueryBuilder.getUnresolvedReferencesInFile}.
+   */
+  getUnresolvedReferencesInFile(filePath: string, limit?: number): UnresolvedReference[] {
+    return this.queries.getUnresolvedReferencesInFile(filePath, limit);
+  }
+
   /**
    * Get all nodes in a file
    */
@@ -1562,7 +1799,16 @@ export class CodeGraph {
    * null when fewer than 3 valid (non-test) routes exist.
    */
   getRoutingManifest(limit?: number): {
-    entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>;
+    entries: Array<{
+      url: string;
+      handler: string;
+      handlerFile: string;
+      handlerLine: number;
+      handlerKind: string;
+      routeId: string;
+      routeFile: string;
+      routeLine: number;
+    }>;
     topHandlerFile: string | null;
     topHandlerFileCount: number;
     totalRoutes: number;
@@ -1810,7 +2056,17 @@ export class CodeGraph {
   }
 
   /**
-   * Find dead code (unreferenced symbols)
+   * Find unreferenced symbols — the RAW candidate set.
+   *
+   * Non-exported symbols of the given kinds with no incoming edge but
+   * `contains`. That is a fact, not a claim: on this engine's own index it
+   * returns ~2 500 symbols, of which about 20 are actually unreachable. The
+   * rest are overrides, framework registrations, mis-resolved twins and
+   * references the extractor never recorded.
+   *
+   * For a list anybody should act on, use `buildDeadCodeReport` from
+   * `src/graph/dead-code.ts`, which applies the exclusions and counts every one
+   * of them. This method is kept as-is because it is a published API.
    *
    * @param kinds - Node kinds to check (default: functions, methods, classes)
    * @returns Array of unreferenced nodes

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor