Bladeren bron

Merge codegraph ui — the browser viewer (CG-39, CG-48, CG-56)

Three epics, 20 tasks, landing as one subsystem.

CG-39 (phase 1, the reader): loopback-only read-only server behind
`codegraph ui`, a read-only JSON API over the index, the Symbol view
(callers | gutter-ported source | line-anchored callee rail), the search
palette and trail, and the File view.

CG-48 (phase 2, the map and the flow): the module-granularity Map, the
Flow strip over one shared path finder, the "where the graph stops" end
cap, the whole-file source view with intra-file call arcs, live refresh
over SSE, the entry-points panel, and SVG/PNG export.

CG-56 (phase 3, depth and a library): syntax classification taken off the
engine's own tree-sitter parse (retiring Shiki and its 56 bundled
grammars), the type hierarchy, dead code and islands, saved trails, and
ui/ packaged as @colbymchenry/codegraph-ui.

Two derivations were lifted out of ToolHandler into src/graph/ so the
viewer and codegraph_explore can never draw different answers from the
same graph: named-symbol-flow.ts and dynamic-boundary-report.ts.

Saved trails are the viewer's only write. The loopback boundary gained a
write shape (POST/DELETE under /api/ carrying x-codegraph-ui, no CORS
headers ever) rather than being widened; `--read-only` turns it off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 week geleden
bovenliggende
commit
ac9580544b
100 gewijzigde bestanden met toevoegingen van 27059 en 379 verwijderingen
  1. 3 0
      .gitignore
  2. 77 0
      CHANGELOG.md
  3. 27 2
      CLAUDE.md
  4. 60 0
      README.md
  5. 12 0
      TELEMETRY.md
  6. 358 0
      __tests__/cli-ui-command.test.ts
  7. 485 0
      __tests__/dead-code.test.ts
  8. 622 0
      __tests__/type-hierarchy.test.ts
  9. 342 0
      __tests__/ui-entry-model.test.ts
  10. 393 0
      __tests__/ui-entrypoints-api.test.ts
  11. 479 0
      __tests__/ui-events-api.test.ts
  12. 531 0
      __tests__/ui-export-svg.test.ts
  13. 304 0
      __tests__/ui-file-model.test.ts
  14. 303 0
      __tests__/ui-filecode-api.test.ts
  15. 403 0
      __tests__/ui-filecode-model.test.ts
  16. 618 0
      __tests__/ui-flow-api.test.ts
  17. 489 0
      __tests__/ui-flow-model.test.ts
  18. 458 0
      __tests__/ui-highlight.test.ts
  19. 446 0
      __tests__/ui-map-api.test.ts
  20. 401 0
      __tests__/ui-map-model.test.ts
  21. 746 0
      __tests__/ui-package.test.ts
  22. 414 0
      __tests__/ui-search-model.test.ts
  23. 1216 0
      __tests__/ui-server-api.test.ts
  24. 566 0
      __tests__/ui-server.test.ts
  25. 577 0
      __tests__/ui-symbol-model.test.ts
  26. 179 0
      __tests__/ui-trails-model.test.ts
  27. 562 0
      __tests__/ui-trails.test.ts
  28. BIN
      assets/codegraph-ui-symbol-view.png
  29. 95 0
      docs/design/cg57-highlighting-parity.md
  30. BIN
      docs/design/cg57-highlighting-parity/csharp.png
  31. BIN
      docs/design/cg57-highlighting-parity/go.png
  32. BIN
      docs/design/cg57-highlighting-parity/php.png
  33. BIN
      docs/design/cg57-highlighting-parity/python.png
  34. BIN
      docs/design/cg57-highlighting-parity/ruby.png
  35. BIN
      docs/design/cg57-highlighting-parity/rust.png
  36. BIN
      docs/design/cg57-highlighting-parity/swift.png
  37. BIN
      docs/design/cg57-highlighting-parity/typescript.png
  38. 783 0
      docs/design/codegraph-ui-design-spec.md
  39. 840 3
      package-lock.json
  40. 8 1
      package.json
  41. 9 0
      scripts/build-bundle.sh
  42. 157 0
      scripts/check-ui-build.mjs
  43. 192 0
      scripts/check-ui-package.mjs
  44. 30 0
      scripts/pack-npm.sh
  45. 47 0
      scripts/sync-ui-version.mjs
  46. 1 0
      site/astro.config.mjs
  47. 1 0
      site/src/content/docs/getting-started/next-steps.md
  48. 156 0
      site/src/content/docs/guides/viewer.md
  49. 17 0
      site/src/content/docs/reference/cli.md
  50. 191 0
      src/bin/codegraph.ts
  51. 821 2
      src/db/queries.ts
  52. 14 0
      src/errors.ts
  53. 13 0
      src/extraction/grammars.ts
  54. 465 0
      src/extraction/syntax-tokens.ts
  55. 886 0
      src/graph/dead-code.ts
  56. 359 0
      src/graph/dynamic-boundary-report.ts
  57. 29 0
      src/graph/index.ts
  58. 672 0
      src/graph/named-symbol-flow.ts
  59. 482 0
      src/graph/type-hierarchy.ts
  60. 258 2
      src/index.ts
  61. 69 365
      src/mcp/tools.ts
  62. 22 4
      src/search/query-utils.ts
  63. 221 0
      src/ui-server/api/deadcode.ts
  64. 342 0
      src/ui-server/api/entrypoints.ts
  65. 480 0
      src/ui-server/api/events.ts
  66. 289 0
      src/ui-server/api/file.ts
  67. 297 0
      src/ui-server/api/filecode.ts
  68. 866 0
      src/ui-server/api/flow.ts
  69. 145 0
      src/ui-server/api/hierarchy.ts
  70. 404 0
      src/ui-server/api/index.ts
  71. 600 0
      src/ui-server/api/map.ts
  72. 493 0
      src/ui-server/api/node.ts
  73. 54 0
      src/ui-server/api/nodes.ts
  74. 207 0
      src/ui-server/api/respond.ts
  75. 151 0
      src/ui-server/api/routes.ts
  76. 237 0
      src/ui-server/api/search.ts
  77. 190 0
      src/ui-server/api/session.ts
  78. 468 0
      src/ui-server/api/source.ts
  79. 149 0
      src/ui-server/api/stats.ts
  80. 332 0
      src/ui-server/api/trail-store.ts
  81. 477 0
      src/ui-server/api/trails.ts
  82. 344 0
      src/ui-server/api/wire.ts
  83. 76 0
      src/ui-server/assets.ts
  84. 36 0
      src/ui-server/constants.ts
  85. 357 0
      src/ui-server/highlight/index.ts
  86. 47 0
      src/ui-server/highlight/languages.ts
  87. 473 0
      src/ui-server/index.ts
  88. 82 0
      src/ui-server/open-browser.ts
  89. 298 0
      src/ui-server/security.ts
  90. 138 0
      src/ui-server/static.ts
  91. 334 0
      ui/README.md
  92. 15 0
      ui/index.html
  93. 60 0
      ui/package.json
  94. 184 0
      ui/src/App.svelte
  95. 129 0
      ui/src/app.css
  96. 80 0
      ui/src/components/CodegraphUi.svelte
  97. 76 0
      ui/src/components/DriftBanner.svelte
  98. 101 0
      ui/src/components/ExportButtons.svelte
  99. 57 0
      ui/src/components/KindGlyph.svelte
  100. 82 0
      ui/src/components/PalettePanel.svelte

+ 3 - 0
.gitignore

@@ -4,6 +4,9 @@ node_modules/
 # Build output
 dist/
 
+# svelte-package's scratch dir (ui/ library build)
+.svelte-kit/
+
 .cmem
 
 # IDE

+ 77 - 0
CHANGELOG.md

@@ -12,6 +12,83 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
+### New Features
+
+- **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
 

+ 27 - 2
CLAUDE.md

@@ -11,7 +11,8 @@ Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer
 ## Build, Test, Run
 
 ```bash
-npm run build           # tsc + copy schema.sql and *.wasm into dist/; chmods dist/bin/codegraph.js
+npm run build           # tsc + copy schema.sql and *.wasm + build the viewer into dist/; chmods dist/bin/codegraph.js
+npm run build:lib       # the viewer's components as @colbymchenry/codegraph-ui (ui/dist) — NOT part of `build`
 npm run dev             # tsc --watch
 npm run clean           # rm -rf dist
 
@@ -29,6 +30,29 @@ npx vitest run __tests__/extraction.test.ts -t "TypeScript"
 
 `copy-assets` (called from `build`) copies `src/db/schema.sql` and all `src/extraction/wasm/*.wasm` files into `dist/`. **Any new SQL or grammar wasm must be copied or it won't ship.**
 
+One other build step writes into `dist/` and is subject to the same rule: `build:ui` builds the
+browser viewer into `dist/viewer/` (never `dist/ui/` — that's the terminal ui).
+`scripts/check-ui-build.mjs` asserts both `dist/viewer/` and the copied grammars in
+`dist/extraction/wasm/` after every build and inside every release archive — the viewer's syntax
+highlighting reads a file with the same grammar the engine indexed it with, so a missing wasm is an
+unhighlighted screen as well as an extraction gap.
+
+`npm run build:lib` is separate and does NOT run as part of `npm run build`: it compiles the same
+`ui/src` tree a second way, with `svelte-package`, into `ui/dist` — the `@colbymchenry/codegraph-ui`
+component library the Pro app imports (task CG-61). `scripts/check-ui-package.mjs` then prunes the
+standalone app's shell out of it, resolves the extensionless import specifiers `svelte-package`
+leaves behind, and asserts the seam: nothing outside `lib/adapter.js` may reach the network. The
+package is **prepared, not published** — `ui/package.json` carries `"private": true` deliberately,
+and `scripts/pack-npm.sh` only packs a tarball when `CODEGRAPH_PACK_UI=1`.
+
+Tests run as **two vitest projects** (`vitest.workspace.mts`): `engine` (node) and `ui` (jsdom, the
+Svelte plugin, `resolve.conditions: ['browser']`) for the single `__tests__/ui-package.test.ts`.
+`npm test` still runs both. The split is not cosmetic — `browser` is a package-resolution
+condition, and applied globally it hands the engine's suites the browser builds of
+`web-tree-sitter` and friends. The root config (`vitest.config.mts`, `.mts` because the plugin is
+ESM-only and the repo is CJS) is the shared base; note that a workspace project **concatenates**
+the base's `include` with its own, which is why the `ui` project does not `extends` it.
+
 Node engines: `>=20.0.0 <25.0.0`. There is a hard exit on Node 25.x and below 20 (see `src/bin/node-version-check.ts`).
 
 ## Architecture
@@ -53,7 +77,8 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
 - `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend).
 - `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread.
 - `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges.
-- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries).
+- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws),
+  `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
 - `src/context/` — `ContextBuilder` + formatter for markdown/JSON output.
 - `src/search/` — full-text query parser and helpers for FTS5.
 - `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers.

+ 60 - 0
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,54 @@ 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.
+- **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.
@@ -516,6 +575,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

+ 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);
+      }
+    }
+  });
+});

+ 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('');
+  });
+});

+ 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));
+  });
+});

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

@@ -0,0 +1,401 @@
+/**
+ * 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,
+  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();
+  });
+});

+ 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();
+  });
+});

+ 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();
+    }
+  });
+});

+ 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);
+});

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


+ 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


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

@@ -0,0 +1,783 @@
+# 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.
+
+## 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`.
+
+## 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; } }
+```

File diff suppressed because it is too large
+ 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}`);

+ 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
  */

+ 821 - 2
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,
@@ -1049,7 +1064,8 @@ export class QueryBuilder {
       `);
     }
     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
  *

+ 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;
+  }
+}

+ 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']);
+
+/**
+ * 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';

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

@@ -0,0 +1,672 @@
+/**
+ * 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',
+]);
+
+/**
+ * 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 (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
+      const newStreak = named.has(c.node.id) ? 0 : 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 (c.edge.kind !== 'calls' || 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 (c.edge.kind !== 'calls' || 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();
+}

+ 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

+ 69 - 365
src/mcp/tools.ts

@@ -40,7 +40,14 @@ import {
 } from 'fs';
 import { createHash } from 'crypto';
 import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
-import { scanDynamicDispatch } from './dynamic-boundaries';
+import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report';
+import { countImplementers } from '../graph/type-hierarchy';
+import {
+  lastQualifierPart,
+  matchesSymbol,
+  findAllSymbols,
+  resolveNamedSymbolFlow,
+} from '../graph/named-symbol-flow';
 import { getUpdateNotice } from '../upgrade/update-check';
 import { ExploreDiagnostics } from './explore-diagnostics';
 import {
@@ -80,8 +87,13 @@ export class NotIndexedError extends Error {}
 /**
  * A security refusal (sensitive system path). Stays `isError: true` WITHOUT
  * retry guidance — abandoning this path is the desired agent reaction.
+ *
+ * Defined in `../errors` so non-MCP read sinks (the `codegraph ui` server) can
+ * enforce the same refusal without importing this module; re-exported here
+ * because this is where every existing caller imports it from.
  */
-export class PathRefusalError extends Error {}
+export { PathRefusalError } from '../errors';
+import { PathRefusalError } from '../errors';
 import { resolve as resolvePath, relative as relativePath } from 'path';
 
 /** Maximum output length to prevent context bloat (characters) */
@@ -103,14 +115,6 @@ const MAX_INPUT_LENGTH = 10_000;
  */
 const MAX_PATH_LENGTH = 4_096;
 
-/**
- * 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`.
- */
-const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
 
 /**
  * Node kinds that contain other symbols. For these, `codegraph_node` with
@@ -122,16 +126,6 @@ const CONTAINER_NODE_KINDS = new Set<NodeKind>([
   'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
 ]);
 
-/**
- * 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).
- */
-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;
-}
 
 /**
  * Normalize Erlang-native symbol spellings in an explore query into the shapes
@@ -2552,98 +2546,13 @@ export class ToolHandler {
     // processRunExecutionData) to the call site instead of dumping the whole body.
     const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>(), spineCallSites: new Map<string, number>() };
     try {
-      const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
-      // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
-      // names (Class.method / Class::method) — the agent's most precise input,
-      // resolved exactly by findAllSymbols. (The old strip mangled Class.method
-      // into Class, throwing the method away.)
-      const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
-      const tokens = [...new Set(
-        query.split(/[\s,()[\]]+/)
-          .map((t) => t.replace(FILE_EXT, '').trim())
-          .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
-      )].slice(0, 16);
-      if (tokens.length < 2) return EMPTY;
-      // Pool of name SEGMENTS (Class + method from every token) used to
-      // disambiguate an ambiguous SIMPLE name: keep a candidate only if its
-      // CONTAINER class is itself named in the query.
-      const segPool = new Set<string>();
-      for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
-      const named = new Map<string, Node>();
-      // Nodes whose token is SPECIFIC — a (near-)unique callable name (<=3 defs in
-      // the whole graph). These are safe to SPARE a file on: the agent named THIS
-      // method (`getResponseWithInterceptorChain`, 1 def). A hyper-polymorphic name
-      // (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
-      // so naming it doesn't keep every backend variant full and flood the budget.
-      const uniqueNamedNodeIds = new Set<string>();
-      // token → resolved node ids: drives the token-coverage check that gates
-      // the dynamic-boundary scan (a token is covered when ANY of its nodes
-      // lands on the main chain — overloads off the chain don't count against).
-      const tokenNodes = new Map<string, string[]>();
-      // token → its full same-name callable family (before the container filter).
-      // A LARGE family that fails to connect on the chain is a polymorphic
-      // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
-      const tokenFamily = new Map<string, Node[]>();
-      // Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED
-      // edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop
-      // is constant→constant — the CALLABLE-only `named` set can't hold it, and
-      // without this the hop is invisible to the Flow path at every tier (the
-      // Relationships section catches it only on repos ≥500 files). Kept SEPARATE
-      // from `named` (which drives the call-chain + source sizing, callable-only);
-      // fed only to the dynamic-dispatch-links scan below.
-      const dynNamed = new Map<string, Node>();
-      const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
-      // Nodes resolved from a SHAPE-PRECISE token (camelCase / PascalCase /
-      // snake_case / qualified) — the same test the gather path uses. It is the
-      // difference between "the agent named this symbol" and "an ordinary English
-      // word in a prose question collided with a callable", and it is what makes
-      // the narrative-less return below safe (see `identityOnly`).
-      const isPreciseToken = (x: string) =>
-        /[._$]|::|\//.test(x) || /[a-z][A-Z]/.test(x) || /^[A-Z]/.test(x);
-      const preciseNamedIds = new Set<string>();
-      const hasHeuristicEdge = (id: string): boolean =>
-        [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
-      for (const t of tokens) {
-        const hits = this.findAllSymbols(cg, t).nodes;
-        const cands = hits.filter((n) => CALLABLE.has(n.kind));
-        tokenFamily.set(t, cands);
-        // A qualified or otherwise-specific name (<=3 hits) keeps all; an
-        // ambiguous simple name keeps only candidates whose container is named.
-        const specific = cands.length <= 3;
-        const pick = specific
-          ? 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 = pick.slice(0, 6);
-        tokenNodes.set(t, kept.map((n) => n.id));
-        const precise = isPreciseToken(t);
-        for (const n of kept) {
-          named.set(n.id, n);
-          if (specific) uniqueNamedNodeIds.add(n.id);
-          if (precise) preciseNamedIds.add(n.id);
-        }
-        // Same token, non-callable synth endpoints (capped, precision-gated on an
-        // actual heuristic edge so plain config constants never qualify).
-        // Per-token sub-cap so one token's many endpoints (10 nix option writes
-        // of `programs.git.enable` across test configs) can't fill the pool
-        // before later tokens (`home.file`) get a slot.
-        if (dynNamed.size < 12) {
-          let tokenDyn = 0;
-          for (const n of hits) {
-            if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue;
-            if (hasHeuristicEdge(n.id)) {
-              dynNamed.set(n.id, n);
-              if (precise) preciseNamedIds.add(n.id);
-              tokenDyn++;
-            }
-            if (dynNamed.size >= 12 || tokenDyn >= 4) break;
-          }
-        }
-        if (named.size > 40) break;
-      }
+      // Token resolution — parsing, overload disambiguation, the CONSTANT/
+      // VARIABLE synth endpoints — is shared with `/api/flow`, so a name written
+      // in the viewer's search box resolves to the same nodes it does here.
+      const flow = resolveNamedSymbolFlow(cg, query);
+      const { named, dynNamed, tokenNodes, tokenFamily, uniqueNamedNodeIds, preciseNamedIds } =
+        flow;
+      if (flow.tokens.length < 2) return EMPTY;
       // Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
       // the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
       // already shown in the rendered main chain (a 2-node chain renders nothing, so a
@@ -2715,47 +2624,16 @@ export class ToolHandler {
         out.push('> Full source for these symbols is below.\n');
         return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map<string, number>() };
       }
-      const MAX_HOPS = 7;
-      let best: Array<{ node: Node; edge: Edge | null }> | null = null;
-      // BFS the full call graph (incl. synth edges) from each named seed, but
-      // only ACCEPT a sink that is also named — both ends anchored to symbols the
-      // agent named, so the chain stays on-topic while bridging intermediates
-      // (e.g. the exact interface overload) that the token resolution missed.
-      for (const seed of [...named.values()].slice(0, 8)) {
-        const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
-        parent.set(seed.id, { prev: null, edge: null, node: seed });
-        const q: Array<{ id: string; depth: number; streak: number }> = [{ id: seed.id, depth: 0, streak: 0 }];
-        let deep: string | null = null, deepDepth = 0;
-        const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
-        for (let h = 0; h < q.length && parent.size < 1500; h++) {
-          const { id, depth, streak } = q[h]!;
-          if (id !== seed.id && named.has(id) && depth > deepDepth) { deep = id; deepDepth = depth; }
-          if (depth >= MAX_HOPS - 1) continue;
-          for (const c of cg.getCallees(id)) {
-            if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
-            const newStreak = named.has(c.node.id) ? 0 : streak + 1;
-            if (newStreak > MAX_BRIDGE) continue;
-            parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
-            q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
-          }
-        }
-        if (!deep) continue;
-        const chain: Array<{ node: Node; edge: Edge | null }> = [];
-        let cur: string | null = deep;
-        while (cur) { const p = parent.get(cur); if (!p) break; chain.push({ node: p.node, edge: p.edge }); cur = p.prev; }
-        chain.reverse();
-        if (!best || chain.length > best.length) best = chain;
-      }
+      // The search itself lives in `../graph/named-symbol-flow`, so the viewer's
+      // Flow strip rides exactly this path finder rather than a second one that
+      // could disagree with it. What stays here is the PROSE — the narrative,
+      // the dynamic-dispatch links, the boundary announcements.
+      const best = flow.chains[0]?.steps ?? null;
       const hasMain = !!best && best.length >= 3;
       const pathIds = new Set((best ?? []).map((s) => s.node.id));
-      // Where each spine node calls the NEXT hop (best[i+1].edge is the edge from
-      // best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets
-      // the assembler window an oversize spine method to the call instead of dumping it.
-      const spineCallSites = new Map<string, number>();
-      if (best) for (let i = 0; i < best.length - 1; i++) {
-        const ln = best[i + 1]?.edge?.line;
-        if (ln && ln > 0 && !spineCallSites.has(best[i]!.node.id)) spineCallSites.set(best[i]!.node.id, ln);
-      }
+      // Where each spine node calls the NEXT hop — lets the assembler window an
+      // oversize spine method to the call instead of dumping the whole body.
+      const spineCallSites = flow.chains[0]?.callSites ?? new Map<string, number>();
 
       // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
       // asked about did not fully connect: some token resolved to nodes but
@@ -2866,37 +2744,22 @@ export class ToolHandler {
    * connected flow never reaches this method.
    */
   private buildDynamicBoundaries(cg: CodeGraph, scanList: Node[], named: Map<string, Node>): string {
-    const MAX_NOTES = 4;       // boundary bullets per explore
-    const MAX_SCAN = 8;        // bodies scanned
-    const MAX_TOTAL_CHARS = 200_000;
-    let projectRoot: string;
-    try { projectRoot = cg.getProjectRoot(); } catch { return ''; }
+    const MAX_NOTES = 4; // boundary bullets per explore
+    // The verdict is not derived here — `findDynamicBoundaries` produces it and
+    // the viewer's end cap renders the same object, so the two can never
+    // disagree about where a flow stops. What is left here is the prose.
+    const reports = findDynamicBoundaries(cg, scanList, { named, maxSites: MAX_NOTES });
     const notes: string[] = [];
-    const seenNode = new Set<string>();
-    const seenSite = new Set<string>();
-    let scanned = 0, charsScanned = 0;
-    for (const node of scanList) {
-      if (notes.length >= MAX_NOTES || 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;
-      for (const m of scanDynamicDispatch(body, node.language || '', node.startLine)) {
+    for (const report of reports) {
+      if (notes.length >= MAX_NOTES) break;
+      for (const site of report.sites) {
         if (notes.length >= MAX_NOTES) break;
-        const siteKey = `${node.filePath}:${m.line}:${m.form}`;
-        if (seenSite.has(siteKey)) continue;
-        seenSite.add(siteKey);
-        const more = m.moreSites ? ` (+${m.moreSites} more such site${m.moreSites > 1 ? 's' : ''} in this body)` : '';
-        notes.push(`- \`${node.name}\` (${node.filePath}:${m.line}) — ${m.label}: \`${m.snippet}\`${more}`);
-        if (m.key) {
-          const cand = this.boundaryCandidates(cg, m.key, !!m.keyIsType, named, node.id);
-          if (cand) notes.push(`  ${cand}`);
-        }
+        const more = site.moreSites
+          ? ` (+${site.moreSites} more such site${site.moreSites > 1 ? 's' : ''} in this body)`
+          : '';
+        notes.push(`- \`${report.node.name}\` (${report.node.filePath}:${site.line}) — ${site.label}: \`${site.snippet}\`${more}`);
+        const cand = this.boundaryCandidates(site);
+        if (cand) notes.push(`  ${cand}`);
       }
     }
     if (notes.length === 0) return '';
@@ -2968,9 +2831,12 @@ export class ToolHandler {
       let best: { node: Node; impl: number; targets: Node[] } | null = null;
       for (const { node, count, targets } of supers.values()) {
         if (count < MIN_SUPPORT) continue;
-        let impl = 0;
-        try { impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length; }
-        catch { /* leave 0 — gated out below */ }
+        // The implementer count is `countImplementers` — the same function the
+        // viewer's type-hierarchy fan counts with, so "dispatch to N types
+        // implementing X" is the same N on both surfaces (CG-58). Distinct
+        // types, not edges: a class tied to its supertype by both a parsed
+        // `extends` and a synthesized `implements` is one implementation.
+        const impl = countImplementers(cg, node.id);
         if (impl < MIN_IMPL) continue;
         if (!best || impl > best.impl) best = { node, impl, targets };
       }
@@ -2998,70 +2864,20 @@ export class ToolHandler {
   }
 
   /**
-   * Shortlist candidate runtime targets for a dispatch key surfaced by
-   * {@link buildDynamicBoundaries}. 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). Symbols the agent already named sort first and
-   * are marked — that's the "you were right, here's the wiring" case.
+   * Render the candidate shortlist for a dispatch site as one line.
+   *
+   * The shortlist itself is `shortlistBoundaryCandidates` in
+   * `../graph/dynamic-boundary-report` — shared with the viewer's end cap, so
+   * "candidates for key `save`" names the same symbols in both places. Symbols
+   * the agent already named are marked: that is the "you were right, here's the
+   * wiring" case.
    */
-  private boundaryCandidates(cg: CodeGraph, key: string, keyIsType: boolean, named: Map<string, Node>, selfId: string): string {
-    const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
-    const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
-    const keyNorm = norm(key);
-    if (keyNorm.length < 3) return '';
-    const cands = new Map<string, Node>();
-    const consider = (n: Node | undefined | null) => {
-      if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id)) return;
-      const nameNorm = norm(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 p of probes) {
-      try { for (const n of cg.getNodesByName(p)) consider(n); } catch { /* exact probe miss is fine */ }
-    }
-    let raw = 0;
-    try {
-      const results = cg.searchNodes(key, { limit: 12 });
-      raw = results.length;
-      for (const r of results) consider(r.node);
-    } catch { /* FTS syntax edge — exact probes already ran */ }
-    if (cands.size === 0) {
-      return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
-    }
-    // A constructor candidate duplicates its class: extractors emit ctors 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}`));
-    const namedNames = new Set([...named.values()].map((n) => n.name));
-    const isNamed = (n: Node) => named.has(n.id) || namedNames.has(n.name); // the flow's named set holds callables only — transfer the mark to the class
-    const list = 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, 4)
-      .map((n) => {
-        // Typed-bus convention: the runtime target is the candidate class's
-        // Handle/Execute/Consume method — name the exact node, not just the class.
-        let display = n.qualifiedName || n.name;
-        let at = `${n.filePath}:${n.startLine}`;
-        if (keyIsType && n.kind === 'class') {
-          try {
-            const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
-            const method = cg.getOutgoingEdges(n.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));
-            if (method) { display = `${n.name}.${method.name}`; at = `${method.filePath}:${method.startLine}`; }
-          } catch { /* class without resolvable members — show the class itself */ }
-        }
-        return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
-      });
-    return `candidates for key \`${key}\`: ${list.join(', ')}`;
+  private boundaryCandidates(site: BoundarySite): string {
+    if (site.candidates.length === 0) return site.candidateNote ?? '';
+    const list = site.candidates.map((c) =>
+      `\`${c.display}\` (${c.node.filePath}:${c.node.startLine})${c.named ? ' ← you named this' : ''}`
+    );
+    return `candidates for key \`${site.key}\`: ${list.join(', ')}`;
   }
 
   /**
@@ -6710,71 +6526,11 @@ export class ToolHandler {
    * Returns the best match and a note about alternatives if any.
    */
   /**
-   * 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`)
+   * Check if a node matches a symbol query — see `matchesSymbol` in
+   * `../graph/named-symbol-flow`, which owns the rules.
    */
   private 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)
-    );
+    return matchesSymbol(node, symbol);
   }
 
   /**
@@ -6838,64 +6594,12 @@ export class ToolHandler {
   /**
    * 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).
+   *
+   * The resolution itself lives in `../graph/named-symbol-flow`, so the Flow
+   * strip and `codegraph_explore` resolve a written name to the same nodes.
    */
   private 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 => this.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 };
+    return findAllSymbols(cg, symbol);
   }
 
   /**

+ 22 - 4
src/search/query-utils.ts

@@ -286,8 +286,29 @@ export function scorePathRelevance(
 
 /**
  * Check if a file path looks like a test file
+ *
+ * "Test" here is the wide reading: anything that is not production code,
+ * including examples, samples, benchmarks and fixtures. That is the right
+ * default for ranking — none of them are what a search is looking for — but it
+ * is the wrong set to put under a heading that says "Tests". A caller that
+ * means literally a test suite wants {@link isTestPath}.
  */
 export function isTestFile(filePath: string): boolean {
+  // Non-production directories: examples, samples, benchmarks, fixtures, demos.
+  // Check both mid-path (/integration/) and start-of-path (integration/) since
+  // file paths may be stored as relative paths without a leading slash.
+  return isTestPath(filePath) || matchesNonProductionDir(filePath.toLowerCase());
+}
+
+/**
+ * Check if a file path names a TEST — a suite that exercises other code.
+ *
+ * The narrow half of {@link isTestFile}: the filename and directory
+ * conventions every ecosystem uses for its test suites, and nothing else. An
+ * example, a benchmark or a fixture is not a test, and a list headed "Tests"
+ * that contains them is telling the reader something untrue.
+ */
+export function isTestPath(filePath: string): boolean {
   const lower = filePath.toLowerCase();
   const fileName = path.basename(filePath);   // original case — needed for camelCase boundaries
   const lowerName = fileName.toLowerCase();
@@ -322,10 +343,7 @@ export function isTestFile(filePath: string): boolean {
     return true;
   }
 
-  // Non-production directories: examples, samples, benchmarks, fixtures, demos.
-  // Check both mid-path (/integration/) and start-of-path (integration/) since
-  // file paths may be stored as relative paths without a leading slash.
-  return matchesNonProductionDir(lower);
+  return false;
 }
 
 /**

+ 221 - 0
src/ui-server/api/deadcode.ts

@@ -0,0 +1,221 @@
+/**
+ * `GET /api/deadcode` — symbols nothing in this repository reaches, grouped by
+ * the file they live in (design spec §3.11).
+ *
+ * The derivation is `src/graph/dead-code.ts`, shared so that a second surface
+ * asking the same question cannot get a different answer. This module is the
+ * renderer, and it has exactly two jobs beyond flattening: hand the report a
+ * source reader that goes through the viewer's read chokepoint, and carry the
+ * exclusion counts onto the wire so the screen can say what the list could not
+ * see. A dead code list without that sentence is a screen that quietly invites
+ * somebody to delete a route handler.
+ *
+ * The rows come back ranked (largest first) and are grouped by file for
+ * display, not re-ranked: the group order follows the best row in it, so the
+ * biggest finding is still at the top of the screen.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { NodeKind } from '../../types';
+import {
+  buildDeadCodeReport,
+  DEAD_CODE_ALLOWED_KINDS,
+  MAX_CORROBORATION_BYTES,
+  MAX_DEAD_CODE_CANDIDATES,
+  type DeadCodeExclusions,
+} from '../../graph/dead-code';
+import { intParam } from './respond';
+import { readIndexedFileText } from './source';
+import { toNodeRef, wireList, type WireList, type WireNodeRef } from './wire';
+
+/** Rows carried on the payload. The screen shows every one it is given. */
+export const MAX_DEAD_CODE_ROWS = 300;
+
+/** Members folded under one row before the row just counts them. */
+export const MAX_DEAD_CODE_MEMBERS = 12;
+
+/** One symbol nothing reaches. */
+export interface WireDeadCodeRow extends WireNodeRef {
+  /** Source lines it spans — the rank, and what deleting it would remove. */
+  lines: number;
+  /**
+   * Members that are unreferenced and live inside this one: a class nobody
+   * instantiates takes its methods with it. Capped; `total` stays real.
+   */
+  members: WireList<WireNodeRef>;
+}
+
+/** The rows of one file, in source order. */
+export interface WireDeadCodeGroup {
+  file: string;
+  /** Tool-generated — drawn dimmed wherever it appears (design spec §2.6). */
+  generated: boolean;
+  test: boolean;
+  /** Lines the rows in this group add up to. */
+  lines: number;
+  rows: WireDeadCodeRow[];
+}
+
+/** One reason candidates were dropped, in the words the screen prints. */
+export interface WireDeadCodeExclusion {
+  reason: keyof DeadCodeExclusions;
+  count: number;
+  label: string;
+}
+
+export interface WireDeadCode {
+  /** Ranked, flat, capped. `total` is the real number of findings. */
+  rows: WireList<WireDeadCodeRow>;
+  /** The SHOWN rows, grouped by file — group order follows the best row. */
+  groups: WireDeadCodeGroup[];
+  /** Symbols with no incoming reference at all, before any exclusion ran. */
+  candidates: number;
+  /** Every exclusion that removed at least one candidate, biggest first. */
+  excluded: WireDeadCodeExclusion[];
+  /** How many candidates every exclusion removed between them. */
+  excludedTotal: number;
+  kinds: NodeKind[];
+  /** Symbols reachable from outside the index are on the list. */
+  includeExported: boolean;
+  includeTests: boolean;
+  includeGenerated: boolean;
+  /** The candidate scan stopped at its cap; there are more. */
+  bounded: boolean;
+  /** Every row was checked against the text of the files that can reach it. */
+  corroborated: boolean;
+  timing: { elapsedMs: number };
+}
+
+/**
+ * The sentence each exclusion prints under the list.
+ *
+ * Written as "N <label>" — so each one reads as a count of candidates, in the
+ * reader's language rather than in the rule's.
+ */
+const EXCLUSION_LABELS: Record<keyof DeadCodeExclusions, string> = {
+  tests: 'in test files',
+  generated: 'in generated files',
+  exported: 'exported, or declared in a header',
+  exportsUnknown: 'in languages this index records no exports for',
+  declarations: 'abstract, or declared on an interface',
+  decorated: 'carrying a decorator, so a framework registers them',
+  overriding: 'overriding a member declared further up',
+  implicit: 'named something the language calls by itself',
+  vendored: 'in vendored directories',
+  testScope: 'inside a test module',
+  markup: 'in component files, where markup can reference them invisibly',
+  unreachableFile: 'in files nothing reaches — islands, drawn on the map',
+  unresolvedName: 'sharing a name the index failed to resolve somewhere',
+  ambiguousName: 'sharing a name with a symbol that IS referenced',
+  mentioned: 'written more than once in a file that can reach them',
+  unreadable: 'in files that could not be read',
+  nested: 'folded into a container on this list',
+};
+
+export function parseDeadCodeQuery(query: URLSearchParams): {
+  limit: number;
+  includeExported: boolean;
+  includeTests: boolean;
+  includeGenerated: boolean;
+  kinds: NodeKind[] | undefined;
+} {
+  const raw = query.get('kinds');
+  const kinds = raw
+    ? (raw
+        .split(',')
+        .map((kind) => kind.trim())
+        .filter((kind) => DEAD_CODE_ALLOWED_KINDS.has(kind as NodeKind)) as NodeKind[])
+    : undefined;
+  return {
+    limit: intParam(query, 'limit', { min: 1, max: MAX_DEAD_CODE_ROWS, default: MAX_DEAD_CODE_ROWS }),
+    includeExported: query.get('exported') === '1',
+    includeTests: query.get('tests') === '1',
+    includeGenerated: query.get('generated') === '1',
+    kinds: kinds && kinds.length > 0 ? kinds : undefined,
+  };
+}
+
+export function buildDeadCode(
+  cg: CodeGraph,
+  projectRoot: string,
+  query: URLSearchParams
+): WireDeadCode {
+  const started = Date.now();
+  const options = parseDeadCodeQuery(query);
+
+  const report = buildDeadCodeReport(cg, {
+    kinds: options.kinds,
+    includeExported: options.includeExported,
+    includeTests: options.includeTests,
+    includeGenerated: options.includeGenerated,
+    limit: options.limit,
+    // The chokepoint, not `fs`: the viewer never opens a path the index does
+    // not name and `resolveProjectFile` has not cleared.
+    readSource: (filePath) =>
+      readIndexedFileText(cg, projectRoot, filePath, MAX_CORROBORATION_BYTES),
+  });
+
+  const generatedFiles = cg.generatedFilePredicate(
+    report.entries.map((entry) => entry.node.filePath)
+  );
+
+  // Groups follow the rows' order: the first time a file appears is where its
+  // group sits, so the largest finding is still at the top of the screen.
+  const rows: WireDeadCodeRow[] = [];
+  const groups: WireDeadCodeGroup[] = [];
+  const byFile = new Map<string, WireDeadCodeGroup>();
+
+  for (const entry of report.entries) {
+    const row: WireDeadCodeRow = {
+      ...toNodeRef(entry.node),
+      lines: entry.lines,
+      members: wireList(
+        entry.members.slice(0, MAX_DEAD_CODE_MEMBERS).map((member) => toNodeRef(member)),
+        entry.members.length
+      ),
+    };
+    rows.push(row);
+
+    let group = byFile.get(row.file);
+    if (!group) {
+      group = {
+        file: row.file,
+        // The path convention plus the indexed banner verdict, both, so a
+        // generated file dims here for the same reason it dims on the map.
+        generated: generatedFiles(entry.node.filePath),
+        test: row.test,
+        lines: 0,
+        rows: [],
+      };
+      byFile.set(row.file, group);
+      groups.push(group);
+    }
+    group.rows.push(row);
+    group.lines += row.lines;
+  }
+  for (const group of groups) group.rows.sort((a, b) => a.line - b.line);
+
+  const excluded: WireDeadCodeExclusion[] = (
+    Object.keys(report.excluded) as Array<keyof DeadCodeExclusions>
+  )
+    .map((reason) => ({ reason, count: report.excluded[reason], label: EXCLUSION_LABELS[reason] }))
+    .filter((entry) => entry.count > 0)
+    .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
+
+  return {
+    rows: wireList(rows, report.total),
+    groups,
+    candidates: report.candidates,
+    excluded,
+    excludedTotal: excluded.reduce((sum, entry) => sum + entry.count, 0),
+    kinds: report.kinds,
+    includeExported: report.includeExported,
+    includeTests: options.includeTests,
+    includeGenerated: options.includeGenerated,
+    bounded: report.bounded,
+    corroborated: report.corroborated,
+    timing: { elapsedMs: Date.now() - started },
+  };
+}
+
+export { MAX_DEAD_CODE_CANDIDATES };

+ 342 - 0
src/ui-server/api/entrypoints.ts

@@ -0,0 +1,342 @@
+/**
+ * `GET /api/entrypoints` — where to start reading a project you have never
+ * opened, and where a flow starts.
+ *
+ * The empty state, the resting search palette and the entry-points panel all
+ * have the same problem: a graph of thirteen thousand symbols and no obvious
+ * door. Four answers, every one of them derived from the graph rather than
+ * from a filename convention:
+ *
+ * - **Routes** — a request arriving from outside is the most literal entry a
+ *   codebase has. Straight from the routing manifest (`/api/routes`), and
+ *   absent for a project that is not a routed app. Carried with the file the
+ *   URL is REGISTERED in as well as the one that serves it, because a router
+ *   file is how a reader groups routes and the two are rarely the same file.
+ * - **Files that run something** — the engine records a statement at the top
+ *   level of a file as an edge out of the *file* node, so a CLI, a worker
+ *   entry or a build script has `calls` where a library module has none. That
+ *   is what makes `src/bin/codegraph.ts` the root of this repo's CLI flow.
+ *   Ranked by calls x how many other files they reach, so the file that both
+ *   runs and wires the project together outranks a registration table that
+ *   makes a hundred module-level calls into itself.
+ * - **Tests** — the other direction: not where the project starts, but what
+ *   already exercises it. Ranked by how many other files a test reaches, so
+ *   the suites that cross the most of the codebase come first.
+ * - **Hubs** — the most depended-on symbols. Not an entry in the "runs first"
+ *   sense; an entry in the sense that reading one tells you the most about
+ *   what the project is made of, and a change to one radiates furthest.
+ *
+ * Tests and fixtures are excluded from the two *reading* lists — "where do I
+ * start reading" never means a test — and the Tests list is built from the
+ * narrow {@link isTestPath}, not from {@link isTestFile}: an example, a
+ * benchmark or a fixture is not a test, and a heading that says "Tests" must
+ * not be quietly counting them.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { Node, NodeKind } from '../../types';
+import { intParam } from './respond';
+import { buildRoutes, type WireRoute } from './routes';
+import { isTestFile, isTestPath } from '../../search/query-utils';
+import { toNodeRef, toPosixPath, wireList, type WireList, type WireNodeRef } from './wire';
+
+/** Rows per derived list, and the default for `limit`. */
+const DEFAULT_LIMIT = 12;
+
+/**
+ * Route rows, and the default for `routes`.
+ *
+ * Separate from `limit` because routes are the one list whose useful length is
+ * set by the project rather than by the reader: a panel that groups 60 routes
+ * under four router files is legible, while 60 rows of "most depended on" is
+ * a wall. Both are honest — every list carries the real total.
+ */
+const DEFAULT_ROUTE_LIMIT = 60;
+const MAX_ROUTE_LIMIT = 300;
+
+/**
+ * Ranked rows examined before the test filter and the per-directory cap run.
+ *
+ * Fixed rather than a multiple of `limit` so the same project answers with the
+ * same rows whatever the caller asks for. It also means the `total` on the two
+ * derived lists is a FLOOR — "at least this many" — because the tests it skips
+ * are only recognisable in JavaScript (`isTestFile` reads directory shapes and
+ * CamelCase suffixes that do not survive translation into SQL). That is the
+ * honest reading, and the viewer prints the rows rather than the count.
+ */
+const SCAN_ROWS = 400;
+
+/**
+ * At most this many executable files from any one directory.
+ *
+ * Without it a repo with twenty one-off scripts in `scripts/` answers "where do
+ * I start" with twenty scripts, and the CLI everybody actually wants falls off
+ * the end. Two keeps a directory represented without letting it own the list.
+ */
+const MAX_FILES_PER_DIR = 2;
+
+/**
+ * Test files asked about per reach query.
+ *
+ * The reach query is driven from `nodes` by file path, so its cost is
+ * proportional to the files in the chunk rather than to the edge table — but a
+ * repo with ten thousand test files would still put ten thousand paths into
+ * one `json_each`. Chunking keeps every statement bounded WITHOUT capping the
+ * candidate list, which would silently drop test files from the ranking.
+ */
+const TEST_CHUNK = 500;
+
+/** Kinds that are never a useful hub row: a mention, a container, or a name. */
+const NON_HUB_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'file',
+  'import',
+  'export',
+  'parameter',
+]);
+
+export interface WireEntryFile extends WireNodeRef {
+  /** Calls and instantiations made at the top level of the file. */
+  calls: number;
+  /** Distinct other files this one's symbols reach. */
+  reaches: number;
+  /** Other files reaching into this one. Zero means nothing imports it. */
+  dependents: number;
+}
+
+export interface WireEntryTest extends WireNodeRef {
+  /** Distinct other files this test reaches — what it exercises. */
+  reaches: number;
+  /** References behind that reach. */
+  refs: number;
+}
+
+export interface WireEntryHub extends WireNodeRef {
+  /** Distinct symbols that depend on this one. */
+  dependents: number;
+}
+
+export interface WireEntryPoints {
+  /**
+   * Frameworks the resolver detected, e.g. `["go"]`, `["express"]`.
+   *
+   * The Routes section's header names them: a route list is a claim about a
+   * framework's conventions, and saying which one produced it is the
+   * difference between a fact and an assertion.
+   */
+  frameworks: string[];
+  routes: {
+    routed: boolean;
+    /** Every `route` node in the graph, resolved handler or not. */
+    routeCount: number;
+    items: WireList<WireRoute>;
+  };
+  /** `total` is a floor on these three — the server counts what its scan saw. */
+  files: WireList<WireEntryFile>;
+  tests: WireList<WireEntryTest>;
+  hubs: WireList<WireEntryHub>;
+  index: { lastIndexedAt: number | null; files: number };
+  timing: { elapsedMs: number; cached: boolean };
+}
+
+// =============================================================================
+// Cache
+// =============================================================================
+
+/**
+ * One answer per (project, index build, limits).
+ *
+ * Unlike `/api/source` and everything downstream of it, nothing here is read
+ * from disk: every field comes out of the index, so an answer is exactly as
+ * fresh as the index build it was keyed on. The Tests list is the reason it is
+ * worth caching at all — it asks a reach query per chunk of test files, and
+ * every screen in the viewer refetches this payload when the index moves.
+ */
+const CACHE_LIMIT = 8;
+const cache = new Map<string, WireEntryPoints>();
+
+export function resetEntryPointsCache(): void {
+  cache.clear();
+}
+
+// =============================================================================
+// Build
+// =============================================================================
+
+export function buildEntryPoints(cg: CodeGraph, query: URLSearchParams): WireEntryPoints {
+  const started = Date.now();
+  const limit = intParam(query, 'limit', { min: 1, max: 50, default: DEFAULT_LIMIT });
+  const routeLimit = intParam(query, 'routes', {
+    min: 3,
+    max: MAX_ROUTE_LIMIT,
+    default: DEFAULT_ROUTE_LIMIT,
+  });
+
+  const stats = cg.getStats();
+  // JSON rather than a joined string: a project root can contain any character
+  // a separator might have picked, and this key is compared for equality only.
+  const key = JSON.stringify([
+    cg.getProjectRoot(),
+    cg.getLastIndexedAt() ?? 0,
+    stats.edgeCount,
+    stats.fileCount,
+    limit,
+    routeLimit,
+  ]);
+  const hit = cache.get(key);
+  if (hit) {
+    // Re-stamp rather than mutate: the body is shared with the next caller.
+    return { ...hit, timing: { elapsedMs: Date.now() - started, cached: true } };
+  }
+
+  const payload: WireEntryPoints = {
+    frameworks: cg.getDetectedFrameworks(),
+    routes: routeEntries(cg, routeLimit),
+    files: executableFiles(cg, limit),
+    tests: testFiles(cg, limit),
+    hubs: hubs(cg, limit),
+    index: { lastIndexedAt: cg.getLastIndexedAt() ?? null, files: stats.fileCount },
+    timing: { elapsedMs: Date.now() - started, cached: false },
+  };
+
+  if (cache.size >= CACHE_LIMIT) {
+    const oldest = cache.keys().next();
+    if (!oldest.done) cache.delete(oldest.value);
+  }
+  cache.set(key, payload);
+  return payload;
+}
+
+/**
+ * The routing manifest, trimmed to a starting-points list.
+ *
+ * `buildRoutes` is reused rather than re-derived so a route row means exactly
+ * the same thing here as on the routes endpoint — including its handler id and
+ * its registration site, which are what make the row navigable and groupable.
+ */
+function routeEntries(cg: CodeGraph, limit: number): WireEntryPoints['routes'] {
+  const manifest = buildRoutes(cg, new URLSearchParams([['limit', String(limit)]]));
+  return {
+    routed: manifest.routed,
+    routeCount: manifest.routeCount,
+    // `shown` counts the rows; `truncated` is the manifest's own verdict on
+    // whether the window cut anything, and it is more trustworthy than
+    // comparing against `routeCount` (which counts URLs whose handler never
+    // resolved as well).
+    items: {
+      total: manifest.truncated ? Math.max(manifest.shown + 1, manifest.routeCount) : manifest.shown,
+      shown: manifest.shown,
+      truncated: manifest.truncated,
+      items: manifest.entries,
+    },
+  };
+}
+
+/**
+ * Files that do something on the way down, most first.
+ *
+ * Over-fetched before filtering, because the two things that shrink the list —
+ * tests and the per-directory cap — are only knowable after the rows come back,
+ * and a project whose noisiest module-level callers are all test files would
+ * otherwise answer with an empty list.
+ */
+function executableFiles(cg: CodeGraph, limit: number): WireList<WireEntryFile> {
+  const ranked = cg.getTopCallingFiles(SCAN_ROWS);
+
+  const kept: Array<{ node: Node; calls: number; reaches: number }> = [];
+  const perDir = new Map<string, number>();
+  let eligible = 0;
+
+  for (const row of ranked) {
+    if (isTestFile(row.filePath)) continue;
+    eligible += 1;
+    if (kept.length >= limit) continue;
+    const dir = directoryOf(row.filePath);
+    const taken = perDir.get(dir) ?? 0;
+    if (taken >= MAX_FILES_PER_DIR) continue;
+    const node = cg.getNode(row.nodeId);
+    if (!node) continue;
+    perDir.set(dir, taken + 1);
+    kept.push({ node, calls: row.calls, reaches: row.reaches });
+  }
+
+  const dependents = cg.getFileDependentCounts(kept.map((k) => k.node.filePath));
+  const items: WireEntryFile[] = kept.map(({ node, calls, reaches }) => ({
+    ...toNodeRef(node),
+    calls,
+    reaches,
+    dependents: dependents.get(node.filePath) ?? 0,
+  }));
+
+  // `eligible` counts every non-test file the scan saw: a floor, never an
+  // overstatement.
+  return wireList(items, Math.max(eligible, items.length));
+}
+
+/**
+ * The suites that exercise the most of the project, widest first.
+ *
+ * Ranked by reach rather than by size or by module-level calls: a test's
+ * useful property is how much of the codebase runs when it does, and only Go,
+ * Rust and Java put that work inside functions where a "runs something at
+ * module level" ranking cannot see it at all.
+ *
+ * `total` is exact here — the candidate list is every test file in the index,
+ * decided in JavaScript before any query runs — which is why it is the one
+ * derived list whose count is not a floor. A test file that reaches nothing
+ * outside itself is left out on purpose: it exercises nothing this graph can
+ * name.
+ */
+function testFiles(cg: CodeGraph, limit: number): WireList<WireEntryTest> {
+  const candidates = cg
+    .getFiles()
+    .map((file) => toPosixPath(file.path))
+    .filter((path) => isTestPath(path));
+  if (candidates.length === 0) return wireList([], 0);
+
+  const reach = new Map<string, { reaches: number; refs: number }>();
+  for (let i = 0; i < candidates.length; i += TEST_CHUNK) {
+    for (const [path, counts] of cg.getFileReachCounts(candidates.slice(i, i + TEST_CHUNK))) {
+      reach.set(toPosixPath(path), counts);
+    }
+  }
+
+  const ranked = [...reach.entries()]
+    .map(([path, counts]) => ({ path, ...counts }))
+    .sort((a, b) => b.reaches - a.reaches || b.refs - a.refs || a.path.localeCompare(b.path));
+
+  const top = ranked.slice(0, limit);
+  const nodes = new Map(cg.getFileNodes(top.map((row) => row.path)).map((n) => [toPosixPath(n.filePath), n]));
+
+  const items: WireEntryTest[] = [];
+  for (const row of top) {
+    const node = nodes.get(row.path);
+    if (!node) continue;
+    items.push({ ...toNodeRef(node), reaches: row.reaches, refs: row.refs });
+  }
+
+  return wireList(items, Math.max(ranked.length, items.length));
+}
+
+/** The most depended-on symbols, tests and non-navigable kinds removed. */
+function hubs(cg: CodeGraph, limit: number): WireList<WireEntryHub> {
+  const ranked = cg.getTopDependedOn(SCAN_ROWS);
+
+  const items: WireEntryHub[] = [];
+  let eligible = 0;
+  for (const row of ranked) {
+    const node = cg.getNode(row.nodeId);
+    if (!node || NON_HUB_KINDS.has(node.kind) || isTestFile(node.filePath)) continue;
+    eligible += 1;
+    if (items.length >= limit) continue;
+    items.push({ ...toNodeRef(node), dependents: row.dependents });
+  }
+
+  return wireList(items, Math.max(eligible, items.length));
+}
+
+/** `src/bin/codegraph.ts` -> `src/bin`; a root file -> `.`. */
+function directoryOf(filePath: string): string {
+  const normalized = filePath.replace(/\\/g, '/');
+  const cut = normalized.lastIndexOf('/');
+  return cut < 0 ? '.' : normalized.slice(0, cut);
+}

+ 480 - 0
src/ui-server/api/events.ts

@@ -0,0 +1,480 @@
+/**
+ * `GET /api/events` — the viewer's live channel (server-sent events).
+ *
+ * Two questions the open browser cannot answer for itself, and one stream that
+ * answers both:
+ *
+ * - **"has the file I'm looking at changed on disk?"** — the drift banner. The
+ *   verdict itself comes from `/api/source` (it hashes the bytes); this stream
+ *   only says *when to ask again*, so the banner appears about a third of a
+ *   second after a save instead of on the next navigation.
+ * - **"has the index moved?"** — the live refresh. Something else (an agent's
+ *   MCP daemon, `codegraph sync`, a git hook) writes the graph; when it does,
+ *   every screen the viewer is showing is one round-trip out of date.
+ *
+ * ## This server watches. It never syncs.
+ *
+ * `codegraph ui` is read-only in every sense — the banner it prints says so —
+ * so the obvious implementation (run the engine's watcher, let it sync) is out.
+ * What is left is *observation*, from two independent directions:
+ *
+ * - the project tree, through the engine's own {@link FileWatcher} with a
+ *   notify-only `syncFn`. It never writes: the callback that would have run a
+ *   sync fans the changed paths out to the browser instead. Everything else
+ *   about it — the per-platform watch strategy, the indexer's ignore scope, the
+ *   adaptive debounce, the degrade latch — is behaviour we would otherwise have
+ *   had to write again, worse.
+ * - the index itself, through one non-recursive `fs.watch` on the data
+ *   directory. That is the only cross-process signal there is: the writer is a
+ *   different process, and the thing it changes is a file. A settled write is
+ *   followed by ONE cheap query (`getIndexRevision`), and only a revision that
+ *   actually moved becomes an event.
+ *
+ * **Nothing polls.** Both watchers are edge-triggered, and both start on the
+ * first subscriber and stop with the last one — a viewer nobody has open costs
+ * no watch descriptors, which matters on Linux where the strategy is
+ * per-directory.
+ *
+ * ## Boundary
+ *
+ * A long-lived response sits inside the loopback boundary exactly like every
+ * other route: `Host`, `Origin` and the GET-only rule are already enforced by
+ * `startUiServer` before this module is reached, and nothing here reads the
+ * repository — the paths it names came from the watcher and the index, and the
+ * viewer has to go back through `/api/source` (and therefore through
+ * `resolveProjectFile`) to see a byte of any of them.
+ */
+
+import * as fs from 'fs';
+import type { IncomingMessage, ServerResponse } from 'http';
+import type { CodeGraph } from '../../index';
+import { getCodeGraphDir } from '../../directory';
+import { FileWatcher } from '../../sync/watcher';
+import type { GraphSession } from './session';
+
+/**
+ * Paths carried in one event. `total` is always the real number — a burst of
+ * two thousand files still says two thousand, it just does not list them.
+ */
+export const MAX_EVENT_FILES = 200;
+
+/** Comment frame keeping the connection (and the client's idea of it) alive. */
+export const HEARTBEAT_MS = 25_000;
+
+/**
+ * Quiet window before an index write is treated as finished.
+ *
+ * A sync writes the WAL continuously, so the *end* of the writing is the signal
+ * — not its start. Long enough that a multi-second sync produces one event
+ * rather than a dozen.
+ */
+const INDEX_SETTLE_MS = 400;
+
+/**
+ * Ceiling on that quiet window. A sync large enough that the WAL never goes
+ * quiet for 400 ms would otherwise hold the first event until it finished; the
+ * cap makes the viewer refresh mid-way instead, which is still true — the graph
+ * really has moved — and costs one query.
+ */
+const INDEX_SETTLE_MAX_MS = 3_000;
+
+/**
+ * Debounce for source-file events. The watcher's own adaptive rule fires a lone
+ * save after `min(300, this)` ms of quiet and keeps the full window for a
+ * burst, so a single edit reaches the browser well inside the one-second bar
+ * while an agent rewriting forty files still arrives as one event.
+ */
+const SOURCE_DEBOUNCE_MS = 500;
+
+/* --------------------------------------------------------------- the wire -- */
+
+export interface WireIndexRevision {
+  lastIndexedAt: number | null;
+  files: number;
+}
+
+/** Sent once, immediately, so a client knows what it is synchronised against. */
+export interface WireEventHello {
+  type: 'hello';
+  index: WireIndexRevision | null;
+  /** Which of the two observers actually came up. */
+  watching: { source: boolean; index: boolean };
+  /** Non-null when live watching has given up; the client must NOT start polling. */
+  degraded: string | null;
+  heartbeatMs: number;
+  at: number;
+}
+
+/** Source files changed on disk. The index has NOT caught up yet. */
+export interface WireEventChanged {
+  type: 'changed';
+  files: string[];
+  total: number;
+  truncated: boolean;
+  /**
+   * True when the change could not be described file by file (a directory
+   * removal, or a burst past the watcher's scoped ceiling). Treat any open file
+   * as possibly affected.
+   */
+  scan: boolean;
+  at: number;
+}
+
+/** The index moved: some other process finished writing the graph. */
+export interface WireEventIndex {
+  type: 'index';
+  index: WireIndexRevision;
+  /** Files this sync re-indexed, newest first. Empty when it only deleted. */
+  files: string[];
+  total: number;
+  truncated: boolean;
+  at: number;
+}
+
+/** Live watching has stopped for good. Sent once; the stream stays open. */
+export interface WireEventDegraded {
+  type: 'degraded';
+  reason: string;
+  at: number;
+}
+
+export type WireEvent =
+  | WireEventHello
+  | WireEventChanged
+  | WireEventIndex
+  | WireEventDegraded;
+
+/* ---------------------------------------------------------------- the hub -- */
+
+interface Client {
+  res: ServerResponse;
+  heartbeat: ReturnType<typeof setInterval>;
+}
+
+/**
+ * Fans filesystem and index changes out to every open viewer.
+ *
+ * One hub per server. It owns the watchers, and owns them lazily: they exist
+ * only while somebody is listening.
+ */
+export class EventHub {
+  private readonly projectRoot: string;
+  private readonly session: GraphSession;
+  private readonly clients = new Set<Client>();
+
+  private sourceWatcher: FileWatcher | null = null;
+  private indexWatcher: fs.FSWatcher | null = null;
+  private indexTimer: ReturnType<typeof setTimeout> | null = null;
+  /** When the current settle window started, for the {@link INDEX_SETTLE_MAX_MS} cap. */
+  private indexPendingSince = 0;
+  private revision: WireIndexRevision | null = null;
+  private sourceUp = false;
+  private indexUp = false;
+  private degraded: string | null = null;
+  private closed = false;
+
+  constructor(projectRoot: string, session: GraphSession) {
+    this.projectRoot = projectRoot;
+    this.session = session;
+  }
+
+  /**
+   * Attach one browser to the stream.
+   *
+   * Returns `true` in every case — the response is answered here, streaming or
+   * not — so it slots into the API's `switch` like any other endpoint.
+   */
+  subscribe(req: IncomingMessage, res: ServerResponse, method: string): true {
+    if (this.closed) {
+      // The server is shutting down. Answer, do not attach: a client that got a
+      // stream here would hold the socket open against `close()`.
+      res.writeHead(503, {
+        'Content-Type': 'application/json; charset=utf-8',
+        'Cache-Control': 'no-store',
+      });
+      res.end(method === 'HEAD' ? undefined : JSON.stringify({ error: 'Shutting down.', code: 'internal' }));
+      return true;
+    }
+
+    res.writeHead(200, {
+      'Content-Type': 'text/event-stream; charset=utf-8',
+      'Cache-Control': 'no-store',
+      // Node would otherwise chunk small writes; an event that sits in a buffer
+      // is an event that did not happen.
+      Connection: 'keep-alive',
+      'X-Accel-Buffering': 'no',
+    });
+
+    if (method === 'HEAD') {
+      res.end();
+      return true;
+    }
+
+    // No keep-alive timeout on this socket: the server sets one globally so
+    // Ctrl-C does not wait on browser connections, and it would close a healthy
+    // stream between heartbeats.
+    res.socket?.setTimeout(0);
+    res.socket?.setNoDelay(true);
+
+    this.ensureWatching();
+
+    const client: Client = {
+      res,
+      heartbeat: setInterval(() => {
+        // A comment frame. Not an event, so no client handler ever sees it —
+        // it exists to notice a socket the other end has already dropped.
+        if (!res.writableEnded) res.write(': ping\n\n');
+      }, HEARTBEAT_MS),
+    };
+    // `unref` so a live stream never keeps the process alive on its own.
+    client.heartbeat.unref?.();
+    this.clients.add(client);
+
+    const drop = (): void => this.drop(client);
+    res.on('close', drop);
+    res.on('error', drop);
+    req.on('aborted', drop);
+
+    this.send(client, {
+      type: 'hello',
+      index: this.revision,
+      watching: { source: this.sourceUp, index: this.indexUp },
+      degraded: this.degraded,
+      heartbeatMs: HEARTBEAT_MS,
+      at: Date.now(),
+    });
+    return true;
+  }
+
+  /** Number of attached clients — for tests and for the watchers' lifetime. */
+  get size(): number {
+    return this.clients.size;
+  }
+
+  /** Stop watching and end every open stream. Idempotent. */
+  close(): void {
+    this.closed = true;
+    this.stopWatching();
+    for (const client of [...this.clients]) {
+      clearInterval(client.heartbeat);
+      this.clients.delete(client);
+      try {
+        client.res.end();
+      } catch {
+        /* the socket is already gone */
+      }
+    }
+  }
+
+  /* ------------------------------------------------------------ plumbing -- */
+
+  private drop(client: Client): void {
+    if (!this.clients.delete(client)) return;
+    clearInterval(client.heartbeat);
+    if (this.clients.size === 0) this.stopWatching();
+  }
+
+  private send(client: Client, event: WireEvent): void {
+    if (client.res.writableEnded) return;
+    try {
+      // `retry` on every frame is cheap and means a client that reconnects with
+      // the browser's own EventSource still backs off the way we asked.
+      client.res.write(`retry: 3000\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
+    } catch {
+      this.drop(client);
+    }
+  }
+
+  private broadcast(event: WireEvent): void {
+    for (const client of [...this.clients]) this.send(client, event);
+  }
+
+  /* ------------------------------------------------------------ watching -- */
+
+  private ensureWatching(): void {
+    if (this.closed) return;
+    this.revision ??= this.probe();
+    this.startSourceWatcher();
+    this.startIndexWatcher();
+  }
+
+  private stopWatching(): void {
+    if (this.indexTimer) {
+      clearTimeout(this.indexTimer);
+      this.indexTimer = null;
+    }
+    this.indexPendingSince = 0;
+    try {
+      this.indexWatcher?.close();
+    } catch {
+      /* already closed */
+    }
+    this.indexWatcher = null;
+    this.indexUp = false;
+    this.sourceWatcher?.stop();
+    this.sourceWatcher = null;
+    this.sourceUp = false;
+  }
+
+  /**
+   * The project tree, through the engine's watcher with the sync taken out.
+   *
+   * The `syncFn` is the whole trick: the watcher calls it with exactly the
+   * paths it would have handed to a scoped sync (or `undefined` when the events
+   * could not describe the change), we announce them and report zero files
+   * changed. It succeeds every time, so the watcher's failure ladder — lock
+   * retries, backoff, the degrade latch — is only ever reached by the watch
+   * layer itself, which is precisely the part we do want.
+   */
+  private startSourceWatcher(): void {
+    if (this.sourceWatcher) return;
+    const watcher = new FileWatcher(
+      this.projectRoot,
+      async (paths?: string[]) => {
+        this.announceChanged(paths);
+        return { filesChanged: 0, durationMs: 0 };
+      },
+      {
+        debounceMs: SOURCE_DEBOUNCE_MS,
+        onDegraded: (reason) => {
+          this.degraded = reason;
+          this.sourceUp = false;
+          this.broadcast({ type: 'degraded', reason, at: Date.now() });
+        },
+      }
+    );
+    this.sourceWatcher = watcher;
+    this.sourceUp = watcher.start();
+    if (!this.sourceUp) {
+      // Watching is off by policy (CODEGRAPH_NO_WATCH, a WSL2 /mnt drive) or
+      // the OS refused. The stream stays — the index watcher is independent —
+      // and `hello` already told the client which half is live.
+      this.sourceWatcher = null;
+    }
+  }
+
+  private announceChanged(paths?: string[]): void {
+    const all = paths ?? [];
+    const files = all.slice(0, MAX_EVENT_FILES);
+    this.broadcast({
+      type: 'changed',
+      files,
+      total: all.length,
+      truncated: files.length < all.length,
+      scan: paths === undefined,
+      at: Date.now(),
+    });
+  }
+
+  /**
+   * The index, through one watch on the data directory.
+   *
+   * Non-recursive and on the directory rather than the database file: SQLite
+   * writes land in `codegraph.db-wal`, and a full re-index REPLACES
+   * `codegraph.db` outright (a watch on the file itself would follow the
+   * unlinked inode and never fire again).
+   */
+  private startIndexWatcher(): void {
+    if (this.indexWatcher) return;
+    const dir = getCodeGraphDir(this.projectRoot);
+    try {
+      const watcher = fs.watch(dir, { persistent: false }, () => this.scheduleProbe());
+      watcher.on('error', () => {
+        // The data directory went away, or the OS dropped the watch. Nothing to
+        // retry against — a client that reloads gets a fresh one.
+        this.indexUp = false;
+        this.indexWatcher = null;
+        try {
+          watcher.close();
+        } catch {
+          /* already closed */
+        }
+      });
+      this.indexWatcher = watcher;
+      this.indexUp = true;
+    } catch {
+      this.indexUp = false;
+    }
+  }
+
+  /**
+   * Wait for the writing to stop, then look once.
+   *
+   * Re-armed by every write, so a sync that takes four seconds produces one
+   * probe at its end — except that {@link INDEX_SETTLE_MAX_MS} caps how long
+   * the first probe can be deferred, so a continuously-writing full index still
+   * refreshes the viewer while it runs.
+   */
+  private scheduleProbe(): void {
+    if (this.closed) return;
+    const now = Date.now();
+    if (this.indexPendingSince === 0) this.indexPendingSince = now;
+    const remaining = Math.max(0, this.indexPendingSince + INDEX_SETTLE_MAX_MS - now);
+    if (this.indexTimer) clearTimeout(this.indexTimer);
+    const timer = setTimeout(() => {
+      this.indexTimer = null;
+      this.indexPendingSince = 0;
+      this.checkIndex();
+    }, Math.min(INDEX_SETTLE_MS, remaining));
+    timer.unref?.();
+    this.indexTimer = timer;
+  }
+
+  /** One query. An unmoved revision is not an event. */
+  private checkIndex(): void {
+    if (this.closed || this.clients.size === 0) return;
+    const next = this.probe();
+    if (next === null) return;
+    const previous = this.revision;
+    this.revision = next;
+    if (
+      previous !== null &&
+      previous.lastIndexedAt === next.lastIndexedAt &&
+      previous.files === next.files
+    ) {
+      return;
+    }
+
+    // Everything re-indexed since the mark we were holding. A sync that only
+    // removed files names nothing here — which is why the revision comparison
+    // above, not this list, decides whether an event happens at all.
+    let files: string[] = [];
+    let total = 0;
+    const since = previous?.lastIndexedAt ?? null;
+    if (since !== null) {
+      try {
+        const changed = this.session.acquire().getFilesIndexedSince(since, MAX_EVENT_FILES);
+        files = changed.paths;
+        total = changed.total;
+      } catch {
+        /* the index went away between the probe and here — the event still stands */
+      }
+    }
+
+    this.broadcast({
+      type: 'index',
+      index: next,
+      files,
+      total: Math.max(total, files.length),
+      truncated: files.length < total,
+      at: Date.now(),
+    });
+  }
+
+  /**
+   * The current revision, or null when there is no readable index.
+   *
+   * A missing index is not an error here: `codegraph ui` refuses to start
+   * without one, but a user can delete `.codegraph/` with the viewer open, and
+   * every endpoint already says so in its own words when asked.
+   */
+  private probe(): WireIndexRevision | null {
+    try {
+      const cg: CodeGraph = this.session.acquire();
+      const revision = cg.getIndexRevision();
+      return { lastIndexedAt: revision.lastIndexedAt, files: revision.fileCount };
+    } catch {
+      return null;
+    }
+  }
+}

+ 289 - 0
src/ui-server/api/file.ts

@@ -0,0 +1,289 @@
+/**
+ * `GET /api/file/<path>` — the File view in one round-trip.
+ *
+ * Three panes: what imports this file, the file's own outline in source order,
+ * and what this file imports. All of it comes from four batched queries — the
+ * file's nodes, their `contains` edges, their `imports` edges in each
+ * direction — never a query per symbol.
+ *
+ * Two things worth knowing about `imports` edges before reading the mapping
+ * below. First, they point at the *symbol* that was imported, not at the file
+ * holding it, so file granularity means mapping each edge's endpoint through
+ * `nodes.file_path`. Second, plenty of them stay inside one file (an import
+ * declaration is a node in the importing file), so the same-file ones have to
+ * be dropped or every file appears to import itself.
+ *
+ * The rails would still read as broken without the third piece: imports that
+ * never resolved. A file importing `react`, `fs` and one local module would
+ * otherwise show a single row, silently implying the other two do not exist.
+ * They are listed separately, as what they are — outside the index.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { Edge, Node } from '../../types';
+import { isTestFile } from '../../search/query-utils';
+import { hasDriftedOnDisk, resolveRequestedFile } from './source';
+import {
+  MAX_IMPORT_FILES,
+  MAX_OUTLINE_NODES,
+  toNodeRef,
+  toPosixPath,
+  wireList,
+  type WireNodeRef,
+} from './wire';
+
+/** Symbols named per import row before it just counts them. */
+const MAX_SYMBOLS_PER_IMPORT = 12;
+
+/** Unresolved imports listed by name. */
+const MAX_UNRESOLVED_IMPORTS = 60;
+
+/** A row in the file outline. */
+export interface WireOutlineEntry extends WireNodeRef {
+  /** Containing symbol within this file, or null for a top-level one. */
+  parentId: string | null;
+  /** Nesting depth from the top level of the file, starting at 0. */
+  depth: number;
+  /** Incoming / outgoing edge counts — the `← in  → out` column. */
+  fanIn: number;
+  fanOut: number;
+}
+
+/** One end of the File view's import rails. */
+export interface WireImportRow {
+  file: string;
+  test: boolean;
+  /** Which symbols the edges name, capped. */
+  symbols: Array<{ id: string; name: string; kind: string; line: number }>;
+  symbolCount: number;
+}
+
+export function buildFile(cg: CodeGraph, projectRoot: string, requested: string): unknown {
+  // Refusal first, index lookup second — a traversal out of the project is a
+  // refusal, not "no such file". See `resolveRequestedFile`.
+  const { record, storedPath } = resolveRequestedFile(cg, projectRoot, requested);
+
+  const nodes = cg.getNodesInFile(storedPath);
+  const nodeIds = nodes.map((n) => n.id);
+  const inThisFile = new Set(nodeIds);
+  const fileNode = nodes.find((n) => n.kind === 'file') ?? null;
+
+  // ---------------------------------------------------------------------------
+  // Outline
+  // ---------------------------------------------------------------------------
+  const { entries: outline, total: outlineTotal } = buildOutlineEntries(cg, nodes);
+
+  // ---------------------------------------------------------------------------
+  // Import rails
+  // ---------------------------------------------------------------------------
+  const importsOut = cg.getOutgoingEdgesFrom(nodeIds, ['imports']);
+  const importsIn = cg.getIncomingEdgesTo(nodeIds, ['imports']);
+
+  const endpointIds = new Set<string>();
+  for (const edge of importsOut) if (!inThisFile.has(edge.target)) endpointIds.add(edge.target);
+  for (const edge of importsIn) if (!inThisFile.has(edge.source)) endpointIds.add(edge.source);
+  const endpoints = cg.getNodesByIds([...endpointIds]);
+
+  const imports = groupByFile(
+    importsOut.filter((e) => !inThisFile.has(e.target)),
+    (e) => e.target,
+    endpoints
+  );
+  const importedBy = groupByFile(
+    importsIn.filter((e) => !inThisFile.has(e.source)),
+    (e) => e.source,
+    endpoints
+  );
+
+  // Import statements that never resolved — the third-party packages and
+  // runtime builtins. Attributed to the file node, which is where extraction
+  // records a file-level import.
+  const unresolvedImports = fileNode ? unresolvedImportsOf(cg, fileNode.id) : [];
+
+  // Whether the file RUNS anything at its top level. Extraction records a
+  // statement outside any definition as an edge out of the FILE node, so a
+  // module that only defines things has none and a CLI entry point has many —
+  // the same signal `/api/entrypoints` ranks on. It is worth a line on this
+  // screen because the outline cannot show it: top-level code belongs to no
+  // symbol, so the only way to read it is to open the file node itself.
+  const topLevelEdges = fileNode
+    ? cg.getOutgoingEdgesFrom([fileNode.id], ['calls', 'instantiates'])
+    : [];
+
+  return {
+    file: {
+      path: toPosixPath(storedPath),
+      language: record.language,
+      size: record.size,
+      modifiedAt: record.modifiedAt,
+      indexedAt: record.indexedAt,
+      contentHash: record.contentHash,
+      nodeCount: record.nodeCount,
+      generated: record.generated === true,
+      test: isTestFile(toPosixPath(storedPath)),
+      errors: record.errors ?? [],
+      /** The file node itself, so the viewer can navigate to it as a symbol. */
+      id: fileNode?.id ?? null,
+    },
+    /**
+     * Calls made at the top level of the file, outside every definition.
+     * Counted as distinct call SITES — `(target, line, column)` — so a call
+     * two resolvers both recorded is one thing to read, not two.
+     */
+    topLevel: {
+      calls: new Set(topLevelEdges.map((e) => `${e.target}:${e.line ?? 0}:${e.column ?? 0}`)).size,
+    },
+    /** The file changed on disk since it was indexed — the outline's lines may be shifted. */
+    drift: hasDriftedOnDisk(projectRoot, storedPath, record),
+    outline: wireList(outline, outlineTotal),
+    imports: wireList(imports.slice(0, MAX_IMPORT_FILES), imports.length),
+    importedBy: wireList(importedBy.slice(0, MAX_IMPORT_FILES), importedBy.length),
+    unresolvedImports,
+    /**
+     * The broader relationship: every file this one has a cross-file edge into,
+     * and every file that has one into it — calls and type references, not just
+     * import statements. `imports` alone understates both, badly in languages
+     * where symbols resolve without an explicit import.
+     */
+    dependencies: cg.getFileDependencies(storedPath).map(toPosixPath).sort(),
+    dependents: cg.getFileDependents(storedPath).map(toPosixPath).sort(),
+  };
+}
+
+/**
+ * A file's symbols in source order, nested under their container.
+ *
+ * Extracted so the whole-file source view (`/api/filecode`) draws the same rows
+ * as the outline view rather than a second, subtly different reading of the
+ * same `contains` edges — an outline rail whose line numbers disagreed with the
+ * source beside it would be worse than no rail.
+ *
+ * Four batched queries whatever the file holds: its nodes are already in hand,
+ * their `contains` edges, and fan-in / fan-out for the whole set at once.
+ *
+ * @returns the capped rows and the TRUE symbol count, which is what a header
+ *          has to print — see `wireList`.
+ */
+export function buildOutlineEntries(
+  cg: CodeGraph,
+  nodes: readonly Node[]
+): { entries: WireOutlineEntry[]; total: number } {
+  const nodeIds = nodes.map((n) => n.id);
+  const inThisFile = new Set(nodeIds);
+  const fileNodeId = nodes.find((n) => n.kind === 'file')?.id;
+
+  const parentOf = new Map<string, string>();
+  for (const edge of cg.getOutgoingEdgesFrom(nodeIds, ['contains'])) {
+    // Only nesting *within* this file: a `contains` edge reaching out of it is
+    // not something a file outline can draw.
+    if (inThisFile.has(edge.target) && !parentOf.has(edge.target)) {
+      parentOf.set(edge.target, edge.source);
+    }
+  }
+
+  const fanIn = cg.getFanIn(nodeIds);
+  const fanOut = cg.getFanOut(nodeIds);
+
+  const outlineNodes = nodes
+    // The file node is the subject of the screen, not a row in its own outline;
+    // import declarations get their own rail and would otherwise be most of it.
+    .filter((n) => n.kind !== 'file' && n.kind !== 'import')
+    .sort((a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name));
+
+  const entries: WireOutlineEntry[] = outlineNodes.slice(0, MAX_OUTLINE_NODES).map((node) => ({
+    ...toNodeRef(node),
+    parentId: resolveOutlineParent(node.id, parentOf, fileNodeId),
+    depth: depthOf(node.id, parentOf, fileNodeId),
+    fanIn: fanIn.get(node.id) ?? 0,
+    fanOut: fanOut.get(node.id) ?? 0,
+  }));
+
+  return { entries, total: outlineNodes.length };
+}
+
+/**
+ * The outline parent of a symbol: its container within the file, or null when
+ * that container is the file node itself (a top-level symbol has no parent row).
+ */
+function resolveOutlineParent(
+  id: string,
+  parentOf: Map<string, string>,
+  fileNodeId: string | undefined
+): string | null {
+  const parent = parentOf.get(id);
+  if (!parent || parent === fileNodeId) return null;
+  return parent;
+}
+
+function depthOf(
+  id: string,
+  parentOf: Map<string, string>,
+  fileNodeId: string | undefined
+): number {
+  let depth = 0;
+  let current = id;
+  // Bounded by the number of links so a cyclic `contains` chain — which should
+  // be impossible, but is one bad index away — cannot spin here.
+  for (let guard = 0; guard < 32; guard++) {
+    const parent = parentOf.get(current);
+    if (!parent || parent === fileNodeId) return depth;
+    depth++;
+    current = parent;
+  }
+  return depth;
+}
+
+/** Fold edges into one row per file at the far end, ordered by symbol count. */
+function groupByFile(
+  edges: readonly Edge[],
+  endpoint: (edge: Edge) => string,
+  nodes: Map<string, Node>
+): WireImportRow[] {
+  const byFile = new Map<string, Map<string, Node>>();
+  for (const edge of edges) {
+    const node = nodes.get(endpoint(edge));
+    if (!node) continue;
+    const file = toPosixPath(node.filePath);
+    let bucket = byFile.get(file);
+    if (!bucket) {
+      bucket = new Map<string, Node>();
+      byFile.set(file, bucket);
+    }
+    bucket.set(node.id, node);
+  }
+
+  return [...byFile.entries()]
+    .map(([file, symbols]) => {
+      const ordered = [...symbols.values()].sort(
+        (a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name)
+      );
+      return {
+        file,
+        test: isTestFile(file),
+        symbols: ordered.slice(0, MAX_SYMBOLS_PER_IMPORT).map((n) => ({
+          id: n.id,
+          name: n.name,
+          kind: n.kind,
+          line: n.startLine,
+        })),
+        symbolCount: ordered.length,
+      };
+    })
+    .sort((a, b) => b.symbolCount - a.symbolCount || a.file.localeCompare(b.file));
+}
+
+function unresolvedImportsOf(
+  cg: CodeGraph,
+  fileNodeId: string
+): Array<{ name: string; line: number }> {
+  try {
+    return cg
+      .getUnresolvedReferencesFrom(fileNodeId)
+      .filter((ref) => ref.referenceKind === 'imports')
+      .sort((a, b) => a.line - b.line || a.referenceName.localeCompare(b.referenceName))
+      .slice(0, MAX_UNRESOLVED_IMPORTS)
+      .map((ref) => ({ name: ref.referenceName, line: ref.line }));
+  } catch {
+    return [];
+  }
+}

+ 297 - 0
src/ui-server/api/filecode.ts

@@ -0,0 +1,297 @@
+/**
+ * `GET /api/filecode/<path>` — the whole-file source view in one round-trip.
+ *
+ * The Symbol view asks "what does this body reach"; this screen asks the same
+ * question of every line of a file at once, and answers it beside the file's
+ * own source. What that needs is one payload holding everything the graph says
+ * about lines in this file, and nothing that depends on scroll position:
+ *
+ * * the file's symbols in source order — the sticky outline rail, and the
+ *   definition line every intra-file arc lands on,
+ * * one row per (calling symbol, called symbol) pair, carrying the call-site
+ *   lines the gutter ports and the callee rail anchor to,
+ * * the references that never resolved, so a line that reaches `console.log`
+ *   still shows a hollow port instead of an empty gutter that reads as
+ *   "nothing happens here",
+ * * the file's total line count, which IS the layout: every line is a fixed
+ *   height, so the viewer can size a 6 800-line document and start drawing
+ *   before a single page of source has arrived.
+ *
+ * The source itself does NOT ride along. A 6 800-line TypeScript file is ~1.5 s
+ * of parsing and megabytes of JSON; the viewer pages it through
+ * `/api/source` as the reader scrolls, which is also what lets the graph
+ * facts — ports, arcs, rail rows — be complete from the first frame while the
+ * text fills in behind them.
+ *
+ * **The arcs are not a separate list.** An arc is a call whose target is
+ * defined in this same file, so the viewer derives them from `calls` and the
+ * `intraFileCalls` count here is computed from the SHOWN groups for the same
+ * reason: a header that counted raw edges would disagree with the picture under
+ * it the moment a cap bit.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { Edge, Node } from '../../types';
+import { isTestFile } from '../../search/query-utils';
+import { buildOutlineEntries, type WireOutlineEntry } from './file';
+import { readFileShape, resolveRequestedFile } from './source';
+import { badRequest } from './respond';
+import {
+  firstLine,
+  groupRelations,
+  toPosixPath,
+  wireList,
+  type WireList,
+  type WireRelation,
+} from './wire';
+
+/**
+ * Call groups returned for one file.
+ *
+ * A generous cap, not a display budget: the viewer only ever draws the rows in
+ * the window it is scrolled to, so the number that matters is what a payload
+ * costs to ship. This repo's largest file (`src/mcp/tools.ts`, 6 820 lines)
+ * produces 498.
+ */
+export const MAX_FILE_CALL_GROUPS = 2000;
+
+/**
+ * Unresolved references returned for one file.
+ *
+ * These are markers, not rows — each one is a hollow port and a soft underline
+ * with nothing behind it. `src/mcp/tools.ts` has 1 113; a generated bundle can
+ * have tens of thousands, and past this point the count says everything the
+ * list would.
+ */
+export const MAX_FILE_OUTSIDE_REFS = 3000;
+
+/**
+ * Unresolved-reference rows read before the count itself becomes a floor.
+ *
+ * `total` has to be the real number — the rest of this API guarantees that a
+ * count equals a list — and the filter below (plain identifiers only) is not
+ * expressible in SQL, so the rows have to be scanned to be counted. This is the
+ * backstop against a generated bundle with a million of them, and it is far
+ * above anything hand-written: the largest file in this repo's own index has
+ * 1 113.
+ */
+export const MAX_FILE_OUTSIDE_SCAN = 50_000;
+
+/** A reference the resolver never landed: a port with no destination. */
+export interface WireFileOutsideRef {
+  line: number;
+  col: number;
+  /** The identifier as written — how the viewer finds the token to underline. */
+  name: string;
+  kind: string;
+}
+
+/** Every edge from ONE symbol in this file to ONE symbol anywhere. */
+export interface WireFileCall {
+  /**
+   * The symbol in this file that makes the calls.
+   *
+   * Never null: extraction records a statement outside every definition as an
+   * edge out of the FILE node, so top-level code has an owner too — the file
+   * itself.
+   */
+  ownerId: string;
+  /** First line of the owner's definition, so a rail row can be attributed. */
+  ownerLine: number;
+  relation: WireRelation;
+}
+
+export interface WireFileCodePayload {
+  file: {
+    path: string;
+    language: string;
+    size: number;
+    indexedAt: number;
+    contentHash: string;
+    generated: boolean;
+    test: boolean;
+    errors: string[];
+    /** The file node's own id — the owner of every top-level call. */
+    id: string | null;
+    /**
+     * Lines on disk right now. Null when the file could not be read, which is
+     * the one case the viewer cannot lay out and says so.
+     */
+    totalLines: number | null;
+  };
+  /** The file changed on disk since it was indexed — every line number is suspect. */
+  drift: boolean;
+  /** Why, when there is something to say beyond the flag. */
+  reason?: string;
+  /** The file's symbols in source order — the same rows `/api/file` draws. */
+  outline: WireList<WireOutlineEntry>;
+  /** One row per (calling symbol, called symbol) pair, in call-site order. */
+  calls: WireList<WireFileCall>;
+  /** References with nothing behind them — hollow ports. */
+  outside: WireList<WireFileOutsideRef>;
+  /**
+   * Calls landing on a definition in THIS file — the arc diagram's total.
+   *
+   * Counted over the groups actually returned, so it always equals the number
+   * of arcs the viewer can draw from this payload.
+   */
+  intraFileCalls: number;
+  timing: { elapsedMs: number };
+}
+
+export function buildFileCode(
+  cg: CodeGraph,
+  projectRoot: string,
+  requested: string
+): WireFileCodePayload {
+  const started = Date.now();
+  if (requested === '') throw badRequest('No file path was given. Use /api/filecode/<path>.');
+
+  // Refusal first, index lookup second — see `resolveRequestedFile`.
+  const { record, storedPath } = resolveRequestedFile(cg, projectRoot, requested);
+  const posixPath = toPosixPath(storedPath);
+
+  const nodes = cg.getNodesInFile(storedPath);
+  const fileNode = nodes.find((n) => n.kind === 'file') ?? null;
+  const { entries: outline, total: outlineTotal } = buildOutlineEntries(cg, nodes);
+
+  const { calls, total: callTotal, intraFileCalls } = buildCalls(cg, nodes, posixPath);
+  const outside = buildOutsideRefs(cg, storedPath);
+
+  // One read answers both the drift verdict and the document's height.
+  const shape = readFileShape(projectRoot, storedPath, record);
+
+  return {
+    file: {
+      path: posixPath,
+      language: record.language,
+      size: record.size,
+      indexedAt: record.indexedAt,
+      contentHash: record.contentHash,
+      generated: record.generated === true,
+      test: isTestFile(posixPath),
+      // Messages, not the raw records: the screen prints a count and a line,
+      // and an extractor's file/line bookkeeping is not something a reader acts
+      // on.
+      errors: (record.errors ?? []).map((e) => e.message),
+      id: fileNode?.id ?? null,
+      totalLines: shape.totalLines,
+    },
+    drift: shape.drift,
+    ...(shape.reason ? { reason: shape.reason } : {}),
+    outline: wireList(outline, outlineTotal),
+    calls: wireList(calls, callTotal),
+    outside: wireList(outside.items, outside.total),
+    intraFileCalls,
+    timing: { elapsedMs: Date.now() - started },
+  };
+}
+
+/**
+ * Every outgoing edge from every symbol in the file, grouped twice over: by the
+ * symbol that makes the call, and within that by the symbol it reaches.
+ *
+ * Grouping by the OWNER as well as the target is what separates this from the
+ * Symbol view's rail. Across one body, a helper called from three lines is one
+ * row with `×3` and one place to sit. Across a 6 800-line file, the same helper
+ * called from two different functions a thousand lines apart cannot be one row
+ * — a row is anchored to a line, and there is no line that is both. So the pair
+ * is the unit, and the rail reads in source order the way the file does.
+ *
+ * `contains` is excluded, as everywhere else: it is structure, not dependency,
+ * and the outline already draws it.
+ */
+function buildCalls(
+  cg: CodeGraph,
+  nodes: readonly Node[],
+  posixPath: string
+): { calls: WireFileCall[]; total: number; intraFileCalls: number } {
+  const nodeIds = nodes.map((n) => n.id);
+  const lineOf = new Map(nodes.map((n) => [n.id, n.startLine] as const));
+
+  const edges = cg.getOutgoingEdgesFrom(nodeIds).filter((e) => e.kind !== 'contains');
+  if (edges.length === 0) return { calls: [], total: 0, intraFileCalls: 0 };
+
+  const bySource = new Map<string, Edge[]>();
+  for (const edge of edges) {
+    const bucket = bySource.get(edge.source);
+    if (bucket) bucket.push(edge);
+    else bySource.set(edge.source, [edge]);
+  }
+
+  // One batched lookup for every counterpart, never one per edge: the engine's
+  // busiest file reaches several hundred distinct symbols.
+  const endpoints = cg.getNodesByIds([...new Set(edges.map((e) => e.target))]);
+
+  const all: WireFileCall[] = [];
+  for (const [ownerId, group] of bySource) {
+    for (const relation of groupRelations(group, (e) => e.target, endpoints)) {
+      all.push({ ownerId, ownerLine: lineOf.get(ownerId) ?? 0, relation });
+    }
+  }
+
+  // Source order — the only ordering this screen has. A row with no recorded
+  // call site (an edge the extractor gave no line) sorts to the end, where it
+  // is also what a cap trims first.
+  all.sort(
+    (a, b) =>
+      firstLine(a.relation) - firstLine(b.relation) ||
+      a.ownerLine - b.ownerLine ||
+      a.relation.node.name.localeCompare(b.relation.node.name)
+  );
+
+  const calls = all.slice(0, MAX_FILE_CALL_GROUPS);
+
+  // Arcs, counted over what was KEPT — see the module comment.
+  let intraFileCalls = 0;
+  for (const call of calls) {
+    if (call.relation.node.file !== posixPath) continue;
+    const target = call.relation.node.line;
+    for (const line of call.relation.lines) if (line !== target) intraFileCalls++;
+  }
+
+  return { calls, total: all.length, intraFileCalls };
+}
+
+/**
+ * The file's unresolved references, as line markers.
+ *
+ * Only plain identifiers survive. The resolver's samples are bookkeeping, and a
+ * "name" that is really a whole arrow function or a receiver expression cannot
+ * be matched to a token on the line — a marker that could not find its
+ * identifier would silently claim the wrong one, which is worse than no marker.
+ * The same filter the Symbol view applies, applied once here rather than per
+ * symbol.
+ */
+function buildOutsideRefs(
+  cg: CodeGraph,
+  storedPath: string
+): { items: WireFileOutsideRef[]; total: number } {
+  let raw;
+  try {
+    // Scanned, not capped at the display limit: `total` must be the real count
+    // and the identifier filter below cannot run in SQL.
+    raw = cg.getUnresolvedReferencesInFile(storedPath, MAX_FILE_OUTSIDE_SCAN);
+  } catch {
+    return { items: [], total: 0 };
+  }
+
+  const items: WireFileOutsideRef[] = [];
+  let total = 0;
+  for (const ref of raw) {
+    const name = lastSegment(ref.referenceName ?? '');
+    if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
+    if (!ref.line) continue;
+    total++;
+    if (items.length < MAX_FILE_OUTSIDE_REFS) {
+      items.push({ line: ref.line, col: ref.column ?? 0, name, kind: ref.referenceKind });
+    }
+  }
+  return { items, total };
+}
+
+/** The trailing segment of a dotted name — what actually appears in the source. */
+function lastSegment(name: string): string {
+  const dot = name.lastIndexOf('.');
+  return dot < 0 ? name : name.slice(dot + 1);
+}

+ 866 - 0
src/ui-server/api/flow.ts

@@ -0,0 +1,866 @@
+/**
+ * `GET /api/flow` — the call path between two symbols, as cards.
+ *
+ * The Flow strip answers "how does A reach B" (design spec §3.5): one card per
+ * hop, each opened at the exact line that makes the next call, with the
+ * synthesized dynamic-dispatch hops drawn dashed and carrying the site they
+ * were wired at. This endpoint produces that path and the source windows for
+ * it; the geometry is a pure function in the viewer (`ui/src/lib/flow-model.ts`).
+ *
+ * **The path finder is not ours.** It is `resolveNamedSymbolFlow` in
+ * `src/graph/named-symbol-flow.ts` — literally the search `codegraph_explore`
+ * leads its answer with, extracted so both callers ride one implementation.
+ * A viewer that drew a different path from the one the MCP tool describes would
+ * be worse than no viewer: the two would be quoted against each other in a code
+ * review and one of them would be wrong.
+ *
+ * Three questions arrive here, and they are one question with different
+ * bindings:
+ *
+ * - `?from=&to=` — a directed question, from the search box's flow grammar.
+ *   Both ends pinned, shortest path wins.
+ * - `?symbols=a,b,c` — explore's own question, verbatim. Longest chain among
+ *   the named symbols wins, at most one unnamed bridge.
+ * - `?hop=s<id>&hop=d<id>…` — the trail, read as a flow. Nothing is searched:
+ *   the hops are the ones the reader walked, and the work is finding the edge
+ *   that already connects each consecutive pair.
+ *
+ * **Nothing here is cached.** Every other multi-symbol endpoint memoises on the
+ * index version, and this one deliberately does not: a flow card carries source
+ * read from disk, and the drift verdict on it changes without the index
+ * changing. A cached "no drift" is exactly the failure `/api/source` exists to
+ * prevent. The search itself costs tens of milliseconds; the windows are seven
+ * lines each.
+ */
+
+import type CodeGraph from '../../index';
+import type { Edge, Node } from '../../types';
+import {
+  resolveNamedSymbolFlow,
+  normalizeToken,
+  DIRECTED_MAX_HOPS,
+} from '../../graph/named-symbol-flow';
+import {
+  continuationsFrom,
+  findDynamicBoundaries,
+  type BoundaryContinuation,
+  type NodeBoundary,
+} from '../../graph/dynamic-boundary-report';
+import { highlightLines, type HighlightResult } from '../highlight';
+import { badRequest, intParam } from './respond';
+import { findIndexedFile, hasDriftedOnDisk, splitLines, toRequestPath } from './source';
+import { resolveProjectFile } from '../security';
+import {
+  toNodeRef,
+  toWireEdge,
+  wireList,
+  UNCERTAIN_BELOW,
+  type WireEdge,
+  type WireList,
+  type WireNodeRef,
+} from './wire';
+import * as fs from 'fs';
+
+/** Lines shown either side of the call site on a card (design spec §3.5). */
+export const SOURCE_WINDOW = 3;
+
+/**
+ * How far above a window the highlighter is allowed to start reading.
+ *
+ * Seven lines tokenised on their own do not know they are inside a block
+ * comment or a template literal, and a window that opens under a JSDoc would
+ * render the prose as code. Leading in from the enclosing symbol's first line
+ * fixes that for every ordinary body; the cap stops a thousand-line god
+ * function from costing a full-file tokenisation for one card. Past it a window
+ * can still open mid-construct — rare, and cheaper than the alternative.
+ */
+const HIGHLIGHT_LEAD_MAX = 200;
+
+/** Distinct paths returned. The header's flow picker is a short list or nothing. */
+export const MAX_FLOWS = 4;
+
+/** Hops accepted from a trail. The trail bar itself is not much longer than this. */
+const MAX_TRAIL_HOPS = 24;
+
+// =============================================================================
+// Wire shapes
+// =============================================================================
+
+export interface WireFlowEdge extends WireEdge {
+  /** The link's label: "calls", "via callback · registered at file:line". */
+  label: string;
+  /** This hop reads callee → caller — the reader stepped UP into it. */
+  upward: boolean;
+  /** `metadata.confidence` below {@link UNCERTAIN_BELOW}: dashed `2 3`. */
+  uncertain: boolean;
+  /** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
+  synthesized: boolean;
+}
+
+export interface WireFlowSource {
+  file: string;
+  language: string;
+  from: number;
+  to: number;
+  /** Absent when `drift` — a mis-sliced window is worse than an empty card. */
+  lines?: string[];
+  highlight?: HighlightResult;
+  drift: boolean;
+  /** Why there are no lines, when there are none. */
+  reason?: string;
+}
+
+/**
+ * The call site this card is opened at — the identifier the strip draws as a
+ * link, and the line the source window is centred on.
+ *
+ * It is not always a call to the NEXT card. Reading a trail backwards steps
+ * from a callee up to its caller, and the line that connects them then lives in
+ * the caller's body and names the symbol on the PREVIOUS card. Either way the
+ * rule is the same: a card opens at the line that ties it to its neighbour.
+ */
+export interface WireFlowCallRef {
+  line: number;
+  /** 0-based column the edge recorded, or null when it carries none. */
+  col: number | null;
+  /** The identifier as the graph names it — what the token must match. */
+  name: string;
+  /** The symbol at the other end of the edge. */
+  targetId: string;
+  /** The link points back at the previous card, not on to the next one. */
+  backwards: boolean;
+}
+
+export interface WireFlowHop {
+  node: WireNodeRef;
+  /** The edge from the PREVIOUS hop into this one; null on the first. */
+  edge: WireFlowEdge | null;
+  /** Where this card is opened, and what it links to. Null when it is neither. */
+  callRef: WireFlowCallRef | null;
+  /** The window this card shows, centred on `callRef` or the definition. */
+  source: WireFlowSource | null;
+}
+
+/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
+export interface WireBoundaryCandidate {
+  node: WireNodeRef;
+  /** How to name it: usually the qualified name, or `Class.handlerMethod`. */
+  display: string;
+  /** The question already named this symbol — "you were right, here's the wiring". */
+  named: boolean;
+}
+
+/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
+export interface WireBoundarySite {
+  /** Stable form id, e.g. `computed-call`. */
+  form: string;
+  /** What to call it on screen: "computed member call", "getattr dispatch". */
+  label: string;
+  /** The source line of the site, trimmed. */
+  snippet: string;
+  line: number;
+  /** The statically visible key (`handlers['save']` → `save`), or null. */
+  key: string | null;
+  /** The key is a TYPE name, so the target is `<Type>Handler` by convention. */
+  keyIsType: boolean;
+  /** Further sites of the same form and key in this body. */
+  moreSites: number;
+  candidates: WireBoundaryCandidate[];
+  /** Why there is no shortlist, when a key was visible but too generic. */
+  candidateNote: string | null;
+}
+
+/** A call out of the stopping symbol, and how sure the resolver was. */
+export interface WireFlowContinuation {
+  node: WireNodeRef;
+  line: number | null;
+  confidence: number | null;
+}
+
+/**
+ * Where the graph stops (design spec §3.5).
+ *
+ * Attached to a flow that does not reach everything the question named. It is
+ * the same verdict `codegraph_explore` announces in prose — both render
+ * `findDynamicBoundaries` — so the strip's end cap and the MCP answer can never
+ * disagree about where a path ends or what could continue it.
+ */
+export interface WireFlowBoundary {
+  /** The last symbol the static path reached. The cap hangs off this card. */
+  node: WireNodeRef;
+  /** Dispatch sites in that symbol's body. Empty when none was detected. */
+  sites: WireBoundarySite[];
+  /** Name-only matches under 0.6 the search did NOT follow. */
+  uncertain: WireList<WireFlowContinuation>;
+  /** Calls the resolver was sure of that this path does not need. */
+  further: WireList<WireFlowContinuation>;
+  /** Symbols the question named that this path never reaches. */
+  missed: WireNodeRef[];
+}
+
+export interface WireFlow {
+  /** Stable within a payload: the hop ids joined. Used as the picker's value. */
+  id: string;
+  /** "execute → rowToFileRecord", for the header's flow picker. */
+  label: string;
+  hops: WireFlowHop[];
+  /**
+   * The end cap, when this path stops short of the question. Null on a flow
+   * that reaches everything it was asked about — a connected answer has no
+   * boundary to announce, and saying otherwise would be noise.
+   */
+  boundary: WireFlowBoundary | null;
+  /**
+   * This strip is not an answer to the question, it is where the answer ran
+   * out: one card at the dispatch site rather than a path.
+   */
+  partial: boolean;
+}
+
+/** An endpoint that named more than one definition, and which one was taken. */
+export interface WireFlowAmbiguity {
+  token: string;
+  chosen: WireNodeRef | null;
+  others: WireNodeRef[];
+}
+
+export interface WireFlowPayload {
+  query: {
+    kind: 'directed' | 'symbols' | 'trail';
+    from: string | null;
+    to: string | null;
+    /** The tokens the search actually used. */
+    symbols: string[];
+  };
+  flows: WireFlow[];
+  ambiguous: WireFlowAmbiguity[];
+  /** Tokens that named nothing in this index. */
+  unresolved: string[];
+  /** Why there is no flow, when there is none. Null when there is one. */
+  reason: string | null;
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number };
+}
+
+// =============================================================================
+// Query
+// =============================================================================
+
+export type FlowQuery =
+  | { kind: 'directed'; from: string; to: string }
+  | { kind: 'symbols'; text: string }
+  | { kind: 'trail'; hops: Array<{ id: string; dir: 'start' | 'down' | 'up' }> };
+
+const DIR_CHARS: Record<string, 'start' | 'down' | 'up'> = { s: 'start', d: 'down', u: 'up' };
+
+/**
+ * Read the question out of the query string.
+ *
+ * A trail hop arrives as its own `hop` parameter rather than in one joined
+ * list, for the same reason `/api/nodes` repeats `id`: a node id can be a file
+ * path and a file path can contain a comma. The one-character direction prefix
+ * mirrors `ui/src/lib/trail-codec.ts`, which owns the format.
+ */
+export function parseFlowQuery(query: URLSearchParams): FlowQuery {
+  const rawHops = query.getAll('hop').filter((h) => h.length > 1);
+  if (rawHops.length > 0) {
+    if (rawHops.length > MAX_TRAIL_HOPS) {
+      throw badRequest(
+        `A trail of ${rawHops.length} hops is longer than this endpoint reads (${MAX_TRAIL_HOPS}).`
+      );
+    }
+    const hops = rawHops.map((raw) => ({
+      id: raw.slice(1),
+      dir: DIR_CHARS[raw[0] as string] ?? ('down' as const),
+    }));
+    if (hops.length < 2) {
+      throw badRequest('A trail needs at least two hops to be read as a flow.');
+    }
+    return { kind: 'trail', hops };
+  }
+
+  const from = (query.get('from') ?? '').trim();
+  const to = (query.get('to') ?? '').trim();
+  if (from && to) {
+    if (normalizeToken(from) === normalizeToken(to)) {
+      throw badRequest('"from" and "to" name the same symbol, so there is no path to draw.');
+    }
+    return { kind: 'directed', from, to };
+  }
+
+  const symbols = (query.get('symbols') ?? '').trim();
+  if (symbols) return { kind: 'symbols', text: symbols };
+
+  throw badRequest(
+    'No flow was asked for.',
+    'Use /api/flow?from=<symbol>&to=<symbol>, ?symbols=a,b,c, or ?hop=s<id>&hop=d<id>.'
+  );
+}
+
+// =============================================================================
+// Edges
+// =============================================================================
+
+/**
+ * The sentence under a link.
+ *
+ * A synthesized hop must never read as a plain `calls`: it is a bridge the
+ * resolver inferred, and the wiring site is the evidence for it. Design spec
+ * §3.5 fixes the phrasing — "via callback · registered at file:line".
+ */
+export function flowEdgeLabel(edge: Edge, upward: boolean): string {
+  const meta = (edge.metadata ?? {}) as Record<string, unknown>;
+  const parts: string[] = [];
+  if (edge.provenance === 'heuristic' && typeof meta.synthesizedBy === 'string') {
+    const mechanism = meta.synthesizedBy.replace(/-/g, ' ');
+    parts.push(`via ${mechanism}`);
+    if (typeof meta.via === 'string' && meta.via) parts.push(meta.via);
+    if (typeof meta.registeredAt === 'string' && meta.registeredAt) {
+      parts.push(`registered at ${meta.registeredAt}`);
+    }
+  } else {
+    parts.push(upward ? 'called by' : edge.kind);
+  }
+  return parts.join(' · ');
+}
+
+function toFlowEdge(edge: Edge, upward: boolean): WireFlowEdge {
+  const meta = (edge.metadata ?? {}) as Record<string, unknown>;
+  const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
+  return {
+    ...toWireEdge(edge),
+    label: flowEdgeLabel(edge, upward),
+    upward,
+    uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
+    synthesized: edge.provenance === 'heuristic',
+  };
+}
+
+// =============================================================================
+// Source windows
+// =============================================================================
+
+/** One read + one hash per file, however many cards land in it. */
+interface FileCache {
+  lines: string[] | null;
+  language: string;
+  drift: boolean;
+  reason?: string;
+}
+
+function loadFile(
+  cg: CodeGraph,
+  projectRoot: string,
+  cache: Map<string, FileCache>,
+  filePath: string
+): FileCache | null {
+  const posix = toRequestPath(filePath);
+  const hit = cache.get(posix);
+  if (hit) return hit;
+
+  const found = findIndexedFile(cg, posix);
+  if (!found) return null;
+
+  let entry: FileCache;
+  if (hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) {
+    entry = {
+      lines: null,
+      language: found.record.language,
+      drift: true,
+      reason:
+        'This file changed on disk after the last index sync, so the recorded call ' +
+        'line no longer reliably points at this call. The window returns after the next sync.',
+    };
+  } else {
+    try {
+      // The chokepoint, before anything is opened — see `source.ts`.
+      const absolute = resolveProjectFile(projectRoot, found.storedPath);
+      entry = {
+        lines: splitLines(fs.readFileSync(absolute, 'utf-8')),
+        language: found.record.language,
+        drift: false,
+      };
+    } catch {
+      entry = {
+        lines: null,
+        language: found.record.language,
+        drift: false,
+        reason: 'This file is in the index but could not be read.',
+      };
+    }
+  }
+  cache.set(posix, entry);
+  return entry;
+}
+
+/**
+ * The ±{@link SOURCE_WINDOW} lines a card shows.
+ *
+ * Anchored on the line that makes the next call. The last card has no next
+ * call, so it anchors on the definition instead — a reader who followed seven
+ * hops to get there wants to see what they arrived at.
+ */
+async function windowFor(
+  cg: CodeGraph,
+  projectRoot: string,
+  cache: Map<string, FileCache>,
+  node: Node,
+  anchor: number
+): Promise<WireFlowSource | null> {
+  const file = loadFile(cg, projectRoot, cache, node.filePath);
+  if (!file) return null;
+  const posix = toRequestPath(node.filePath);
+  if (file.lines === null) {
+    return {
+      file: posix,
+      language: file.language,
+      from: anchor,
+      to: anchor,
+      drift: file.drift,
+      ...(file.reason ? { reason: file.reason } : {}),
+    };
+  }
+
+  const total = file.lines.length;
+  const from = Math.max(1, Math.min(anchor - SOURCE_WINDOW, total));
+  const to = Math.max(from, Math.min(anchor + SOURCE_WINDOW, total));
+  // Tokenise with the lead-in, then keep only the window — see HIGHLIGHT_LEAD_MAX.
+  const leadFrom = Math.max(1, Math.min(from, Math.max(node.startLine, from - HIGHLIGHT_LEAD_MAX)));
+  const highlighted = await highlightLines(file.lines.slice(leadFrom - 1, to), {
+    language: file.language,
+    cacheKey: `${posix}:${leadFrom}:${to}`,
+  });
+  return {
+    file: posix,
+    language: file.language,
+    from,
+    to,
+    lines: file.lines.slice(from - 1, to),
+    highlight: {
+      ...highlighted,
+      lines: highlighted.lines.slice(from - leadFrom),
+    },
+    drift: false,
+  };
+}
+
+// =============================================================================
+// Building the flows
+// =============================================================================
+
+/** The steps of one chain, plus the edge that brought the reader into each. */
+interface RawHop {
+  node: Node;
+  edge: Edge | null;
+  upward: boolean;
+  /**
+   * Open the card here instead of at the call site or the definition. Set on a
+   * boundary-only strip, whose single card exists to show the dispatch line.
+   */
+  anchor?: number;
+}
+
+async function toWireFlow(
+  cg: CodeGraph,
+  projectRoot: string,
+  cache: Map<string, FileCache>,
+  raw: readonly RawHop[],
+  extra: { boundary?: WireFlowBoundary | null; partial?: boolean } = {}
+): Promise<WireFlow> {
+  const hops: WireFlowHop[] = [];
+  for (let i = 0; i < raw.length; i++) {
+    const step = raw[i] as RawHop;
+    const previous = raw[i - 1];
+    const next = raw[i + 1];
+    // Forward: the edge into the NEXT hop was recorded at the line inside THIS
+    // body that makes the call. Backwards (a trail read from a callee up to its
+    // caller): this card IS the caller, and its own incoming edge carries the
+    // line where it calls the card before it.
+    let callRef: WireFlowCallRef | null = null;
+    if (next !== undefined && !next.upward && next.edge?.line) {
+      callRef = {
+        line: next.edge.line,
+        col: typeof next.edge.column === 'number' ? next.edge.column : null,
+        name: next.node.name,
+        targetId: next.node.id,
+        backwards: false,
+      };
+    } else if (step.upward && previous !== undefined && step.edge?.line) {
+      callRef = {
+        line: step.edge.line,
+        col: typeof step.edge.column === 'number' ? step.edge.column : null,
+        name: previous.node.name,
+        targetId: previous.node.id,
+        backwards: true,
+      };
+    }
+    hops.push({
+      node: toNodeRef(step.node),
+      edge: step.edge === null ? null : toFlowEdge(step.edge, step.upward),
+      callRef,
+      source: await windowFor(
+        cg,
+        projectRoot,
+        cache,
+        step.node,
+        step.anchor ?? callRef?.line ?? step.node.startLine
+      ),
+    });
+  }
+  const first = raw[0]?.node.name ?? '?';
+  const last = raw[raw.length - 1]?.node.name ?? '?';
+  return {
+    id: raw.map((h) => h.node.id).join('>'),
+    label: extra.partial ? `${first} → stops here` : `${first} → ${last}`,
+    hops,
+    boundary: extra.boundary ?? null,
+    partial: extra.partial === true,
+  };
+}
+
+// =============================================================================
+// Where the graph stops
+// =============================================================================
+
+/** Continuations listed in an end cap before it just counts the rest. */
+const MAX_CONTINUATIONS = 6;
+
+/** Symbols named and never reached, listed in an end cap. */
+const MAX_MISSED = 4;
+
+/** Dispatch sites reported per strip. One cap is a card, not a report. */
+const MAX_SITES_PER_FLOW = 3;
+
+function toContinuation(c: BoundaryContinuation): WireFlowContinuation {
+  return { node: toNodeRef(c.node), line: c.line, confidence: c.confidence };
+}
+
+/**
+ * Build the end cap for a path that stopped short.
+ *
+ * `reports` comes from the shared detector, so the form, the key and the
+ * candidate targets are the ones `codegraph_explore` would print. Everything
+ * else on the cap is graph state around the stopping symbol: the calls it makes
+ * that this path did not need, and the name-only matches under 0.6 that the
+ * search refused to follow. That last list is the honest half — an unfollowed
+ * guess left invisible reads as "there is nothing here".
+ */
+function buildBoundary(
+  cg: CodeGraph,
+  stop: Node,
+  reports: readonly NodeBoundary[],
+  missed: readonly Node[],
+  onPath: ReadonlySet<string>
+): WireFlowBoundary {
+  const sites: WireBoundarySite[] = [];
+  for (const report of reports) {
+    for (const site of report.sites) {
+      if (sites.length >= MAX_SITES_PER_FLOW) break;
+      sites.push({
+        form: site.form,
+        label: site.label,
+        snippet: site.snippet,
+        line: site.line,
+        key: site.key ?? null,
+        keyIsType: site.keyIsType === true,
+        moreSites: site.moreSites ?? 0,
+        candidates: site.candidates.map((c) => ({
+          node: toNodeRef(c.node),
+          display: c.display,
+          named: c.named,
+        })),
+        candidateNote: site.candidateNote,
+      });
+    }
+  }
+  const { resolved, uncertain } = continuationsFrom(cg, stop, onPath);
+  return {
+    node: toNodeRef(stop),
+    sites,
+    uncertain: wireList(uncertain.slice(0, MAX_CONTINUATIONS).map(toContinuation), uncertain.length),
+    further: wireList(resolved.slice(0, MAX_CONTINUATIONS).map(toContinuation), resolved.length),
+    missed: missed.slice(0, MAX_MISSED).map(toNodeRef),
+  };
+}
+
+/**
+ * The edge that already connects two symbols the reader walked between.
+ *
+ * A trail is not searched — the hops are given — so all that is missing is
+ * which recorded edge the reader crossed. A `down` hop is a call out of the
+ * previous symbol; an `up` hop is the same edge read backwards, which is why
+ * `upward` exists and why the link says "called by" rather than "calls".
+ */
+function edgeBetween(
+  cg: CodeGraph,
+  from: Node,
+  to: Node
+): { edge: Edge; upward: boolean } | null {
+  let best: Edge | null = null;
+  for (const { node, edge } of cg.getCallees(from.id)) {
+    if (node.id !== to.id) continue;
+    if (best === null || (edge.kind === 'calls' && best.kind !== 'calls')) best = edge;
+  }
+  if (best) return { edge: best, upward: false };
+  for (const { node, edge } of cg.getCallers(from.id)) {
+    if (node.id !== to.id) continue;
+    if (best === null || (edge.kind === 'calls' && best.kind !== 'calls')) best = edge;
+  }
+  return best ? { edge: best, upward: true } : null;
+}
+
+function ambiguitiesOf(
+  tokenNodes: ReadonlyMap<string, string[]>,
+  named: ReadonlyMap<string, Node>,
+  chosen: ReadonlySet<string>,
+  tokens: readonly string[]
+): WireFlowAmbiguity[] {
+  const out: WireFlowAmbiguity[] = [];
+  for (const token of tokens) {
+    const ids = tokenNodes.get(token) ?? [];
+    if (ids.length < 2) continue;
+    const picked = ids.find((id) => chosen.has(id)) ?? null;
+    out.push({
+      token,
+      chosen: picked ? toNodeRef(named.get(picked) as Node) : null,
+      others: ids
+        .filter((id) => id !== picked)
+        .map((id) => named.get(id))
+        .filter((n): n is Node => !!n)
+        .map(toNodeRef),
+    });
+  }
+  return out;
+}
+
+export async function buildFlow(
+  cg: CodeGraph,
+  projectRoot: string,
+  query: URLSearchParams
+): Promise<WireFlowPayload> {
+  const started = Date.now();
+  const parsed = parseFlowQuery(query);
+  const maxFlows = intParam(query, 'limit', { min: 1, max: MAX_FLOWS, default: MAX_FLOWS });
+  const stats = cg.getStats();
+  const cache = new Map<string, FileCache>();
+
+  const base = {
+    flows: [] as WireFlow[],
+    ambiguous: [] as WireFlowAmbiguity[],
+    unresolved: [] as string[],
+    reason: null as string | null,
+    index: {
+      lastIndexedAt: cg.getLastIndexedAt() ?? null,
+      edges: stats.edgeCount,
+      files: stats.fileCount,
+    },
+  };
+
+  if (parsed.kind === 'trail') {
+    const byId = cg.getNodesByIds(parsed.hops.map((h) => h.id));
+    const raw: RawHop[] = [];
+    const missing: string[] = [];
+    for (const hop of parsed.hops) {
+      const node = byId.get(hop.id);
+      if (!node) {
+        missing.push(hop.id);
+        continue;
+      }
+      const previous = raw[raw.length - 1];
+      const link = previous ? edgeBetween(cg, previous.node, node) : null;
+      raw.push({ node, edge: link?.edge ?? null, upward: link?.upward ?? hop.dir === 'up' });
+    }
+    const flows = raw.length >= 2 ? [await toWireFlow(cg, projectRoot, cache, raw)] : [];
+    return {
+      ...base,
+      query: { kind: 'trail', from: null, to: null, symbols: [] },
+      flows,
+      unresolved: missing,
+      reason:
+        flows.length > 0
+          ? null
+          : 'None of the symbols on this trail are still in the index. Re-index, or start a new trail.',
+      timing: { elapsedMs: Date.now() - started },
+    };
+  }
+
+  const directed = parsed.kind === 'directed';
+  const text = directed ? `${parsed.from} ${parsed.to}` : parsed.text;
+  const flow = resolveNamedSymbolFlow(
+    cg,
+    text,
+    directed
+      ? { mode: 'directed', from: parsed.from, to: parsed.to, maxChains: maxFlows }
+      : { mode: 'named', maxChains: maxFlows }
+  );
+
+  const unresolved = flow.tokens.filter((t) => (flow.tokenNodes.get(t) ?? []).length === 0);
+  const chosen = new Set(flow.chains.flatMap((c) => c.steps.map((s) => s.node.id)));
+  const flows: WireFlow[] = [];
+  for (const chain of flow.chains) {
+    const onPath = new Set(chain.steps.map((s) => s.node.id));
+    // A path that reaches everything the question named is connected, and a
+    // connected answer gets no cap — this is the gate the whole feature turns
+    // on. In directed mode a chain ends at `to` by construction, so it is
+    // always connected and this is always empty.
+    const missed = uncoveredNamed(flow, onPath);
+    let boundary: WireFlowBoundary | null = null;
+    if (missed.length > 0) {
+      const stop = (chain.steps[chain.steps.length - 1] as { node: Node }).node;
+      // Scan order is explore's: the dead end first (that IS where the partial
+      // flow stopped), then the symbols it never reached.
+      const reports = findDynamicBoundaries(cg, [stop, ...missed], {
+        named: flow.named,
+        maxSites: MAX_SITES_PER_FLOW,
+      });
+      boundary = buildBoundary(cg, stop, reports, missed, onPath);
+    }
+    // The last card opens at the dispatch line rather than at its definition,
+    // so the window shows the site the cap beside it is describing. Without
+    // this a long body puts them hundreds of lines apart and the cap reads as a
+    // claim about code the reader cannot see.
+    const stopLine = boundary?.sites[0]?.line;
+    flows.push(
+      await toWireFlow(
+        cg,
+        projectRoot,
+        cache,
+        chain.steps.map((s, i) => ({
+          node: s.node,
+          edge: s.edge,
+          upward: false,
+          ...(stopLine !== undefined && i === chain.steps.length - 1 ? { anchor: stopLine } : {}),
+        })),
+        { boundary }
+      )
+    );
+  }
+
+  // No path at all. If a dispatch site explains why, the strip is that site:
+  // one card opened at the line where the static path ends, and the cap. Saying
+  // "not connected" while the answer sits three lines into the body would be
+  // the same silence this whole feature exists to break. With nothing detected
+  // we do NOT invent a stopping point — the search covered a whole region, and
+  // pinning "the graph stops here" on the seed would be a claim, not a finding.
+  if (flows.length === 0 && flow.named.size > 0) {
+    const seeds = boundarySeeds(flow, directed ? parsed.from : null, directed ? parsed.to : null);
+    const reports = findDynamicBoundaries(cg, seeds, {
+      named: flow.named,
+      maxSites: MAX_SITES_PER_FLOW,
+    });
+    const first = reports[0];
+    if (first && first.sites[0]) {
+      const stop = first.node;
+      const missed = uncoveredNamed(flow, new Set([stop.id]));
+      flows.push(
+        await toWireFlow(
+          cg,
+          projectRoot,
+          cache,
+          [{ node: stop, edge: null, upward: false, anchor: first.sites[0].line }],
+          {
+            boundary: buildBoundary(cg, stop, reports, missed, new Set([stop.id])),
+            partial: true,
+          }
+        )
+      );
+    }
+  }
+
+  return {
+    ...base,
+    query: {
+      kind: parsed.kind,
+      from: directed ? parsed.from : null,
+      to: directed ? parsed.to : null,
+      symbols: flow.tokens,
+    },
+    flows,
+    ambiguous: ambiguitiesOf(flow.tokenNodes, flow.named, chosen, flow.tokens),
+    unresolved,
+    // A boundary strip is not a path, so the reason still stands: it says what
+    // was not found, and the cap says where the looking stopped.
+    reason: flow.chains.length > 0 ? null : noFlowReason(parsed, flow.tokens.length, unresolved),
+    timing: { elapsedMs: Date.now() - started },
+  };
+}
+
+/**
+ * The named symbols this path never reaches, deduped by name.
+ *
+ * Per TOKEN, not per node: a token whose overloads are all off the path is
+ * genuinely unreached, but a token with one overload on it is answered — which
+ * is exactly how `codegraph_explore` decides whether to announce a boundary.
+ * The reader's own vocabulary (`uniqueNamedNodeIds`) sorts first, because a
+ * symbol only they named is the one they are actually asking about.
+ */
+function uncoveredNamed(
+  flow: ReturnType<typeof resolveNamedSymbolFlow>,
+  onPath: ReadonlySet<string>
+): Node[] {
+  const out: Node[] = [];
+  const seenName = new Set<string>();
+  for (const ids of flow.tokenNodes.values()) {
+    if (ids.length === 0 || ids.some((id) => onPath.has(id))) continue;
+    for (const id of ids) {
+      const node = flow.named.get(id);
+      if (!node || seenName.has(node.name)) continue;
+      seenName.add(node.name);
+      out.push(node);
+    }
+  }
+  return out.sort(
+    (a, b) =>
+      (flow.uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (flow.uniqueNamedNodeIds.has(a.id) ? 1 : 0)
+  );
+}
+
+/**
+ * Bodies to scan when nothing connected, in the order worth scanning them.
+ *
+ * The outward walk starts at `from`, so a dispatch in `from`'s body is the one
+ * that stopped it; `to`'s body is scanned after, because a flow can equally
+ * break on the far side (the handler is reached by a bus nobody calls
+ * directly). A `?symbols=` question has no direction and scans what it named.
+ */
+function boundarySeeds(
+  flow: ReturnType<typeof resolveNamedSymbolFlow>,
+  from: string | null,
+  to: string | null
+): Node[] {
+  if (from === null || to === null) return [...flow.named.values()];
+  const pick = (token: string): Node[] =>
+    (flow.tokenNodes.get(normalizeToken(token)) ?? [])
+      .map((id) => flow.named.get(id))
+      .filter((n): n is Node => !!n);
+  return [...pick(from), ...pick(to)];
+}
+
+/**
+ * Why there is no strip, in the words that say what to do next.
+ *
+ * "Not connected" is a real answer about this index, not a failure — a flow
+ * that runs through a dynamic dispatch the resolver could not bridge genuinely
+ * has no static path, and saying so is the honest end of the search. CG-51
+ * turns this sentence into the boundary end cap that names the dispatch site.
+ */
+function noFlowReason(
+  parsed: FlowQuery,
+  tokenCount: number,
+  unresolved: readonly string[]
+): string {
+  if (unresolved.length > 0) {
+    return `${unresolved.join(' and ')} ${unresolved.length > 1 ? 'name' : 'names'} nothing in this index.`;
+  }
+  if (parsed.kind === 'directed') {
+    return (
+      `No chain of calls reaches ${parsed.to} from ${parsed.from} within ${DIRECTED_MAX_HOPS} hops. ` +
+      'The path may run through a dynamic dispatch — a callback, a registry, a reflective ' +
+      'call — that no static edge records.'
+    );
+  }
+  if (tokenCount < 2) {
+    return 'Name at least two symbols: a flow is a path between them.';
+  }
+  return 'Those symbols do not call one another, directly or through one intermediate.';
+}

+ 145 - 0
src/ui-server/api/hierarchy.ts

@@ -0,0 +1,145 @@
+/**
+ * The type hierarchy block on `/api/node` — ancestors up, subtypes down, and
+ * the fan an interface call dispatches into (design spec §3.10).
+ *
+ * The walk itself is `src/graph/type-hierarchy.ts`, shared with
+ * `codegraph_explore`'s interface-dispatch announcement so the two can never
+ * print different implementation counts for the same interface. This module is
+ * the renderer: it flattens the tree into rows the viewer can draw without
+ * measuring anything, and caps the fan while keeping the true totals.
+ *
+ * It rides on `/api/node` rather than sitting behind its own endpoint for the
+ * same reason `highlight` rides on `/api/source`: the block is part of the
+ * Symbol view's first paint, and a second round-trip would let the screen
+ * settle and then grow a tree above the code the reader had already started
+ * reading. The cost of carrying it is gated to types — `canHaveHierarchy` is a
+ * kind test, and the overwhelming majority of symbols a reader opens are
+ * functions.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { Node } from '../../types';
+import {
+  buildTypeHierarchy,
+  canHaveHierarchy,
+  type HierarchyEntry,
+  type HierarchyRelation,
+  type TypeHierarchy,
+} from '../../graph/type-hierarchy';
+import { toNodeRef, wireList, type WireList, type WireNodeRef } from './wire';
+
+/** Subtype rows carried on the payload. The viewer folds long fans again at 12. */
+export const MAX_HIERARCHY_DESCENDANTS = 240;
+
+/** Supertype rows carried on the payload. A chain longer than this is generated code. */
+export const MAX_HIERARCHY_ANCESTORS = 24;
+
+/** One type in the tree: the ref, its place, and how it got there. */
+export interface WireHierarchyNode extends WireNodeRef {
+  /** Steps from the focus, in whichever direction the row sits. 1 = direct. */
+  depth: number;
+  /** The row this one hangs off — the focus's id at depth 1. */
+  parentId: string;
+  relation: HierarchyRelation;
+  /**
+   * The edge was synthesized rather than parsed — Go's implicit interface
+   * satisfaction is the common case. Drawn dashed, with `registeredAt` naming
+   * the wiring site, exactly as the Flow strip draws a synthesized hop.
+   */
+  synthesized: boolean;
+  via?: string;
+  registeredAt?: string;
+  /** Direct subtypes of this row that are NOT in the payload. */
+  hiddenSubtypes: number;
+}
+
+/** Everything the type-hierarchy block draws. */
+export interface WireHierarchy {
+  /** Supertypes, nearest first. */
+  ancestors: WireList<WireHierarchyNode>;
+  /** Subtypes, breadth-first: depth 1 is complete before depth 2 starts. */
+  descendants: WireList<WireHierarchyNode>;
+  /** True number of DIRECT subtypes, whatever `descendants` was capped to. */
+  direct: number;
+  /** Of `direct`, the ones tied by `implements` — what a call through the type reaches. */
+  implementers: number;
+  /** Subtypes exist below what the walk returned. */
+  bounded: boolean;
+  /** A call through this type dispatches at runtime rather than to one target. */
+  polymorphic: boolean;
+}
+
+/** A member of the focus that redeclares an ancestor's member. */
+export interface WireOverride {
+  /** The member it redeclares — open it to read what is being replaced. */
+  baseId: string;
+  baseTypeId: string;
+  baseTypeName: string;
+  /** `implements` reads as "satisfies", `extends` as "overrides". */
+  relation: HierarchyRelation;
+}
+
+/**
+ * Build the block, or `null` when there is nothing to draw.
+ *
+ * `null` is the answer for every function, and for a class that neither
+ * extends nor is extended — the viewer draws no empty tree and no "no
+ * hierarchy" note, because a class with no subtypes is the normal case and
+ * saying so on every screen is noise.
+ */
+export function buildHierarchy(
+  cg: CodeGraph,
+  node: Node
+): { wire: WireHierarchy; overrides: Map<string, WireOverride> } | null {
+  if (!canHaveHierarchy(node)) return null;
+  let hierarchy: TypeHierarchy | null;
+  try {
+    hierarchy = buildTypeHierarchy(cg, node);
+  } catch {
+    return null;
+  }
+  if (!hierarchy) return null;
+
+  const ancestors = hierarchy.ancestors.slice(0, MAX_HIERARCHY_ANCESTORS).map(toWireHierarchyNode);
+  const descendants = hierarchy.descendants
+    .slice(0, MAX_HIERARCHY_DESCENDANTS)
+    .map(toWireHierarchyNode);
+
+  const overrides = new Map<string, WireOverride>();
+  for (const [memberId, match] of hierarchy.overrides) {
+    overrides.set(memberId, {
+      baseId: match.baseId,
+      baseTypeId: match.baseTypeId,
+      baseTypeName: match.baseTypeName,
+      relation: match.relation,
+    });
+  }
+
+  return {
+    wire: {
+      ancestors: wireList(ancestors, hierarchy.ancestors.length),
+      descendants: wireList(descendants, hierarchy.descendants.length),
+      direct: hierarchy.directSubtypes,
+      implementers: hierarchy.directImplementers,
+      bounded: hierarchy.bounded,
+      polymorphic: hierarchy.polymorphic,
+    },
+    overrides,
+  };
+}
+
+function toWireHierarchyNode(entry: HierarchyEntry): WireHierarchyNode {
+  const meta = (entry.edge.metadata ?? {}) as Record<string, unknown>;
+  const wire: WireHierarchyNode = {
+    ...toNodeRef(entry.node),
+    depth: entry.depth,
+    parentId: entry.parentId,
+    relation: entry.relation,
+    synthesized: entry.synthesized,
+    hiddenSubtypes: entry.hiddenSubtypes,
+  };
+  if (typeof meta.synthesizedBy === 'string') wire.via = meta.synthesizedBy;
+  else if (typeof meta.via === 'string') wire.via = meta.via;
+  if (typeof meta.registeredAt === 'string') wire.registeredAt = meta.registeredAt;
+  return wire;
+}

+ 404 - 0
src/ui-server/api/index.ts

@@ -0,0 +1,404 @@
+/**
+ * The read-only JSON API the viewer reads its screens from.
+ *
+ * Thirteen endpoints, one per screen, each answering in a single round-trip —
+ * the same principle as `codegraph_explore`: return enough that the caller does
+ * not have to ask a follow-up question — plus one that does not answer at all
+ * and stays open instead (`/api/events`), so a screen learns that its answer
+ * went stale rather than waiting to be asked again.
+ *
+ * All but one are *readers* of the existing schema; nothing here indexes or
+ * resolves. The exception is `/api/trails`, which saves the reader's own named
+ * walks as JSON under `.codegraph/ui/trails/` — the only write the viewer makes,
+ * to the only directory it may write to, and refused outright under
+ * `--read-only`. See `./trail-store.ts`.
+ *
+ * ```
+ * GET /api/stats                     what this index is and how much to trust it
+ * GET /api/search?q=                 the search palette
+ * GET /api/node/<id>                 the Symbol view: rails, members, hierarchy, tests, blast
+ * GET /api/nodes?id=&id=             names for ids you already have (the trail)
+ * GET /api/source?file=&from=&to=    verbatim source, with a drift verdict
+ * GET /api/file/<path>               the File view: outline and import rails
+ * GET /api/filecode/<path>           the whole-file view: ports, arcs, callee rail
+ * GET /api/routes                    the URL to handler map, when there is one
+ * GET /api/entrypoints               where to start reading: routes, roots, tests, hubs
+ * GET /api/map?root=&depth=          the module map: modules, links, cycles
+ * GET /api/deadcode                  symbols nothing reaches, and what was excluded
+ * GET /api/flow?from=&to=            the flow strip: one card per hop
+ * GET /api/events                    the live channel (SSE): drift and refresh
+ * GET /api/trails                    saved trails, re-resolved against the index
+ * POST /api/trails                   save one   (refused under --read-only)
+ * DELETE /api/trails/<id>            remove one (refused under --read-only)
+ * ```
+ *
+ * It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
+ * the loopback boundary in `security.ts`: the `Host` allowlist, the absence of
+ * CORS headers and the method restriction are already enforced by the time a
+ * handler here runs — including the extra shape a write has to have. The one
+ * obligation that remains ours is the path chokepoint, `resolveProjectFile` for
+ * anything that touches the repository. Two modules here reach the filesystem
+ * and no others: `source.ts` reads the project's code, and `trail-store.ts`
+ * reads and writes `.codegraph/ui/trails/`.
+ */
+
+import type { UiApiHandler, UiRequestContext } from '../index';
+import { PathRefusalError } from '../security';
+import { GraphSession } from './session';
+import { ApiError, badRequest, fail, notFound, ok, readJsonBody } from './respond';
+import { buildStats } from './stats';
+import { buildSearch } from './search';
+import { buildNode } from './node';
+import { buildSource } from './source';
+import { buildFile } from './file';
+import { buildFileCode } from './filecode';
+import { buildRoutes } from './routes';
+import { buildEntryPoints } from './entrypoints';
+import { buildNodeRefs } from './nodes';
+import { buildMap } from './map';
+import { buildDeadCode } from './deadcode';
+import { buildFlow } from './flow';
+import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
+import { EventHub } from './events';
+
+export { GraphSession } from './session';
+export { ApiError } from './respond';
+export * from './wire';
+export type {
+  WireEntryPoints,
+  WireEntryFile,
+  WireEntryTest,
+  WireEntryHub,
+} from './entrypoints';
+export type { WireRoute, WireRoutes } from './routes';
+export type { WireHierarchy, WireHierarchyNode, WireOverride } from './hierarchy';
+export { MAX_HIERARCHY_ANCESTORS, MAX_HIERARCHY_DESCENDANTS } from './hierarchy';
+export type { WireNodeRefs } from './nodes';
+export type {
+  WireFlowPayload,
+  WireFlow,
+  WireFlowHop,
+  WireFlowEdge,
+  WireFlowSource,
+  WireFlowCallRef,
+  WireFlowAmbiguity,
+} from './flow';
+export type {
+  WireFileCodePayload,
+  WireFileCall,
+  WireFileOutsideRef,
+} from './filecode';
+export { EventHub, MAX_EVENT_FILES, HEARTBEAT_MS } from './events';
+export type {
+  WireEvent,
+  WireEventHello,
+  WireEventChanged,
+  WireEventIndex,
+  WireEventDegraded,
+  WireIndexRevision,
+} from './events';
+export type {
+  WireMapPayload,
+  WireMapModule,
+  WireMapLink,
+  WireMapCycle,
+} from './map';
+export type {
+  WireDeadCode,
+  WireDeadCodeExclusion,
+  WireDeadCodeGroup,
+  WireDeadCodeRow,
+} from './deadcode';
+export { MAX_DEAD_CODE_MEMBERS, MAX_DEAD_CODE_ROWS } from './deadcode';
+export type {
+  WireTrail,
+  WireTrailHop,
+  WireTrailHopStatus,
+  WireTrails,
+  SaveTrailRequest,
+  TrailsOptions,
+} from './trails';
+export { buildTrails, encodeResolvedRun, resolveHop, resolveTrail } from './trails';
+export type { StoredHop, StoredTrail } from './trail-store';
+export {
+  MAX_TRAILS,
+  MAX_TRAIL_HOPS,
+  MAX_TRAIL_NAME,
+  MAX_TRAIL_NOTE,
+  TRAILS_RELATIVE_DIR,
+  TRAIL_FORMAT_VERSION,
+  isTrailId,
+  listStoredTrails,
+  parseTrail,
+  slugify,
+} from './trail-store';
+
+/**
+ * A mounted API, plus the handle it holds open.
+ *
+ * `close()` releases the index; the CLI calls it on Ctrl-C so the process does
+ * not exit with a live SQLite connection.
+ */
+export interface GraphApi {
+  handler: UiApiHandler;
+  close(): void;
+}
+
+export interface GraphApiOptions {
+  /** Absolute path of the indexed project to read. */
+  projectRoot: string;
+  /**
+   * Refuse every write, so the viewer is a pure reader again.
+   *
+   * The one thing it would otherwise write is a saved trail into
+   * `.codegraph/ui/trails/`. Turning this on is for a checkout that must not
+   * change (a review sandbox, a read-only mount, a shared machine); the viewer
+   * still lists trails that are already there, and says why Save is gone.
+   */
+  readOnly?: boolean;
+  /** The sentence shown in place of Save. Defaults to a generic one. */
+  readOnlyReason?: string;
+}
+
+/** What `GET /api` answers: the endpoint list, for anyone poking at it by hand. */
+const API_INDEX = {
+  name: 'codegraph ui',
+  /**
+   * Every endpoint but `/api/trails` is a pure read. Kept as a field rather
+   * than dropped, because it was `true` and something may be reading it; it is
+   * now the honest, narrower claim.
+   */
+  readOnly: false,
+  writes: ['POST /api/trails', 'DELETE /api/trails/<id>'],
+  endpoints: [
+    { path: '/api/stats', description: 'Index state, graph counts, detected frameworks.' },
+    { path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] },
+    {
+      path: '/api/node/<id>',
+      description:
+        'One symbol: callers, callees, members, type hierarchy, tests, blast radius.',
+    },
+    { path: '/api/nodes', description: 'Names and locations for ids you already have.', params: ['id'] },
+    {
+      path: '/api/source',
+      description: 'Verbatim source for an indexed file, omitted when it has drifted on disk.',
+      params: ['file', 'from', 'to'],
+    },
+    { path: '/api/file/<path>', description: 'One file: outline and import rails.' },
+    {
+      path: '/api/filecode/<path>',
+      description:
+        'One file, line by line: call sites, unresolved references and the calls that stay inside it.',
+    },
+    { path: '/api/routes', description: 'URL to handler map, when the project is a routed app.', params: ['limit'] },
+    {
+      path: '/api/map',
+      description: 'The repository at module granularity: modules, cross-module links, cycles.',
+      params: ['root', 'depth'],
+    },
+    {
+      path: '/api/flow',
+      description: 'The call path between symbols: one hop per card, opened at the calling line.',
+      params: ['from', 'to', 'symbols', 'hop', 'limit'],
+    },
+    {
+      path: '/api/events',
+      description:
+        'Live channel (server-sent events): source files that changed on disk, and the index moving.',
+    },
+    {
+      path: '/api/deadcode',
+      description:
+        'Symbols nothing in the index reaches, grouped by file, with every reason a candidate was excluded.',
+      params: ['limit', 'kinds', 'exported', 'tests', 'generated'],
+    },
+    {
+      path: '/api/entrypoints',
+      description: 'Where to start reading: routes, files that run something, and hubs.',
+      params: ['limit'],
+    },
+    {
+      path: '/api/trails',
+      description:
+        'Saved trails, each hop re-resolved against the current index. POST saves one, ' +
+        'DELETE /api/trails/<id> removes it. The only endpoint that writes.',
+    },
+  ],
+};
+
+export function createGraphApi(options: GraphApiOptions): GraphApi {
+  const session = new GraphSession(options.projectRoot);
+  // Watches nothing until a browser subscribes, and stops again when the last
+  // one goes away — mounting the API costs no watch descriptors.
+  const events = new EventHub(options.projectRoot, session);
+  const trails: TrailsOptions = {
+    readOnly: options.readOnly === true,
+    readOnlyReason:
+      options.readOnly === true
+        ? options.readOnlyReason ?? 'This viewer is running read-only, so trails cannot be saved.'
+        : null,
+  };
+
+  // Async because `/api/source` highlights: everything else answers straight
+  // out of SQLite and resolves on the same tick.
+  const handler: UiApiHandler = async (req, res, ctx) => {
+    const route = normalize(ctx.pathname);
+    try {
+      // Writes first: they are the only requests that carry a body, and
+      // routing them beside the readers would put a `case` that mutates in a
+      // switch every other arm of which is a query.
+      if (ctx.method === 'POST' || ctx.method === 'DELETE') {
+        return await dispatchWrite(route, req, res, ctx, session, trails);
+      }
+      switch (route) {
+        case '/api':
+          return ok(res, API_INDEX, ctx.method);
+        case '/api/stats':
+          return ok(res, buildStats(session.acquire(), ctx.projectRoot), ctx.method);
+        case '/api/search':
+          return ok(res, buildSearch(session.acquire(), ctx.query), ctx.method);
+        case '/api/routes':
+          return ok(res, buildRoutes(session.acquire(), ctx.query), ctx.method);
+        case '/api/map':
+          return ok(res, buildMap(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
+        case '/api/deadcode':
+          return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
+        case '/api/entrypoints':
+          return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method);
+        case '/api/trails':
+          return ok(res, buildTrails(session.acquire(), ctx.projectRoot, trails), ctx.method);
+        case '/api/nodes':
+          return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
+        case '/api/source':
+          return ok(res, await buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
+        case '/api/flow':
+          return ok(res, await buildFlow(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
+        case '/api/events':
+          // Streams instead of answering: it writes its own headers and keeps
+          // the socket open, so it never goes through `ok()`.
+          return events.subscribe(req, res, ctx.method);
+        default:
+          return dispatchPathRoutes(route, res, ctx, session);
+      }
+    } catch (err) {
+      // A refusal from the read chokepoint is a 403 with the reason attached —
+      // the request asked for something outside the project, and there is no
+      // version of it we would serve.
+      if (err instanceof PathRefusalError) {
+        return fail(res, new ApiError('refused', err.message), ctx.method);
+      }
+      return fail(res, err, ctx.method);
+    }
+  };
+
+  return {
+    handler,
+    close: () => {
+      // Streams first: a client still attached would hold the socket open
+      // against the server's own close.
+      events.close();
+      session.close();
+    },
+  };
+}
+
+/**
+ * The write half: `/api/trails` and nothing else.
+ *
+ * Kept to one function so the answer to "what can this server change?" is one
+ * place a reviewer can read in full. Anything else that arrives with a write
+ * method is a 405 naming the endpoint that does accept one — by the time this
+ * runs, `isWriteRequest` has already established the request could not have
+ * been forged from another origin, so an unhelpfully vague refusal here would
+ * only confuse the person poking at their own API.
+ */
+async function dispatchWrite(
+  route: string,
+  req: Parameters<UiApiHandler>[0],
+  res: Parameters<UiApiHandler>[1],
+  ctx: UiRequestContext,
+  session: GraphSession,
+  trails: TrailsOptions
+): Promise<boolean> {
+  if (ctx.method === 'POST' && route === '/api/trails') {
+    const body = await readJsonBody(req);
+    return ok(res, saveTrail(session.acquire(), ctx.projectRoot, body, trails), ctx.method);
+  }
+
+  if (ctx.method === 'DELETE') {
+    const id = suffixAfter(route, '/api/trails/');
+    if (id !== null && id !== '') {
+      return ok(res, removeTrail(session.acquire(), ctx.projectRoot, id, trails), ctx.method);
+    }
+    if (route === '/api/trails') {
+      throw badRequest('Deleting a trail needs its id: DELETE /api/trails/<id>.');
+    }
+  }
+
+  res.setHeader('Allow', 'GET, HEAD');
+  throw new ApiError(
+    'bad-request',
+    `${ctx.method} ${route} is not something this server changes.`,
+    'The only endpoint that writes is /api/trails (POST to save, DELETE /api/trails/<id> to remove).'
+  );
+}
+
+/**
+ * The two endpoints that carry their argument in the path.
+ *
+ * `ctx.pathname` is already percent-decoded, so a node id or a file path
+ * containing `/` (`file:src/a.ts`) arrives whole — the remainder after the
+ * prefix IS the argument, slashes and all. Node ids are opaque: they go
+ * straight to an exact lookup, and anything that names nothing is a 404. File
+ * paths go through the read chokepoint before anything is opened.
+ */
+function dispatchPathRoutes(
+  route: string,
+  res: Parameters<UiApiHandler>[1],
+  ctx: UiRequestContext,
+  session: GraphSession
+): boolean {
+  const nodeId = suffixAfter(route, '/api/node/');
+  if (nodeId !== null) {
+    if (nodeId === '') throw badRequest('No symbol id was given. Use /api/node/<id>.');
+    return ok(res, buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
+  }
+
+  // Before `/api/file/`: that prefix is not a prefix of this route, but keeping
+  // the more specific one first means adding another `/api/file…` sibling later
+  // cannot silently start matching the shorter one.
+  const codePath = suffixAfter(route, '/api/filecode/');
+  if (codePath !== null) {
+    return ok(res, buildFileCode(session.acquire(), ctx.projectRoot, codePath), ctx.method);
+  }
+
+  const filePath = suffixAfter(route, '/api/file/');
+  if (filePath !== null) {
+    if (filePath === '') throw badRequest('No file path was given. Use /api/file/<path>.');
+    return ok(res, buildFile(session.acquire(), ctx.projectRoot, filePath), ctx.method);
+  }
+
+  // `/api/node` and `/api/file` with no argument at all, so the message can say
+  // what the endpoint wants instead of falling through to a bare 404.
+  if (route === '/api/filecode') {
+    throw badRequest('/api/filecode needs an argument: /api/filecode/<path>.');
+  }
+
+  if (route === '/api/node' || route === '/api/file') {
+    throw badRequest(`${route} needs an argument: ${route}/<${route.endsWith('node') ? 'id' : 'path'}>.`);
+  }
+
+  throw notFound(
+    `No such endpoint: ${route}`,
+    'GET /api lists everything this server answers.'
+  );
+}
+
+/** Drop a single trailing slash, so `/api/stats/` and `/api/stats` are one route. */
+function normalize(pathname: string): string {
+  return pathname.length > 4 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
+}
+
+function suffixAfter(route: string, prefix: string): string | null {
+  return route.startsWith(prefix) ? route.slice(prefix.length) : null;
+}

+ 600 - 0
src/ui-server/api/map.ts

@@ -0,0 +1,600 @@
+/**
+ * `GET /api/map` — the repository at module granularity.
+ *
+ * The Map answers "what is in here and how is it organised" without anybody
+ * having drawn a diagram: modules are directories, the arrows between them are
+ * the edges the index already holds, and the vertical order falls out of the
+ * dependency direction (design spec §3.6). This module produces the *data*;
+ * the layering, cycle-breaking and geometry are pure functions in the viewer
+ * (`ui/src/lib/map-model.ts`), so toggling tests or selecting a module never
+ * costs a round-trip.
+ *
+ * Three decisions shape the payload, and all three are about not lying:
+ *
+ * **A module is a directory, not a guess.** `moduleIdFor` maps each indexed
+ * file to the first {@link MapQuery.depth} path segments under the chosen root.
+ * A file sitting loose in the root gets folded into one `(root files)` box —
+ * except a façade (`index.ts`, `lib.rs`, `__init__.py`), which is its own box
+ * because it is the thing everything else imports. No clustering, no
+ * heuristics about "what belongs together": if two files are in the same
+ * directory the repository already said they belong together.
+ *
+ * **Weight counts edges; layering counts *declared* edges.** A link's `count`
+ * is every confident cross-module edge behind it, which is what the reader
+ * sees as thickness. Its `declared` count is the subset resolved through an
+ * import, a qualified name, an inheritance clause or a typed receiver — and
+ * that is what the layout layers on. The difference is not academic: on this
+ * repository, bare name matching resolves calls to `run`, `push` and `finish`
+ * across unrelated directories, and layering on raw counts puts the storage
+ * layer directly under the CLI. Layering on declared edges reproduces the
+ * pipeline the project's own docs describe.
+ *
+ * **Nothing is dropped silently.** Uncertain edges (confidence below
+ * {@link UNCERTAIN_BELOW}) are excluded from every count, and how many were
+ * excluded rides on the payload so the side panel can say so.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { EdgeKind, Language } from '../../types';
+import { isTestFile } from '../../search/query-utils';
+import { badRequest } from './respond';
+import { UNCERTAIN_BELOW, toPosixPath, wireList, type WireList } from './wire';
+
+/**
+ * The edge kinds that count as "module A reaches into module B".
+ *
+ * `contains` is absent on purpose — a file containing its own symbols is not a
+ * dependency, and including it would make every module depend on itself.
+ */
+export const MAP_EDGE_KINDS: readonly EdgeKind[] = [
+  'calls',
+  'imports',
+  'references',
+  'instantiates',
+  'extends',
+  'implements',
+];
+
+/**
+ * The kinds whose symbol pairs the tooltip names.
+ *
+ * A `references` edge to a type is real traffic but "Config → Config" is not
+ * an interesting row; calls and imports are what a reader wants named.
+ */
+const PAIR_EDGE_KINDS: readonly EdgeKind[] = ['calls', 'imports', 'instantiates'];
+
+/** Symbol pairs kept per link — the tooltip shows four (design spec §3.6). */
+const TOP_PAIRS_PER_LINK = 4;
+
+/**
+ * File paths listed per module.
+ *
+ * The panel's file list is a drill-down, not a directory listing, and it rides
+ * on this payload so that clicking a module and then one of its files costs no
+ * round-trip at all. Capped because a module can hold hundreds of files and the
+ * map is not where you read them; `total` stays the real number.
+ */
+const MAX_FILES_PER_MODULE = 40;
+
+/** Longest cycle reported, and how many. Beyond this a cycle list stops being readable. */
+const MAX_FILE_CYCLES = 40;
+const MAX_CYCLE_LENGTH = 12;
+
+/** Default segments below the root that name a module. */
+const DEFAULT_DEPTH = 1;
+const MAX_DEPTH = 4;
+
+/**
+ * Basenames that stay their own box when they sit loose in a module root.
+ *
+ * These are façades — the file every other module imports the directory
+ * *through*. Folding `src/index.ts` into a "(root files)" bucket with the type
+ * declarations next to it hides the busiest node on the map.
+ */
+const FACADE_STEMS = new Set(['index', 'main', 'lib', 'mod', '__init__', 'init']);
+
+/** Id of the bucket loose files fall into. Deliberately not a real directory name. */
+export function rootFilesId(root: string): string {
+  return root ? `${root}/(root files)` : '(root files)';
+}
+
+// =============================================================================
+// Wire shapes
+// =============================================================================
+
+export interface WireMapModule {
+  /** Directory path, or the `(root files)` bucket, or a façade file's own path. */
+  id: string;
+  /** Last path segment — what the node label shows when the id is long. */
+  label: string;
+  files: number;
+  symbols: number;
+  /** File count by language, most files first. */
+  languages: Array<{ language: Language; files: number }>;
+  /** More than half its files are tests — drawn dashed, hidden by default. */
+  test: boolean;
+  /**
+   * How many of its files are tool-generated. A module whose files are ALL
+   * generated is drawn in ink-4 (design spec §2.6): code nobody wrote by hand
+   * and nobody deletes by hand.
+   */
+  generated: number;
+  /** Which of {@link fileList}'s entries are generated, so a row can dim too. */
+  generatedFiles: string[];
+  /** True when this box is a single file kept out of the root bucket (a façade). */
+  facade: boolean;
+  /** Its files, capped — what the side panel lists when the module is selected. */
+  fileList: WireList<string>;
+}
+
+export interface WireMapLink {
+  source: string;
+  target: string;
+  /** Every confident cross-module edge behind this link. Drives thickness. */
+  count: number;
+  /**
+   * The subset resolved through an import, a qualified name, an inheritance
+   * clause or a typed receiver. Drives the layering — see the module header.
+   */
+  declared: number;
+  /** `count` broken down by edge kind, biggest first. */
+  byKind: Array<{ kind: EdgeKind; count: number }>;
+  /**
+   * The busiest symbol pairs behind the link, at most
+   * {@link TOP_PAIRS_PER_LINK}, declared ones first.
+   */
+  topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
+}
+
+export interface WireMapCycle {
+  /** How many files are in the component. `files` may be shorter. */
+  size: number;
+  /** The files, capped — a 200-file knot is a fact, not a list anybody reads. */
+  files: string[];
+  /** The modules the cycle passes through, deduped in order. */
+  modules: string[];
+}
+
+export interface WireMapPayload {
+  root: string;
+  depth: number;
+  /** Every root the selector may offer, this index's own directories. */
+  roots: Array<{ root: string; label: string; files: number }>;
+  modules: WireMapModule[];
+  links: WireMapLink[];
+  /**
+   * File-level circular dependencies — the strongly connected components of
+   * the file graph, which is what `findCircularDependencies` reports, computed
+   * from one query so it stays affordable on a large index.
+   */
+  cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
+  excluded: {
+    /** Cross-module edges left out for being name-only guesses. */
+    uncertainEdges: number;
+    /** The confidence floor applied. */
+    confidenceBelow: number;
+  };
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  /** How long the aggregation took, and whether this answer came from the cache. */
+  timing: { elapsedMs: number; cached: boolean };
+}
+
+export interface MapQuery {
+  root: string;
+  depth: number;
+}
+
+// =============================================================================
+// Module naming
+// =============================================================================
+
+/** Strip a trailing slash and any leading `./`, so `src/` and `src` are one root. */
+export function normalizeRoot(raw: string | undefined): string {
+  let root = (raw ?? '').trim().replace(/\\/g, '/');
+  while (root.startsWith('./')) root = root.slice(2);
+  while (root.endsWith('/')) root = root.slice(0, -1);
+  if (root === '.' || root === '/') return '';
+  return root;
+}
+
+function stemOf(basename: string): string {
+  const dot = basename.indexOf('.');
+  return dot <= 0 ? basename : basename.slice(0, dot);
+}
+
+/**
+ * Which module a file belongs to, or `null` when it is outside the root.
+ *
+ * `depth` segments under the root name the module. A file with fewer segments
+ * than that is loose in the root: a façade keeps its own box, everything else
+ * joins the `(root files)` bucket.
+ */
+export function moduleIdFor(
+  filePath: string,
+  root: string,
+  depth: number
+): { id: string; facade: boolean } | null {
+  const path = toPosixPath(filePath);
+  let rel = path;
+  if (root) {
+    if (!path.startsWith(`${root}/`)) return null;
+    rel = path.slice(root.length + 1);
+  }
+  const parts = rel.split('/').filter(Boolean);
+  if (parts.length === 0) return null;
+  if (parts.length <= depth) {
+    // A loose file. The directories it DOES have still qualify it, so
+    // `src/a/b.ts` at depth 2 lands in `src/a/(root files)`, not the top one.
+    const dir = [root, ...parts.slice(0, -1)].filter(Boolean).join('/');
+    if (FACADE_STEMS.has(stemOf(parts[parts.length - 1] ?? ''))) {
+      return { id: [root, ...parts].filter(Boolean).join('/'), facade: true };
+    }
+    return { id: rootFilesId(dir), facade: false };
+  }
+  return { id: [root, ...parts.slice(0, depth)].filter(Boolean).join('/'), facade: false };
+}
+
+/**
+ * The root the map opens on: the directory holding the most non-test symbols.
+ *
+ * A repository's source almost always lives under one directory (`src`, `lib`,
+ * `pkg`, `app`), and opening there is what keeps the default map about the
+ * program rather than about its tests, scripts and sibling packages. The
+ * fallback is the repository root, which is correct for a flat project.
+ *
+ * A directory only wins if it holds a clear majority of the symbols — anything
+ * less and the honest answer is "this repository has no single source root".
+ */
+export function pickDefaultRoot(
+  files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>
+): string {
+  const byDir = new Map<string, number>();
+  let total = 0;
+  for (const file of files) {
+    if (file.test) continue;
+    const slash = file.path.indexOf('/');
+    if (slash <= 0) continue;
+    const dir = file.path.slice(0, slash);
+    byDir.set(dir, (byDir.get(dir) ?? 0) + file.symbols);
+    total += file.symbols;
+  }
+  if (total === 0) return '';
+  let best = '';
+  let bestSymbols = 0;
+  for (const [dir, symbols] of [...byDir].sort((a, b) => a[0].localeCompare(b[0]))) {
+    if (symbols > bestSymbols) {
+      best = dir;
+      bestSymbols = symbols;
+    }
+  }
+  return bestSymbols * 2 > total ? best : '';
+}
+
+// =============================================================================
+// Cache
+// =============================================================================
+
+/**
+ * One aggregation per (project, index build, root, depth).
+ *
+ * The map is the one screen whose cost is proportional to the whole edge
+ * table, so it is also the one screen worth caching. Keyed on the index's
+ * stamp AND its edge count, exactly as the blast scale is: a re-index or a
+ * sync that only moved edges must invalidate it, or the map draws a shape the
+ * code no longer has. A handful of entries, because the root selector is the
+ * only thing that varies.
+ */
+const CACHE_LIMIT = 8;
+const cache = new Map<string, WireMapPayload>();
+
+export function resetMapCache(): void {
+  cache.clear();
+}
+
+// =============================================================================
+// Build
+// =============================================================================
+
+export function parseMapQuery(query: URLSearchParams): { root: string | null; depth: number } {
+  const rawDepth = query.get('depth');
+  let depth = DEFAULT_DEPTH;
+  if (rawDepth !== null && rawDepth !== '') {
+    depth = Number.parseInt(rawDepth, 10);
+    if (!Number.isFinite(depth) || depth < 1 || depth > MAX_DEPTH) {
+      throw badRequest(`depth must be a whole number from 1 to ${MAX_DEPTH}.`);
+    }
+  }
+  const rawRoot = query.get('root');
+  return { root: rawRoot === null ? null : normalizeRoot(rawRoot), depth };
+}
+
+export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload {
+  const started = Date.now();
+  const { root: requestedRoot, depth } = parseMapQuery(query);
+
+  const fileRecords = cg.getFiles().map((file) => {
+    const path = toPosixPath(file.path);
+    return {
+      path,
+      language: file.language,
+      symbols: file.nodeCount ?? 0,
+      test: isTestFile(path),
+      generated: file.generated === true,
+    };
+  });
+
+  const root = requestedRoot ?? pickDefaultRoot(fileRecords);
+  const stats = cg.getStats();
+  const key = [
+    projectRoot,
+    cg.getLastIndexedAt() ?? 0,
+    stats.edgeCount,
+    stats.fileCount,
+    root,
+    depth,
+  ].join('\u0000');
+  const hit = cache.get(key);
+  if (hit) {
+    // Re-stamp rather than mutate: the cached body is shared, and a caller
+    // must not see another request's elapsed time.
+    return { ...hit, timing: { elapsedMs: Date.now() - started, cached: true } };
+  }
+
+  const assignments: Array<{ filePath: string; module: string }> = [];
+  const modules = new Map<
+    string,
+    {
+      id: string;
+      facade: boolean;
+      files: number;
+      symbols: number;
+      testFiles: number;
+      generatedFiles: number;
+      generatedPaths: Set<string>;
+      languages: Map<Language, number>;
+      paths: string[];
+    }
+  >();
+  const moduleOfFile = new Map<string, string>();
+
+  for (const file of fileRecords) {
+    const assigned = moduleIdFor(file.path, root, depth);
+    if (assigned === null) continue;
+    assignments.push({ filePath: file.path, module: assigned.id });
+    moduleOfFile.set(file.path, assigned.id);
+    let entry = modules.get(assigned.id);
+    if (!entry) {
+      entry = {
+        id: assigned.id,
+        facade: assigned.facade,
+        files: 0,
+        symbols: 0,
+        testFiles: 0,
+        generatedFiles: 0,
+        generatedPaths: new Set(),
+        languages: new Map(),
+        paths: [],
+      };
+      modules.set(assigned.id, entry);
+    }
+    entry.files += 1;
+    entry.paths.push(file.path);
+    entry.symbols += file.symbols;
+    if (file.test) entry.testFiles += 1;
+    if (file.generated) {
+      entry.generatedFiles += 1;
+      entry.generatedPaths.add(file.path);
+    }
+    entry.languages.set(file.language, (entry.languages.get(file.language) ?? 0) + 1);
+  }
+
+  const aggregation = cg.getModuleAggregation(assignments, {
+    kinds: MAP_EDGE_KINDS,
+    minConfidence: UNCERTAIN_BELOW,
+    topPairsPerLink: TOP_PAIRS_PER_LINK,
+    pairKinds: PAIR_EDGE_KINDS,
+  });
+
+  const links = new Map<string, WireMapLink>();
+  let uncertainEdges = 0;
+  for (const row of aggregation.links) {
+    // The same pass counts what the confidence floor left out, so the "N
+    // name-only matches excluded" note reports the number the map actually
+    // applied rather than a second query's opinion of it.
+    uncertainEdges += row.uncertain;
+    if (row.count === 0) continue;
+    const id = `${row.source}\u0000${row.target}`;
+    let link = links.get(id);
+    if (!link) {
+      link = { source: row.source, target: row.target, count: 0, declared: 0, byKind: [], topPairs: [] };
+      links.set(id, link);
+    }
+    link.count += row.count;
+    link.declared += row.declared;
+    link.byKind.push({ kind: row.kind, count: row.count });
+  }
+  for (const link of links.values()) {
+    link.byKind.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
+  }
+  for (const pair of aggregation.pairs) {
+    const link = links.get(`${pair.source}\u0000${pair.target}`);
+    if (link && link.topPairs.length < TOP_PAIRS_PER_LINK) {
+      link.topPairs.push({
+        from: pair.from,
+        to: pair.to,
+        count: pair.count,
+        declared: pair.declared,
+      });
+    }
+  }
+
+  const payload: WireMapPayload = {
+    root,
+    depth,
+    roots: rootOptions(fileRecords),
+    modules: [...modules.values()]
+      .map((entry) => {
+        const shown = entry.paths.slice().sort().slice(0, MAX_FILES_PER_MODULE);
+        return {
+          id: entry.id,
+          label: entry.id.slice(entry.id.lastIndexOf('/') + 1) || entry.id,
+          files: entry.files,
+          symbols: entry.symbols,
+          languages: [...entry.languages]
+            .map(([language, files]) => ({ language, files }))
+            .sort((a, b) => b.files - a.files || a.language.localeCompare(b.language)),
+          test: entry.testFiles * 2 > entry.files,
+          generated: entry.generatedFiles,
+          facade: entry.facade,
+          // Only the SHOWN paths, so the list the panel dims and the list it
+          // draws are the same list — the count-equals-list rule.
+          generatedFiles: shown.filter((path) => entry.generatedPaths.has(path)),
+          fileList: wireList(shown, entry.files),
+        };
+      })
+      // Sorted so two runs over one index produce byte-identical payloads —
+      // the layout is deterministic, and it cannot be if its input is not.
+      .sort((a, b) => a.id.localeCompare(b.id)),
+    links: [...links.values()].sort(
+      (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
+    ),
+    cycles: fileCycles(cg, moduleOfFile),
+    excluded: { uncertainEdges, confidenceBelow: UNCERTAIN_BELOW },
+    index: {
+      lastIndexedAt: cg.getLastIndexedAt(),
+      edges: stats.edgeCount,
+      files: stats.fileCount,
+    },
+    timing: { elapsedMs: Date.now() - started, cached: false },
+  };
+
+  if (cache.size >= CACHE_LIMIT) {
+    const oldest = cache.keys().next();
+    if (!oldest.done) cache.delete(oldest.value);
+  }
+  cache.set(key, payload);
+  return payload;
+}
+
+/**
+ * File-level circular dependencies, as strongly connected components.
+ *
+ * Tarjan over the one-query file edge list. Components of size 1 are not
+ * cycles (a file depending on itself is a same-file edge, already excluded),
+ * and a component longer than {@link MAX_CYCLE_LENGTH} is reported truncated
+ * rather than printed — a 200-file knot is a fact about the repository, not a
+ * list anybody reads.
+ */
+function fileCycles(
+  cg: CodeGraph,
+  moduleOfFile: Map<string, string>
+): WireMapPayload['cycles'] {
+  const adjacency = new Map<string, string[]>();
+  for (const pair of cg.getFileDependencyPairs(UNCERTAIN_BELOW)) {
+    if (!moduleOfFile.has(pair.source) || !moduleOfFile.has(pair.target)) continue;
+    let out = adjacency.get(pair.source);
+    if (!out) adjacency.set(pair.source, (out = []));
+    out.push(pair.target);
+  }
+  // Deterministic iteration: SQLite's DISTINCT ordering is not a contract.
+  const nodes = [...new Set([...adjacency.keys(), ...[...adjacency.values()].flat()])].sort();
+  for (const list of adjacency.values()) list.sort();
+
+  const components = tarjan(nodes, (id) => adjacency.get(id) ?? []);
+  const cycles = components
+    .filter((component) => component.length > 1)
+    .map((component) => component.slice().sort())
+    .sort((a, b) => a.length - b.length || (a[0] ?? '').localeCompare(b[0] ?? ''));
+
+  const items = cycles.slice(0, MAX_FILE_CYCLES).map((files) => ({
+    size: files.length,
+    files: files.slice(0, MAX_CYCLE_LENGTH),
+    modules: [...new Set(files.map((file) => moduleOfFile.get(file) ?? file))].sort(),
+  }));
+  return {
+    total: cycles.length,
+    shown: items.length,
+    truncated: cycles.length > items.length,
+    items,
+  };
+}
+
+/** Tarjan's strongly connected components, iterative so a deep graph cannot blow the stack. */
+function tarjan(nodes: readonly string[], edgesOf: (id: string) => readonly string[]): string[][] {
+  const index = new Map<string, number>();
+  const low = new Map<string, number>();
+  const onStack = new Set<string>();
+  const stack: string[] = [];
+  const out: string[][] = [];
+  let counter = 0;
+
+  for (const start of nodes) {
+    if (index.has(start)) continue;
+    const work: Array<{ id: string; edges: readonly string[]; at: number }> = [
+      { id: start, edges: edgesOf(start), at: 0 },
+    ];
+    index.set(start, counter);
+    low.set(start, counter);
+    counter += 1;
+    stack.push(start);
+    onStack.add(start);
+
+    while (work.length > 0) {
+      const frame = work[work.length - 1];
+      if (frame === undefined) break;
+      if (frame.at < frame.edges.length) {
+        const next = frame.edges[frame.at]!;
+        frame.at += 1;
+        if (!index.has(next)) {
+          index.set(next, counter);
+          low.set(next, counter);
+          counter += 1;
+          stack.push(next);
+          onStack.add(next);
+          work.push({ id: next, edges: edgesOf(next), at: 0 });
+        } else if (onStack.has(next)) {
+          low.set(frame.id, Math.min(low.get(frame.id) ?? 0, index.get(next) ?? 0));
+        }
+        continue;
+      }
+      work.pop();
+      if (low.get(frame.id) === index.get(frame.id)) {
+        const component: string[] = [];
+        for (;;) {
+          const popped = stack.pop();
+          if (popped === undefined) break;
+          onStack.delete(popped);
+          component.push(popped);
+          if (popped === frame.id) break;
+        }
+        out.push(component);
+      }
+      const parent = work[work.length - 1];
+      if (parent) low.set(parent.id, Math.min(low.get(parent.id) ?? 0, low.get(frame.id) ?? 0));
+    }
+  }
+  return out;
+}
+
+/**
+ * The roots the selector offers: the repository root plus every top-level
+ * directory that holds indexed files, biggest first.
+ *
+ * A monorepo's answer to "which project am I looking at" — and on a single
+ * project it is a one-line list nobody has to use.
+ */
+function rootOptions(
+  files: ReadonlyArray<{ path: string; symbols: number }>
+): WireMapPayload['roots'] {
+  const byDir = new Map<string, number>();
+  for (const file of files) {
+    const slash = file.path.indexOf('/');
+    if (slash <= 0) continue;
+    const dir = file.path.slice(0, slash);
+    byDir.set(dir, (byDir.get(dir) ?? 0) + 1);
+  }
+  const dirs = [...byDir]
+    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+    .map(([root, count]) => ({ root, label: root, files: count }));
+  return [{ root: '', label: 'whole repository', files: files.length }, ...dirs];
+}

+ 493 - 0
src/ui-server/api/node.ts

@@ -0,0 +1,493 @@
+/**
+ * `GET /api/node/<id>` — everything the Symbol view draws, in one round-trip.
+ *
+ * The Symbol view is three panes and a strip: callers on the left, the verbatim
+ * body in the middle with a port per call site, callees on the right anchored
+ * to those lines, and a blast-radius summary underneath. Splitting that across
+ * five endpoints would mean five waterfalls before the screen settles, and the
+ * screen is the product. So this endpoint answers all of it.
+ *
+ * Two properties it has to hold, and the reasons they are not obvious:
+ *
+ * **No N+1, anywhere.** The engine's own busiest symbol has 545 incoming edges.
+ * Resolving those one `getNode` at a time is 545 queries and blows the budget on
+ * its own; so every edge list is resolved with one batched `getNodesByIds`, and
+ * fan-in for the rail pills comes from one batched `getFanIn`.
+ *
+ * **Capped lists that still tell the truth.** 545 callers cannot all be rows,
+ * but the payload must never suggest there are fewer. Every list carries the
+ * true `total` beside the `shown` slice, and the ordering is chosen so the
+ * slice is the useful end: same file first, then production code, then tests.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { Edge, Node, NodeKind } from '../../types';
+import { isTestFile } from '../../search/query-utils';
+import { buildHierarchy, type WireOverride } from './hierarchy';
+import { notFound } from './respond';
+import { findIndexedFile, hasDriftedOnDisk } from './source';
+import {
+  BLAST_DEPTH,
+  CALLER_EDGE_KINDS,
+  CONTAINER_KINDS,
+  HUB_THRESHOLD,
+  MAX_INCOMING_GROUPS,
+  MAX_OUTGOING_GROUPS,
+  MAX_OUTLINE_NODES,
+  MAX_OUTSIDE_INDEX_SAMPLES,
+  MAX_TEST_FILES,
+  TEST_CALLER_BUDGET,
+  TEST_CALLER_HOPS,
+  TYPE_KINDS,
+  firstLine,
+  groupRelations,
+  toNodeDetail,
+  toNodeRef,
+  toPosixPath,
+  wireList,
+  type WireNodeRef,
+} from './wire';
+
+/** A member row in the focal symbol's outline, with its place in the tree. */
+export interface WireMember extends WireNodeRef {
+  /** The container this member belongs to — the focal node, or one of its children. */
+  parentId: string;
+  /** 1 = direct member, 2 = a member of a member (a class's method inside a file). */
+  depth: number;
+  /**
+   * Edges in and out of this member — the outline's `← in  → out` columns.
+   *
+   * A container's own fan-out is usually zero (a class calls nothing; its
+   * methods do), so without these an outline of a 700-line class says nothing
+   * about which member is load-bearing and which is a getter. Edge counts, not
+   * distinct counterparts: the column is a weight, and it sits beside a
+   * signature rather than beside a caller list it could contradict.
+   */
+  fanIn: number;
+  fanOut: number;
+  /**
+   * This member redeclares one an ancestor type declares — a name match inside
+   * a chain the graph already links, not an `overrides` edge (nothing emits
+   * one). Absent for every member that declares something new.
+   */
+  overrides?: WireOverride;
+}
+
+export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
+  const node = cg.getNode(nodeId);
+  if (!node) {
+    throw notFound(
+      'No symbol with that id is in this index.',
+      'Symbol ids change whenever the file is re-indexed — search for the symbol by ' +
+        'name instead of reusing an id from an older session.'
+    );
+  }
+
+  const incomingAll = cg.getIncomingEdges(nodeId);
+  const outgoingAll = cg.getOutgoingEdges(nodeId);
+
+  // `contains` is structure, not dependency: upward it is the parent (already in
+  // `ancestors`), downward it is the members outline. Leaving it in the rails
+  // would put a symbol's own class in its caller list.
+  const incoming = incomingAll.filter((e) => e.kind !== 'contains');
+  const outgoingRest: Edge[] = [];
+  const containsOut: Edge[] = [];
+  for (const edge of outgoingAll) {
+    if (edge.kind === 'contains') containsOut.push(edge);
+    else outgoingRest.push(edge);
+  }
+
+  const ancestors = cg.getAncestors(nodeId);
+
+  // ---------------------------------------------------------------------------
+  // One batched resolve for every endpoint this payload names.
+  // ---------------------------------------------------------------------------
+  const endpointIds = new Set<string>();
+  for (const edge of incoming) endpointIds.add(edge.source);
+  for (const edge of outgoingRest) endpointIds.add(edge.target);
+  for (const edge of containsOut) endpointIds.add(edge.target);
+  const endpoints = cg.getNodesByIds([...endpointIds]);
+
+  // A `references` edge into a type is "uses type X", not "calls X" — the
+  // header shows those as chips rather than as callee rows. Split at the EDGE
+  // level so a class that is both instantiated and named as a type appears in
+  // both places, which is what the source actually says.
+  const calleeEdges: Edge[] = [];
+  const typeRefs: Edge[] = [];
+  for (const edge of outgoingRest) {
+    const target = endpoints.get(edge.target);
+    if (edge.kind === 'references' && target && TYPE_KINDS.has(target.kind)) typeRefs.push(edge);
+    else calleeEdges.push(edge);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Rails
+  // ---------------------------------------------------------------------------
+  const focalFile = toPosixPath(node.filePath);
+
+  const incomingGroups = groupRelations(incoming, (e) => e.source, endpoints);
+  incomingGroups.sort((a, b) => {
+    // The symbol's own file first ("same file" in the left rail), then
+    // production code, then tests — so a cap trims the least useful end.
+    const aSame = a.node.file === focalFile ? 0 : 1;
+    const bSame = b.node.file === focalFile ? 0 : 1;
+    if (aSame !== bSame) return aSame - bSame;
+    if (a.node.test !== b.node.test) return a.node.test ? 1 : -1;
+    return a.node.file.localeCompare(b.node.file) || firstLine(a) - firstLine(b);
+  });
+
+  const outgoingGroups = groupRelations(calleeEdges, (e) => e.target, endpoints);
+  // The right rail is line-anchored: rows sit beside the line that calls them.
+  outgoingGroups.sort((a, b) => firstLine(a) - firstLine(b) || a.node.name.localeCompare(b.node.name));
+
+  const typeGroups = groupRelations(typeRefs, (e) => e.target, endpoints);
+  typeGroups.sort((a, b) => firstLine(a) - firstLine(b) || a.node.name.localeCompare(b.node.name));
+
+  const shownIncoming = incomingGroups.slice(0, MAX_INCOMING_GROUPS);
+  const shownOutgoing = outgoingGroups.slice(0, MAX_OUTGOING_GROUPS);
+
+  // Fan-in for the rail pills ("hub · N"), for the rows actually returned —
+  // one query, not one per row.
+  const fanInOf = cg.getFanIn([
+    ...shownIncoming.map((r) => r.node.id),
+    ...shownOutgoing.map((r) => r.node.id),
+    ...typeGroups.map((r) => r.node.id),
+  ]);
+  for (const relation of [...shownIncoming, ...shownOutgoing, ...typeGroups]) {
+    const count = fanInOf.get(relation.node.id) ?? 0;
+    relation.fanIn = count;
+    relation.hub = count >= HUB_THRESHOLD;
+  }
+
+  // ---------------------------------------------------------------------------
+  // Members outline
+  // ---------------------------------------------------------------------------
+  // The type hierarchy, and the override marks it puts on the outline. Gated
+  // to types inside `buildHierarchy`, so a function costs one kind test.
+  const hierarchy = buildHierarchy(cg, node);
+  const members = buildMembers(cg, node, containsOut, endpoints, hierarchy?.overrides);
+
+  // ---------------------------------------------------------------------------
+  // Counts, tests, what leaves the index, blast radius
+  // ---------------------------------------------------------------------------
+  const directCallers: Node[] = [];
+  const seenCaller = new Set<string>();
+  for (const edge of incoming) {
+    if (!CALLER_EDGE_KINDS.has(edge.kind) || seenCaller.has(edge.source)) continue;
+    seenCaller.add(edge.source);
+    const source = endpoints.get(edge.source);
+    if (source) directCallers.push(source);
+  }
+
+  const drift = driftFor(cg, projectRoot, node.filePath);
+
+  return {
+    node: toNodeDetail(node),
+    /** Outermost first: file, then module/class, then the symbol's own parent. */
+    ancestors: [...ancestors].reverse().map(toNodeRef),
+    members: wireList(members.items, members.total),
+    /**
+     * Ancestors, subtypes and the dispatch fan — `null` for anything that is
+     * not a type, and for a type with no hierarchy at all.
+     */
+    hierarchy: hierarchy?.wire ?? null,
+    incoming: wireList(shownIncoming, incomingGroups.length),
+    outgoing: wireList(shownOutgoing, outgoingGroups.length),
+    /** `references` edges into a type — the header's "uses types …" chips. */
+    typesUsed: typeGroups,
+    counts: {
+      // Every count below is the length of a list this payload also returns, so
+      // a badge and the rail beneath it can never disagree.
+      /** Distinct symbols that reach this one — `incoming.total`. Drives `hub`. */
+      callers: incomingGroups.length,
+      /** Distinct symbols this one calls — `outgoing.total`. Types are counted separately. */
+      callees: outgoingGroups.length,
+      /** Distinct types this symbol names — `typesUsed.length`. */
+      typesUsed: typeGroups.length,
+      /** EDGE counts, which run higher: one caller can call from many lines. */
+      fanIn: incoming.length,
+      fanOut: outgoingRest.length,
+      members: members.total,
+      hub: incomingGroups.length >= HUB_THRESHOLD,
+    },
+    tests: summarizeTestCallers(cg, directCallers),
+    outsideIndex: summarizeOutsideIndex(cg, nodeId),
+    blast: summarizeBlast(cg, node, incomingGroups.length),
+    /** The symbol's file changed on disk since the index — line ranges may be shifted. */
+    drift,
+  };
+}
+
+// =============================================================================
+// Members
+// =============================================================================
+
+/**
+ * The focal symbol's members, in source order, one level of nesting deep.
+ *
+ * A file's outline is file → class → method, so direct children alone would
+ * show a class and nothing inside it. The grandchildren come from ONE batched
+ * `getOutgoingEdgesFrom` over the container children, never a query per child.
+ */
+function buildMembers(
+  cg: CodeGraph,
+  focal: Node,
+  containsOut: readonly Edge[],
+  endpoints: Map<string, Node>,
+  overrides?: Map<string, WireOverride>
+): { items: WireMember[]; total: number } {
+  const direct: Array<{ node: Node; parentId: string; depth: number }> = [];
+  for (const edge of containsOut) {
+    const child = endpoints.get(edge.target);
+    if (child) direct.push({ node: child, parentId: focal.id, depth: 1 });
+  }
+
+  const containerIds = direct
+    .filter((entry) => CONTAINER_KINDS.has(entry.node.kind))
+    .map((entry) => entry.node.id);
+
+  const nested: Array<{ node: Node; parentId: string; depth: number }> = [];
+  if (containerIds.length > 0) {
+    const grandEdges = cg.getOutgoingEdgesFrom(containerIds, ['contains']);
+    const grandNodes = cg.getNodesByIds(grandEdges.map((e) => e.target));
+    for (const edge of grandEdges) {
+      const child = grandNodes.get(edge.target);
+      if (child) nested.push({ node: child, parentId: edge.source, depth: 2 });
+    }
+  }
+
+  const all = [...direct, ...nested].sort(
+    (a, b) => a.node.startLine - b.node.startLine || a.node.name.localeCompare(b.node.name)
+  );
+  const shown = all.slice(0, MAX_OUTLINE_NODES);
+
+  // Two queries for the whole outline, not two per row: a file with 400
+  // symbols would otherwise be 800 lookups behind one screen.
+  const memberIds = shown.map((entry) => entry.node.id);
+  const fanIn = cg.getFanIn(memberIds);
+  const fanOut = cg.getFanOut(memberIds);
+
+  return {
+    items: shown.map((entry) => {
+      const member: WireMember = {
+        ...toNodeRef(entry.node),
+        parentId: entry.parentId,
+        depth: entry.depth,
+        fanIn: fanIn.get(entry.node.id) ?? 0,
+        fanOut: fanOut.get(entry.node.id) ?? 0,
+      };
+      const override = overrides?.get(entry.node.id);
+      if (override) member.overrides = override;
+      return member;
+    }),
+    total: all.length,
+  };
+}
+
+// =============================================================================
+// Test coverage
+// =============================================================================
+
+export interface WireTestSummary {
+  /** A test file reaches this symbol within {@link TEST_CALLER_HOPS} caller hops. */
+  reached: boolean;
+  /** How many hops away the nearest test was. 1 = a test calls it directly. */
+  hops: number | null;
+  fileCount: number;
+  files: string[];
+  /**
+   * The search finished rather than running out of budget. `false` weakens the
+   * claim from "no test reaches this within 3 hops" to "no test calls this
+   * directly", which is all that was actually checked.
+   */
+  exhaustive: boolean;
+  hopsSearched: number;
+}
+
+/**
+ * Which tests reach this symbol — the same question, and the same method,
+ * behind `codegraph_explore`'s "tests:" line.
+ *
+ * Direct test callers first; failing that, walk up to two more caller hops,
+ * because a helper called only by production code is still tested through
+ * whatever calls it. The budget bounds a god-symbol, and running out of it is
+ * reported rather than papered over: claiming "no test reaches this" after an
+ * incomplete search would be exactly the kind of confident wrong answer the
+ * viewer exists to avoid.
+ */
+function summarizeTestCallers(cg: CodeGraph, directCallers: readonly Node[]): WireTestSummary {
+  const directFiles = [
+    ...new Set(directCallers.map((n) => toPosixPath(n.filePath)).filter(isTestFile)),
+  ];
+  if (directFiles.length > 0) {
+    return {
+      reached: true,
+      hops: 1,
+      fileCount: directFiles.length,
+      files: directFiles.slice(0, MAX_TEST_FILES),
+      exhaustive: true,
+      hopsSearched: 1,
+    };
+  }
+
+  let budget = TEST_CALLER_BUDGET;
+  const visited = new Set(directCallers.map((n) => n.id));
+  let frontier: Node[] = [...directCallers];
+  let hopsSearched = 1;
+
+  for (let hop = 2; hop <= TEST_CALLER_HOPS && frontier.length > 0 && budget > 0; hop++) {
+    hopsSearched = hop;
+    const next: Node[] = [];
+    const found = new Set<string>();
+    for (const current of frontier) {
+      if (budget-- <= 0) break;
+      let callers: Array<{ node: Node }>;
+      try {
+        callers = cg.getCallers(current.id) as Array<{ node: Node }>;
+      } catch {
+        continue;
+      }
+      for (const caller of callers) {
+        const source = caller?.node;
+        if (!source || visited.has(source.id)) continue;
+        visited.add(source.id);
+        const file = toPosixPath(source.filePath);
+        if (isTestFile(file)) found.add(file);
+        else next.push(source);
+      }
+    }
+    if (found.size > 0) {
+      const files = [...found];
+      return {
+        reached: true,
+        hops: hop,
+        fileCount: files.length,
+        files: files.slice(0, MAX_TEST_FILES),
+        exhaustive: true,
+        hopsSearched: hop,
+      };
+    }
+    frontier = next;
+  }
+
+  return {
+    reached: false,
+    hops: null,
+    fileCount: 0,
+    files: [],
+    exhaustive: budget > 0,
+    hopsSearched,
+  };
+}
+
+// =============================================================================
+// References that leave the index
+// =============================================================================
+
+/**
+ * Calls and type mentions from this symbol that never resolved to a node — a
+ * third-party package, a runtime builtin, a construct extraction doesn't model.
+ *
+ * Without this the callee rail would silently be shorter than the body's call
+ * sites, which reads as "nothing else happens here". Saying "+N calls into
+ * symbols outside the index" is the honest version of the same screen.
+ */
+function summarizeOutsideIndex(
+  cg: CodeGraph,
+  nodeId: string
+): {
+  total: number;
+  byKind: Record<string, number>;
+  samples: Array<{ name: string; kind: string; line: number; col: number }>;
+} {
+  let refs;
+  try {
+    refs = cg.getUnresolvedReferencesFrom(nodeId);
+  } catch {
+    return { total: 0, byKind: {}, samples: [] };
+  }
+
+  const byKind: Record<string, number> = {};
+  for (const ref of refs) byKind[ref.referenceKind] = (byKind[ref.referenceKind] ?? 0) + 1;
+
+  const samples = [...refs]
+    .sort((a, b) => a.line - b.line || a.column - b.column)
+    .slice(0, MAX_OUTSIDE_INDEX_SAMPLES)
+    .map((ref) => ({
+      name: ref.referenceName,
+      kind: ref.referenceKind,
+      line: ref.line,
+      col: ref.column,
+    }));
+
+  return { total: refs.length, byKind, samples };
+}
+
+// =============================================================================
+// Blast radius
+// =============================================================================
+
+export interface WireBlastSummary {
+  /** Distinct symbols that depend on this one directly. */
+  direct: number;
+  /** Distinct symbols reached within {@link BLAST_DEPTH} dependency hops. */
+  withinHops: number;
+  hops: number;
+  files: number;
+  testFiles: number;
+  routes: number;
+  /** Up to 40 of the dependent files, most-affected first, for the "what would need re-checking" fold. */
+  topFiles: Array<{ file: string; symbols: number; test: boolean }>;
+}
+
+/**
+ * What would need re-checking if this symbol changed.
+ *
+ * `getImpactRadius` at depth 3 is the engine's own answer to that question —
+ * incoming dependencies only, `contains` excluded upward so a leaf symbol does
+ * not explode into its whole class, container members expanded downward so
+ * callers of a class's methods count against the class.
+ */
+function summarizeBlast(cg: CodeGraph, node: Node, direct: number): WireBlastSummary | null {
+  let subgraph;
+  try {
+    subgraph = cg.getImpactRadius(node.id, BLAST_DEPTH);
+  } catch {
+    return null;
+  }
+
+  const perFile = new Map<string, number>();
+  let routes = 0;
+  for (const [id, dependent] of subgraph.nodes) {
+    if (id === node.id) continue;
+    const file = toPosixPath(dependent.filePath);
+    perFile.set(file, (perFile.get(file) ?? 0) + 1);
+    if (dependent.kind === ('route' as NodeKind)) routes++;
+  }
+
+  const testFiles = [...perFile.keys()].filter(isTestFile).length;
+  const topFiles = [...perFile.entries()]
+    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+    .slice(0, 40)
+    .map(([file, symbols]) => ({ file, symbols, test: isTestFile(file) }));
+
+  return {
+    direct,
+    withinHops: Math.max(0, subgraph.nodes.size - 1),
+    hops: BLAST_DEPTH,
+    files: perFile.size,
+    testFiles,
+    routes,
+    topFiles,
+  };
+}
+
+// =============================================================================
+// Drift
+// =============================================================================
+
+function driftFor(cg: CodeGraph, projectRoot: string, filePath: string): boolean {
+  const found = findIndexedFile(cg, filePath);
+  if (!found) return false;
+  return hasDriftedOnDisk(projectRoot, found.storedPath, found.record);
+}

+ 54 - 0
src/ui-server/api/nodes.ts

@@ -0,0 +1,54 @@
+/**
+ * `GET /api/nodes?id=…&id=…` — names for ids you already have.
+ *
+ * The trail is the reason this exists. It travels in the URL, and a URL can
+ * only carry ids, so a shared or reloaded six-hop trail arrives as six opaque
+ * `method:<hash>` strings with nothing to draw. Every other screen learns a
+ * symbol's name as a side effect of asking for the symbol; the trail never
+ * asks, because it draws hops it is not looking at.
+ *
+ * Deliberately the ref shape (`WireNodeRef`) and not the Symbol view payload:
+ * six of those would ship six rail sets and six blast radiuses to render six
+ * words. Ids arrive as repeated `id` parameters rather than one comma-joined
+ * list — a node id can be a file path, and a file path can contain a comma.
+ */
+
+import type { CodeGraph } from '../../index';
+import { badRequest } from './respond';
+import { toNodeRef, type WireNodeRef } from './wire';
+
+/** Ids per request. A trail long enough to exceed this is not a trail. */
+export const MAX_NODE_REFS = 60;
+
+export interface WireNodeRefs {
+  items: WireNodeRef[];
+  /** Ids that name nothing in this index — a stale link, not an error. */
+  missing: string[];
+}
+
+export function buildNodeRefs(cg: CodeGraph, query: URLSearchParams): WireNodeRefs {
+  const ids = query.getAll('id').filter((id) => id !== '');
+  if (ids.length === 0) {
+    throw badRequest(
+      'No ids were given.',
+      'Use /api/nodes?id=<id>&id=<id> — one `id` parameter per symbol.'
+    );
+  }
+  if (ids.length > MAX_NODE_REFS) {
+    throw badRequest(`Too many ids: ${ids.length}. At most ${MAX_NODE_REFS} per request.`);
+  }
+
+  const unique = [...new Set(ids)];
+  const byId = cg.getNodesByIds(unique);
+
+  const items: WireNodeRef[] = [];
+  const missing: string[] = [];
+  // Answer in the order asked, so the caller never has to re-sort.
+  for (const id of unique) {
+    const node = byId.get(id);
+    if (node) items.push(toNodeRef(node));
+    else missing.push(id);
+  }
+
+  return { items, missing };
+}

+ 207 - 0
src/ui-server/api/respond.ts

@@ -0,0 +1,207 @@
+/**
+ * How the JSON API answers — success, refusal, and every failure in between.
+ *
+ * The viewer is the only client, and it runs on the same machine as the index,
+ * so an error here is a message to a developer looking at their own project,
+ * not information to withhold from a prober. Every failure therefore says what
+ * went wrong and — where there is one — what to do about it, exactly the way
+ * the CLI and the MCP tools do. What it never does is leak a stack trace.
+ */
+
+import type { IncomingMessage, ServerResponse } from 'http';
+import { sendJson } from '../static';
+
+/**
+ * Machine-readable failure reasons. The viewer switches on these rather than
+ * on prose, so renaming a message never breaks a screen.
+ */
+export type ApiErrorCode =
+  | 'bad-request'
+  | 'not-found'
+  | 'refused'
+  | 'no-index'
+  | 'index-unusable'
+  | 'internal';
+
+const STATUS: Record<ApiErrorCode, number> = {
+  'bad-request': 400,
+  'not-found': 404,
+  // A path refusal, not an authentication failure — the request asked for
+  // something outside the project (traversal, an absolute path, a sensitive
+  // directory) and there is no version of it we would serve.
+  refused: 403,
+  // The index is missing or unusable. 503 rather than 404: the endpoint is
+  // real, the data behind it is not there *yet* — `codegraph init` fixes it.
+  'no-index': 503,
+  'index-unusable': 503,
+  internal: 500,
+};
+
+/** An error that already carries a user-facing message and a status. */
+export class ApiError extends Error {
+  readonly code: ApiErrorCode;
+  /** Optional second line: what the user can do about it. */
+  readonly hint: string | undefined;
+
+  constructor(code: ApiErrorCode, message: string, hint?: string) {
+    super(message);
+    this.name = 'ApiError';
+    this.code = code;
+    this.hint = hint;
+  }
+}
+
+export function badRequest(message: string, hint?: string): ApiError {
+  return new ApiError('bad-request', message, hint);
+}
+
+export function notFound(message: string, hint?: string): ApiError {
+  return new ApiError('not-found', message, hint);
+}
+
+/** Send a successful payload. */
+export function ok(res: ServerResponse, payload: unknown, method: string): true {
+  sendJson(res, 200, payload, method);
+  return true;
+}
+
+/** Send a failure. Anything that is not an {@link ApiError} becomes a 500. */
+export function fail(res: ServerResponse, err: unknown, method: string): true {
+  if (err instanceof ApiError) {
+    const body: { error: string; code: ApiErrorCode; hint?: string } = {
+      error: err.message,
+      code: err.code,
+    };
+    if (err.hint) body.hint = err.hint;
+    sendJson(res, STATUS[err.code], body, method);
+    return true;
+  }
+  sendJson(
+    res,
+    500,
+    {
+      error: err instanceof Error ? err.message : String(err),
+      code: 'internal' satisfies ApiErrorCode,
+    },
+    method
+  );
+  return true;
+}
+
+// =============================================================================
+// Request bodies
+// =============================================================================
+
+/**
+ * Bytes a request body may carry.
+ *
+ * The only body this server reads is a saved trail: a name and up to 64 node
+ * ids. 64 KB is generous for that and small enough that a runaway client cannot
+ * make the process hold a megabyte per socket.
+ */
+export const MAX_BODY_BYTES = 64 * 1024;
+
+/**
+ * Read a request body as JSON.
+ *
+ * Counts BYTES, not characters, and stops at the cap by destroying the socket
+ * rather than draining a body nobody is going to parse — a `Content-Length`
+ * header is a claim, and the only limit that holds is the one applied to what
+ * actually arrives.
+ *
+ * @throws {ApiError} `bad-request` for a body that is too large or is not JSON.
+ */
+export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
+  const chunks: Buffer[] = [];
+  let size = 0;
+  try {
+    for await (const chunk of req) {
+      const buf = chunk as Buffer;
+      size += buf.length;
+      if (size > MAX_BODY_BYTES) {
+        req.destroy();
+        throw badRequest(`That request body is too large (max ${MAX_BODY_BYTES} bytes).`);
+      }
+      chunks.push(buf);
+    }
+  } catch (err) {
+    if (err instanceof ApiError) throw err;
+    throw badRequest('That request body could not be read.');
+  }
+  if (size === 0) throw badRequest('That request needs a JSON body.');
+  try {
+    return JSON.parse(Buffer.concat(chunks).toString('utf-8')) as unknown;
+  } catch {
+    throw badRequest('That request body is not valid JSON.');
+  }
+}
+
+// =============================================================================
+// Query parameters
+// =============================================================================
+
+/** A required, non-empty string parameter. */
+export function requiredParam(query: URLSearchParams, name: string): string {
+  const raw = query.get(name);
+  if (raw === null || raw.trim() === '') {
+    throw badRequest(`Missing required parameter "${name}".`);
+  }
+  return raw;
+}
+
+/**
+ * A bounded integer parameter.
+ *
+ * Out-of-range values are an error rather than silently clamped: a viewer
+ * asking for line 10 000 000 of a 200-line file has a bug, and answering it
+ * with line 200 would hide that.
+ */
+export function intParam(
+  query: URLSearchParams,
+  name: string,
+  opts: { min: number; max: number; default?: number }
+): number {
+  const raw = query.get(name);
+  if (raw === null || raw.trim() === '') {
+    if (opts.default !== undefined) return opts.default;
+    throw badRequest(`Missing required parameter "${name}".`);
+  }
+  const value = Number(raw);
+  if (!Number.isInteger(value) || value < opts.min || value > opts.max) {
+    throw badRequest(
+      `Parameter "${name}" must be a whole number between ${opts.min} and ${opts.max} (got "${raw}").`
+    );
+  }
+  return value;
+}
+
+/**
+ * Free-form text input, bounded.
+ *
+ * The same reasoning as the MCP tools' input ceiling: a huge string is never a
+ * real query, and letting one through means a full-table LIKE scan or an FTS5
+ * parse over megabytes.
+ */
+export const MAX_QUERY_LENGTH = 2_000;
+
+export function textParam(query: URLSearchParams, name: string): string {
+  const raw = requiredParam(query, name);
+  return boundLength(raw, name);
+}
+
+/**
+ * Text that must be PRESENT but may be empty — a search box the user has
+ * cleared. Absent is still an error; empty is a legitimate state.
+ */
+export function optionalTextParam(query: URLSearchParams, name: string): string {
+  const raw = query.get(name);
+  if (raw === null) throw badRequest(`Missing required parameter "${name}".`);
+  return boundLength(raw, name);
+}
+
+function boundLength(raw: string, name: string): string {
+  if (raw.length > MAX_QUERY_LENGTH) {
+    throw badRequest(`Parameter "${name}" is too long (max ${MAX_QUERY_LENGTH} characters).`);
+  }
+  return raw;
+}

+ 151 - 0
src/ui-server/api/routes.ts

@@ -0,0 +1,151 @@
+/**
+ * `GET /api/routes` — the URL to handler map, when the project has one.
+ *
+ * The engine's routing manifest is a flat list of (url, handler, file, line)
+ * rows; it deliberately carries no node ids, because its own consumer (the MCP
+ * context builder) renders text. A reader needs to *navigate*, so each entry is
+ * matched back to its handler's node id here — batched by file, never a lookup
+ * per route.
+ *
+ * `null` from the engine means "fewer than three real routes", i.e. this
+ * project is not a routed app. That is reported as an empty manifest with
+ * `routed: false` rather than as an error: "this isn't a web app" is an
+ * answer, not a failure.
+ *
+ * Two things about the manifest shape the numbers here have to work around.
+ * Its `limit` is applied in SQL *before* the three-route test, so asking for
+ * fewer than three would make every routed project look unrouted — hence the
+ * floor on the parameter. And its own `totalRoutes` counts only the rows inside
+ * that window, so the headline count comes from the graph's `route` nodes
+ * instead, which is the number a reader means by "how many routes are there".
+ */
+
+import type { CodeGraph } from '../../index';
+import { intParam } from './respond';
+import { toPosixPath } from './wire';
+
+/**
+ * HTTP verbs a route name may lead with, plus the two stand-ins the resolvers
+ * emit when the registration names no verb (`mux.Handle`, `app.use`).
+ *
+ * The split is done against this list rather than against "the first word" so
+ * a file-routed page (`/blog/[slug]`) or a message-bus subscription keeps its
+ * whole name in the URL column instead of losing its first segment to a
+ * method column that was never there.
+ */
+const HTTP_METHODS: ReadonlySet<string> = new Set([
+  'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT',
+  'ANY', 'ALL', 'USE',
+]);
+
+/** One row of the URL → handler map. */
+export interface WireRoute {
+  /** The route node's name, verbatim: "POST /v1/users/{id}". */
+  url: string;
+  /** The verb, when the name leads with one. Null for file-routed pages. */
+  method: string | null;
+  /** The URL without the verb — the same string as `url` when there is none. */
+  path: string;
+  handler: string;
+  handlerKind: string;
+  /** Where the request is SERVED. */
+  file: string;
+  line: number;
+  handlerId: string | null;
+  /** Where the URL is REGISTERED — the router file, which is how routes group. */
+  routeFile: string;
+  routeLine: number;
+  routeId: string;
+}
+
+export interface WireRoutes {
+  routed: boolean;
+  /** Every URL the index holds, whether or not its handler resolved. */
+  routeCount: number;
+  /** Rows in `entries` — the ones whose handler the manifest could name. */
+  shown: number;
+  truncated: boolean;
+  topHandlerFile: string | null;
+  topHandlerFileCount: number;
+  entries: WireRoute[];
+}
+
+/** "POST /v1/users" -> { method: 'POST', path: '/v1/users' }. */
+export function splitRouteName(url: string): { method: string | null; path: string } {
+  const space = url.indexOf(' ');
+  if (space <= 0) return { method: null, path: url };
+  const head = url.slice(0, space);
+  if (!HTTP_METHODS.has(head.toUpperCase())) return { method: null, path: url };
+  return { method: head.toUpperCase(), path: url.slice(space + 1).trimStart() };
+}
+
+/** Distinct handler files we will resolve node ids for. */
+const MAX_HANDLER_FILES = 60;
+
+/**
+ * The engine needs three surviving rows to call a project routed, and applies
+ * `limit` before that test — so anything below three is a question that cannot
+ * be answered truthfully rather than a small page.
+ */
+const MIN_LIMIT = 3;
+
+export function buildRoutes(cg: CodeGraph, query: URLSearchParams): WireRoutes {
+  const limit = intParam(query, 'limit', { min: MIN_LIMIT, max: 500, default: 200 });
+
+  // One row over the limit, purely to learn whether there were more.
+  const manifest = cg.getRoutingManifest(limit + 1);
+  const routeCount = cg.getStats().nodesByKind.route ?? 0;
+
+  if (!manifest) {
+    return {
+      routed: false,
+      routeCount,
+      shown: 0,
+      truncated: false,
+      topHandlerFile: null,
+      topHandlerFileCount: 0,
+      entries: [],
+    };
+  }
+
+  const truncated = manifest.entries.length > limit;
+  const rows = manifest.entries.slice(0, limit);
+
+  // One `getNodesInFile` per distinct handler file — typically one or two, and
+  // capped so a project that scatters handlers across hundreds of files cannot
+  // turn one request into hundreds of queries.
+  const handlerFiles = [...new Set(rows.map((e) => e.handlerFile))].slice(0, MAX_HANDLER_FILES);
+  const byFileLineName = new Map<string, string>();
+  for (const file of handlerFiles) {
+    for (const node of cg.getNodesInFile(file)) {
+      // Keyed on what the manifest actually knows: file, line and name. Two
+      // symbols can share a line (a decorator and its method); the name breaks
+      // the tie, and a miss simply leaves that entry unlinked.
+      byFileLineName.set(`${node.filePath} ${node.startLine} ${node.name}`, node.id);
+    }
+  }
+
+  const entries: WireRoute[] = rows.map((entry) => ({
+    url: entry.url,
+    ...splitRouteName(entry.url),
+    handler: entry.handler,
+    handlerKind: entry.handlerKind,
+    file: toPosixPath(entry.handlerFile),
+    line: entry.handlerLine,
+    handlerId:
+      byFileLineName.get(`${entry.handlerFile} ${entry.handlerLine} ${entry.handler}`) ?? null,
+    routeFile: toPosixPath(entry.routeFile),
+    routeLine: entry.routeLine,
+    routeId: entry.routeId,
+  }));
+
+  return {
+    routed: true,
+    routeCount,
+    shown: entries.length,
+    truncated,
+    topHandlerFile: manifest.topHandlerFile ? toPosixPath(manifest.topHandlerFile) : null,
+    topHandlerFileCount: manifest.topHandlerFileCount,
+    entries,
+  };
+}

+ 237 - 0
src/ui-server/api/search.ts

@@ -0,0 +1,237 @@
+/**
+ * `GET /api/search?q=` — the search palette's one round-trip.
+ *
+ * Three lookups feed it, because no single one covers what a person types into
+ * a palette:
+ *
+ * - `getNodesByNameSubstring` — case-insensitive, catches the exact, prefix and
+ *   mid-name matches (`profileInfo` inside `getProfileInfoV2`) that FTS tokens
+ *   cannot.
+ * - `searchNodes` — FTS5, plus the engine's own LIKE and fuzzy fallbacks, and
+ *   the `kind:` / `lang:` / `path:` / `name:` filter grammar for free.
+ * - `getNodesByName` — every symbol with exactly that name, uncapped, so a
+ *   heavily-overloaded name never loses its definitions below a search cut.
+ *
+ * They are then merged and ranked by HOW the name matched — exact, prefix,
+ * substring, qualified name, file path — rather than by any single engine's
+ * score, because those scores are not comparable with each other. Results are
+ * grouped by kind: "did I mean the class or the method" is the question a
+ * palette actually has to answer.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { Node, NodeKind } from '../../types';
+import { parseQuery, type ParsedQuery } from '../../search/query-parser';
+import { intParam, optionalTextParam } from './respond';
+import { toNodeRef, wireList, type WireNodeRef } from './wire';
+
+/** How a result's text matched the query. Also the primary sort key. */
+export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
+
+const MATCH_RANK: Record<MatchKind, number> = {
+  exact: 0,
+  prefix: 1,
+  substring: 2,
+  qualified: 3,
+  file: 4,
+  // Matched by FTS through a signature, docstring or fuzzy neighbour — real,
+  // but never what someone typing a name is looking for first.
+  related: 5,
+};
+
+/** Candidates pulled from each source before ranking trims to `limit`. */
+const CANDIDATE_POOL = 400;
+
+export interface WireSearchResult extends WireNodeRef {
+  matchKind: MatchKind;
+}
+
+/**
+ * Tie-break inside a match tier: the kinds someone navigates to, before the
+ * kinds that merely mention a name.
+ */
+function kindRank(kind: NodeKind): number {
+  switch (kind) {
+    case 'function':
+    case 'method':
+    case 'class':
+    case 'component':
+    case 'interface':
+    case 'struct':
+    case 'trait':
+    case 'protocol':
+    case 'enum':
+    case 'union':
+    case 'type_alias':
+    case 'route':
+      return 0;
+    case 'constant':
+    case 'property':
+    case 'field':
+    case 'variable':
+    case 'enum_member':
+      return 1;
+    case 'file':
+    case 'module':
+    case 'namespace':
+      return 2;
+    default:
+      // import / export / parameter — a mention, not a definition.
+      return 3;
+  }
+}
+
+function classify(node: Node, needle: string): MatchKind | null {
+  const name = node.name.toLowerCase();
+  if (name === needle) return 'exact';
+  if (name.startsWith(needle)) return 'prefix';
+  if (name.includes(needle)) return 'substring';
+  if (node.qualifiedName.toLowerCase().includes(needle)) return 'qualified';
+  if (node.filePath.toLowerCase().replace(/\\/g, '/').includes(needle)) return 'file';
+  return null;
+}
+
+export function buildSearch(cg: CodeGraph, query: URLSearchParams): unknown {
+  const raw = optionalTextParam(query, 'q');
+  const limit = intParam(query, 'limit', { min: 1, max: 200, default: 60 });
+
+  // An empty search box is the palette's resting state, not a mistake — it
+  // answers with nothing rather than with an error the viewer has to special-
+  // case. A MISSING `q` is still a 400: that is a caller bug.
+  if (raw.trim() === '') return emptySearch(raw);
+
+  // The filter grammar (`kind:function auth`) belongs to `searchNodes`; the
+  // name lookups only ever want the free-text part of what was typed.
+  const parsed = parseQuery(raw);
+  const text = parsed.text.trim();
+  const needle = text.toLowerCase();
+
+  const candidates = new Map<string, Node>();
+  const remember = (node: Node): void => {
+    if (!candidates.has(node.id)) candidates.set(node.id, node);
+  };
+
+  if (text.length > 0) {
+    for (const node of cg.getNodesByName(text)) remember(node);
+    for (const node of cg.getNodesByNameSubstring(text, { limit: CANDIDATE_POOL })) remember(node);
+  }
+  for (const result of cg.searchNodes(raw, { limit: CANDIDATE_POOL })) remember(result.node);
+
+  const scored: Array<{ node: Node; match: MatchKind }> = [];
+  for (const node of candidates.values()) {
+    // `searchNodes` applies the filter grammar to its own results, but the two
+    // direct name lookups above know nothing about it — so `kind:class Cache`
+    // would otherwise pull in `CacheKey` and every `Cache` method through the
+    // substring lookup. The gate belongs to the merged candidate set.
+    if (!matchesFilters(node, parsed)) continue;
+    // An empty text portion means the query was pure filters (`kind:route`);
+    // everything `searchNodes` returned already satisfies them, so there is no
+    // name match to grade and every row is equally "related".
+    const match = needle.length === 0 ? 'related' : classify(node, needle) ?? 'related';
+    scored.push({ node, match });
+  }
+
+  scored.sort((a, b) => {
+    const byMatch = MATCH_RANK[a.match] - MATCH_RANK[b.match];
+    if (byMatch !== 0) return byMatch;
+    const byKind = kindRank(a.node.kind) - kindRank(b.node.kind);
+    if (byKind !== 0) return byKind;
+    // Production code before tests and fixtures: both are real answers, but one
+    // of them is the one someone searching for a symbol usually means.
+    const aTest = isTestPath(a.node.filePath);
+    const bTest = isTestPath(b.node.filePath);
+    if (aTest !== bTest) return aTest ? 1 : -1;
+    // Shorter names are closer to what was typed (`get` before `getOrCreate`).
+    const byLength = a.node.name.length - b.node.name.length;
+    if (byLength !== 0) return byLength;
+    return (
+      a.node.filePath.localeCompare(b.node.filePath) || a.node.startLine - b.node.startLine
+    );
+  });
+
+  const top = scored.slice(0, limit);
+  // One bounded lookup for the whole page of results, so a generated stub
+  // reads as one at a glance instead of after a click.
+  const isGenerated = cg.generatedFilePredicate(top.map(({ node }) => node.filePath));
+  const results: WireSearchResult[] = top.map(({ node, match }) => {
+    const result: WireSearchResult = { ...toNodeRef(node), matchKind: match };
+    if (isGenerated(node.filePath)) result.generated = true;
+    return result;
+  });
+
+  // Groups keep the ranked order: a group appears where its best result did, so
+  // flattening the groups reproduces the flat ranking for keyboard navigation.
+  const groups: Array<{ kind: NodeKind; count: number; items: WireSearchResult[] }> = [];
+  const byKind = new Map<NodeKind, WireSearchResult[]>();
+  for (const result of results) {
+    const bucket = byKind.get(result.kind);
+    if (bucket) {
+      bucket.push(result);
+    } else {
+      const created = [result];
+      byKind.set(result.kind, created);
+      groups.push({ kind: result.kind, count: 0, items: created });
+    }
+  }
+  for (const group of groups) group.count = group.items.length;
+
+  return {
+    query: raw,
+    text,
+    filters: {
+      kinds: parsed.kinds,
+      languages: parsed.languages,
+      paths: parsed.pathFilters,
+      names: parsed.nameFilters,
+    },
+    results: wireList(results, scored.length),
+    groups,
+  };
+}
+
+/**
+ * Deliberately a plain path check rather than the engine's `isTestFile`: this
+ * is a ranking nudge inside one tier, and `isTestFile` also treats `examples/`,
+ * `benchmarks/` and `fixtures/` as tests — pushing a legitimately-searched
+ * example below an unrelated production symbol.
+ */
+function isTestPath(filePath: string): boolean {
+  const lower = filePath.toLowerCase().replace(/\\/g, '/');
+  return (
+    /(^|\/)(tests?|specs?|__tests__)\//.test(lower) ||
+    /[._-](test|tests|spec|specs)\.[a-z0-9]+$/.test(lower)
+  );
+}
+
+/**
+ * The hard gate the `kind:` / `lang:` / `path:` / `name:` grammar asks for.
+ *
+ * Deliberately the same predicates `searchNodes` uses internally — kinds and
+ * languages exact, paths and names case-insensitive substrings, each list OR'd
+ * within itself and AND'd across lists — so a filtered search means the same
+ * thing whichever lookup a result came from.
+ */
+function matchesFilters(node: Node, parsed: ParsedQuery): boolean {
+  if (parsed.kinds.length > 0 && !parsed.kinds.includes(node.kind)) return false;
+  if (parsed.languages.length > 0 && !parsed.languages.includes(node.language)) return false;
+  if (parsed.pathFilters.length > 0) {
+    const file = node.filePath.toLowerCase();
+    if (!parsed.pathFilters.some((p) => file.includes(p.toLowerCase()))) return false;
+  }
+  if (parsed.nameFilters.length > 0) {
+    const name = node.name.toLowerCase();
+    if (!parsed.nameFilters.some((n) => name.includes(n.toLowerCase()))) return false;
+  }
+  return true;
+}
+
+/** The resting state of the palette: the shape of a real answer, with nothing in it. */
+function emptySearch(raw: string): unknown {
+  return {
+    query: raw,
+    text: '',
+    filters: { kinds: [], languages: [], paths: [], names: [] },
+    results: wireList<WireSearchResult>([], 0),
+    groups: [],
+  };
+}

+ 190 - 0
src/ui-server/api/session.ts

@@ -0,0 +1,190 @@
+/**
+ * The one open handle on the project's index.
+ *
+ * `CodeGraph.openSync` costs tens of milliseconds and runs pending migrations,
+ * so it happens once for the life of the server rather than once per request.
+ * That leaves two things this module has to get right:
+ *
+ * - **A missing index is guidance, not a stack trace.** `codegraph ui` refuses
+ *   to start without one, but a user can delete `.codegraph/` while the viewer
+ *   is open, so every endpoint has to be able to say so in the same words the
+ *   CLI does.
+ * - **A re-index must not be served from a phantom database.** `codegraph init`
+ *   on an already-indexed project *replaces the database file* (see
+ *   `CodeGraph.recreate`). On POSIX our handle would keep reading the unlinked
+ *   inode and happily serve a graph that no longer exists on disk. So the file
+ *   identity is re-checked on acquisition — one `stat` — and a swapped file
+ *   reopens the connection.
+ * - **A sync by ANOTHER process must not be served from memory.** The same
+ *   `stat` also notices the database growing, and when it has, the read caches
+ *   go (`dropReadCaches`). The query layer holds an LRU of nodes by id which
+ *   only a write through *this* instance invalidates, so without it
+ *   `/api/node/<id>` keeps answering with a symbol an agent's sync deleted
+ *   while `/api/search` — which never caches — correctly says it is gone. That
+ *   is not a stale screen, it is two screens contradicting each other; and
+ *   because a node's id contains its start line, ANY edit above a symbol
+ *   renames it, so this is the common case rather than the corner one.
+ */
+
+import * as fs from 'fs';
+import { CodeGraph } from '../../index';
+import { getDatabasePath } from '../../db';
+import { isInitialized } from '../../directory';
+import { ApiError } from './respond';
+
+/**
+ * Identity of the database file, so a swap underneath us is detectable — plus
+ * the marks that say it was WRITTEN to without being replaced.
+ *
+ * The WAL is measured as well as the database: in WAL mode a commit lands in
+ * `codegraph.db-wal` and may not touch `codegraph.db` until a checkpoint, so a
+ * whole sync can go by with the main file's size and mtime unchanged.
+ */
+interface FileIdentity {
+  ino: number;
+  birthtimeMs: number;
+  size: number;
+  mtimeMs: number;
+  walSize: number;
+  walMtimeMs: number;
+}
+
+function identify(dbPath: string): FileIdentity | null {
+  try {
+    const st = fs.statSync(dbPath);
+    let walSize = 0;
+    let walMtimeMs = 0;
+    try {
+      const wal = fs.statSync(`${dbPath}-wal`);
+      walSize = wal.size;
+      walMtimeMs = wal.mtimeMs;
+    } catch {
+      // No WAL sidecar: either not in WAL mode, or fully checkpointed. Both are
+      // "nothing pending", which is what zeroes mean here.
+    }
+    return {
+      ino: st.ino,
+      birthtimeMs: st.birthtimeMs,
+      size: st.size,
+      mtimeMs: st.mtimeMs,
+      walSize,
+      walMtimeMs,
+    };
+  } catch {
+    return null;
+  }
+}
+
+function sameFile(a: FileIdentity | null, b: FileIdentity | null): boolean {
+  if (a === null || b === null) return false;
+  // `ino` is 0 on a few Windows filesystems; birthtime alone still catches a
+  // recreate there, and a false "changed" only costs one reopen.
+  return a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
+}
+
+/** Same file, but written to since we last looked. */
+function sameContent(a: FileIdentity | null, b: FileIdentity | null): boolean {
+  if (a === null || b === null) return false;
+  return (
+    a.size === b.size &&
+    a.mtimeMs === b.mtimeMs &&
+    a.walSize === b.walSize &&
+    a.walMtimeMs === b.walMtimeMs
+  );
+}
+
+/**
+ * Guidance shown when there is no index to read. Deliberately the same three
+ * facts the CLI prints: the viewer never creates an index, `codegraph init`
+ * does, and you can point the viewer somewhere already indexed.
+ */
+function noIndexError(projectRoot: string): ApiError {
+  return new ApiError(
+    'no-index',
+    `No CodeGraph index found for ${projectRoot}.`,
+    'The viewer reads an index that already exists — it never creates one. ' +
+      'Run "codegraph init" in that project, or start the viewer against a project ' +
+      'that has one: codegraph ui /path/to/indexed/project'
+  );
+}
+
+/**
+ * Holds the project's `CodeGraph` open for the life of the server.
+ *
+ * Not thread-safe and does not need to be: `node:http` dispatches on one
+ * thread, and every read below is synchronous.
+ */
+export class GraphSession {
+  readonly projectRoot: string;
+  private readonly dbPath: string;
+  private cg: CodeGraph | null = null;
+  private identity: FileIdentity | null = null;
+
+  constructor(projectRoot: string) {
+    this.projectRoot = projectRoot;
+    this.dbPath = getDatabasePath(projectRoot);
+  }
+
+  /**
+   * The open graph, opening (or reopening) it if needed.
+   *
+   * @throws {ApiError} `no-index` when the project has no index,
+   *   `index-unusable` when it has one that will not open.
+   */
+  acquire(): CodeGraph {
+    const current = identify(this.dbPath);
+
+    if (this.cg !== null) {
+      if (sameFile(this.identity, current)) {
+        // Same file, but somebody wrote to it. SQLite itself is fine — a WAL
+        // reader sees the new commits — but our in-memory node cache is not,
+        // so it goes. One `stat` already paid for; clearing a bounded Map is
+        // the whole cost.
+        if (!sameContent(this.identity, current)) {
+          this.identity = current;
+          this.cg.dropReadCaches();
+        }
+        return this.cg;
+      }
+      // The database was replaced (a re-index) or removed. Drop the stale
+      // handle; falling through re-opens against whatever is there now.
+      this.closeQuietly();
+    }
+
+    if (!isInitialized(this.projectRoot)) throw noIndexError(this.projectRoot);
+
+    try {
+      this.cg = CodeGraph.openSync(this.projectRoot);
+    } catch (err) {
+      this.cg = null;
+      this.identity = null;
+      throw new ApiError(
+        'index-unusable',
+        `The CodeGraph index for ${this.projectRoot} could not be opened: ` +
+          (err instanceof Error ? err.message : String(err)),
+        'If another CodeGraph process is rebuilding it, wait for that to finish. ' +
+          'If the index is damaged, rebuild it with "codegraph init".'
+      );
+    }
+    this.identity = current ?? identify(this.dbPath);
+    return this.cg;
+  }
+
+  /** Release the handle. Idempotent — the CLI calls it on Ctrl-C. */
+  close(): void {
+    this.closeQuietly();
+  }
+
+  private closeQuietly(): void {
+    const cg = this.cg;
+    this.cg = null;
+    this.identity = null;
+    if (!cg) return;
+    try {
+      cg.close();
+    } catch {
+      // A close that fails has nothing left to release — the process is either
+      // exiting or the file is already gone. Never let it fail a request.
+    }
+  }
+}

+ 468 - 0
src/ui-server/api/source.ts

@@ -0,0 +1,468 @@
+/**
+ * `GET /api/source?file=&from=&to=` — verbatim source, or an honest refusal.
+ *
+ * This is the one endpoint that reads the user's repository, so two rules
+ * govern it and neither is negotiable.
+ *
+ * **Every read goes through `resolveProjectFile`.** That is the chokepoint from
+ * `security.ts` — traversal, in-tree symlinks pointing out of the root,
+ * absolute paths, sensitive system directories. Without it,
+ * `?file=../../.ssh/id_rsa` is a credential leak over a port the user opened to
+ * read their own code.
+ *
+ * **A file that changed on disk since it was indexed is never sliced under the
+ * index's numbering.** The viewer asks for line ranges the *index* recorded; if
+ * the file moved on since, those ranges can point at a different symbol's body,
+ * which would be served under the requested name and look perfectly plausible.
+ * So the bytes are hashed and compared against `files.content_hash`, and on a
+ * mismatch the slice is omitted with `drift: true` — the same call
+ * `codegraph_node` makes when it says "changed on disk after the last index
+ * sync".
+ *
+ * A caller that has ALREADY decided the index's numbering is off — a viewer
+ * about to draw a drift banner — asks with `ondrift=current` and gets the
+ * file's CURRENT lines instead of nothing. That is the other half of
+ * `codegraph_node`'s behaviour (issue #1474): a drifted file is served whole
+ * and current rather than omitted, because current bytes are correct by
+ * construction. `showing` says which of the two came back, on every response,
+ * so nothing has to infer it from the presence of `lines`.
+ *
+ * Only files that are IN the index are served. That is a tighter boundary than
+ * the MCP tools take, and it costs the viewer nothing (it only ever renders
+ * indexed symbols) while making the drift verdict meaningful for every answer:
+ * there is always a hash to compare against.
+ */
+
+import { createHash } from 'crypto';
+import * as fs from 'fs';
+import * as path from 'path';
+import type { FileRecord } from '../../types';
+import type { CodeGraph } from '../../index';
+import { resolveProjectFile } from '../security';
+import { highlightLines, type HighlightResult } from '../highlight';
+import { ApiError, badRequest, intParam, notFound, textParam } from './respond';
+
+/**
+ * Largest file we will read to answer a source request.
+ *
+ * The whole file has to be read to hash it, so this bounds the work one request
+ * can cause. Well above the 1 MB ceiling extraction itself applies, so anything
+ * actually in the index is comfortably inside it.
+ */
+export const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
+
+/** Lines returned in one response. The Symbol view asks for windows, not files. */
+export const MAX_SOURCE_LINES = 4000;
+
+/**
+ * Look up a file record by a viewer-supplied path, WITHOUT validating it.
+ *
+ * Indexed paths are normalized to forward slashes at extraction time, so that
+ * is the form tried first; the platform-separator form is a fallback for an
+ * index written before that normalization.
+ *
+ * Callers that go on to READ the file must use {@link resolveRequestedFile}
+ * instead — it puts the path through the security chokepoint first. This one is
+ * for endpoints that only need the record (a drift flag on a path the index
+ * itself handed us).
+ */
+export function findIndexedFile(
+  cg: CodeGraph,
+  requested: string
+): { record: FileRecord; storedPath: string } | null {
+  const posix = toRequestPath(requested);
+  const record = cg.getFile(posix);
+  if (record) return { record, storedPath: posix };
+
+  const native = posix.split('/').join(path.sep);
+  if (native !== posix) {
+    const legacy = cg.getFile(native);
+    if (legacy) return { record: legacy, storedPath: native };
+  }
+  return null;
+}
+
+/**
+ * Forward slashes and no leading `./` — the form indexed paths are stored in.
+ *
+ * A LEADING SLASH IS LEFT ALONE on purpose. Stripping it would quietly turn
+ * `/etc/passwd` into the project-relative `etc/passwd` and answer "not in this
+ * index" — reinterpreting the request instead of refusing it, and leaving the
+ * chokepoint's absolute-path rule with nothing to catch.
+ */
+export function toRequestPath(requested: string): string {
+  return requested.replace(/\\/g, '/').replace(/^\.\//, '');
+}
+
+/**
+ * Validate a viewer-supplied path, THEN look it up in the index.
+ *
+ * The order is the point. `resolveProjectFile` runs first, so a traversal, an
+ * absolute path or a sensitive system directory is refused as what it is,
+ * before the index is consulted — a 403 that says "outside the project", not a
+ * 404 that says "not indexed" and quietly depends on the index lookup missing.
+ * It also means the absolute path every reader uses has already been through
+ * the chokepoint by construction, rather than by remembering to call it.
+ *
+ * @throws {PathRefusalError} the path is not one we would ever read.
+ * @throws {ApiError} `not-found` when it is fine but not in the index.
+ */
+export function resolveRequestedFile(
+  cg: CodeGraph,
+  projectRoot: string,
+  requested: string
+): { record: FileRecord; storedPath: string; absolute: string } {
+  const posix = toRequestPath(requested);
+  // Refusals happen here, ahead of everything.
+  const absolute = resolveProjectFile(projectRoot, posix);
+
+  const found = findIndexedFile(cg, posix);
+  if (!found) throw notIndexedError(posix);
+  return { ...found, absolute };
+}
+
+export function notIndexedError(file: string): ApiError {
+  return notFound(
+    `${file} is not in this CodeGraph index.`,
+    'The viewer only reads files the index knows about. If the file is new, ' +
+      'it appears after the next sync; if it is excluded (gitignored, generated, ' +
+      'or too large to parse), it will not appear at all.'
+  );
+}
+
+/**
+ * Split source the way the index counted it.
+ *
+ * Rows are `\n`-delimited — that is how tree-sitter numbers them — so a CRLF
+ * file has the same line numbers here as in the graph. The trailing `\r` is
+ * dropped per line so it does not render as a stray glyph.
+ */
+export function splitLines(content: string): string[] {
+  const lines = content.split('\n');
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i] as string;
+    if (line.endsWith('\r')) lines[i] = line.slice(0, -1);
+  }
+  // A file ending in a newline splits to a final empty string that is not a
+  // line of source. Every other trailing empty line IS one.
+  if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
+  return lines;
+}
+
+/**
+ * The whole text of an INDEXED file, or `null` for anything unreadable.
+ *
+ * The dead code report's corroboration pass needs to count an identifier in a
+ * file's text, and this module is the only one in `api/` that opens a file — so
+ * the reader it uses lives here, behind the same chokepoint. Three refusals,
+ * all answering `null` rather than throwing, because the caller's rule is
+ * already "cannot read it → do not make the claim":
+ *
+ * - not in the index (the viewer never reads a file the graph does not know);
+ * - outside the project (`resolveProjectFile` throws; caught here);
+ * - bigger than `maxBytes`.
+ *
+ * Drift is deliberately NOT checked. The question being asked is "does anything
+ * in this file write this name", and the file's current bytes are the better
+ * answer to it than the bytes we indexed.
+ */
+export function readIndexedFileText(
+  cg: CodeGraph,
+  projectRoot: string,
+  requested: string,
+  maxBytes: number
+): string | null {
+  try {
+    const found = findIndexedFile(cg, requested);
+    if (!found) return null;
+    const absolute = resolveProjectFile(projectRoot, found.storedPath);
+    const stats = fs.statSync(absolute);
+    if (!stats.isFile() || stats.size > maxBytes) return null;
+    return fs.readFileSync(absolute, 'utf8');
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Whether an indexed file has changed on disk since it was indexed — the same
+ * verdict `/api/source` returns, for endpoints that must *flag* drift without
+ * serving source (a symbol header, a file outline).
+ *
+ * Cheap first: size plus floored mtime is the identical freshness test the sync
+ * fast path uses, so an untouched file costs one `stat`. Only a stat mismatch
+ * pays for a hash, which is what keeps a `touch` or a checkout that rewrote
+ * identical bytes from reading as drift.
+ *
+ * Any failure answers `false`. A wrong "stale" flag would put a warning banner
+ * over correct source; the cases that would trip it (missing record, unreadable
+ * file) have their own handling in the endpoints that actually read.
+ */
+export function hasDriftedOnDisk(
+  projectRoot: string,
+  storedPath: string,
+  record: FileRecord
+): boolean {
+  try {
+    const absolute = resolveProjectFile(projectRoot, storedPath);
+    const stats = fs.statSync(absolute);
+    if (stats.size === record.size && Math.floor(stats.mtimeMs) === Math.floor(record.modifiedAt)) {
+      return false;
+    }
+    if (stats.size > MAX_SOURCE_BYTES) return true;
+    const content = fs.readFileSync(absolute, 'utf-8');
+    return createHash('sha256').update(content).digest('hex') !== record.contentHash;
+  } catch {
+    return false;
+  }
+}
+
+/**
+ * The drift verdict AND the file's length, from one read.
+ *
+ * The whole-file view needs both before it draws anything: the drift banner,
+ * and the line count that fixes the height of the scrolling document (every
+ * line is a fixed 20px, so the total IS the layout). Asking
+ * {@link hasDriftedOnDisk} and then a source page would answer the first
+ * question against one read of the file and the second against another, which
+ * is exactly the window in which a file can change underneath the two.
+ *
+ * Unlike `hasDriftedOnDisk` there is no stat-only fast path: the bytes have to
+ * be read to be counted. That is the cost of knowing the length, and it is
+ * bounded by {@link MAX_SOURCE_BYTES} like every other read here.
+ */
+export function readFileShape(
+  projectRoot: string,
+  storedPath: string,
+  record: FileRecord
+): { drift: boolean; totalLines: number | null; reason?: string } {
+  let absolute: string;
+  try {
+    absolute = resolveProjectFile(projectRoot, storedPath);
+  } catch {
+    // A refusal on a path the INDEX handed us is not a request to refuse — the
+    // caller already passed the chokepoint. Treat it as unreadable.
+    return { drift: false, totalLines: null };
+  }
+  try {
+    const stats = fs.statSync(absolute);
+    if (stats.size > MAX_SOURCE_BYTES) {
+      return { drift: false, totalLines: null, reason: 'The file is too large to read here.' };
+    }
+    const content = fs.readFileSync(absolute, 'utf-8');
+    const drift = createHash('sha256').update(content).digest('hex') !== record.contentHash;
+    return {
+      drift,
+      totalLines: splitLines(content).length,
+      ...(drift
+        ? {
+            reason:
+              'This file changed on disk after the last index sync, so the line ' +
+              'numbers the graph holds no longer match it.',
+          }
+        : {}),
+    };
+  } catch {
+    return {
+      drift: true,
+      totalLines: null,
+      reason: 'The file is in the index but could not be read from disk.',
+    };
+  }
+}
+
+export interface SourceResult {
+  file: string;
+  language: string;
+  /** The file on disk differs from what was indexed. */
+  drift: boolean;
+  /**
+   * Which numbering the returned lines belong to.
+   *
+   * `'indexed'` — the file matches the index, so the two are the same thing.
+   * `'current'` — the file drifted and the caller asked for it anyway
+   * (`ondrift=current`): these are the bytes on disk right now, and NOTHING the
+   * graph holds about this file (symbol ranges, call-site lines, ports) lines
+   * up with them.
+   * `'none'` — the file drifted and no slice is served.
+   */
+  showing: 'indexed' | 'current' | 'none';
+  contentHash: string;
+  indexedAt: number;
+  generated: boolean;
+  totalLines: number | null;
+  from?: number;
+  to?: number;
+  lines?: string[];
+  truncated?: boolean;
+  reason?: string;
+  /**
+   * The same lines, classified for the code block — one entry per line, each a
+   * list of `[classId, text]` pairs indexed into `highlight.classes`.
+   *
+   * It rides with the slice rather than living behind its own endpoint because
+   * the two are only ever wanted together, and because a second round-trip
+   * would let the viewer paint unhighlighted source and then reflow it. Absent
+   * whenever `lines` is — a drifted file is not served at all.
+   */
+  highlight?: HighlightResult;
+}
+
+/**
+ * What to do when the file on disk no longer matches the index.
+ *
+ * `omit` (the default) is the safe answer for a caller that has not decided
+ * anything yet. `current` is for one that has: it is about to say, in the
+ * pixels, that these are the file's CURRENT lines and that nothing the graph
+ * holds about them applies.
+ */
+export type OnDrift = 'omit' | 'current';
+
+/** Said once, so the two places that answer with current bytes cannot diverge. */
+const DRIFT_CURRENT_REASON =
+  'This file changed on disk after the last index sync. These are its current ' +
+  'lines; the indexed line ranges — symbol bodies, call sites, ports — no longer ' +
+  'match them. The next sync picks it up.';
+
+export function parseOnDrift(query: URLSearchParams): OnDrift {
+  const raw = query.get('ondrift');
+  if (raw === null || raw === '' || raw === 'omit') return 'omit';
+  if (raw === 'current') return 'current';
+  throw badRequest(
+    `Parameter "ondrift" must be "omit" or "current" (got "${raw}").`,
+    'Omit it to leave a drifted file unsliced; "current" serves the bytes on disk instead.'
+  );
+}
+
+export async function buildSource(
+  cg: CodeGraph,
+  projectRoot: string,
+  query: URLSearchParams
+): Promise<SourceResult> {
+  const requested = textParam(query, 'file');
+  // Refusal first, index lookup second — see `resolveRequestedFile`.
+  const { record, storedPath, absolute } = resolveRequestedFile(cg, projectRoot, requested);
+
+  const from = intParam(query, 'from', { min: 1, max: 5_000_000, default: 1 });
+  const to = intParam(query, 'to', { min: 1, max: 5_000_000, default: 0 });
+  if (to !== 0 && to < from) {
+    throw badRequest(`Parameter "to" (${to}) must not be before "from" (${from}).`);
+  }
+  const onDrift = parseOnDrift(query);
+
+  const base: SourceResult = {
+    file: storedPath.replace(/\\/g, '/'),
+    language: record.language,
+    drift: false,
+    showing: 'indexed',
+    contentHash: record.contentHash,
+    indexedAt: record.indexedAt,
+    generated: record.generated === true,
+    totalLines: null,
+  };
+
+  let stats: fs.Stats;
+  try {
+    stats = fs.statSync(absolute);
+  } catch {
+    // Indexed but gone. That IS drift, and the strongest kind: nothing on disk
+    // corresponds to the ranges the graph holds — and `ondrift=current` has
+    // nothing to fall back to either.
+    return {
+      ...base,
+      drift: true,
+      showing: 'none',
+      reason: 'The file is in the index but no longer on disk.',
+    };
+  }
+  if (stats.size > MAX_SOURCE_BYTES) {
+    throw badRequest(
+      `${base.file} is ${Math.round(stats.size / 1024 / 1024)} MB — too large to serve as source.`
+    );
+  }
+
+  let content: string;
+  try {
+    content = fs.readFileSync(absolute, 'utf-8');
+  } catch (err) {
+    throw new ApiError(
+      'internal',
+      `Could not read ${base.file}: ${err instanceof Error ? err.message : String(err)}`
+    );
+  }
+
+  // Byte-identical to extraction's `hashContent` (sha256 over the utf-8
+  // string). A touch or a checkout that rewrote the same bytes must not count
+  // as drift, which is exactly what hashing content rather than mtime buys.
+  const hash = createHash('sha256').update(content).digest('hex');
+  const drift = hash !== record.contentHash;
+  if (drift && onDrift === 'omit') {
+    return {
+      ...base,
+      drift: true,
+      showing: 'none',
+      reason:
+        'This file changed on disk after the last index sync, so the indexed line ' +
+        'ranges no longer reliably match. Source is omitted rather than risk showing ' +
+        "a different symbol's code; it returns after the next sync.",
+    };
+  }
+
+  const all = splitLines(content);
+  // Past the end of the file `from` names nothing, which is a caller bug worth
+  // surfacing rather than answering with the last line as if that were meant.
+  // `to` past the end is different — "line 30 to the end, whatever that is" is
+  // an ordinary way to ask, so it clamps.
+  //
+  // The exception is a drifted file the caller asked for anyway: it has already
+  // been told the numbering does not hold, and a save that SHORTENED the file
+  // between the length it was given and this read is an ordinary race, not a
+  // bug. Those get an empty slice.
+  if (from > all.length) {
+    if (!drift) {
+      throw badRequest(
+        `Parameter "from" (${from}) is past the end of ${base.file}, which has ${all.length} lines.`
+      );
+    }
+    return {
+      ...base,
+      drift: true,
+      showing: 'current',
+      totalLines: all.length,
+      from,
+      to: from - 1,
+      lines: [],
+      truncated: false,
+      reason: DRIFT_CURRENT_REASON,
+    };
+  }
+  const start = from;
+  const requestedEnd = to === 0 ? all.length : Math.min(to, all.length);
+  const end = Math.min(requestedEnd, start + MAX_SOURCE_LINES - 1);
+  const slice = all.slice(start - 1, end);
+
+  return {
+    ...base,
+    drift,
+    // The bytes are always the ones on disk. What changes with drift is what
+    // they can be *used* for: under `current` the caller must not map anything
+    // the index holds onto these numbers.
+    showing: drift ? 'current' : 'indexed',
+    ...(drift ? { reason: DRIFT_CURRENT_REASON } : {}),
+    totalLines: all.length,
+    from: start,
+    to: end,
+    lines: slice,
+    truncated: end < requestedEnd,
+    // Keyed on the hash of the bytes ACTUALLY BEING SERVED, so the cache is
+    // invalidated by the file changing rather than by a clock, and two viewers
+    // looking at the same symbol share one tokenisation. It must be the disk
+    // hash rather than the record's: on a drifted file those differ, and keying
+    // current lines under the indexed hash would serve the previous edit's
+    // colours over this one's text.
+    highlight: await highlightLines(slice, {
+      language: record.language,
+      cacheKey: `${hash}:${start}:${end}`,
+    }),
+  };
+}

+ 149 - 0
src/ui-server/api/stats.ts

@@ -0,0 +1,149 @@
+/**
+ * `GET /api/stats` — what this index is, and how much to trust it.
+ *
+ * The viewer's top bar shows a couple of numbers from here, but the reason the
+ * endpoint carries more than that is honesty: an index can be truncated
+ * (`state: "indexing"` after a killed run), built by an older extractor, or
+ * simply old. A reader that draws confident graphs over a half-built index is
+ * the failure mode worth designing against, so the state travels with the
+ * counts rather than being something the UI has to ask for separately.
+ */
+
+import * as path from 'path';
+import type { CodeGraph } from '../../index';
+import { BLAST_DEPTH, HUB_THRESHOLD, UNCERTAIN_BELOW } from './wire';
+
+/**
+ * How many of the index's most-depended-on symbols the blast scale measures.
+ *
+ * The Symbol view's blast bar is a comparison — "wide for this repo, or
+ * narrow?" — so it needs a denominator, and the honest one is the widest
+ * radius in the index. Measuring all of them means a depth-3 traversal per
+ * symbol, which on a large repo is minutes. Measuring the most-depended-on
+ * ones costs 24 traversals and finds the widest radius in practice: a radius
+ * is grown by dependents, so the symbol with the widest one is very nearly
+ * always near the top of that list.
+ *
+ * "Very nearly always" is not "always" — a symbol with three dependents that
+ * each have three hundred can beat them — so the scale is a floor, not a
+ * claim: {@link blastScaleFor} reports it as `sampled`, and the viewer raises
+ * it whenever the symbol on screen exceeds it rather than drawing past 100%.
+ */
+const BLAST_SCALE_SAMPLE = 24;
+
+export interface WireBlastScale {
+  /** Most distinct dependents any symbol in the index has. Exact — one query. */
+  maxDirect: number;
+  /** Widest depth-{@link BLAST_DEPTH} radius found across the sampled symbols. */
+  maxWithinHops: number;
+  hops: number;
+  /** How many symbols were measured for `maxWithinHops`. */
+  sampled: number;
+  /** True whenever `maxWithinHops` came from a sample rather than every symbol. */
+  estimated: boolean;
+}
+
+/**
+ * The denominator for the Symbol view's blast bar.
+ *
+ * Computed once per process and cached against the index's build stamp: it is
+ * a property of the whole graph, every Symbol view needs it, and re-deriving it
+ * per request would put 24 traversals in front of every screen.
+ */
+let cachedScale: { key: string; value: WireBlastScale } | null = null;
+
+export function blastScaleFor(
+  cg: CodeGraph,
+  projectRoot: string,
+  edgeCount: number
+): WireBlastScale {
+  // Keyed on the project AND the index's stamp AND its edge count, so a
+  // re-index (or a sync that only moved edges) invalidates it and two indexes
+  // opened by one process cannot share a denominator. A stale one would
+  // silently rescale every bar in the app.
+  const key = `${projectRoot}\u0000${cg.getLastIndexedAt() ?? 0}:${edgeCount}`;
+  if (cachedScale?.key === key) return cachedScale.value;
+
+  const top = cg.getTopDependedOn(BLAST_SCALE_SAMPLE);
+  let maxWithinHops = 0;
+  for (const candidate of top) {
+    try {
+      const subgraph = cg.getImpactRadius(candidate.nodeId, BLAST_DEPTH);
+      maxWithinHops = Math.max(maxWithinHops, subgraph.nodes.size - 1);
+    } catch {
+      // A candidate that cannot be traversed (a node the edge table names but
+      // the node table lost) narrows the sample; it must not fail the screen.
+    }
+  }
+
+  const value: WireBlastScale = {
+    maxDirect: top[0]?.dependents ?? 0,
+    maxWithinHops,
+    hops: BLAST_DEPTH,
+    sampled: top.length,
+    estimated: true,
+  };
+  cachedScale = { key, value };
+  return value;
+}
+
+/** Drop the memoised scale — for tests, which build a fresh index per case. */
+export function resetBlastScaleCache(): void {
+  cachedScale = null;
+}
+
+export function buildStats(cg: CodeGraph, projectRoot: string): unknown {
+  const stats = cg.getStats();
+  const build = cg.getIndexBuildInfo();
+
+  return {
+    project: {
+      root: projectRoot,
+      name: path.basename(projectRoot) || projectRoot,
+    },
+    index: {
+      /**
+       * `complete` is the only good value. `indexing` means a run was killed
+       * part-way and the graph on disk is a truncated one; `partial`/`failed`
+       * mean the run finished but dropped files. `null` predates the marker.
+       */
+      state: cg.getIndexState(),
+      lastIndexedAt: cg.getLastIndexedAt(),
+      /** Built by an older extractor — a re-index would add data no migration can. */
+      stale: cg.isIndexStale(),
+      version: build.version,
+      extractionVersion: build.extractionVersion,
+      backend: cg.getBackend(),
+      journalMode: cg.getJournalMode(),
+      /** References still waiting to resolve; > 0 means edges are still missing. */
+      pendingReferences: cg.getPendingReferenceCount(),
+      generatedFiles: cg.getGeneratedFileCount(),
+      watching: cg.isWatching(),
+      watcherDegraded: cg.isWatcherDegraded(),
+    },
+    graph: {
+      nodes: stats.nodeCount,
+      edges: stats.edgeCount,
+      files: stats.fileCount,
+      nodesByKind: stats.nodesByKind,
+      edgesByKind: stats.edgesByKind,
+      filesByLanguage: stats.filesByLanguage,
+      dbSizeBytes: stats.dbSizeBytes,
+      walSizeBytes: stats.walSizeBytes,
+    },
+    frameworks: cg.getDetectedFrameworks(),
+    /**
+     * The thresholds the API itself applied, so the viewer's copy ("hub · N",
+     * "confidence < 0.6") stays in step with the data instead of hard-coding a
+     * second copy of the same numbers.
+     */
+    thresholds: { hub: HUB_THRESHOLD, uncertainBelow: UNCERTAIN_BELOW },
+    /**
+     * The denominator the Symbol view's blast bar is drawn against, so one
+     * symbol's radius reads as wide or narrow *for this repo* instead of as a
+     * bare number. See {@link blastScaleFor} for what "sampled" costs and
+     * concedes.
+     */
+    blastScale: blastScaleFor(cg, projectRoot, stats.edgeCount),
+  };
+}

+ 332 - 0
src/ui-server/api/trail-store.ts

@@ -0,0 +1,332 @@
+/**
+ * Where saved trails live on disk — the only thing `codegraph ui` ever writes.
+ *
+ * Every other module under `api/` is a reader. This one holds the single write
+ * path in the whole viewer, and it is scoped as narrowly as a write can be: one
+ * directory, `<CODEGRAPH_DIR>/ui/trails/`, inside the project the server was
+ * started on, one JSON file per trail. It never touches source, never touches
+ * the index, and never writes anywhere a `codegraph init` would not already
+ * have created. `.codegraph/.gitignore` ignores everything but itself, so a
+ * saved trail is local by default; exporting one to commit is a copy the reader
+ * makes deliberately.
+ *
+ * Two rules hold it inside the boundary described in `../security.ts`:
+ *
+ * - **The directory is resolved through `resolveProjectFile`**, exactly like a
+ *   source read, so a trail id that tried to be a path is refused by the same
+ *   chokepoint that refuses `?file=../../.ssh/id_rsa`. It is belt and braces on
+ *   top of {@link isTrailId}, which already refuses anything but a slug.
+ * - **A write is atomic.** Temp file in the same directory, then rename. A
+ *   half-written trail read back by the list would look like a corrupt one, and
+ *   the list would then have to decide whether to hide it — which is a decision
+ *   nobody should have to make about a file they saved a second ago.
+ *
+ * The format is deliberately plain: a reader can open one in an editor, and a
+ * hop is described by what it IS (a qualified name in a file) rather than by the
+ * node id it happened to have. Node ids contain a start line, so any edit above
+ * a symbol renames it — a trail keyed on ids would not survive its own project.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { CODEGRAPH_DIR } from '../../directory';
+import { resolveProjectFile } from '../security';
+import { ApiError, badRequest } from './respond';
+
+/** Where trails live, relative to the project root. Forward slashes always. */
+export const TRAILS_RELATIVE_DIR = `${CODEGRAPH_DIR}/ui/trails`;
+
+/** The only `version` this build writes, and the only one it reads. */
+export const TRAIL_FORMAT_VERSION = 1;
+
+/** Trail files read from the directory before the list stops looking. */
+export const MAX_TRAILS = 200;
+
+/** Hops one trail may carry. Past this it is a history, not a tour. */
+export const MAX_TRAIL_HOPS = 64;
+
+/** Characters in a trail's name. */
+export const MAX_TRAIL_NAME = 120;
+
+/** Characters in a trail's note. */
+export const MAX_TRAIL_NOTE = 600;
+
+/** Bytes a single trail file may be before it is skipped as not-ours. */
+export const MAX_TRAIL_FILE_BYTES = 64 * 1024;
+
+/** Characters in a generated slug, before any de-duplicating suffix. */
+const MAX_SLUG = 60;
+
+/** How a reader got from the previous hop to this one. Mirrors the viewer's `HopDirection`. */
+export type StoredHopDirection = 'start' | 'down' | 'up';
+
+/**
+ * One hop, described by what it is rather than by the id it had.
+ *
+ * `id` is kept as a HINT — when the file has not changed it resolves in one
+ * lookup — but `qualifiedName` + `kind` + `file` is what the trail is actually
+ * keyed on, and what lets it survive a re-index.
+ */
+export interface StoredHop {
+  dir: StoredHopDirection;
+  name: string;
+  qualifiedName: string;
+  kind: string;
+  /** Project-relative, forward slashes. */
+  file: string;
+  line: number;
+  /** The node id at save time. A fast path, never the identity. */
+  id: string;
+}
+
+export interface StoredTrail {
+  version: number;
+  /** Slug, and the file's basename. */
+  id: string;
+  name: string;
+  note: string;
+  /** Whoever saved it — git's `user.name`, or the OS user. */
+  author: string;
+  createdAt: string;
+  updatedAt: string;
+  hops: StoredHop[];
+}
+
+/* ------------------------------------------------------------------ paths -- */
+
+/**
+ * Whether a string is a trail id we would have written.
+ *
+ * Lowercase slug characters only: no dot, no separator, no leading dash. This
+ * is what makes `<id>.json` a filename rather than a path expression, and it
+ * runs before the id is ever joined to anything.
+ */
+export function isTrailId(value: string): boolean {
+  return /^[a-z0-9][a-z0-9-]{0,79}$/.test(value);
+}
+
+/**
+ * `Read a file with these lines` -> `read-a-file-with-these-lines`.
+ *
+ * Names that carry no ASCII letters or digits at all (a trail named entirely in
+ * Chinese, or in emoji) slug to nothing; they get `trail`, and the collision
+ * handling in {@link saveTrail} keeps them distinct from each other.
+ */
+export function slugify(name: string): string {
+  const slug = name
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, MAX_SLUG)
+    .replace(/-+$/g, '');
+  return slug === '' ? 'trail' : slug;
+}
+
+/** The absolute trails directory, having been through the read chokepoint. */
+export function trailsDirectory(projectRoot: string): string {
+  return resolveProjectFile(projectRoot, TRAILS_RELATIVE_DIR);
+}
+
+/**
+ * The absolute path of one trail file.
+ *
+ * @throws {ApiError} `bad-request` when the id is not a slug we would have
+ *   written — checked before the join, so nothing path-shaped is ever built.
+ */
+export function trailPath(projectRoot: string, id: string): string {
+  if (!isTrailId(id)) {
+    throw badRequest(
+      `"${id}" is not a saved trail id.`,
+      'Trail ids are the lowercase slug in the file name, e.g. "how-a-request-is-served".'
+    );
+  }
+  return resolveProjectFile(projectRoot, `${TRAILS_RELATIVE_DIR}/${id}.json`);
+}
+
+/* ------------------------------------------------------------------- read -- */
+
+/**
+ * Parse a file into a trail, or `null` if it is not one.
+ *
+ * Everything is re-validated rather than trusted: the directory is a place a
+ * user may hand-edit a file, or drop one somebody else exported, and a trail
+ * that half-parsed would draw a row with holes in it. A file that fails is
+ * skipped and counted, never repaired in place.
+ */
+export function parseTrail(id: string, text: string): StoredTrail | null {
+  let raw: unknown;
+  try {
+    raw = JSON.parse(text);
+  } catch {
+    return null;
+  }
+  if (typeof raw !== 'object' || raw === null) return null;
+  const value = raw as Record<string, unknown>;
+  if (typeof value.name !== 'string' || value.name.trim() === '') return null;
+  if (!Array.isArray(value.hops) || value.hops.length === 0) return null;
+
+  const hops: StoredHop[] = [];
+  for (const entry of value.hops.slice(0, MAX_TRAIL_HOPS)) {
+    if (typeof entry !== 'object' || entry === null) return null;
+    const hop = entry as Record<string, unknown>;
+    const qualifiedName = typeof hop.qualifiedName === 'string' ? hop.qualifiedName : '';
+    const name = typeof hop.name === 'string' ? hop.name : '';
+    if (qualifiedName === '' && name === '') return null;
+    hops.push({
+      dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
+      name: name || qualifiedName,
+      qualifiedName: qualifiedName || name,
+      kind: typeof hop.kind === 'string' ? hop.kind : '',
+      file: typeof hop.file === 'string' ? hop.file : '',
+      line: typeof hop.line === 'number' && hop.line > 0 ? Math.floor(hop.line) : 0,
+      id: typeof hop.id === 'string' ? hop.id : '',
+    });
+  }
+
+  const created = typeof value.createdAt === 'string' ? value.createdAt : '';
+  return {
+    version: typeof value.version === 'number' ? value.version : TRAIL_FORMAT_VERSION,
+    // The FILE's name wins over any `id` inside it: the basename is what the
+    // delete route addresses, so a hand-copied file is addressable under the
+    // name it actually has rather than the one it remembers having.
+    id,
+    name: value.name.slice(0, MAX_TRAIL_NAME),
+    note: typeof value.note === 'string' ? value.note.slice(0, MAX_TRAIL_NOTE) : '',
+    author: typeof value.author === 'string' ? value.author.slice(0, 120) : '',
+    createdAt: created,
+    updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : created,
+    hops,
+  };
+}
+
+export interface StoredTrailList {
+  trails: StoredTrail[];
+  /** Files in the directory that were not readable trails. */
+  skipped: number;
+}
+
+/**
+ * Every trail in the project, newest save first.
+ *
+ * A missing directory is the ordinary state of a project nobody has saved a
+ * trail in — an empty list, never an error.
+ */
+export function listStoredTrails(projectRoot: string): StoredTrailList {
+  const dir = trailsDirectory(projectRoot);
+  let names: string[];
+  try {
+    names = fs.readdirSync(dir);
+  } catch {
+    return { trails: [], skipped: 0 };
+  }
+
+  const trails: StoredTrail[] = [];
+  let skipped = 0;
+  for (const name of names.sort()) {
+    if (!name.endsWith('.json')) continue;
+    if (trails.length >= MAX_TRAILS) break;
+    const id = name.slice(0, -'.json'.length);
+    if (!isTrailId(id)) {
+      skipped += 1;
+      continue;
+    }
+    const trail = readTrailFile(path.join(dir, name), id);
+    if (trail) trails.push(trail);
+    else skipped += 1;
+  }
+
+  // Newest save first: a tour written a minute ago is the one being iterated on.
+  trails.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.name.localeCompare(b.name)));
+  return { trails, skipped };
+}
+
+function readTrailFile(absolute: string, id: string): StoredTrail | null {
+  try {
+    const stat = fs.statSync(absolute);
+    // A file too big to be a trail is skipped rather than read: this directory
+    // is inside the project, and something else may one day put a log in it.
+    if (!stat.isFile() || stat.size > MAX_TRAIL_FILE_BYTES) return null;
+    return parseTrail(id, fs.readFileSync(absolute, 'utf-8'));
+  } catch {
+    return null;
+  }
+}
+
+/** One trail by id, or `null` when there is no such file. */
+export function readStoredTrail(projectRoot: string, id: string): StoredTrail | null {
+  return readTrailFile(trailPath(projectRoot, id), id);
+}
+
+/* ------------------------------------------------------------------ write -- */
+
+/**
+ * Write a trail, atomically.
+ *
+ * Temp file beside the target then `rename`, so a reader either sees the
+ * previous trail or the new one and never a partial file. The temp name carries
+ * the pid: two `codegraph ui` processes on one project is unusual but not
+ * forbidden, and two writers sharing a temp name would corrupt each other's.
+ */
+export function writeStoredTrail(projectRoot: string, trail: StoredTrail): void {
+  const dir = trailsDirectory(projectRoot);
+  try {
+    fs.mkdirSync(dir, { recursive: true });
+  } catch (err) {
+    throw writeFailure(err);
+  }
+  const target = trailPath(projectRoot, trail.id);
+  const temp = `${target}.${process.pid}.tmp`;
+  try {
+    fs.writeFileSync(temp, `${JSON.stringify(trail, null, 2)}\n`, 'utf-8');
+    fs.renameSync(temp, target);
+  } catch (err) {
+    try {
+      fs.unlinkSync(temp);
+    } catch {
+      // Nothing to clean up, or nothing we can do about it. The write already
+      // failed; the caller is about to be told so.
+    }
+    throw writeFailure(err);
+  }
+}
+
+/** Remove a trail. Returns false when there was nothing there. */
+export function deleteStoredTrail(projectRoot: string, id: string): boolean {
+  try {
+    fs.unlinkSync(trailPath(projectRoot, id));
+    return true;
+  } catch (err) {
+    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
+    throw writeFailure(err);
+  }
+}
+
+/**
+ * An id nothing in `taken` is using, preferring the plain slug.
+ *
+ * A save under a name that is already there REPLACES it — that is what a reader
+ * pressing Save with the same name means — so the caller passes the ids of
+ * trails carrying a *different* name, and this only steps aside for those.
+ */
+export function uniqueTrailId(base: string, taken: ReadonlySet<string>): string {
+  if (!taken.has(base)) return base;
+  for (let n = 2; n < 1000; n += 1) {
+    const candidate = `${base}-${n}`;
+    if (!taken.has(candidate)) return candidate;
+  }
+  // 999 trails sharing one slug is not a state worth a clever answer.
+  throw new ApiError('bad-request', `Too many saved trails are already named like "${base}".`);
+}
+
+function writeFailure(err: unknown): ApiError {
+  const code = (err as NodeJS.ErrnoException).code;
+  const detail = err instanceof Error ? err.message : String(err);
+  if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
+    return new ApiError(
+      'refused',
+      `Saved trails could not be written: ${detail}`,
+      `The viewer writes only to ${TRAILS_RELATIVE_DIR} inside this project. Check that it is writable.`
+    );
+  }
+  return new ApiError('internal', `Saved trails could not be written: ${detail}`);
+}

+ 477 - 0
src/ui-server/api/trails.ts

@@ -0,0 +1,477 @@
+/**
+ * `GET/POST/DELETE /api/trails` — saved trails, the reader's own tours through
+ * the graph (design spec §3.12).
+ *
+ * A trail is the path of symbols someone walked to explain something: "how a
+ * request is served", "everything the token expiry touches". The viewer already
+ * carries one in the URL; this is the same walk given a name and kept, so the
+ * next person — or the same person next week — starts at the explanation rather
+ * than at the search box.
+ *
+ * ## The one thing this feature has to get right
+ *
+ * **A trail must survive a re-index.** A node's id contains its start line, so
+ * inserting an import at the top of a file renames every symbol below it. A
+ * trail keyed on ids would break the first time anybody edited the code it
+ * describes — which is exactly when it matters. So a hop is stored as what it
+ * *is* — qualified name, kind, file — with the id kept only as a fast path, and
+ * every hop is re-resolved against the current index on the way out:
+ *
+ * - the recorded id still names the same symbol → `ok`
+ * - the qualified name resolves somewhere else → `moved`, and the row says
+ *   where from
+ * - the name is now carried by several symbols and none is in the recorded
+ *   file → `ambiguous`, best guess offered and labelled as one
+ * - nothing answers to it → `missing`, and the row says "moved or renamed"
+ *
+ * Nothing is silently dropped and nothing is silently guessed: a trail that has
+ * decayed says so on its own row, which is the point at which its author can
+ * fix it.
+ *
+ * ## What it opens
+ *
+ * A trail with a hole in it cannot be handed to the viewer whole — the `t`
+ * param is a PATH, and stitching hop 2 to hop 4 would draw an adjacency that
+ * does not exist. So the payload carries the longest run of consecutive
+ * resolved hops, and the row says when that is less than the whole trail.
+ *
+ * Storage — the only write `codegraph ui` makes — is `./trail-store.ts`.
+ */
+
+import { execFileSync } from 'child_process';
+import * as os from 'os';
+import type { CodeGraph } from '../../index';
+import type { Node } from '../../types';
+import { ApiError, badRequest, notFound } from './respond';
+import {
+  MAX_TRAIL_HOPS,
+  MAX_TRAIL_NAME,
+  MAX_TRAIL_NOTE,
+  MAX_TRAILS,
+  TRAILS_RELATIVE_DIR,
+  TRAIL_FORMAT_VERSION,
+  deleteStoredTrail,
+  listStoredTrails,
+  slugify,
+  uniqueTrailId,
+  writeStoredTrail,
+  type StoredHop,
+  type StoredHopDirection,
+  type StoredTrail,
+} from './trail-store';
+import { toNodeRef } from './wire';
+
+/* ------------------------------------------------------------------ wire -- */
+
+/** How a saved hop fared against the current index. */
+export type WireTrailHopStatus = 'ok' | 'moved' | 'ambiguous' | 'missing';
+
+export interface WireTrailHop {
+  dir: StoredHopDirection;
+  /** The name as it was when the trail was saved. */
+  name: string;
+  qualifiedName: string;
+  kind: string;
+  /** Where the symbol was when the trail was saved. */
+  savedFile: string;
+  savedLine: number;
+  status: WireTrailHopStatus;
+  /** The symbol's id NOW. Null when nothing answers to it any more. */
+  id: string | null;
+  file: string | null;
+  line: number | null;
+  /** Finished screen wording for a status that is not `ok`; null when it is. */
+  note: string | null;
+}
+
+export interface WireTrail {
+  id: string;
+  name: string;
+  note: string;
+  author: string;
+  createdAt: string;
+  updatedAt: string;
+  hops: WireTrailHop[];
+  /** Hops that still resolve to a symbol in this index. */
+  resolved: number;
+  /** Every hop resolved, and none of them moved. */
+  intact: boolean;
+  /**
+   * The longest run of CONSECUTIVE resolved hops, encoded as the `t` param.
+   * Null when nothing in the trail resolves. Never stitched across a hole: the
+   * trail is a path, and a fabricated adjacency is worse than a short one.
+   */
+  encoded: string | null;
+  /** 1-based index of the first hop `encoded` carries. */
+  openFrom: number;
+  /** How many hops `encoded` carries. */
+  openCount: number;
+  /** The symbol the trail opens at — the last hop of that run. */
+  openId: string | null;
+}
+
+export interface WireTrails {
+  trails: WireTrail[];
+  /** Writes are off. The viewer hides Save and Delete, and says why. */
+  readOnly: boolean;
+  readOnlyReason: string | null;
+  /** Project-relative directory the files live in. The screen names it. */
+  directory: string;
+  /** Files in that directory that were not readable trails. */
+  skipped: number;
+  /** The list stopped at {@link MAX_TRAILS}. */
+  bounded: boolean;
+  /** The id just written, on the answer to a POST. */
+  saved?: string;
+  /** That POST replaced a trail of the same name. */
+  replaced?: boolean;
+  /** The id just removed, on the answer to a DELETE. */
+  deleted?: string;
+}
+
+/* -------------------------------------------------------------- resolution -- */
+
+/**
+ * Re-resolve one saved hop against the index as it is now.
+ *
+ * Order matters: the recorded id first, because in the common case (nothing
+ * above the symbol changed) it is one lookup and exactly right. It is still
+ * verified against the qualified name — an id is a hash of position as well as
+ * identity, and a recycled one pointing at a different symbol would put a
+ * stranger in the middle of somebody's explanation.
+ */
+export function resolveHop(cg: CodeGraph, hop: StoredHop): WireTrailHop {
+  const base = {
+    dir: hop.dir,
+    name: hop.name,
+    qualifiedName: hop.qualifiedName,
+    kind: hop.kind,
+    savedFile: hop.file,
+    savedLine: hop.line,
+  };
+
+  const byId = hop.id ? cg.getNode(hop.id) : null;
+  if (byId && matches(byId, hop)) {
+    return { ...base, status: 'ok', id: byId.id, file: byId.filePath, line: byId.startLine, note: null };
+  }
+
+  const candidates = cg
+    .getNodesByQualifiedName(hop.qualifiedName)
+    .filter((node) => hop.kind === '' || node.kind === hop.kind);
+
+  if (candidates.length === 0) {
+    return {
+      ...base,
+      status: 'missing',
+      id: null,
+      file: null,
+      line: null,
+      note: `no longer in the index — moved or renamed since this trail was saved`,
+    };
+  }
+
+  const sameFile = candidates.filter((node) => node.filePath === hop.file);
+  if (sameFile.length === 1) {
+    const node = sameFile[0] as Node;
+    return { ...base, status: 'ok', id: node.id, file: node.filePath, line: node.startLine, note: null };
+  }
+
+  if (candidates.length === 1) {
+    const node = candidates[0] as Node;
+    return {
+      ...base,
+      status: 'moved',
+      id: node.id,
+      file: node.filePath,
+      line: node.startLine,
+      note: `moved from ${hop.file || 'an unrecorded file'} to ${node.filePath}`,
+    };
+  }
+
+  // Several symbols carry this name and none of them is where it used to be.
+  // The best guess is offered — a row nobody can open is not more honest, it
+  // is just less useful — but it is labelled as a guess.
+  const pick = (sameFile[0] ?? candidates[0]) as Node;
+  return {
+    ...base,
+    status: 'ambiguous',
+    id: pick.id,
+    file: pick.filePath,
+    line: pick.startLine,
+    note: `${candidates.length} symbols now carry this name — showing the one in ${pick.filePath}`,
+  };
+}
+
+function matches(node: Node, hop: StoredHop): boolean {
+  if (hop.kind !== '' && node.kind !== hop.kind) return false;
+  return node.qualifiedName === hop.qualifiedName || node.name === hop.name;
+}
+
+/** The `t` param's own encoding — kept identical to `ui/src/lib/trail-codec.ts`. */
+const DIR_CHAR: Record<StoredHopDirection, string> = { start: 's', down: 'd', up: 'u' };
+
+/**
+ * Turn resolved hops into something the viewer can open.
+ *
+ * The longest CONSECUTIVE run, not every resolved hop: skipping a missing hop
+ * would encode a step from A to C that no edge supports, and the Flow strip
+ * reads a trail as exactly that sequence of edges. The first hop of the run is
+ * always written as `start`, because a run beginning mid-trail arrived from
+ * nothing the viewer can draw.
+ */
+export function encodeResolvedRun(hops: readonly WireTrailHop[]): {
+  encoded: string | null;
+  openFrom: number;
+  openCount: number;
+  openId: string | null;
+} {
+  let bestStart = -1;
+  let bestLength = 0;
+  let start = -1;
+  for (let i = 0; i <= hops.length; i += 1) {
+    const resolved = i < hops.length && (hops[i] as WireTrailHop).id !== null;
+    if (resolved) {
+      if (start < 0) start = i;
+      continue;
+    }
+    if (start >= 0 && i - start > bestLength) {
+      bestStart = start;
+      bestLength = i - start;
+    }
+    start = -1;
+  }
+  if (bestLength === 0) return { encoded: null, openFrom: 0, openCount: 0, openId: null };
+
+  const run = hops.slice(bestStart, bestStart + bestLength);
+  const encoded = run
+    .map((hop, index) => `${index === 0 ? 's' : DIR_CHAR[hop.dir]}${encodeURIComponent(hop.id as string)}`)
+    .join(',');
+  return {
+    encoded,
+    openFrom: bestStart + 1,
+    openCount: bestLength,
+    openId: (run[run.length - 1] as WireTrailHop).id,
+  };
+}
+
+export function resolveTrail(cg: CodeGraph, stored: StoredTrail): WireTrail {
+  const hops = stored.hops.map((hop) => resolveHop(cg, hop));
+  const run = encodeResolvedRun(hops);
+  return {
+    id: stored.id,
+    name: stored.name,
+    note: stored.note,
+    author: stored.author,
+    createdAt: stored.createdAt,
+    updatedAt: stored.updatedAt,
+    hops,
+    resolved: hops.filter((hop) => hop.id !== null).length,
+    intact: hops.every((hop) => hop.status === 'ok'),
+    ...run,
+  };
+}
+
+/* ------------------------------------------------------------------ read -- */
+
+export interface TrailsOptions {
+  /** Writes refused, and the sentence saying why. */
+  readOnly: boolean;
+  readOnlyReason: string | null;
+}
+
+export function buildTrails(
+  cg: CodeGraph,
+  projectRoot: string,
+  options: TrailsOptions
+): WireTrails {
+  const { trails, skipped } = listStoredTrails(projectRoot);
+  return {
+    trails: trails.map((stored) => resolveTrail(cg, stored)),
+    readOnly: options.readOnly,
+    readOnlyReason: options.readOnlyReason,
+    directory: TRAILS_RELATIVE_DIR,
+    skipped,
+    bounded: trails.length >= MAX_TRAILS,
+  };
+}
+
+/* ----------------------------------------------------------------- write -- */
+
+/** What a POST body has to be. Everything else about a hop comes from the graph. */
+export interface SaveTrailRequest {
+  name: string;
+  note?: string;
+  hops: Array<{ dir?: string; id: string }>;
+}
+
+/**
+ * Save a trail.
+ *
+ * The client sends ids and directions and nothing else: the name, kind, file
+ * and line of every hop are read out of the index here. A client that supplied
+ * its own metadata could save a trail describing symbols that are not in the
+ * graph, and the whole value of the feature is that a trail is a claim the
+ * index can re-check.
+ *
+ * A save under a name that already exists REPLACES that trail, keeping its
+ * `createdAt`. That is what pressing Save with the same name means, and the
+ * answer says `replaced` so the screen can too.
+ */
+export function saveTrail(
+  cg: CodeGraph,
+  projectRoot: string,
+  body: unknown,
+  options: TrailsOptions
+): WireTrails {
+  if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
+  const request = parseSaveRequest(body);
+
+  const hops: StoredHop[] = [];
+  request.hops.forEach((hop, index) => {
+    const node = cg.getNode(hop.id);
+    if (!node) {
+      throw badRequest(
+        `Hop ${index + 1} is not in the index: ${hop.id}`,
+        'Trails are saved from symbols the index holds. Reload the page and walk the trail again.'
+      );
+    }
+    const ref = toNodeRef(node);
+    hops.push({
+      dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
+      name: ref.name,
+      qualifiedName: ref.qualifiedName,
+      kind: ref.kind,
+      file: ref.file,
+      line: ref.line,
+      id: ref.id,
+    });
+  });
+  // The first hop is where the walk began, whatever the client called it.
+  if (hops[0]) hops[0].dir = 'start';
+
+  const existing = listStoredTrails(projectRoot).trails;
+  const sameName = existing.find((trail) => trail.name === request.name);
+  const takenByOthers = new Set(
+    existing.filter((trail) => trail.name !== request.name).map((trail) => trail.id)
+  );
+  const id = sameName ? sameName.id : uniqueTrailId(slugify(request.name), takenByOthers);
+  const now = new Date().toISOString();
+
+  writeStoredTrail(projectRoot, {
+    version: TRAIL_FORMAT_VERSION,
+    id,
+    name: request.name,
+    note: request.note,
+    author: trailAuthor(projectRoot),
+    createdAt: sameName?.createdAt || now,
+    updatedAt: now,
+    hops,
+  });
+
+  return { ...buildTrails(cg, projectRoot, options), saved: id, replaced: sameName !== undefined };
+}
+
+export function removeTrail(
+  cg: CodeGraph,
+  projectRoot: string,
+  id: string,
+  options: TrailsOptions
+): WireTrails {
+  if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
+  if (!deleteStoredTrail(projectRoot, id)) {
+    throw notFound(`There is no saved trail called "${id}".`);
+  }
+  return { ...buildTrails(cg, projectRoot, options), deleted: id };
+}
+
+function readOnlyRefusal(reason: string | null): ApiError {
+  return new ApiError(
+    'refused',
+    reason ?? 'This viewer is running read-only, so trails cannot be saved.',
+    `Restart without --read-only to let the viewer write trails into ${TRAILS_RELATIVE_DIR}.`
+  );
+}
+
+function parseSaveRequest(body: unknown): { name: string; note: string; hops: SaveTrailRequest['hops'] } {
+  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
+    throw badRequest('A trail is saved from a JSON object: { name, hops }.');
+  }
+  const value = body as Record<string, unknown>;
+
+  const name = typeof value.name === 'string' ? value.name.trim().replace(/\s+/g, ' ') : '';
+  if (name === '') throw badRequest('A saved trail needs a name.');
+  if (name.length > MAX_TRAIL_NAME) {
+    throw badRequest(`That name is too long (max ${MAX_TRAIL_NAME} characters).`);
+  }
+
+  const note = typeof value.note === 'string' ? value.note.trim() : '';
+  if (note.length > MAX_TRAIL_NOTE) {
+    throw badRequest(`That note is too long (max ${MAX_TRAIL_NOTE} characters).`);
+  }
+
+  if (!Array.isArray(value.hops) || value.hops.length === 0) {
+    throw badRequest('A saved trail needs at least one hop.');
+  }
+  if (value.hops.length > MAX_TRAIL_HOPS) {
+    throw badRequest(`A saved trail can hold at most ${MAX_TRAIL_HOPS} hops.`);
+  }
+
+  const hops: SaveTrailRequest['hops'] = [];
+  for (const entry of value.hops) {
+    if (typeof entry !== 'object' || entry === null) throw badRequest('Each hop is { dir, id }.');
+    const hop = entry as Record<string, unknown>;
+    if (typeof hop.id !== 'string' || hop.id === '') throw badRequest('Each hop needs an id.');
+    hops.push({ id: hop.id, ...(typeof hop.dir === 'string' ? { dir: hop.dir } : {}) });
+  }
+
+  return { name, note, hops };
+}
+
+/* ---------------------------------------------------------------- author -- */
+
+/**
+ * Who to record as the author.
+ *
+ * Git's `user.name` first, because a trail is a thing one person wrote for
+ * others to read and that is the name they already sign work with in this
+ * project; the OS user is the fallback. Read ONCE per process — `git config` is
+ * a subprocess, and a save should not pay for it twice — and never sent
+ * anywhere: it goes into a file inside the user's own `.codegraph/`.
+ */
+let cachedAuthor: string | null = null;
+
+export function trailAuthor(projectRoot: string): string {
+  if (cachedAuthor !== null) return cachedAuthor;
+  cachedAuthor = gitUserName(projectRoot) ?? osUserName() ?? '';
+  return cachedAuthor;
+}
+
+/** Test seam: forget the cached author. */
+export function resetTrailAuthor(): void {
+  cachedAuthor = null;
+}
+
+function gitUserName(projectRoot: string): string | null {
+  try {
+    const out = execFileSync('git', ['config', 'user.name'], {
+      cwd: projectRoot,
+      encoding: 'utf-8',
+      timeout: 2_000,
+      stdio: ['ignore', 'pipe', 'ignore'],
+    });
+    const name = out.trim();
+    return name === '' ? null : name.slice(0, 120);
+  } catch {
+    // No git, no config, not a repository — all ordinary. Fall through.
+    return null;
+  }
+}
+
+function osUserName(): string | null {
+  try {
+    const name = os.userInfo().username.trim();
+    return name === '' ? null : name.slice(0, 120);
+  } catch {
+    return null;
+  }
+}

+ 344 - 0
src/ui-server/api/wire.ts

@@ -0,0 +1,344 @@
+/**
+ * The wire shapes the viewer reads, and the rules for producing them.
+ *
+ * Two ideas run through this file:
+ *
+ * 1. **One round-trip per screen.** Every endpoint returns everything a screen
+ *    draws, in the spirit of `codegraph_explore`: the Symbol view never has to
+ *    ask a follow-up question to render a rail, a badge or a count.
+ * 2. **Capped lists, honest totals.** A symbol with 545 callers cannot ship 545
+ *    rows, but it must never claim it has fewer. Every capped list carries the
+ *    true `total` beside the `shown` slice, so the UI can say "+N more" rather
+ *    than quietly truncating.
+ *
+ * Nothing here reads the filesystem — that lives in `source.ts`, behind
+ * `resolveProjectFile`.
+ */
+
+import type { Edge, EdgeKind, Language, Node, NodeKind } from '../../types';
+import { isTestFile } from '../../search/query-utils';
+
+// =============================================================================
+// Caps and thresholds
+// =============================================================================
+
+/**
+ * Fan-in at or above which a symbol is a "hub" — changing it is a
+ * repo-wide event. Matches the threshold the Symbol view's `hub · N` badge
+ * uses (design spec §3.2).
+ */
+export const HUB_THRESHOLD = 40;
+
+/**
+ * Below this resolution confidence an edge is a name-only guess. The viewer
+ * folds these away behind "Uncertain · N name-only matches, confidence < 0.6"
+ * rather than mixing them into the rails as if they were resolved.
+ */
+export const UNCERTAIN_BELOW = 0.6;
+
+/** Caller groups (one per calling symbol) returned for a node. */
+export const MAX_INCOMING_GROUPS = 300;
+
+/** Callee groups (one per called symbol) returned for a node. */
+export const MAX_OUTGOING_GROUPS = 200;
+
+/** Edges kept inside a single group — one symbol calling another 400 times. */
+export const MAX_EDGES_PER_GROUP = 40;
+
+/** Test files named in a node's test-caller summary (explore uses the same shape). */
+export const MAX_TEST_FILES = 6;
+
+/**
+ * Dependency hops the blast-radius summary walks. Matches the depth
+ * `codegraph_explore` claims when it says "within 3 hops".
+ */
+export const BLAST_DEPTH = 3;
+
+/** Caller hops walked looking for a test. Mirrors `codegraph_explore`'s "tests:" line. */
+export const TEST_CALLER_HOPS = 3;
+
+/** `getCallers` lookups the test walk may spend, so a god-symbol can't stall a request. */
+export const TEST_CALLER_BUDGET = 64;
+
+/** Unresolved references listed by name before the payload just counts them. */
+export const MAX_OUTSIDE_INDEX_SAMPLES = 40;
+
+/** Symbols in a file outline. Beyond this the outline is truncated, not dropped. */
+export const MAX_OUTLINE_NODES = 3000;
+
+/** Files listed in each direction of the File view's import rails. */
+export const MAX_IMPORT_FILES = 300;
+
+// =============================================================================
+// Node shapes
+// =============================================================================
+
+/**
+ * A symbol as it appears in a rail, an outline or a search result: enough to
+ * draw a row and navigate to it, and nothing else. Deliberately excludes the
+ * docstring — a 300-caller rail would otherwise ship 300 docstrings.
+ */
+export interface WireNodeRef {
+  id: string;
+  kind: NodeKind;
+  name: string;
+  qualifiedName: string;
+  /** Project-relative, forward slashes on every platform. */
+  file: string;
+  line: number;
+  endLine: number;
+  language: Language;
+  signature?: string;
+  exported?: boolean;
+  /** The file this symbol lives in looks like test/fixture code. */
+  test: boolean;
+  /**
+   * The file this symbol lives in is tool-generated, so the row draws in ink-4.
+   *
+   * OPTIONAL and absent by default: the verdict is a bounded lookup
+   * (`generatedFilePredicate`), affordable over a screen's worth of rows and
+   * not over a 545-caller rail. An endpoint fills it where it shows.
+   */
+  generated?: boolean;
+}
+
+/** The focal symbol of a Symbol view — the ref, plus everything the header shows. */
+export interface WireNodeDetail extends WireNodeRef {
+  startColumn: number;
+  endColumn: number;
+  docstring?: string;
+  visibility?: string;
+  async?: boolean;
+  static?: boolean;
+  abstract?: boolean;
+  decorators?: string[];
+  typeParameters?: string[];
+  returnType?: string;
+  /** `endLine - line + 1`, so the header can print "N lines" without the source. */
+  lines: number;
+}
+
+const rel = (p: string): string => p.replace(/\\/g, '/');
+
+export function toNodeRef(node: Node): WireNodeRef {
+  const file = rel(node.filePath);
+  const ref: WireNodeRef = {
+    id: node.id,
+    kind: node.kind,
+    name: node.name,
+    qualifiedName: node.qualifiedName,
+    file,
+    line: node.startLine,
+    endLine: node.endLine,
+    language: node.language,
+    test: isTestFile(file),
+  };
+  if (node.signature) ref.signature = node.signature;
+  if (node.isExported) ref.exported = true;
+  return ref;
+}
+
+export function toNodeDetail(node: Node): WireNodeDetail {
+  const detail: WireNodeDetail = {
+    ...toNodeRef(node),
+    startColumn: node.startColumn,
+    endColumn: node.endColumn,
+    lines: Math.max(1, node.endLine - node.startLine + 1),
+  };
+  if (node.docstring) detail.docstring = node.docstring;
+  if (node.visibility) detail.visibility = node.visibility;
+  if (node.isAsync) detail.async = true;
+  if (node.isStatic) detail.static = true;
+  if (node.isAbstract) detail.abstract = true;
+  if (node.decorators?.length) detail.decorators = node.decorators;
+  if (node.typeParameters?.length) detail.typeParameters = node.typeParameters;
+  if (node.returnType) detail.returnType = node.returnType;
+  return detail;
+}
+
+// =============================================================================
+// Edge shapes
+// =============================================================================
+
+/**
+ * One edge, flattened.
+ *
+ * `metadata` is a free-form JSON blob in the schema; the fields lifted out here
+ * are the ones the viewer draws with — confidence decides the uncertain fold,
+ * `provenance`/`synthesizedBy`/`via`/`registeredAt` decide how a connector is
+ * dashed and what the "via <mechanism>" pill says, `valueRef` distinguishes
+ * "passes as value" from "calls". Anything else in the blob stays out: it is
+ * resolver bookkeeping, not something a reader can act on.
+ */
+export interface WireEdge {
+  kind: EdgeKind;
+  line?: number;
+  col?: number;
+  confidence?: number;
+  resolvedBy?: string;
+  provenance?: string;
+  synthesizedBy?: string;
+  via?: string;
+  registeredAt?: string;
+  valueRef?: boolean;
+}
+
+export function toWireEdge(edge: Edge): WireEdge {
+  const meta = (edge.metadata ?? {}) as Record<string, unknown>;
+  const wire: WireEdge = { kind: edge.kind };
+  if (typeof edge.line === 'number') wire.line = edge.line;
+  if (typeof edge.column === 'number') wire.col = edge.column;
+  if (typeof meta.confidence === 'number') wire.confidence = meta.confidence;
+  if (typeof meta.resolvedBy === 'string') wire.resolvedBy = meta.resolvedBy;
+  if (edge.provenance) wire.provenance = edge.provenance;
+  if (typeof meta.synthesizedBy === 'string') wire.synthesizedBy = meta.synthesizedBy;
+  if (typeof meta.via === 'string') wire.via = meta.via;
+  if (typeof meta.registeredAt === 'string') wire.registeredAt = meta.registeredAt;
+  if (meta.valueRef === true) wire.valueRef = true;
+  return wire;
+}
+
+// =============================================================================
+// Relations — edges grouped by the symbol at the other end
+// =============================================================================
+
+/**
+ * Every edge between the focal symbol and ONE other symbol, as a single row.
+ *
+ * Grouping is what makes the rails readable: a helper called from eleven lines
+ * of the same function is one row with eleven call-site chips, not eleven rows.
+ */
+export interface WireRelation {
+  node: WireNodeRef;
+  /** Distinct edge kinds between the two, in first-seen order. */
+  edgeKinds: EdgeKind[];
+  /** Up to {@link MAX_EDGES_PER_GROUP} edges, ordered by line. */
+  edges: WireEdge[];
+  /** True number of edges, even when `edges` was capped. */
+  edgeCount: number;
+  /** Distinct call-site lines, ascending — what the gutter ports anchor to. */
+  lines: number[];
+  /** Highest confidence any edge in the group carries; null when none does. */
+  confidence: number | null;
+  /** The whole group is a name-only guess (see {@link UNCERTAIN_BELOW}). */
+  uncertain: boolean;
+  /** At least one edge was synthesized rather than parsed (dynamic dispatch). */
+  synthesized: boolean;
+  /** Fan-in of the other symbol — the `hub · N` pill. Only filled where the UI shows it. */
+  fanIn?: number;
+  hub?: boolean;
+}
+
+/** A capped list that still knows how long it really is. */
+export interface WireList<T> {
+  total: number;
+  shown: number;
+  truncated: boolean;
+  items: T[];
+}
+
+export function wireList<T>(items: T[], total: number): WireList<T> {
+  return { total, shown: items.length, truncated: items.length < total, items };
+}
+
+/**
+ * Fold edges into one relation per counterpart symbol.
+ *
+ * @param edges     edges all sharing the focal node at one end
+ * @param endpoint  which end of each edge names the OTHER symbol
+ * @param nodes     batch-resolved endpoint nodes (never a lookup per edge)
+ */
+export function groupRelations(
+  edges: readonly Edge[],
+  endpoint: (edge: Edge) => string,
+  nodes: Map<string, Node>
+): WireRelation[] {
+  const byNode = new Map<string, Edge[]>();
+  for (const edge of edges) {
+    const id = endpoint(edge);
+    const bucket = byNode.get(id);
+    if (bucket) bucket.push(edge);
+    else byNode.set(id, [edge]);
+  }
+
+  const relations: WireRelation[] = [];
+  for (const [id, group] of byNode) {
+    const node = nodes.get(id);
+    // An edge whose endpoint is missing from `nodes` means the graph and the
+    // node table disagree — skip it rather than invent a row. Callers still see
+    // it in the totals they computed from the raw edge list.
+    if (!node) continue;
+    const ordered = [...group].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
+    const wireEdges = ordered.slice(0, MAX_EDGES_PER_GROUP).map(toWireEdge);
+
+    const edgeKinds: EdgeKind[] = [];
+    for (const edge of ordered) if (!edgeKinds.includes(edge.kind)) edgeKinds.push(edge.kind);
+
+    const lines = [
+      ...new Set(ordered.map((e) => e.line).filter((l): l is number => typeof l === 'number' && l > 0)),
+    ].sort((a, b) => a - b);
+
+    let confidence: number | null = null;
+    let synthesized = false;
+    for (const edge of ordered) {
+      const value = (edge.metadata as Record<string, unknown> | undefined)?.confidence;
+      if (typeof value === 'number' && (confidence === null || value > confidence)) confidence = value;
+      if (edge.provenance === 'heuristic') synthesized = true;
+    }
+
+    relations.push({
+      node: toNodeRef(node),
+      edgeKinds,
+      edges: wireEdges,
+      edgeCount: ordered.length,
+      lines,
+      confidence,
+      // No confidence recorded is NOT uncertain: tree-sitter edges extracted
+      // straight from the AST carry none precisely because they are certain.
+      uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
+      synthesized,
+    });
+  }
+  return relations;
+}
+
+/** First call-site line of a relation, for line-anchored ordering. Unlined rows sort last. */
+export function firstLine(relation: WireRelation): number {
+  return relation.lines[0] ?? Number.MAX_SAFE_INTEGER;
+}
+
+/** Node kinds that count as "a type" for the Symbol view's "types used" chips. */
+export const TYPE_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'interface',
+  'type_alias',
+  'class',
+  'struct',
+  'enum',
+  'union',
+  'trait',
+  'protocol',
+]);
+
+/** Container kinds whose members the outline nests one level deeper. */
+export const CONTAINER_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'file',
+  'module',
+  'namespace',
+  'class',
+  'struct',
+  'interface',
+  'trait',
+  'protocol',
+  'enum',
+  'union',
+]);
+
+/** The four edge kinds `getCallers` treats as "reaches this symbol". */
+export const CALLER_EDGE_KINDS: ReadonlySet<EdgeKind> = new Set<EdgeKind>([
+  'calls',
+  'references',
+  'imports',
+  'instantiates',
+]);
+
+export { rel as toPosixPath };

+ 76 - 0
src/ui-server/assets.ts

@@ -0,0 +1,76 @@
+/**
+ * Locating the built browser viewer on disk.
+ *
+ * The viewer is a static Vite build that ships inside the package, exactly like
+ * `schema.sql` and the tree-sitter grammars: emitted into `dist/viewer/`,
+ * copied wholesale by `scripts/build-bundle.sh`, packed by
+ * `scripts/pack-npm.sh`. So it is found the same way `db/index.ts` finds
+ * `schema.sql` — relative to `__dirname`, never to `process.cwd()`, which is
+ * whatever directory the user happened to be standing in.
+ *
+ * `dist/viewer`, NOT `dist/ui`: `src/ui/` is the engine's TERMINAL ui and tsc
+ * already compiles it to `dist/ui/`. See `ui/vite.config.ts`.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { VIEWER_PATH_ENV } from './constants';
+
+export { VIEWER_PATH_ENV };
+
+/**
+ * The viewer build is missing — the package was assembled without it, or the
+ * repo was built with `tsc` alone. Carries user-facing remediation rather than
+ * a stack trace, because the CLI prints `.message` verbatim.
+ */
+export class ViewerMissingError extends Error {
+  constructor(searched: readonly string[]) {
+    super(
+      'The CodeGraph viewer assets are missing from this installation.\n' +
+        'Looked in:\n' +
+        searched.map((p) => `  ${p}`).join('\n') +
+        '\n\nIf you installed CodeGraph normally, reinstall it — the release bundle ' +
+        'ships the viewer.\nIf you are working from a source checkout, run: npm run build'
+    );
+    this.name = 'ViewerMissingError';
+  }
+}
+
+/**
+ * Candidate locations for the viewer, most-specific first.
+ *
+ * 1. The `CODEGRAPH_VIEWER_PATH` override.
+ * 2. `<__dirname>/../viewer` — the shipped layout (`dist/ui-server/` →
+ *    `dist/viewer/`).
+ * 3. `<__dirname>/../../dist/viewer` — running the TypeScript straight out of
+ *    `src/` (vitest, tsx), where `__dirname` is `src/ui-server/`.
+ */
+export function viewerDirCandidates(): string[] {
+  const override = process.env[VIEWER_PATH_ENV]?.trim();
+  const candidates = [
+    path.join(__dirname, '..', 'viewer'),
+    path.join(__dirname, '..', '..', 'dist', 'viewer'),
+  ];
+  return override ? [path.resolve(override), ...candidates] : candidates;
+}
+
+/**
+ * Resolve the directory holding the built viewer.
+ *
+ * @throws {ViewerMissingError} when no candidate contains an `index.html`.
+ */
+export function resolveViewerDir(): string {
+  const candidates = viewerDirCandidates();
+  for (const dir of candidates) {
+    try {
+      if (fs.statSync(path.join(dir, 'index.html')).isFile()) {
+        // realpath so the containment checks in `security.ts` compare like for
+        // like when the install lives behind a symlink (Homebrew, nvm, pnpm).
+        return fs.realpathSync(dir);
+      }
+    } catch {
+      // Not here — try the next candidate.
+    }
+  }
+  throw new ViewerMissingError(candidates);
+}

+ 36 - 0
src/ui-server/constants.ts

@@ -0,0 +1,36 @@
+/**
+ * User-facing constants for the `codegraph ui` server.
+ *
+ * Deliberately dependency-free so the CLI can import them for `--help` text
+ * without pulling `node:http` (and the rest of the server) into every
+ * invocation of every other subcommand. `ui-server/index.ts` re-exports them,
+ * so consumers have one import to reach for.
+ */
+
+/** The port `codegraph ui` asks for first. */
+export const DEFAULT_UI_PORT = 4747;
+
+/** How many consecutive ports to try before giving up. */
+export const DEFAULT_PORT_ATTEMPTS = 20;
+
+/**
+ * The only interface the server ever binds. Not configurable, on purpose: this
+ * process serves the user's source code, and a `--host` flag is one typo away
+ * from publishing it to the local network.
+ */
+export const LOOPBACK_ADDRESS = '127.0.0.1';
+
+/**
+ * Overrides which browser (if any) `codegraph ui` launches. `none` — or `0`,
+ * `false`, `off`, or an empty value — suppresses the launch entirely, the same
+ * as `--no-open`. Any other value is run as a command with the URL as its
+ * single argument.
+ */
+export const BROWSER_ENV = 'CODEGRAPH_BROWSER';
+
+/**
+ * Development/test override for the directory served as the viewer. Point it at
+ * a directory containing an `index.html` to serve something other than the
+ * shipped build.
+ */
+export const VIEWER_PATH_ENV = 'CODEGRAPH_VIEWER_PATH';

+ 357 - 0
src/ui-server/highlight/index.ts

@@ -0,0 +1,357 @@
+/**
+ * Server-side syntax classification for the viewer's code block.
+ *
+ * The classes come off the engine's OWN tree-sitter parse (CG-57). Until then
+ * the viewer ran a second highlighter — Shiki, plus 56 pruned TextMate grammars
+ * shipped beside the binary — over source the engine had already parsed with a
+ * real grammar. That is gone: one grammar set, one opinion about what a `.ts`
+ * file is, nothing extra in the bundle, and roughly an order of magnitude off
+ * the cost on the language that used to be worst (see below).
+ *
+ * Three properties are unchanged, because they are what make this safe to
+ * depend on:
+ *
+ * * **It never fails a request.** A grammar that will not load, a parse that
+ *   throws, a language nobody wrote a grammar for, a slice too big to be worth
+ *   parsing — every one of them answers `engine: 'plain'` with a reason and the
+ *   source still goes out. Highlighting is the part that degrades; nothing else
+ *   does.
+ * * **Identifiers survive whatever token boundaries the grammar chose.** Every
+ *   code token is split into identifier runs before it goes on the wire, which
+ *   is what lets the viewer wrap a call site as a link by *claiming a token*
+ *   rather than re-tokenising the line on top of the classifier's answer.
+ * * **The classification is a class name, not a colour.** The viewer paints
+ *   from CSS custom properties, so one token stream serves light and dark and
+ *   the design tokens live in exactly one place.
+ *
+ * ## Cost, measured
+ *
+ * On the dev Mac, 3 000 lines, cold (parse + classify + wire):
+ *
+ * | | TypeScript | Go | Python |
+ * |---|---|---|---|
+ * | Shiki (was) | ~700 ms | 43–57 ms | 35–47 ms |
+ * | tree-sitter (now) | 24–41 ms | ~30 ms | 25–29 ms |
+ *
+ * Rust, Ruby, PHP, C# and Swift all land between 14 and 27 ms on the same
+ * measurement. TypeScript's TextMate grammar was 5–7× every other one and the
+ * cost was regex *execution*, not compilation — nothing about the old module
+ * could have fixed it, and it is now the same order as everything else. The
+ * slice cache still exists, because a re-render (a theme flip, a resize,
+ * stepping back through the trail) should cost nothing at all, and because a
+ * whole-file view pages the same file repeatedly.
+ */
+
+import { SYNTAX_TOKEN_CLASSES, tokenizeSource, type SyntaxTokenClass } from '../../extraction/syntax-tokens';
+import type { Language } from '../../types';
+import { grammarFor } from './languages';
+
+export { COMPONENT_LANGUAGES, grammarFor, isHighlightable } from './languages';
+export { SYNTAX_TOKEN_CLASSES as TOKEN_CLASSES } from '../../extraction/syntax-tokens';
+
+/** One token on the wire: its class id, then its text. */
+export type WireToken = [number, string];
+
+export interface HighlightResult {
+  /** `tree-sitter` when a grammar produced the classes; `plain` when nothing did. */
+  engine: 'tree-sitter' | 'plain';
+  /** The grammar the source was read with, or null. */
+  grammar: string | null;
+  /** Class names, indexed by the first element of every {@link WireToken}. */
+  classes: readonly string[];
+  /** One entry per source line, in order. */
+  lines: WireToken[][];
+  /** Why the answer is plain, when it is. Absent on the happy path. */
+  reason?: string;
+}
+
+/**
+ * Lines above this are not classified.
+ *
+ * Matches `MAX_SOURCE_LINES`, so anything the source endpoint will serve, this
+ * will try to classify.
+ */
+export const MAX_HIGHLIGHT_LINES = 4000;
+
+/**
+ * Characters above this are not classified.
+ *
+ * The line cap alone does not bound the work: one minified bundle line can be
+ * two megabytes, and a parser walks it character by character. This is the
+ * guard that keeps a single request from wedging a single-threaded loopback
+ * server, and it is generous — 600 kB is far more source than any screen
+ * renders.
+ */
+export const MAX_HIGHLIGHT_CHARS = 600_000;
+
+/** Classified slices kept in memory. Most are one symbol's body. */
+export const SLICE_CACHE_LIMIT = 96;
+
+/**
+ * Total cached lines, which is the bound that actually matters.
+ *
+ * The entry count alone does not bound memory: 96 slices of a symbol body is a
+ * megabyte, 96 whole 4 000-line files is two orders of magnitude more, and this
+ * process is a reader someone leaves open all day. Twenty thousand lines is
+ * roughly a working set of every symbol a session visits, or a handful of whole
+ * files, and the eviction is the same recency order.
+ */
+export const SLICE_CACHE_LINES = 20_000;
+
+/** Class name → its index in {@link SYNTAX_TOKEN_CLASSES}, which is what the wire carries. */
+const CLASS_ID = Object.fromEntries(
+  SYNTAX_TOKEN_CLASSES.map((name, index) => [name, index])
+) as Record<SyntaxTokenClass, number>;
+
+/**
+ * Classes that are never merged with their neighbour.
+ *
+ * Every identifier-shaped token has to stay claimable on its own — the overlay
+ * wraps exactly one of them as a call-site link, and two merged into one token
+ * would underline both or neither.
+ */
+const UNMERGEABLE: ReadonlySet<SyntaxTokenClass> = new Set<SyntaxTokenClass>([
+  'ident',
+  'type',
+  'def',
+]);
+
+/* -------------------------------------------------------------- the cache -- */
+
+const sliceCache = new Map<string, HighlightResult>();
+let cachedLines = 0;
+
+function cacheGet(key: string): HighlightResult | undefined {
+  const hit = sliceCache.get(key);
+  // Re-insert so the map's insertion order is a recency order and the first
+  // key is always the coldest.
+  if (hit) {
+    sliceCache.delete(key);
+    sliceCache.set(key, hit);
+  }
+  return hit;
+}
+
+function cachePut(key: string, value: HighlightResult): void {
+  sliceCache.set(key, value);
+  cachedLines += value.lines.length;
+  while (
+    sliceCache.size > SLICE_CACHE_LIMIT ||
+    (cachedLines > SLICE_CACHE_LINES && sliceCache.size > 1)
+  ) {
+    const oldest = sliceCache.keys().next();
+    if (oldest.done) break;
+    cachedLines -= sliceCache.get(oldest.value)?.lines.length ?? 0;
+    sliceCache.delete(oldest.value);
+  }
+}
+
+/** Drop everything cached. Tests use it; nothing in the server needs to. */
+export function clearHighlightCache(): void {
+  sliceCache.clear();
+  cachedLines = 0;
+}
+
+/** What the slice cache is holding — for tests, and for anyone diagnosing it. */
+export function highlightCacheStats(): { entries: number; lines: number } {
+  return { entries: sliceCache.size, lines: cachedLines };
+}
+
+/* ------------------------------------------------------------- the entry -- */
+
+export interface HighlightOptions {
+  /** The engine's language for the file, e.g. `typescript`. */
+  language?: string | null;
+  /**
+   * A key that changes whenever the text does — the file's content hash plus
+   * the requested range. Omit it and the slice is classified every time.
+   */
+  cacheKey?: string;
+}
+
+/**
+ * Classify `lines` for the viewer's code block.
+ *
+ * Never throws and never rejects: every failure path returns a plain result
+ * carrying the reason, because the caller is serving source and the source is
+ * the part that matters.
+ */
+export async function highlightLines(
+  lines: readonly string[],
+  options: HighlightOptions = {}
+): Promise<HighlightResult> {
+  const grammar = grammarFor(options.language);
+  const key = options.cacheKey ? `${grammar ?? '-'} ${options.cacheKey}` : null;
+  if (key) {
+    const hit = cacheGet(key);
+    if (hit) return hit;
+  }
+
+  const result = await highlightUncached(lines, options.language ?? null, grammar);
+  if (key) cachePut(key, result);
+  return result;
+}
+
+async function highlightUncached(
+  lines: readonly string[],
+  language: string | null,
+  grammar: string | null
+): Promise<HighlightResult> {
+  if (!grammar) {
+    return plain(lines, null, 'No syntax grammar covers this file type.');
+  }
+  if (lines.length > MAX_HIGHLIGHT_LINES) {
+    return plain(lines, grammar, `Too many lines to highlight (over ${MAX_HIGHLIGHT_LINES}).`);
+  }
+  const text = lines.join('\n');
+  if (text.length > MAX_HIGHLIGHT_CHARS) {
+    return plain(lines, grammar, 'Too much text on too few lines to highlight (minified?).');
+  }
+
+  const tokenized = await tokenizeSource(text, language as Language);
+  if (!tokenized || tokenized.spans.length === 0) {
+    return plain(lines, grammar, `The ${grammar} grammar is not available in this build.`);
+  }
+
+  return {
+    engine: 'tree-sitter',
+    grammar: tokenized.grammars.join('+') || grammar,
+    classes: SYNTAX_TOKEN_CLASSES,
+    lines: toWireLines(lines, text, tokenized.spans),
+  };
+}
+
+function plain(lines: readonly string[], grammar: string | null, reason?: string): HighlightResult {
+  return {
+    engine: 'plain',
+    grammar,
+    classes: SYNTAX_TOKEN_CLASSES,
+    lines: lines.map(atomizePlain),
+    ...(reason ? { reason } : {}),
+  };
+}
+
+/* -------------------------------------------------------- spans to lines -- */
+
+/**
+ * Cut the classifier's spans into one token list per source line.
+ *
+ * The classifier answers over the whole slice, in string offsets, and leaves
+ * the gaps between spans unclassified — those are whitespace and the layout
+ * separators no grammar names. Here they become plain tokens, multi-line spans
+ * (a block comment, a heredoc) are split at the newlines, and every line ends
+ * up with a token list whose texts concatenate back to exactly that line.
+ *
+ * One entry per line, always: the code block indexes rows positionally, so a
+ * short answer would render every line below it against the wrong source.
+ */
+function toWireLines(
+  lines: readonly string[],
+  text: string,
+  spans: readonly { start: number; end: number; cls: SyntaxTokenClass }[]
+): WireToken[][] {
+  const pieces: { start: number; end: number; cls: SyntaxTokenClass }[] = [];
+  let cursor = 0;
+  for (const span of spans) {
+    if (span.end <= cursor) continue;
+    const start = Math.max(span.start, cursor);
+    if (start > cursor) pieces.push({ start: cursor, end: start, cls: 'other' });
+    pieces.push({ start, end: span.end, cls: span.cls });
+    cursor = span.end;
+  }
+  if (cursor < text.length) pieces.push({ start: cursor, end: text.length, cls: 'other' });
+
+  const out: WireToken[][] = [];
+  let lineStart = 0;
+  let first = 0;
+  for (const line of lines) {
+    const lineEnd = lineStart + line.length;
+    const row: WireToken[] = [];
+    while (first < pieces.length && (pieces[first] as { end: number }).end <= lineStart) first += 1;
+    for (let i = first; i < pieces.length; i++) {
+      const piece = pieces[i] as { start: number; end: number; cls: SyntaxTokenClass };
+      if (piece.start >= lineEnd) break;
+      const from = Math.max(piece.start, lineStart);
+      const to = Math.min(piece.end, lineEnd);
+      if (to > from) pushPiece(row, text.slice(from, to), piece.cls);
+    }
+    out.push(row);
+    lineStart = lineEnd + 1; // the '\n' the join put back
+  }
+  return out;
+}
+
+/* ---------------------------------------------------------- atomisation -- */
+
+/**
+ * An identifier, 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.
+ */
+const IDENT = /[A-Za-z_$À-￿][\w$À-￿]*/g;
+
+/**
+ * Add one classified run to a line, splitting it into identifier atoms.
+ *
+ * This is the step that makes the graph's call-site links independent of how a
+ * grammar chose to chunk a line: the viewer has to be able to wrap exactly
+ * `withLock` in `this.mutex.withLock`, and giving it identifier-sized atoms up
+ * front means the overlay only ever *claims* a token, never re-cuts one.
+ *
+ * Comments and strings are left whole on purpose: no edge points inside one,
+ * and a doc comment split into forty atoms is forty times the wire bytes for
+ * nothing.
+ */
+function pushPiece(row: WireToken[], text: string, cls: SyntaxTokenClass): void {
+  if (cls === 'comment' || cls === 'string') {
+    push(row, cls, text);
+    return;
+  }
+  splitIdentifiers(row, text, cls);
+}
+
+function atomizePlain(line: string): WireToken[] {
+  const out: WireToken[] = [];
+  splitIdentifiers(out, line, 'other');
+  return out;
+}
+
+/**
+ * Emit `text` as alternating non-identifier and identifier runs.
+ *
+ * An identifier inside a run the grammar called a keyword keeps the keyword
+ * class — `func` should still carry its weight — while the overlay's matcher
+ * looks at a token's *text*, not its class, so a language whose grammar calls a
+ * declared type name something unexpected still links.
+ */
+function splitIdentifiers(out: WireToken[], text: string, cls: SyntaxTokenClass): void {
+  if (text === '') return;
+  IDENT.lastIndex = 0;
+  let at = 0;
+  let match: RegExpExecArray | null;
+  while ((match = IDENT.exec(text)) !== null) {
+    if (match.index > at) push(out, gapClass(cls), text.slice(at, match.index));
+    push(out, cls === 'other' ? 'ident' : cls, match[0]);
+    at = match.index + match[0].length;
+  }
+  if (at < text.length) push(out, gapClass(cls), text.slice(at));
+}
+
+/** The class for the non-identifier remainder of a run. */
+function gapClass(cls: SyntaxTokenClass): SyntaxTokenClass {
+  return UNMERGEABLE.has(cls) ? 'other' : cls;
+}
+
+/** Append, merging into the previous token when it carries the same class. */
+function push(out: WireToken[], cls: SyntaxTokenClass, text: string): void {
+  if (text === '') return;
+  const id = CLASS_ID[cls];
+  const last = out[out.length - 1];
+  if (last && last[0] === id && !UNMERGEABLE.has(cls)) {
+    last[1] += text;
+    return;
+  }
+  out.push([id, text]);
+}

+ 47 - 0
src/ui-server/highlight/languages.ts

@@ -0,0 +1,47 @@
+/**
+ * Which engine language a file's source is classified with (CG-57).
+ *
+ * There is no second grammar table any more. The viewer reads a file with the
+ * grammar the *engine* parsed it with, so this is a question about coverage
+ * rather than about mapping: a language the extractor has a tree-sitter grammar
+ * for classifies; one it does not renders plain, with its identifiers still
+ * split out so the graph's call-site links land exactly as they do everywhere
+ * else. Highlighting is the part that degrades, never the linking.
+ *
+ * The three single-file-component formats are the exception worth naming. A
+ * `.svelte`, `.vue` or `.astro` file has no grammar of its own here — the
+ * extractors pull the `<script>` block out and hand it to TypeScript or
+ * JavaScript — and the classifier does exactly the same thing, so a component's
+ * code is read by the grammar its symbols came from while the surrounding
+ * markup stays plain.
+ */
+
+import type { Language } from '../../types';
+import { hasTreeSitterGrammar } from '../../extraction/grammars';
+
+/**
+ * Formats whose source is classified through their embedded script blocks.
+ *
+ * Kept beside `syntaxRegionsFor`, which decides where those blocks are — this
+ * list only has to agree about *which* formats have them.
+ */
+export const COMPONENT_LANGUAGES: readonly Language[] = ['svelte', 'vue', 'astro'];
+
+/**
+ * The grammar a file of this language is read with, or null when it has none.
+ *
+ * Accepts the raw string off a `FileRecord` rather than a `Language`, because
+ * an index written by an older engine can hold a language this build has since
+ * renamed, and a viewer must not throw over that.
+ */
+export function grammarFor(language: string | undefined | null): string | null {
+  if (!language) return null;
+  const lang = language as Language;
+  if (COMPONENT_LANGUAGES.includes(lang)) return 'typescript';
+  return hasTreeSitterGrammar(lang) ? lang : null;
+}
+
+/** Whether a file of this language classifies at all. For tests and diagnostics. */
+export function isHighlightable(language: string | undefined | null): boolean {
+  return grammarFor(language) !== null;
+}

+ 473 - 0
src/ui-server/index.ts

@@ -0,0 +1,473 @@
+/**
+ * The `codegraph ui` server.
+ *
+ * A loopback-only `node:http` server that hands the browser the built viewer
+ * (`dist/viewer/`) and, through the JSON API mounted on the `api` seam below
+ * (`./api`), a view of one indexed project. No framework, no new dependency: it
+ * answers GET, serves files, and refuses everything else.
+ *
+ * It is a reader with one exception, added deliberately and scoped as narrowly
+ * as it could be: `POST`/`DELETE /api/trails` saves and removes the reader's own
+ * named trails, as JSON files under `.codegraph/ui/trails/`. Nothing else it
+ * serves has a side effect, no other path accepts a write, and `--read-only`
+ * turns even that one off. See `security.ts` for what a write has to carry.
+ *
+ * The interesting part is not the routing, it is the boundary in `security.ts`.
+ * Read that first.
+ */
+
+import * as fs from 'fs';
+import * as http from 'http';
+import * as path from 'path';
+import { resolveViewerDir } from './assets';
+import {
+  ALLOWED_METHODS,
+  READ_METHODS,
+  WRITE_HEADER,
+  isAllowedHost,
+  isAllowedOrigin,
+  isSafeRequestPath,
+  isWriteMethod,
+  isWriteRequest,
+  resolveStaticAsset,
+} from './security';
+import { sendFile, sendJson, sendText, shouldFallBackToIndex } from './static';
+import { DEFAULT_PORT_ATTEMPTS, DEFAULT_UI_PORT, LOOPBACK_ADDRESS } from './constants';
+
+export { ViewerMissingError } from './assets';
+export {
+  BROWSER_ENV,
+  DEFAULT_PORT_ATTEMPTS,
+  DEFAULT_UI_PORT,
+  LOOPBACK_ADDRESS,
+  VIEWER_PATH_ENV,
+} from './constants';
+export {
+  ALLOWED_METHODS,
+  READ_METHODS,
+  WRITE_HEADER,
+  WRITE_METHODS,
+  PathRefusalError,
+  isAllowedHost,
+  isAllowedOrigin,
+  isSafeRequestPath,
+  isWriteMethod,
+  isWriteRequest,
+  resolveProjectFile,
+  resolveStaticAsset,
+} from './security';
+export { browserOpenCommand, openBrowser } from './open-browser';
+export { contentTypeFor, cacheControlFor } from './static';
+export { createGraphApi, GraphSession, ApiError } from './api';
+export type { GraphApi, GraphApiOptions } from './api';
+
+
+/**
+ * Everything a request handler needs, already validated.
+ */
+export interface UiRequestContext {
+  /** Percent-decoded path portion of the request URL, always starting with `/`. */
+  pathname: string;
+  /** Parsed query string. */
+  query: URLSearchParams;
+  /** Absolute path of the indexed project this server is reading. */
+  projectRoot: string;
+  /**
+   * The request method. `GET` or `HEAD` for every read; `POST` or `DELETE`
+   * only for a request that already passed {@link isWriteRequest}, which is
+   * `/api/trails` and nothing else.
+   */
+  method: string;
+}
+
+/**
+ * A handler mounted under `/api/`. Returns `true` when it answered the request
+ * (i.e. wrote a response), `false` to fall through to a 404.
+ *
+ * This is the seam the JSON API plugs into. Everything it reads out of — or
+ * writes into — the user's repository must go through `resolveProjectFile`;
+ * see `security.ts`.
+ */
+export type UiApiHandler = (
+  req: http.IncomingMessage,
+  res: http.ServerResponse,
+  ctx: UiRequestContext
+) => boolean | Promise<boolean>;
+
+export interface UiServerOptions {
+  /** Absolute path of the indexed project to read. */
+  projectRoot: string;
+  /**
+   * Port to bind. `0` lets the OS choose. Defaults to {@link DEFAULT_UI_PORT}.
+   */
+  port?: number;
+  /**
+   * Try the next port when the requested one is taken (default `true`).
+   *
+   * The CLI turns this OFF for an explicit `--port`: a scripted invocation that
+   * silently lands somewhere else is worse than one that says the port is busy.
+   */
+  portFallback?: boolean;
+  /** How many ports to try in total. Defaults to {@link DEFAULT_PORT_ATTEMPTS}. */
+  maxPortAttempts?: number;
+  /** Directory of built viewer assets. Defaults to the shipped `dist/viewer/`. */
+  viewerDir?: string;
+  /** Optional read-only JSON API mounted under `/api/`. */
+  api?: UiApiHandler;
+}
+
+export interface UiServerHandle {
+  /** The port actually bound (may differ from the requested one — see fallback). */
+  port: number;
+  /** The URL to open. */
+  url: string;
+  /** Directory being served as the viewer. */
+  viewerDir: string;
+  /** The underlying server, for tests and for callers that want raw events. */
+  server: http.Server;
+  /** Stop listening and drop live connections. Idempotent. */
+  close(): Promise<void>;
+}
+
+/**
+ * Response headers sent on EVERY response.
+ *
+ * `frame-ancestors`/`X-Frame-Options` stop another page from framing the viewer
+ * and reading it by overlay; `nosniff` stops an asset with a surprising
+ * extension from being executed as script; the CSP pins every resource to this
+ * origin, so a future viewer change cannot start phoning out with what it read.
+ * `style-src` keeps `'unsafe-inline'` because the syntax highlighter emits
+ * inline `style=` attributes on code spans.
+ *
+ * Note what is NOT here: any `Access-Control-*` header. Their absence is what
+ * makes a cross-origin read of a response body impossible even if a request
+ * somehow gets past the `Host` check.
+ */
+const SECURITY_HEADERS: Readonly<Record<string, string>> = {
+  'X-Content-Type-Options': 'nosniff',
+  'X-Frame-Options': 'DENY',
+  'Referrer-Policy': 'no-referrer',
+  'Content-Security-Policy': [
+    "default-src 'none'",
+    "script-src 'self'",
+    "style-src 'self' 'unsafe-inline'",
+    "img-src 'self' data:",
+    "font-src 'self'",
+    "connect-src 'self'",
+    "base-uri 'none'",
+    "form-action 'none'",
+    "frame-ancestors 'none'",
+  ].join('; '),
+};
+
+/**
+ * Start the viewer server.
+ *
+ * Resolves once the socket is bound, so the caller can print a URL that is
+ * already answering.
+ */
+export async function startUiServer(options: UiServerOptions): Promise<UiServerHandle> {
+  // realpath, not just resolve: `resolveStaticAsset` hands back realpaths (the
+  // symlink check in `validatePathWithinRoot` resolves them), so a viewerDir
+  // that still holds a symlink — every macOS `/var/folders` temp dir, plenty of
+  // package managers — would make `path.relative` between the two nonsense, and
+  // the cache policy that keys off it silently wrong.
+  const viewerDir = options.viewerDir ? realpath(options.viewerDir) : resolveViewerDir();
+  const projectRoot = path.resolve(options.projectRoot);
+  const indexHtml = path.join(viewerDir, 'index.html');
+
+  // The bound port is needed by the Host check, but is only known after listen.
+  // Captured by reference so the handler always sees the real value.
+  let boundPort = 0;
+
+  const server = http.createServer((req, res) => {
+    handleRequest(req, res, {
+      viewerDir,
+      indexHtml,
+      projectRoot,
+      api: options.api,
+      port: () => boundPort,
+    }).catch(() => {
+      // handleRequest already answers every error it can; reaching here means
+      // the socket itself is gone. Never let it become an unhandled rejection,
+      // which the CLI's fatal handlers would turn into a process exit.
+      if (!res.writableEnded) res.destroy();
+    });
+  });
+
+  // A browser holds keep-alive sockets open; without this, `close()` would wait
+  // for them and Ctrl-C would appear to hang.
+  server.keepAliveTimeout = 5_000;
+
+  boundPort = await listenWithFallback(server, {
+    port: options.port ?? DEFAULT_UI_PORT,
+    fallback: options.portFallback ?? true,
+    attempts: options.maxPortAttempts ?? DEFAULT_PORT_ATTEMPTS,
+  });
+
+  let closed = false;
+  return {
+    port: boundPort,
+    url: `http://${LOOPBACK_ADDRESS}:${boundPort}`,
+    viewerDir,
+    server,
+    close(): Promise<void> {
+      if (closed) return Promise.resolve();
+      closed = true;
+      return new Promise<void>((resolve) => {
+        server.closeAllConnections();
+        server.close(() => resolve());
+      });
+    },
+  };
+}
+
+interface HandlerDeps {
+  viewerDir: string;
+  indexHtml: string;
+  projectRoot: string;
+  api: UiApiHandler | undefined;
+  port: () => number;
+}
+
+/**
+ * One request, start to finish. Order matters: the cheap refusals (method,
+ * `Host`, `Origin`) run before anything touches the filesystem.
+ */
+async function handleRequest(
+  req: http.IncomingMessage,
+  res: http.ServerResponse,
+  deps: HandlerDeps
+): Promise<void> {
+  const method = req.method ?? 'GET';
+  for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
+    res.setHeader(name, value);
+  }
+
+  if (!ALLOWED_METHODS.includes(method)) {
+    res.setHeader('Allow', ALLOWED_METHODS.join(', '));
+    sendText(res, 405, `codegraph ui does not answer ${method}.`, method);
+    return;
+  }
+
+  const port = deps.port();
+  if (!isAllowedHost(req.headers.host, port)) {
+    // The DNS-rebinding refusal. Say why, since a human hitting this through a
+    // proxy or a container hostname needs to know what to change.
+    sendText(
+      res,
+      403,
+      'Refused: codegraph ui only answers requests addressed to this machine ' +
+        `(localhost, 127.0.0.1 or [::1] on port ${port}).\n` +
+        `This request said Host: ${forEcho(req.headers.host)}`,
+      method
+    );
+    return;
+  }
+
+  if (!isAllowedOrigin(readHeader(req, 'origin'), port)) {
+    sendText(res, 403, 'Refused: cross-origin requests are not served.', method);
+    return;
+  }
+
+  // Checked on the RAW url, before WHATWG parsing folds `..` segments away.
+  const rawPath = (req.url ?? '/').split(/[?#]/)[0] ?? '/';
+  // The `/api/` namespace answers JSON for EVERY outcome, refusals included:
+  // the viewer parses these responses, and a text/plain body here would surface
+  // as a parse error instead of the refusal it actually is.
+  const jsonNamespace = rawPath === '/api' || rawPath.startsWith('/api/');
+
+  // The one place this server stops being a pure reader. A write has to be
+  // under /api/ and carry the marker header — see `isWriteRequest` for what
+  // that closes that Host and Origin do not.
+  if (isWriteMethod(method)) {
+    const verdict = isWriteRequest(rawPath, {
+      marker: readHeader(req, WRITE_HEADER),
+      contentType: readHeader(req, 'content-type'),
+    });
+    if (!verdict.ok) {
+      if (!jsonNamespace) res.setHeader('Allow', READ_METHODS.join(', '));
+      const body = `Refused: ${verdict.reason}`;
+      if (jsonNamespace) sendJson(res, 403, { error: body, code: 'refused' }, method);
+      else sendText(res, 405, body, method);
+      return;
+    }
+  }
+
+  if (!isSafeRequestPath(rawPath)) {
+    if (jsonNamespace) {
+      sendJson(res, 404, { error: 'Not found', code: 'not-found' }, method);
+    } else {
+      sendText(res, 404, 'Not found', method);
+    }
+    return;
+  }
+
+  let url: URL;
+  try {
+    url = new URL(req.url ?? '/', `http://${LOOPBACK_ADDRESS}:${port}`);
+  } catch {
+    sendText(res, 400, 'Bad request URL.', method);
+    return;
+  }
+
+  // `/api/` is reserved — it must 404 as JSON rather than fall through to the
+  // SPA, or a typo'd endpoint returns 200 + HTML and the viewer parses the app
+  // shell as a payload.
+  if (url.pathname === '/api' || url.pathname.startsWith('/api/')) {
+    const ctx: UiRequestContext = {
+      pathname: safeDecode(url.pathname),
+      query: url.searchParams,
+      projectRoot: deps.projectRoot,
+      method,
+    };
+    if (deps.api) {
+      try {
+        if (await deps.api(req, res, ctx)) return;
+      } catch (err) {
+        if (!res.headersSent) {
+          sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }, method);
+        } else {
+          res.destroy();
+        }
+        return;
+      }
+    }
+    if (!res.headersSent) sendJson(res, 404, { error: `No such endpoint: ${url.pathname}` }, method);
+    return;
+  }
+
+  const requested = url.pathname === '/' ? '/index.html' : url.pathname;
+  const file = resolveStaticAsset(deps.viewerDir, requested);
+  if (file) {
+    sendFile(res, file, { rootDir: deps.viewerDir, method });
+    return;
+  }
+
+  if (shouldFallBackToIndex(url.pathname)) {
+    sendFile(res, deps.indexHtml, { rootDir: deps.viewerDir, method });
+    return;
+  }
+
+  sendText(res, 404, 'Not found', method);
+}
+
+/** `path.resolve` + symlink resolution, falling back when the path is missing. */
+function realpath(dir: string): string {
+  const resolved = path.resolve(dir);
+  try {
+    return fs.realpathSync(resolved);
+  } catch {
+    return resolved;
+  }
+}
+
+/**
+ * Bound and de-fang an attacker-supplied header before echoing it back.
+ *
+ * The refused `Host` is worth showing — a human hitting this through a proxy or
+ * a container hostname needs to know what was actually sent. But it is
+ * attacker-chosen text, so it goes out truncated and stripped of control bytes.
+ * (The response is `text/plain` + `nosniff`, so there is nothing to inject
+ * into; this is belt and braces.)
+ */
+function forEcho(value: string | undefined): string {
+  if (!value) return '(none)';
+  // eslint-disable-next-line no-control-regex -- stripping raw control bytes IS the point
+  const clean = value.replace(/[\x00-\x1f\x7f]/g, '?');
+  return clean.length > 100 ? `${clean.slice(0, 100)}…` : clean;
+}
+
+/** Read a header as a single string (node gives arrays for some headers). */
+function readHeader(req: http.IncomingMessage, name: string): string | undefined {
+  const value = req.headers[name];
+  if (value === undefined) return undefined;
+  return Array.isArray(value) ? value[0] : value;
+}
+
+/** Percent-decode for display; the raw value is used for anything security-relevant. */
+function safeDecode(value: string): string {
+  try {
+    return decodeURIComponent(value);
+  } catch {
+    return value;
+  }
+}
+
+/**
+ * Bind the first free port at or after `port`, on loopback only.
+ *
+ * Only `EADDRINUSE` advances to the next port — a permission failure or a bad
+ * address will not get better one port over, and retrying twenty times would
+ * only bury the real error.
+ */
+async function listenWithFallback(
+  server: http.Server,
+  opts: { port: number; fallback: boolean; attempts: number }
+): Promise<number> {
+  // Port 0 means "any free port", so there is nothing to fall back from.
+  const attempts = opts.port === 0 || !opts.fallback ? 1 : Math.max(1, opts.attempts);
+
+  for (let i = 0; i < attempts; i++) {
+    const candidate = opts.port === 0 ? 0 : opts.port + i;
+    try {
+      await listenOnce(server, candidate);
+      const address = server.address();
+      if (address === null || typeof address === 'string') {
+        throw new Error('The UI server bound to an unexpected address.');
+      }
+      return address.port;
+    } catch (err) {
+      const code = (err as NodeJS.ErrnoException).code;
+      if (code !== 'EADDRINUSE' || i === attempts - 1) {
+        throw describeBindFailure(err, candidate, opts);
+      }
+    }
+  }
+  /* istanbul ignore next — the loop either returns or throws */
+  throw new Error('The UI server could not bind a port.');
+}
+
+/**
+ * One `listen()` attempt, with both outcomes as a promise.
+ *
+ * The same `http.Server` is reused across attempts: a `listen()` that failed
+ * with EADDRINUSE never took a handle, so it can be listened on again directly
+ * (verified on Node 20 and 22 — `server.listening` is still `false` afterwards,
+ * and `close()` on a never-listening server would itself throw).
+ */
+function listenOnce(server: http.Server, port: number): Promise<void> {
+  return new Promise<void>((resolve, reject) => {
+    const onError = (err: Error): void => {
+      server.removeListener('listening', onListening);
+      reject(err);
+    };
+    const onListening = (): void => {
+      server.removeListener('error', onError);
+      resolve();
+    };
+    server.once('error', onError);
+    server.once('listening', onListening);
+    server.listen(port, LOOPBACK_ADDRESS);
+  });
+}
+
+/** Turn a bind failure into something a user can act on. */
+function describeBindFailure(
+  err: unknown,
+  port: number,
+  opts: { port: number; fallback: boolean; attempts: number }
+): Error {
+  const code = (err as NodeJS.ErrnoException).code;
+  if (code === 'EADDRINUSE') {
+    return opts.fallback
+      ? new Error(
+          `Ports ${opts.port}–${port} are all in use. Free one, or pick another with --port.`
+        )
+      : new Error(`Port ${port} is already in use. Pick another with --port, or omit --port to let CodeGraph find a free one.`);
+  }
+  if (code === 'EACCES') {
+    return new Error(`Not allowed to listen on port ${port}. Ports below 1024 usually need elevated privileges — pick a higher one with --port.`);
+  }
+  return err instanceof Error ? err : new Error(String(err));
+}

+ 82 - 0
src/ui-server/open-browser.ts

@@ -0,0 +1,82 @@
+/**
+ * Opening the user's browser at the viewer URL.
+ *
+ * No dependency: the three platform openers are one-liners, and pulling in a
+ * package to shell out to `open` would be the only runtime dependency the
+ * viewer adds to a CLI that currently has ten.
+ */
+
+import { spawn } from 'child_process';
+import { BROWSER_ENV } from './constants';
+
+export { BROWSER_ENV };
+
+const SUPPRESS_VALUES: ReadonlySet<string> = new Set(['', 'none', '0', 'false', 'off']);
+
+export interface OpenCommand {
+  command: string;
+  args: string[];
+}
+
+/**
+ * The command that would open `url`, or `null` when opening is suppressed.
+ *
+ * Split out from {@link openBrowser} so the platform mapping is testable
+ * without launching anything.
+ */
+export function browserOpenCommand(
+  url: string,
+  platform: NodeJS.Platform,
+  override?: string
+): OpenCommand | null {
+  if (override !== undefined) {
+    const trimmed = override.trim();
+    if (SUPPRESS_VALUES.has(trimmed.toLowerCase())) return null;
+    // Windows: go through `cmd /c` rather than spawning the override directly.
+    // `spawn` there is CreateProcess, which only ever launches a real .exe — a
+    // `.cmd`/`.bat` browser shim (how most Windows wrappers are written) fails
+    // outright, and an extension-less name only resolves because CreateProcess
+    // appends `.exe`. Routing through cmd makes .exe, .cmd and .bat all work,
+    // and node quotes each argument, so a path with spaces survives. Caught on
+    // the Windows VM, where the direct spawn silently launched nothing.
+    if (platform === 'win32') return { command: 'cmd', args: ['/c', trimmed, url] };
+    return { command: trimmed, args: [url] };
+  }
+  if (platform === 'darwin') return { command: 'open', args: [url] };
+  if (platform === 'win32') {
+    // `start` is a cmd builtin, not an executable. The empty string is the
+    // window title — without it `start` treats a quoted URL as the title and
+    // opens a blank console instead.
+    return { command: 'cmd', args: ['/c', 'start', '', url] };
+  }
+  return { command: 'xdg-open', args: [url] };
+}
+
+/**
+ * Open `url` in the user's default browser, best effort.
+ *
+ * Never throws and never keeps the CLI alive: the child is detached and
+ * unref'd, and a missing opener (a headless Linux box with no `xdg-open`) is
+ * swallowed — the URL is already printed, which is the part that matters.
+ *
+ * @returns `true` if a launch was attempted.
+ */
+export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): boolean {
+  const open = browserOpenCommand(url, platform, process.env[BROWSER_ENV]);
+  if (!open) return false;
+  try {
+    const child = spawn(open.command, open.args, {
+      detached: true,
+      stdio: 'ignore',
+      // `start` is a shell builtin reached through `cmd /c`, so no shell here.
+      shell: false,
+    });
+    child.on('error', () => {
+      /* no opener installed — the printed URL is the fallback */
+    });
+    child.unref();
+    return true;
+  } catch {
+    return false;
+  }
+}

+ 298 - 0
src/ui-server/security.ts

@@ -0,0 +1,298 @@
+/**
+ * The `codegraph ui` server's security boundary.
+ *
+ * Threat model, stated plainly: this process serves a browser-readable view of
+ * the user's SOURCE CODE from a port on their machine. It binds loopback, so
+ * nothing on the network can reach it. That leaves one realistic attack —
+ * **DNS rebinding**: any page the user visits can point `evil.example` at
+ * `127.0.0.1` and then have the browser issue same-origin requests to us. The
+ * browser will happily connect; the only thing that distinguishes the attacker's
+ * request from the viewer's own is the `Host` header, which the browser fills in
+ * from the URL and script cannot forge.
+ *
+ * So the rules are:
+ *
+ * - **`Host` must be a loopback name** (`localhost`, `127.0.0.1`, `[::1]`) and,
+ *   if it carries a port, that port must be ours. Anything else is 403.
+ * - **`Origin`, when present, must be loopback too.** Belt and braces: absent on
+ *   the viewer's own same-origin GETs, and present-and-foreign only on a
+ *   cross-site request we want nothing to do with.
+ * - **No CORS headers, ever.** Not adding `Access-Control-Allow-Origin` is what
+ *   keeps a cross-origin reader from seeing a response body even if it does
+ *   reach us. There is deliberately no way to turn this on.
+ * - **GET/HEAD everywhere; POST/DELETE only under `/api/`, and only for a
+ *   request that could not have been forged by a form.** See
+ *   {@link isWriteRequest} below — the viewer went from a pure reader to one
+ *   that saves trails into `.codegraph/ui/`, and that is the entire change to
+ *   this boundary.
+ * - **Every path resolves through {@link validatePathWithinRoot}** — the same
+ *   chokepoint the MCP read sinks use, which catches `../` traversal AND
+ *   in-tree symlinks pointing out of the root (#527).
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { PathRefusalError } from '../errors';
+import { validatePathWithinRoot, validateProjectPath } from '../utils';
+
+export { PathRefusalError };
+
+/**
+ * Host names that mean "this machine". A browser only ever sends the bracketed
+ * form for IPv6, but the raw form is accepted after brackets are stripped.
+ */
+const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '::1']);
+
+/** Methods that answer anywhere: the viewer's assets and every read endpoint. */
+export const READ_METHODS: readonly string[] = ['GET', 'HEAD'];
+
+/**
+ * Methods that answer under `/api/` only, and only for a request carrying
+ * {@link WRITE_HEADER}. The viewer's one write is a saved trail.
+ */
+export const WRITE_METHODS: readonly string[] = ['POST', 'DELETE'];
+
+/** HTTP methods the viewer server answers at all. Everything else is 405. */
+export const ALLOWED_METHODS: readonly string[] = [...READ_METHODS, ...WRITE_METHODS];
+
+/**
+ * The header a write has to carry.
+ *
+ * Belt and braces behind the `Host` and `Origin` checks, and worth the two
+ * lines because it fails *differently*: a custom request header cannot be sent
+ * cross-origin without a CORS preflight, and this server answers no preflight
+ * and sends no `Access-Control-*` header, so the browser never issues the real
+ * request. That closes the one shape those checks lean on a header for — a
+ * `<form method="post">` submitted from another page, which sends no `Origin`
+ * in some older browsers and cannot set a custom header in any of them.
+ */
+export const WRITE_HEADER = 'x-codegraph-ui';
+
+/** The content type a write body must declare. A form can send none of these. */
+const WRITE_CONTENT_TYPE = 'application/json';
+
+export function isWriteMethod(method: string): boolean {
+  return WRITE_METHODS.includes(method);
+}
+
+/**
+ * Whether a mutating request is one the viewer could have made.
+ *
+ * @param method     the request method, already known to be a write method
+ * @param pathname   the raw request path
+ * @param headers    `x-codegraph-ui` and, for a body-carrying method, `content-type`
+ */
+export function isWriteRequest(
+  pathname: string,
+  headers: { marker: string | undefined; contentType: string | undefined }
+): { ok: true } | { ok: false; reason: string } {
+  // Writes live under /api/ and nowhere else. The static side of this server
+  // serves a built bundle; there is nothing there to POST to.
+  if (pathname !== '/api' && !pathname.startsWith('/api/')) {
+    return { ok: false, reason: 'Only the /api/ endpoints accept writes.' };
+  }
+  if (headers.marker === undefined || headers.marker.trim() === '') {
+    return { ok: false, reason: `A write must carry the ${WRITE_HEADER} header.` };
+  }
+  if (headers.contentType !== undefined) {
+    const type = headers.contentType.split(';')[0]?.trim().toLowerCase();
+    if (type !== '' && type !== WRITE_CONTENT_TYPE) {
+      return { ok: false, reason: `A write body must be ${WRITE_CONTENT_TYPE}.` };
+    }
+  }
+  return { ok: true };
+}
+
+interface HostParts {
+  hostname: string;
+  /** `undefined` when the header carried no `:port` suffix. */
+  port: number | undefined;
+}
+
+/**
+ * Split a `Host` header into hostname and port, or `null` if it is malformed.
+ *
+ * An unbracketed IPv6 literal (`::1`) is malformed per RFC 7230 and is rejected
+ * rather than guessed at — no browser produces one, so accepting it would only
+ * widen the parser for an attacker's benefit.
+ */
+function splitHostPort(host: string): HostParts | null {
+  const trimmed = host.trim();
+  if (!trimmed) return null;
+
+  if (trimmed.startsWith('[')) {
+    const end = trimmed.indexOf(']');
+    if (end < 0) return null;
+    const port = parsePortSuffix(trimmed.slice(end + 1));
+    if (port === null) return null;
+    return { hostname: trimmed.slice(1, end), port };
+  }
+
+  const colon = trimmed.indexOf(':');
+  if (colon === -1) return { hostname: trimmed, port: undefined };
+  // A second colon without brackets is a bare IPv6 literal or junk.
+  if (trimmed.indexOf(':', colon + 1) !== -1) return null;
+  const port = parsePortSuffix(trimmed.slice(colon));
+  if (port === null) return null;
+  return { hostname: trimmed.slice(0, colon), port };
+}
+
+/**
+ * Parse the `:1234` tail of a `Host` header.
+ *
+ * @returns the port, `undefined` for an empty suffix, or `null` when the suffix
+ *   is present but not a plain port number.
+ */
+function parsePortSuffix(suffix: string): number | undefined | null {
+  if (suffix === '') return undefined;
+  if (!suffix.startsWith(':')) return null;
+  const digits = suffix.slice(1);
+  if (!/^\d{1,5}$/.test(digits)) return null;
+  const port = Number(digits);
+  return port >= 0 && port <= 65535 ? port : null;
+}
+
+/**
+ * Whether a request's `Host` header names this loopback server.
+ *
+ * A missing `Host` is rejected: HTTP/1.1 requires it, and the one client that
+ * may legally omit it (HTTP/1.0) is not a browser we need to serve.
+ */
+export function isAllowedHost(host: string | undefined, port: number): boolean {
+  if (typeof host !== 'string') return false;
+  const parts = splitHostPort(host);
+  if (!parts) return false;
+  if (!LOOPBACK_HOSTNAMES.has(parts.hostname.toLowerCase())) return false;
+  return parts.port === undefined || parts.port === port;
+}
+
+/**
+ * Whether a request's `Origin` header is acceptable.
+ *
+ * An ABSENT `Origin` is allowed — browsers omit it on same-origin GETs, which
+ * is every request the viewer makes. A present one must be loopback-on-our-port;
+ * the literal `null` origin (sandboxed iframe, `file://` page) is refused.
+ */
+export function isAllowedOrigin(origin: string | undefined, port: number): boolean {
+  if (origin === undefined) return true;
+  const trimmed = origin.trim();
+  if (trimmed === '') return true;
+  if (trimmed === 'null') return false;
+
+  let url: URL;
+  try {
+    url = new URL(trimmed);
+  } catch {
+    return false;
+  }
+  if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
+  // WHATWG keeps IPv6 hostnames bracketed; the allowlist stores them bare.
+  const hostname = url.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
+  if (!LOOPBACK_HOSTNAMES.has(hostname)) return false;
+  return url.port === '' || Number(url.port) === port;
+}
+
+/**
+ * Whether a raw request path is worth resolving at all.
+ *
+ * Rejects any `..` segment outright rather than letting containment sort it
+ * out later. Containment WOULD catch it — but the SPA fallback sits behind
+ * containment, so `GET /../../etc/passwd` would otherwise be answered with the
+ * app shell (a 200) instead of the 404 a traversal attempt deserves. Nothing
+ * outside the root leaks either way; this just stops the server from
+ * pretending a hostile path was an ordinary route.
+ *
+ * Takes the RAW path from `req.url`, before WHATWG URL parsing folds `..`
+ * segments away — that folding is what would hide the attempt.
+ */
+export function isSafeRequestPath(rawPath: string): boolean {
+  const decoded = decodePath(rawPath);
+  if (decoded === null) return false;
+  return !decoded.split('/').includes('..');
+}
+
+/**
+ * Resolve a request path to a file inside the static asset root.
+ *
+ * Returns the absolute path, or `null` for anything that is not a readable file
+ * inside `rootDir` — a traversal attempt, a symlink escape, a directory, a
+ * missing file. Callers turn `null` into a 404 (never a 403): telling a prober
+ * which of those it hit is free information.
+ *
+ * Percent-decoding happens HERE, before containment is checked, so an encoded
+ * `..%2f` is caught by the same guard as a literal `../`.
+ */
+export function resolveStaticAsset(rootDir: string, urlPath: string): string | null {
+  const decoded = decodePath(urlPath);
+  if (decoded === null) return null;
+
+  const relative = decoded.replace(/^\/+/, '');
+  const absolute = validatePathWithinRoot(rootDir, relative);
+  if (!absolute) return null;
+
+  try {
+    return fs.statSync(absolute).isFile() ? absolute : null;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Percent-decode a URL path and reject the encodings that only ever show up in
+ * an attack: NUL (truncates a path in some syscalls), other C0 control bytes,
+ * and backslashes (a separator on Windows, a legal filename character on POSIX
+ * — treating it as a separator everywhere is the safe direction, and no built
+ * asset name contains one).
+ *
+ * @returns the decoded path, or `null` if it is unusable.
+ */
+function decodePath(urlPath: string): string | null {
+  let decoded: string;
+  try {
+    decoded = decodeURIComponent(urlPath);
+  } catch {
+    return null; // malformed percent-encoding
+  }
+  // eslint-disable-next-line no-control-regex -- rejecting raw control bytes IS the point
+  if (/[\x00-\x1f\x7f\\]/.test(decoded)) return null;
+  return decoded;
+}
+
+/**
+ * Resolve a project-relative source path to an absolute path that is safe to
+ * read and hand to the browser.
+ *
+ * This is the single read chokepoint for anything served OUT OF THE USER'S
+ * REPOSITORY (as opposed to the viewer's own bundled assets). The JSON API
+ * built on top of this server must route every file read through it — that is
+ * what keeps `/api/source?path=../../.ssh/id_rsa` from being a credential leak
+ * over a port the user opened to read their own code.
+ *
+ * @throws {PathRefusalError} when the root is a sensitive system directory, or
+ *   the path escapes the root by traversal or symlink.
+ */
+export function resolveProjectFile(projectRoot: string, relativePath: string): string {
+  if (typeof relativePath !== 'string' || relativePath.trim() === '') {
+    throw new PathRefusalError('No file path was given.');
+  }
+  const decoded = decodePath(relativePath);
+  if (decoded === null) {
+    throw new PathRefusalError(`Refusing to read an unusable path: ${relativePath}`);
+  }
+
+  // Sensitive-directory refusal, same list the MCP entry points use. Checked on
+  // the ROOT rather than the leaf: a root of `/etc` makes every path under it
+  // sensitive, and a leaf check would have to enumerate the world.
+  const rootError = validateProjectPath(projectRoot);
+  if (rootError) throw new PathRefusalError(rootError);
+
+  if (path.isAbsolute(decoded)) {
+    throw new PathRefusalError(`Refusing to read an absolute path: ${decoded}`);
+  }
+
+  const absolute = validatePathWithinRoot(projectRoot, decoded);
+  if (!absolute) {
+    throw new PathRefusalError(`Refusing to read a path outside the project: ${decoded}`);
+  }
+  return absolute;
+}

+ 138 - 0
src/ui-server/static.ts

@@ -0,0 +1,138 @@
+/**
+ * Static file serving for the viewer's own bundle (`dist/viewer/`).
+ *
+ * Deliberately small: a MIME table, a stream, and the SPA fallback. Everything
+ * that decides WHETHER a path may be read lives in `security.ts`.
+ */
+
+import * as fs from 'fs';
+import type { ServerResponse } from 'http';
+import * as path from 'path';
+
+/**
+ * Content types for everything the Vite build emits, plus the handful of things
+ * a future viewer asset might be. Unknown extensions fall back to
+ * `application/octet-stream`, which — with `X-Content-Type-Options: nosniff` —
+ * a browser will download rather than execute.
+ */
+const CONTENT_TYPES: Readonly<Record<string, string>> = {
+  '.html': 'text/html; charset=utf-8',
+  '.js': 'text/javascript; charset=utf-8',
+  '.mjs': 'text/javascript; charset=utf-8',
+  '.css': 'text/css; charset=utf-8',
+  '.json': 'application/json; charset=utf-8',
+  '.map': 'application/json; charset=utf-8',
+  '.txt': 'text/plain; charset=utf-8',
+  '.svg': 'image/svg+xml',
+  '.png': 'image/png',
+  '.jpg': 'image/jpeg',
+  '.jpeg': 'image/jpeg',
+  '.gif': 'image/gif',
+  '.webp': 'image/webp',
+  '.avif': 'image/avif',
+  '.ico': 'image/x-icon',
+  '.woff': 'font/woff',
+  '.woff2': 'font/woff2',
+  '.ttf': 'font/ttf',
+  '.otf': 'font/otf',
+  '.wasm': 'application/wasm',
+};
+
+/** The content type to send for a file, by extension. */
+export function contentTypeFor(filePath: string): string {
+  return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
+}
+
+/**
+ * Cache policy.
+ *
+ * Vite content-hashes everything under `assets/`, so those are immutable for a
+ * year — a reload of the viewer refetches nothing, and an upgraded CodeGraph
+ * changes the hash and therefore the URL. `index.html` names those hashes, so
+ * it must never be cached.
+ */
+export function cacheControlFor(relativePath: string): string {
+  const normalized = relativePath.split(path.sep).join('/');
+  return normalized.startsWith('assets/')
+    ? 'public, max-age=31536000, immutable'
+    : 'no-store';
+}
+
+/**
+ * Stream a file as the response body.
+ *
+ * `HEAD` gets identical headers and no body — it is a GET whose body the client
+ * asked us to skip, which keeps it read-only by construction.
+ */
+export function sendFile(
+  res: ServerResponse,
+  absolutePath: string,
+  options: { rootDir: string; method: string; extraHeaders?: Record<string, string> }
+): void {
+  let stats: fs.Stats;
+  try {
+    stats = fs.statSync(absolutePath);
+  } catch {
+    sendText(res, 404, 'Not found', options.method);
+    return;
+  }
+
+  const relative = path.relative(options.rootDir, absolutePath);
+  res.writeHead(200, {
+    'Content-Type': contentTypeFor(absolutePath),
+    'Content-Length': String(stats.size),
+    'Cache-Control': cacheControlFor(relative),
+    'Last-Modified': stats.mtime.toUTCString(),
+    ...options.extraHeaders,
+  });
+
+  if (options.method === 'HEAD') {
+    res.end();
+    return;
+  }
+
+  const stream = fs.createReadStream(absolutePath);
+  stream.on('error', () => {
+    // Headers are already out, so there is no status left to change: drop the
+    // connection so the client sees a truncated body rather than a silent lie.
+    res.destroy();
+  });
+  res.on('close', () => stream.destroy());
+  stream.pipe(res);
+}
+
+/** Send a plain-text status response (the error path for a browser or curl). */
+export function sendText(res: ServerResponse, status: number, message: string, method: string): void {
+  const body = Buffer.from(message.endsWith('\n') ? message : `${message}\n`, 'utf-8');
+  res.writeHead(status, {
+    'Content-Type': 'text/plain; charset=utf-8',
+    'Content-Length': String(body.byteLength),
+    'Cache-Control': 'no-store',
+  });
+  res.end(method === 'HEAD' ? undefined : body);
+}
+
+/** Send a JSON response. Used for `/api/*`, which must never get HTML back. */
+export function sendJson(res: ServerResponse, status: number, payload: unknown, method: string): void {
+  const body = Buffer.from(JSON.stringify(payload), 'utf-8');
+  res.writeHead(status, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Content-Length': String(body.byteLength),
+    'Cache-Control': 'no-store',
+  });
+  res.end(method === 'HEAD' ? undefined : body);
+}
+
+/**
+ * Whether a request path should fall back to `index.html` when no file matches.
+ *
+ * The viewer is hash-routed (`/#/s/<id>`), so in practice only `/` is ever
+ * requested — but a bookmarked or hand-typed `/anything` should still open the
+ * app rather than a 404 page. A path that names a FILE (has an extension) never
+ * falls back: answering `/assets/index-abc123.js` with HTML would hand the
+ * browser a script that is not a script, and hide a genuinely missing asset
+ * behind a page that looks like it loaded.
+ */
+export function shouldFallBackToIndex(pathname: string): boolean {
+  return path.extname(pathname) === '';
+}

+ 334 - 0
ui/README.md

@@ -0,0 +1,334 @@
+# ui/ — the `codegraph ui` viewer, and `@colbymchenry/codegraph-ui`
+
+One source tree, two builds.
+
+- **The app** — the browser reader for an indexed project: Svelte 5 + Vite,
+  built as static files into `../dist/viewer` and served by the CLI over
+  loopback.
+- **The library** — the same components, packaged with `svelte-package` into
+  `dist/` as `@colbymchenry/codegraph-ui`, so a host (CodeGraph Pro) renders
+  the Symbol view, the Flow strip and the Map over its **own** graph reads.
+
+They are one tree on purpose. A forked component is a second answer to the same
+question about the same graph, and sooner or later the two get quoted against
+each other in a review.
+
+An npm workspace of the engine, so `npm ci` at the repo root installs the
+toolchain for both.
+
+Design spec (every token, size and measurement):
+`../docs/design/codegraph-ui-design-spec.md`.
+
+## Build
+
+```bash
+npm run build          # from the repo root: tsc -> copy-assets -> this app
+npm run build:ui       # just the app, plus the dist assertion
+npm run build:lib      # the LIBRARY: svelte-package -> ui/dist, plus its checks
+npm run dev -w ui      # Vite dev server on 127.0.0.1:5174
+npm run check -w ui    # svelte-check
+```
+
+`build:lib` is deliberately not part of `npm run build`: the CLI does not need
+it, and a release that fails because a component library would not compile is a
+release that failed for the wrong reason.
+
+`npm run build` emits **`dist/viewer/`** (`index.html` + hashed assets).
+`scripts/check-ui-build.mjs` then asserts the tree is complete, so a broken UI
+build fails the release instead of shipping a CLI that serves a 404. The same
+check runs again in `scripts/build-bundle.sh` (after the bundle stage copies
+`dist`) and in `scripts/pack-npm.sh` (after each archive is unpacked).
+
+### Why `dist/viewer` and not `dist/ui`
+
+`src/ui/` is the engine's **terminal** UI (shimmer progress and its worker) and
+tsc compiles it to `dist/ui/`. Pointing Vite there deletes those modules — the
+CLI then dies at startup with `Cannot find module '../ui/shimmer-progress'` —
+and would also leave the static server handing out compiled engine internals.
+`check-ui-build.mjs` re-asserts the compiled engine is intact after every UI
+build so that mistake cannot land twice.
+
+## `@colbymchenry/codegraph-ui`
+
+```svelte
+<script lang="ts">
+  import { CodegraphUi, SymbolView, FlowStrip, ArchitectureMap }
+    from '@colbymchenry/codegraph-ui';
+  import '@colbymchenry/codegraph-ui/theme.css';
+</script>
+
+<CodegraphUi adapter={myAdapter} nav={myNavigation}>
+  <SymbolView id={symbolId} line={null} />
+</CodegraphUi>
+```
+
+Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`,
+`FileSourceView`, `EntryPointsView`, `DeadCodeView`, `TypeHierarchy`, `TrailBar`,
+`SavedTrails`, `SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`,
+`KindGlyph`, `ExportButtons`, `CodegraphUi` — plus every pure model function the screens are
+built from (`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`,
+`buildHierarchyModel`, `tokensByLine`, …) and the `Wire*` types an adapter
+answers in.
+
+`TypeHierarchy` is the one screen that takes its data as a prop rather than
+asking the adapter: it is part of `SymbolView`'s payload (`/api/node`'s
+`hierarchy`), so a host that already holds a `WireSymbolPayload` can render the
+tree on its own without a second read.
+
+### The adapter is the only way data arrives
+
+```ts
+interface GraphAdapter {
+  stats(signal?): Promise<WireStats>;
+  search(query, opts?, signal?): Promise<WireSearch>;
+  node(id, signal?): Promise<WireSymbolPayload>;
+  nodes(ids, signal?): Promise<WireNodeRefs>;
+  source(request, signal?): Promise<WireSource>;
+  file(path, signal?): Promise<WireFilePayload>;
+  fileCode(path, signal?): Promise<WireFileCodePayload>;
+  flow(request, signal?): Promise<WireFlowPayload>;
+  map(request?, signal?): Promise<WireMapPayload>;
+  routes(request?, signal?): Promise<WireRoutes>;
+  entryPoints(request?, signal?): Promise<WireEntryPoints>;
+  deadCode(request?, signal?): Promise<WireDeadCode>;
+  trails(signal?): Promise<WireTrails>;
+
+  // The only mutating pair, and the only optional methods besides `events`.
+  saveTrail?(request, signal?): Promise<WireTrails>;
+  deleteTrail?(id, signal?): Promise<WireTrails>;
+  events?(handlers): () => void;   // optional: the live channel
+}
+```
+
+The shapes are exactly what `src/ui-server/api/` serialises, and they live in
+`src/lib/wire.ts` — no imports, no runtime — so a host can depend on the
+vocabulary without depending on the viewer. The default implementation,
+`createHttpAdapter()`, is the loopback JSON API; a host that already holds the
+index implements the same thirteen required methods against its own reads and
+never makes an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the
+built package but `lib/adapter.js` touches the network, because a screen that
+reached past the adapter would be a screen that ignored the host.
+
+`events` is optional. Omit it and nothing connects and nothing polls; a host
+that learns about a sync some other way calls `live.signal('index')` instead,
+which is the same code path the stream uses.
+
+`saveTrail` / `deleteTrail` are optional for a different reason: they are the
+only methods in the interface that CHANGE anything, and a host must be able to
+render the reader without inheriting a write it never asked for. Omit them and
+`TrailBar` grows no Save button and `SavedTrails` says the host does not store
+them — the same thing it does when `trails()` answers `readOnly: true`, which is
+how a host that *can* store them declines a particular project. `trails()` itself
+is required: a host with nowhere to keep them answers an empty read-only list, so
+the screen is explained rather than silently missing.
+
+### Three things that will bite
+
+1. **Import `theme.css` once.** Every component paints from the design tokens.
+   Override any variable on a narrower selector — including on a container,
+   since custom properties inherit; `<CodegraphUi theme="light">` uses exactly
+   that to put a light reader inside a dark application.
+2. **The adapter and the navigation driver are module-level, not context.** The
+   pure model modules are plain TypeScript and cannot read a component's
+   context, so one page reads one project. `<CodegraphUi>` installs them during
+   initialisation, once — swapping projects means re-mounting the subtree
+   (`{#key project}`), not swapping the prop.
+3. **Geometry is not themable.** 34px rail rows, the 300/320px rails, the 20px
+   code line: the Symbol view measures these against each other to put a callee
+   row beside the line that calls it. Colour and type are yours.
+
+### Navigation
+
+Every link the components build goes through a `NavigationDriver`
+(`src/lib/navigation.ts`). The default is the viewer's own hash space
+(`#/s/<id>`); a host installs one that addresses its app instead, and the rails,
+breadcrumbs, chips and cards follow. They are hrefs rather than click handlers
+because middle-click, cmd-click and "copy link address" are how people read
+code.
+
+The app's half — parsing the hash, holding the live route — is
+`src/lib/router.svelte.ts`, which attaches `hashchange`/`popstate` listeners at
+module scope and is therefore **pruned out of the published package**. Nothing a
+host imports may drag a hash router into its application.
+
+### Versioning and publishing
+
+The package is versioned with the engine (`scripts/sync-ui-version.mjs` runs on
+every `build:lib`): `@colbymchenry/codegraph-ui@X.Y.Z` is the reader for
+`codegraph@X.Y.Z`, because the payload shapes are versioned with the binary that
+serves them.
+
+It is **prepared, not published.** `"private": true` in `package.json` is the
+guard — npm refuses to publish it — and `scripts/pack-npm.sh` only builds the
+tarball when `CODEGRAPH_PACK_UI=1`, into `release/npm-ui/` (never
+`release/npm/`, whose `codegraph-*` glob the release workflow publishes).
+Publishing is the maintainer's call and takes two deliberate edits.
+
+## Layout
+
+```
+src/
+  index.ts                the LIBRARY's entry — everything the package exports
+  main.ts                 fonts + tokens, mounts App into index.html's #app
+  app.css                 the app's reset, shell grid and primitives
+  lib/theme.css           the design tokens (light/dark) + the Svelte Flow map
+  lib/adapter.ts          GraphAdapter, createHttpAdapter, the registry
+  lib/wire.ts             every Wire* payload shape — types only, no runtime
+  lib/api.ts              the screens' calls, one line each, over the adapter
+  lib/navigation.ts       href builders + navigate, behind a driver
+  App.svelte              top bar / trail bar / main, global keys
+  lib/router.svelte.ts    hash router: #/s/<id>, #/file/<path>, #/map, #/flow, #/entry
+  lib/trail.svelte.ts     the walked path; mirrored into the `t` query param
+  lib/trails.svelte.ts    saved trails: one shared fetch, and the two writes
+  lib/trails-model.ts     what a saved trail's row says, incl. its decay (pure)
+  lib/kinds.ts            kind glyph letters
+  lib/map-model.ts        the Map's deterministic layered layout (pure)
+  lib/flow-model.ts       the Flow strip's card/link geometry + the end cap — a DAG (pure)
+  lib/filecode-model.ts   the whole-file view: fixed line height, arcs, paging (pure)
+  lib/entry-model.ts      the entry-points panel: rows, file groups, flow arming (pure)
+  lib/export-svg.ts       the Flow strip and the Map as a standalone SVG (pure)
+  lib/export-image.ts     rasterising that SVG to PNG, clipboard and download
+  lib/live.svelte.ts      /api/events: two counters every screen refreshes from
+  lib/toast.svelte.ts     the one transient note ("Index updated · reloaded")
+  components/             TopBar, TrailBar, SavedTrails, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
+  views/                  one component per route
+```
+
+Fonts (Archivo Variable, IBM Plex Mono) are vendored through `@fontsource*` and
+emitted into `dist/viewer/assets`: a local reader must work offline and must not
+announce the project to a font CDN.
+
+## Export
+
+The Flow strip's header and the Map's side panel carry **Copy image** (a PNG on
+the clipboard) and **Download SVG** (a file for a README). Both render the
+**light** theme whatever the viewer is set to — an image is read on somebody
+else's screen — with 24px of paper around the drawing, a caption naming the path
+or the root, and a "CodeGraph" mark in the corner.
+
+`export-svg.ts` **serialises the layout object**; it does not scrape the DOM.
+`buildFlowLayout` and `buildMapLayout` already compute every rectangle, port and
+curve before a component renders, so the image and the screen come from one
+piece of arithmetic and cannot disagree — and the exporter is a pure function
+that a test can run with no browser at all. The output is presentation-only SVG
+(no script, no `foreignObject`, no external reference), which is what GitHub
+will render in a README.
+
+Fonts travel as `font-family` stacks rather than embedded bytes. An SVG loaded
+as an image may not fetch a webfont, so a raster falls back to the platform's
+own monospace; every fallback in the stack advances at ~0.6em like IBM Plex
+Mono, so the code grid survives and only the letterforms change.
+
+## Routes
+
+| hash | view |
+|---|---|
+| `#/` | nothing selected |
+| `#/s/<id>?hl=<line>&t=<trail>` | symbol view |
+| `#/file/<path>?hl=<line>` | file view — outline in source order |
+| `#/file/<path>?src=1` | file view — the whole file's source, with ports and call arcs |
+| `#/map?root=&depth=&tests=1` | module map |
+| `#/flow?from=&to=` | flow strip — the call path between two symbols |
+| `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |
+| `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
+| `#/entry` | entry points — routes, files that run something, tests, hubs |
+
+## Entry points
+
+`#/entry` draws `/api/entrypoints` as file groups, reusing the Symbol view's
+`.filegroup` / `.row` shapes rather than inventing a second visual language for
+"a list of code, grouped by where it lives". Three things about it are decisions,
+not accidents:
+
+- **Routes group by where the URL is REGISTERED, not where it is served.** A
+  router file is the shape a reader already has in mind; handlers scatter across
+  a package. The payload carries both, and the row's meta line names the handler
+  and its `file:line`.
+- **A row offers a flow only if it names a callable symbol.** `/api/flow`
+  searches the graph by NAME, and a file has none the path finder can look up —
+  so route and hub rows carry a `Flow ›` chip and file and test rows do not. A
+  chip that always failed would be worse than no chip.
+- **No empty Routes box.** A project with fewer than three resolvable routes is
+  not a routed app, and the section is absent rather than empty; the panel falls
+  back to the files that run something and the tests that exercise them.
+
+`buildEntryPanel` is pure and keeps `panel.rows` exactly equal to the sections it
+draws, the same identity the search palette rests its keyboard on.
+
+## Where the graph stops
+
+A flow that does not reach everything it was asked about carries a
+`boundary` on the wire, and `buildFlowLayout` turns it into an extra 240px node
+one column past the symbol the path stopped at, joined by a dotted `2 4` link
+labelled "end of static path" that deliberately has **no arrowhead** — an arrow
+would point at a continuation, and the absence of one is the finding.
+
+Two rules hold it together:
+
+- **The cap's height is arithmetic, like a card's.** `endCapText()` builds every
+  sentence the cap shows and `endCapHeight()` measures them; the component then
+  renders exactly what was measured. Change the wording in one and the other
+  moves with it — they are the same function read twice.
+- **One cap per stopping symbol, not per flow.** Two paths that run out at the
+  same place ran out for the same reason, and two caps side by side would read
+  as two different findings.
+
+The verdict itself is not computed here or in the server: it is
+`findDynamicBoundaries` in `src/graph/dynamic-boundary-report.ts`, the same
+detector `codegraph_explore` announces boundaries with.
+
+## The type hierarchy
+
+A class, interface, struct, trait or enum carries a `hierarchy` on its
+`/api/node` payload: ancestors up, subtypes down, and the fan an interface call
+dispatches into. `buildHierarchyModel` turns it into a tree whose geometry is
+arithmetic — 24px rows, 22px of indent per descendant level, orthogonal 1px
+connectors computed from those two numbers. Nothing is measured; the same
+payload always draws the same picture.
+
+The details worth knowing before changing it:
+
+- **`extends` is solid, `implements` dashed `4 3`, a synthesized edge dashed
+  `6 3`** with a `via <mechanism>` pill. In Go, `System` satisfies `Clock`
+  without either file naming the other and the edge exists only because the
+  resolver made it — the block says so rather than drawing it like a parse.
+- **Overrides on the members outline are a NAME match**, not an `overrides`
+  edge (nothing in the engine emits one). They are matched against the nearest
+  ancestor that declares the name and are blind to signatures, and the tooltip
+  says which claim was actually checked.
+- **The fold trims the deepest end**, because the walk is breadth-first: a
+  reader looking at an interface gets every direct implementation before any
+  subclass of one appears at all.
+
+The walk is not computed here or in the server: it is `buildTypeHierarchy` in
+`src/graph/type-hierarchy.ts`, whose `countImplementers` is also the number
+`codegraph_explore` prints when it announces an interface dispatch — so "N types
+implement X" is the same N wherever you read it.
+
+## Live updates
+
+The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on
+`/api/events` for the life of the page and exposes two counters:
+
+- **`indexTick`** — the graph moved (somebody synced). Every screen refetches:
+  a rail is an answer about the whole graph, and a symbol gains a caller when
+  some *other* file is edited, so filtering by the focused file would leave the
+  rails quietly wrong. One request per sync.
+- **`diskTick`** — source files changed on disk and the index has not caught up.
+  Only the screen showing one of those files reacts, and what it does is draw a
+  drift banner.
+
+`liveRefresh(file, refresh)` is the three lines of bookkeeping that turns a
+counter into a single call; the Map and the Flow strip instead read
+`live.indexTick` straight inside the effect that already fetches them.
+
+Reconnection is ours, not `EventSource`'s: each failure closes the stream and
+schedules ONE retry on a backoff that ends after eight attempts (~90 s), at
+which point the top bar says "Not live" and nothing more is requested until the
+tab is focused again. A `degraded` event — the server's watcher gave up — is
+shown the same way and never answered with a poll.
+
+Node ids and file paths are encoded per slash-separated segment, so
+`#/file/src/mcp/tools.ts` stays readable and still round-trips a segment
+containing a reserved character. Build hashes with `symbolHref()` /
+`fileHref()` / `mapHref()` / `flowHref()` rather than by hand.

+ 15 - 0
ui/index.html

@@ -0,0 +1,15 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="color-scheme" content="light dark" />
+    <title>CodeGraph</title>
+    <!-- Inline, so a loopback server never has to answer a favicon request. -->
+    <link rel="icon" href="data:," />
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 60 - 0
ui/package.json

@@ -0,0 +1,60 @@
+{
+  "name": "@colbymchenry/codegraph-ui",
+  "private": true,
+  "version": "1.6.0",
+  "type": "module",
+  "description": "The CodeGraph reader as Svelte components: Symbol view, Flow strip and architecture Map behind one data adapter.",
+  "keywords": [
+    "codegraph",
+    "svelte",
+    "code-intelligence",
+    "knowledge-graph"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/colbymchenry/codegraph.git",
+    "directory": "ui"
+  },
+  "license": "MIT",
+  "files": [
+    "dist",
+    "README.md"
+  ],
+  "svelte": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "sideEffects": [
+    "**/*.css"
+  ],
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "svelte": "./dist/index.js",
+      "default": "./dist/index.js"
+    },
+    "./theme.css": "./dist/lib/theme.css",
+    "./package.json": "./package.json"
+  },
+  "scripts": {
+    "build": "vite build",
+    "build:lib": "node ../scripts/sync-ui-version.mjs && svelte-package -i src -o dist && node ../scripts/check-ui-package.mjs",
+    "dev": "vite",
+    "preview": "vite preview",
+    "check": "svelte-check --tsconfig ./tsconfig.json"
+  },
+  "peerDependencies": {
+    "svelte": "^5.25.0"
+  },
+  "dependencies": {
+    "@xyflow/svelte": "^1.6.5"
+  },
+  "devDependencies": {
+    "@fontsource-variable/archivo": "^5.3.0",
+    "@fontsource/ibm-plex-mono": "^5.3.0",
+    "@sveltejs/package": "^2.5.8",
+    "@sveltejs/vite-plugin-svelte": "^6.2.4",
+    "svelte": "^5.56.10",
+    "svelte-check": "^4.7.6",
+    "typescript": "^5.0.0",
+    "vite": "^7.3.6"
+  }
+}

+ 184 - 0
ui/src/App.svelte

@@ -0,0 +1,184 @@
+<script lang="ts">
+  import { untrack } from 'svelte';
+  import TopBar from './components/TopBar.svelte';
+  import TrailBar from './components/TrailBar.svelte';
+  import HomeView from './views/HomeView.svelte';
+  import SymbolView from './views/SymbolView.svelte';
+  import FileView from './views/FileView.svelte';
+  import FileCodeView from './views/FileCodeView.svelte';
+  import MapView from './views/MapView.svelte';
+  import FlowView from './views/FlowView.svelte';
+  import EntryView from './views/EntryView.svelte';
+  import DeadCodeView from './views/DeadCodeView.svelte';
+  import NotFoundView from './views/NotFoundView.svelte';
+  import Toast from './components/Toast.svelte';
+  import {
+    router,
+    navigate,
+    back,
+    mapHref,
+    flowHref,
+    entryHref,
+    deadHref,
+  } from './lib/router.svelte';
+  import { palette } from './lib/palette.svelte';
+  import { trail, resolveTrailNames } from './lib/trail.svelte';
+  import { trails } from './lib/trails.svelte';
+  import { project } from './lib/project.svelte';
+  import { live } from './lib/live.svelte';
+  import { toast } from './lib/toast.svelte';
+
+  // One `/api/stats` for the whole app: the top bar's counts and the Symbol
+  // view's blast-radius denominator come out of the same payload.
+  $effect(() => {
+    void project.ensure();
+  });
+
+  // The live channel: one connection for the page, opened once. Every screen
+  // reads its counters; nothing polls.
+  $effect(() => {
+    live.start();
+  });
+
+  // The index moving is the one thing worth a note — the screen under it has
+  // already refetched by the time this shows. `/api/stats` is re-read for the
+  // same reason: the top bar's counts came from the graph that just changed.
+  let seenIndexTick = live.indexTick;
+  $effect(() => {
+    const tick = live.indexTick;
+    untrack(() => {
+      if (tick === seenIndexTick) return;
+      seenIndexTick = tick;
+      void project.reload();
+      // The entry points describe the index, and they are fetched once and
+      // kept — so without this the resting palette, the empty screen and the
+      // entry-points panel would all keep describing the graph as it was.
+      void palette.reloadEntries();
+      // Saved trails are re-resolved by the server against the index that just
+      // moved, so their decay lines are stale the moment it does — a hop that
+      // was "gone" a minute ago may be back, and vice versa.
+      void trails.reload();
+      toast.show('Index updated · reloaded');
+    });
+  });
+
+  let topbar: TopBar | null = $state(null);
+
+  let route = $derived(router.route);
+
+  // Keep the in-memory trail and the `t` param in step. untrack() because the
+  // body writes the same store it would otherwise read itself into a loop.
+  $effect(() => {
+    const current = router.route;
+    const encoded = router.params.get('t');
+    untrack(() => {
+      trail.hydrate(encoded);
+      if (current.view === 'symbol' && trail.current?.id !== current.id) {
+        trail.push({ id: current.id });
+      }
+    });
+  });
+
+  // Hops restored from a URL carry ids and nothing else; one batched request
+  // turns the bar back into names. Runs after every trail change, and does
+  // nothing when every hop already has one.
+  $effect(() => {
+    void trail.hops.length;
+    void resolveTrailNames();
+  });
+
+  function isTypingTarget(target: EventTarget | null): boolean {
+    if (!(target instanceof HTMLElement)) return false;
+    return (
+      target.isContentEditable ||
+      target instanceof HTMLInputElement ||
+      target instanceof HTMLTextAreaElement ||
+      target instanceof HTMLSelectElement
+    );
+  }
+
+  function onkeydown(event: KeyboardEvent) {
+    if (event.defaultPrevented) return;
+
+    // Cmd/Ctrl+K reaches the search box even from inside another field.
+    if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
+      event.preventDefault();
+      topbar?.focusSearch();
+      return;
+    }
+
+    if (event.metaKey || event.ctrlKey || event.altKey) return;
+    if (isTypingTarget(event.target)) return;
+
+    switch (event.key) {
+      case '/':
+        event.preventDefault();
+        topbar?.focusSearch();
+        break;
+      case 'm':
+        event.preventDefault();
+        navigate(mapHref());
+        break;
+      case 'f':
+        event.preventDefault();
+        navigate(flowHref());
+        break;
+      case 'e':
+        event.preventDefault();
+        navigate(entryHref());
+        break;
+      case 'd':
+        event.preventDefault();
+        navigate(deadHref());
+        break;
+      case 'Backspace':
+      case '[':
+        event.preventDefault();
+        back();
+        break;
+    }
+  }
+</script>
+
+<svelte:window {onkeydown} />
+
+<TopBar bind:this={topbar} project={project.name} stats={project.summary} />
+<TrailBar />
+<main>
+  {#if route.view === 'symbol'}
+    <SymbolView id={route.id} line={route.line} />
+  {:else if route.view === 'file' && route.source}
+    <FileCodeView path={route.path} line={route.line} />
+  {:else if route.view === 'file'}
+    <FileView path={route.path} line={route.line} />
+  {:else if route.view === 'map'}
+    <MapView root={route.root} depth={route.depth} tests={route.tests} />
+  {:else if route.view === 'flow'}
+    <FlowView
+      from={route.from}
+      to={route.to}
+      symbols={route.symbols}
+      trailParam={route.trail}
+    />
+  {:else if route.view === 'entry'}
+    <EntryView project={project.name} />
+  {:else if route.view === 'dead'}
+    <DeadCodeView exported={route.exported} />
+  {:else if route.view === 'unknown'}
+    <NotFoundView path={route.path} />
+  {:else}
+    <HomeView project={project.name} />
+  {/if}
+</main>
+<Toast />
+
+<style>
+  /* The shell grid lives on #app (index.html's mount host) in app.css —
+     Svelte's scoped styles cannot reach an element this component does not
+     render. Only <main>, which it does render, is styled here. */
+  main {
+    /* min-height:0 lets the row shrink so the view, not the page, scrolls. */
+    min-height: 0;
+    overflow: hidden;
+  }
+</style>

+ 129 - 0
ui/src/app.css

@@ -0,0 +1,129 @@
+/* =====================================================================
+   codegraph ui — the app's global primitives
+
+   The design tokens themselves live in `lib/theme.css`, which is also
+   what `@colbymchenry/codegraph-ui` exports for a host to import and
+   override. This file is everything ON TOP of them that only the
+   standalone viewer needs: the reset, the shell grid, and the handful of
+   primitives shared across views.
+
+   Component-specific rules live in each .svelte file's scoped <style>.
+   ===================================================================== */
+
+@import './lib/theme.css';
+
+/* ---------- reset ---------- */
+html,
+body {
+  height: 100%;
+}
+
+body {
+  margin: 0;
+  /* body always paints --paper: the bars are transparent over it and a
+     short view must not reveal the browser's own canvas colour. */
+  background: var(--paper);
+  color: var(--ink);
+  font-family: var(--sans);
+  font-size: 13px;
+  line-height: 1.45;
+  -webkit-font-smoothing: antialiased;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+  /* Square corners are non-negotiable in this system, including on the
+     UA-styled controls (input, select, button) we do not restyle. */
+  border-radius: 0 !important;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+button {
+  font: inherit;
+  color: inherit;
+  background: none;
+  border: 0;
+  padding: 0;
+  cursor: pointer;
+}
+
+h1,
+h2,
+h3 {
+  font-weight: 600;
+  text-wrap: balance;
+}
+
+:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: 1px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *,
+  *::before,
+  *::after {
+    transition: none !important;
+    animation: none !important;
+    scroll-behavior: auto !important;
+  }
+}
+
+/* ---------- app shell ----------
+   Design spec §3.1: top bar 48px / trail bar 34px / main. The grid is on
+   index.html's mount host, which App.svelte fills directly (no wrapper — a
+   second #app would duplicate the id).
+
+   The trail row is `auto`, not `--trailbar-h`: the bar keeps that height on
+   its own (see TrailBar.svelte) and grows only while the save-trail form is
+   open. Pinning the row instead would clip the form. */
+#app {
+  height: 100vh;
+  display: grid;
+  grid-template-rows: var(--topbar-h) auto 1fr;
+}
+
+/* ---------- cross-view primitives ---------- */
+.mono {
+  font-family: var(--mono);
+}
+
+.dim {
+  color: var(--ink-3);
+}
+
+.tnum {
+  font-variant-numeric: tabular-nums;
+}
+
+/* Empty / not-yet-loaded states. Sentence case, no exclamation marks —
+   say what is missing and what to do about it. */
+.emptystate {
+  padding: 40px;
+  max-width: 60ch;
+  color: var(--ink-2);
+  line-height: 1.5;
+}
+
+.emptystate h2 {
+  margin: 0 0 8px;
+  font-size: 16px;
+  color: var(--ink);
+}
+
+.emptystate p {
+  margin: 0 0 10px;
+}
+
+.emptystate code {
+  font-family: var(--mono);
+  font-size: 12px;
+  background: var(--press);
+  padding: 1px 4px;
+}

+ 80 - 0
ui/src/components/CodegraphUi.svelte

@@ -0,0 +1,80 @@
+<script lang="ts">
+  /**
+   * The provider: installs the adapter and the navigation driver, then renders
+   * whatever the host puts inside it.
+   *
+   * It is a convenience, not a boundary. `setGraphAdapter` and
+   * `setNavigationDriver` are module-level (see `lib/adapter.ts` for why: the
+   * pure model modules are plain TypeScript and cannot read a component's
+   * context), so this component's whole job is to call them during
+   * initialisation — before any child's `$effect` has run and asked for data.
+   *
+   * That also means the last one to mount wins. A page shows one project; a
+   * host that needs two at once needs two documents, not two providers.
+   */
+  import { untrack, type Snippet } from 'svelte';
+  import { setGraphAdapter, type GraphAdapter } from '../lib/adapter';
+  import { setNavigationDriver, type NavigationDriver } from '../lib/navigation';
+
+  interface Props {
+    /** Where every screen's data comes from. Omit for the loopback JSON API. */
+    adapter?: GraphAdapter | null;
+    /** Where a click on a symbol, file, flow or module goes. Omit for `#/…`. */
+    nav?: NavigationDriver | null;
+    /**
+     * Force a colour scheme on this subtree.
+     *
+     * `'auto'` (the default) leaves it to the tokens, which follow the OS
+     * unless `:root[data-theme]` says otherwise. The other two set
+     * `data-theme` on this component's own wrapper, so a host can put a light
+     * reader inside a dark application without redefining a single variable.
+     */
+    theme?: 'auto' | 'light' | 'dark';
+    /** Fills the host's box by default; set false to size it yourself. */
+    fill?: boolean;
+    children?: Snippet;
+  }
+
+  let { adapter = null, nav = null, theme = 'auto', fill = true, children }: Props = $props();
+
+  // Init, not $effect: a child's data effect can run before the parent's, so
+  // installing these in an effect would let the first render ask the previous
+  // adapter — or the default HTTP one, against a host that serves no `/api`.
+  //
+  // Once only, and `untrack` says so. Swapping the adapter on a mounted tree
+  // would leave every screen holding answers from the old project until
+  // something happened to refetch; a host that changes project re-mounts the
+  // subtree instead (`{#key project}`), which is honest and one line.
+  setGraphAdapter(untrack(() => adapter));
+  setNavigationDriver(untrack(() => nav));
+</script>
+
+<div class="codegraph-ui" class:fill data-theme={theme === 'auto' ? undefined : theme}>
+  {@render children?.()}
+</div>
+
+<style>
+  /* The tokens are on :root (theme.css); this wrapper only re-establishes the
+     type and the paper, so a component dropped into a host with its own body
+     font does not inherit it. Geometry stays with the components. */
+  .codegraph-ui {
+    background: var(--paper);
+    color: var(--ink);
+    font-family: var(--sans);
+    font-size: 13px;
+    line-height: 1.45;
+  }
+
+  .fill {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    min-height: 0;
+  }
+
+  /* Every screen fills the provider; the view scrolls, not the page. */
+  .fill > :global(*) {
+    flex: 1;
+    min-height: 0;
+  }
+</style>

+ 76 - 0
ui/src/components/DriftBanner.svelte

@@ -0,0 +1,76 @@
+<!--
+  "This file changed on disk after the last index sync."
+
+  One block, said the same way on every screen that can say it (design spec:
+  paper-2 fill, hairline rule, ⚠ in ink-3, 12.5px ink-2). Deliberately NOT
+  amber: amber is the untested badge's colour and nothing else's, and a warning
+  that borrows it makes two unrelated things look like the same kind of problem.
+  Deliberately not a modal either — the screen underneath is still mostly true,
+  and interrupting to say so would be the overclaim.
+
+  The caller supplies the tail of the sentence, because what follows the dash is
+  the only part that differs: what this particular screen did about it.
+-->
+<script lang="ts">
+  import type { Snippet } from 'svelte';
+
+  interface Props {
+    /** Project-relative path, shown in mono. */
+    file: string;
+    /** The rest of the sentence: what this screen is showing instead. */
+    children: Snippet;
+  }
+
+  let { file, children }: Props = $props();
+</script>
+
+<div class="drift" role="status">
+  <span class="glyph" aria-hidden="true">⚠</span>
+  <span class="body"><code>{file}</code> changed on disk after the last index sync — {@render children()}</span>
+</div>
+
+<style>
+  .drift {
+    display: grid;
+    grid-template-columns: 16px 1fr;
+    gap: 6px;
+    align-items: start;
+    padding: 8px 12px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper-2);
+    color: var(--ink-2);
+    font-size: 12.5px;
+    line-height: 1.5;
+  }
+
+  .glyph {
+    color: var(--ink-3);
+    font-size: 12px;
+    line-height: 1.55;
+  }
+
+  .body :global(code) {
+    font-family: var(--mono);
+    font-size: 12px;
+    color: var(--ink);
+  }
+
+  .body :global(button) {
+    background: none;
+    border: 0;
+    padding: 0;
+    color: var(--accent);
+    font: inherit;
+    cursor: pointer;
+    text-decoration: underline;
+    text-decoration-color: var(--accent-line);
+    text-underline-offset: 3px;
+  }
+
+  .body :global(a) {
+    color: var(--accent);
+    text-decoration: underline;
+    text-decoration-color: var(--accent-line);
+    text-underline-offset: 3px;
+  }
+</style>

+ 101 - 0
ui/src/components/ExportButtons.svelte

@@ -0,0 +1,101 @@
+<!--
+  Take the picture with you (design spec §3.9).
+
+  Two buttons, because there are exactly two destinations: a PR comment, which
+  wants a PNG on the clipboard, and a README, which wants an SVG on disk. Both
+  render the light theme whatever the viewer is set to — an image is read on
+  somebody else's screen, and a dark strip on GitHub's white comment background
+  reads as a mistake rather than a preference.
+
+  The SVG is built lazily, at click time, from the layout the canvas is already
+  drawing. Nothing is measured, nothing is scraped, and the button costs nothing
+  until it is pressed.
+-->
+<script lang="ts">
+  import { copyPngToClipboard, downloadSvg, svgToPng, PNG_SCALE } from '../lib/export-image';
+  import { toast } from '../lib/toast.svelte';
+
+  interface Props {
+    /** Builds the SVG at a given device-pixel scale. */
+    build: (scale: number) => string;
+    /** File stem for the download, without an extension. */
+    filename: string;
+    disabled?: boolean;
+  }
+
+  let { build, filename, disabled = false }: Props = $props();
+  let busy = $state(false);
+
+  async function copyImage(): Promise<void> {
+    if (busy) return;
+    busy = true;
+    try {
+      const where = await copyPngToClipboard(
+        () => svgToPng(build(PNG_SCALE)),
+        `${filename}.png`
+      );
+      toast.show(
+        where === 'copied'
+          ? 'Image copied · paste it into a comment'
+          : 'Clipboard unavailable · image saved instead'
+      );
+    } catch (error) {
+      toast.show(error instanceof Error ? error.message : 'The image could not be made.');
+    } finally {
+      busy = false;
+    }
+  }
+
+  function saveSvg(): void {
+    try {
+      downloadSvg(build(1), `${filename}.svg`);
+      toast.show('SVG saved');
+    } catch (error) {
+      toast.show(error instanceof Error ? error.message : 'The file could not be saved.');
+    }
+  }
+</script>
+
+<div class="exp">
+  <button type="button" onclick={copyImage} disabled={disabled || busy}>
+    {busy ? 'Rendering…' : 'Copy image'}
+  </button>
+  <button type="button" onclick={saveSvg} {disabled}>Download SVG</button>
+</div>
+
+<style>
+  .exp {
+    display: flex;
+    flex: 0 0 auto;
+    /* Pushed to the trailing edge of a flex header; inert in a block panel. */
+    margin-left: auto;
+    gap: 6px;
+  }
+
+  .exp button {
+    padding: 3px 8px;
+    background: var(--paper-2);
+    border: 1px solid var(--rule-soft);
+    border-radius: 0;
+    color: var(--ink-2);
+    cursor: pointer;
+    font: 12.5px var(--sans);
+    white-space: nowrap;
+  }
+
+  .exp button:hover:not(:disabled) {
+    background: var(--press);
+    border-color: var(--ink-3);
+    color: var(--ink);
+  }
+
+  .exp button:disabled {
+    color: var(--ink-4);
+    cursor: default;
+  }
+
+  .exp button:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: 1px;
+  }
+</style>

+ 57 - 0
ui/src/components/KindGlyph.svelte

@@ -0,0 +1,57 @@
+<script lang="ts">
+  import { kindLetter, kindWord, FILLED_KINDS } from '../lib/kinds';
+
+  interface Props {
+    kind: string | null | undefined;
+    /** Adds a tooltip; off by default so rails do not fight the browser. */
+    titled?: boolean;
+  }
+
+  let { kind, titled = false }: Props = $props();
+
+  // An unknown kind (a trail hop restored from a URL, before its node is
+  // fetched) draws an empty box. A '?' would read as a claim about the symbol.
+  let letter = $derived(kind ? kindLetter(kind) : '');
+  let filled = $derived(kind ? FILLED_KINDS.has(kind) : false);
+  let dashed = $derived(kind === 'file');
+</script>
+
+<span
+  class="k"
+  class:filled
+  class:dashed
+  class:wide={letter.length > 1}
+  title={titled ? kindWord(kind) : undefined}
+  aria-hidden={titled ? undefined : 'true'}
+>{letter}</span>
+
+<style>
+  .k {
+    display: inline-flex;
+    width: 16px;
+    height: 16px;
+    flex: 0 0 auto;
+    align-items: center;
+    justify-content: center;
+    border: 1px solid var(--ink-3);
+    color: var(--ink-2);
+    font: 500 9.5px var(--mono);
+    line-height: 1;
+    user-select: none;
+  }
+
+  .k.filled {
+    background: var(--press);
+  }
+
+  .k.dashed {
+    border-style: dashed;
+  }
+
+  /* Two-character letters (Tr, im, ex) need to lose a little tracking to
+     sit inside the 16px box without touching the rule. */
+  .k.wide {
+    font-size: 8.5px;
+    letter-spacing: -0.02em;
+  }
+</style>

+ 82 - 0
ui/src/components/PalettePanel.svelte

@@ -0,0 +1,82 @@
+<script lang="ts">
+  /**
+   * The results panel under the search box (design spec §3.7).
+   *
+   * It renders whatever `palette.view` is: the entry points when the box is
+   * empty, the ranked kind groups when it is not. The keyboard lives in
+   * `TopBar` (the keys are pressed in the input, not here) and arrives as the
+   * `selected` index; this component's only job beyond drawing is keeping that
+   * row in view when the selection moves past the panel's edge.
+   */
+  import PaletteRows from './PaletteRows.svelte';
+  import { palette } from '../lib/palette.svelte';
+  import type { PaletteItem } from '../lib/search-model';
+
+  interface Props {
+    onpick: (item: PaletteItem) => void;
+  }
+
+  let { onpick }: Props = $props();
+
+  let panel: HTMLDivElement | null = $state(null);
+  let view = $derived(palette.view);
+
+  $effect(() => {
+    const index = palette.selected;
+    if (!panel) return;
+    const row = panel.querySelector(`[data-palette-row="${index}"]`);
+    row?.scrollIntoView({ block: 'nearest' });
+  });
+</script>
+
+<div class="panel" bind:this={panel} id="palette-panel" role="listbox" aria-label="Search results">
+  {#if view.hint}
+    <p class="hint">{view.hint}</p>
+  {/if}
+
+  <PaletteRows
+    palette={view}
+    selected={palette.selected}
+    rowRole="option"
+    {onpick}
+    onhover={(index) => palette.select(index)}
+  />
+
+  {#if palette.failure}
+    <p class="note">{palette.failure}</p>
+  {:else if palette.pending && view.items.length === 0}
+    <p class="note">Searching…</p>
+  {:else if view.empty}
+    <p class="note">{view.empty}</p>
+  {/if}
+</div>
+
+<style>
+  .panel {
+    position: absolute;
+    z-index: 40;
+    top: 32px;
+    right: 0;
+    left: 0;
+    max-height: 420px;
+    overflow: auto;
+    background: var(--paper);
+    border: 1px solid var(--ink);
+  }
+
+  .hint {
+    margin: 0;
+    padding: 8px 10px;
+    border-bottom: 1px solid var(--rule-faint);
+    background: var(--paper-2);
+    color: var(--ink-2);
+    font-size: 12px;
+  }
+
+  .note {
+    margin: 0;
+    padding: 8px 10px;
+    color: var(--ink-3);
+    font-size: 12px;
+  }
+</style>

Some files were not shown because too many files changed in this diff