Просмотр исходного кода

feat(steps): draw all arms of conditional navigations as separate edges

Adds multi-arm navigation support: when a destination is produced by a conditional, every arm is now drawn as its own edge. Introduces helpers (hrefArms, destinationsForHref) and updates framework resolvers and edge creation to emit multiple navigates edges (via alsoTargets) instead of a single one. Also introduces per-app rooted route tables to avoid cross-app crossings, and updates various resolvers (React Router, TanStack Router, Vue Router, SvelteKit, Vue, and SvelteKit’s linker) and the UI to reflect multiple possible destinations. Tests and docs updated to reflect the new behavior, ensuring the Screens tab shows all possible navigation paths from conditional destinations. This makes navigation visualization more accurate for forked destinations.
Colby McHenry 5 дней назад
Родитель
Сommit
6f4887db80

+ 24 - 0
CHANGELOG.md

@@ -18,6 +18,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; `<Link href>`, an internal `<a href>`, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading.
 
+- **TanStack Router apps land on the Screens tab too.** Routes are read both ways a TanStack app declares them: file-based, where `createFileRoute('/posts/$postId')` carries the whole path as a literal, and code-based, where each `createRoute({ path, getParentRoute })` names a fragment that is composed through its parent into `/posts/$postId`. `navigate({ to })` from `useNavigate`, a thrown `redirect({ to })` from a loader or `beforeLoad`, and `<Link to>` / `<Navigate to>` are the transitions between them. TanStack is the one router here whose destination is the route PATTERN rather than a filled address — `<Link to="/posts/$postId" params={{ postId }}>` names the route and passes the values beside it — so a destination is read as a pattern and matched against the route it names. Addresses that are not pages are left off the map: a `_auth` segment is a pathless layout and never appears in the URL, a `(group)` folder is invisible, a `__root` route wraps everything without being a page, and a file that renders an `<Outlet />` is the layout around an address while the index route beside it is the page at it. A computed `to`, a pattern no route serves, and a `navigate({ search })` that only changes the query are left out rather than guessed. Re-index after upgrading.
+
+- **Vue Router and SvelteKit apps land on the Screens tab too.** Both drew their screens as islands with no transitions, so the tab stayed hidden; now the navigation between pages is read for each. **Vue:** the routes are read out of `createRouter({ routes: [...] })` — path, name, and the view each entry names, including a lazy `component: () => import('@/views/Login')` — and `router.push` / `router.replace` / `$router.push`, Nuxt's `navigateTo`, and `<router-link>` / `<RouterLink>` / `<NuxtLink>` are the transitions. Vue apps usually navigate by route NAME rather than by path, so `router.push({ name: 'profile' })` and `:to="{ name: 'profile' }"` resolve by name, and `router.push({ path: '/', query })` by path. **SvelteKit:** `goto('/login')`, `redirect(303, '/article/' + slug)` from a load or a form action — whose destination is its *second* argument, after the status — and the plain `<a href="/login">` that is a link in a SvelteKit app. A SvelteKit page also opens with a body now — it is joined to the page file that serves it and to the `+page.server.js` beside it — so its Steps picture draws its loader's work, its form actions, and the auth guard the loader performs (`redirect(302, '/login')` under `if (!locals.user)`) as a transition to the sign-in page, with the condition on the arrow. A computed destination, a path or name nothing declares, a relative path in a nested route, and a conditional whose two arms go to different pages are left out rather than guessed. Re-index after upgrading.
+
+- **A React Router app lands on the Screens tab too.** `<Route path='/payment' component={PaymentScreen}>` (v5), `<Route path='/payment' element={<PaymentScreen/>}>` (v6) and `createBrowserRouter([{ path, element }])` already named a project's screens; now the navigation between them is drawn as well. `history.push('/placeorder')` and `history.replace`, `navigate('/placeorder')` from `useNavigate`, `redirect()` in a loader or an action, and `<Link to>` / `<NavLink to>` / `<Navigate to>` / react-router-bootstrap's `<LinkContainer to>` each become a transition — attributed back to the screen it starts on, with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. A route with an optional parameter (`/cart/:id?`) is reached by both `/cart` and `/cart/5`. Until now a React Router project's screens were drawn as islands with no transitions at all, and a screen's Steps picture left out every page it sends you to — a checkout step showed its saved payment method but not that it goes on to place the order. A computed destination (`history.push(redirect)`), a path no route serves, and a relative path inside a nested route are left out rather than guessed, and an ordinary `paths.push('/x')` on an array is never mistaken for navigation. Re-index after upgrading.
+
 - **Double-click a box to go there.** On the Steps tab a double-click on any step starts the picture from it — the same as the panel's *Start here* — so an endpoint the page calls, or another screen drawn as a boundary, opens as its own chapter in one gesture; on the Screens tab a double-click on a screen opens what happens from it. A boundary's panel now says it is not entered rather than that nothing leaves it.
 
 - **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up.
@@ -34,6 +40,24 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- **A link written under a condition says so on the Screens tab.** A checkout stepper whose tabs are each enabled by their own prop, and a navbar whose admin links only render for an admin, both read as **always** — every transition written in markup was drawn with no condition at all, while the ones written as calls carried theirs. They are read the same way now: a store's checkout tabs say `step1` … `step4`, its navbar says `userInfo && userInfo.isAdmin` for the admin links and `!userInfo` for sign-in, and 59 of that store's 74 transitions carry the condition they actually run under, up from 20. A template language with no condition rules of its own still says nothing rather than guessing.
+
+- **A link in markup no longer reads as a helper's return value.** `<Link to='/shipping'>` was labelled `return /shipping`, which in this picture means the destination came back from somewhere else and was inferred. It is written right there, so it now reads `link /shipping` — and an internal `<a href>` reads `a`. Only a destination that genuinely arrives from elsewhere still says `return`.
+
+- **A link that goes to one of several places now draws all of them.** A destination written as a choice — `!isAdmin ? keyword ? \`/search/${keyword}/page/${x}\` : \`/page/${x}\` : \`/admin/productlist/${x}\`, which is how a paginator shared between a storefront and an admin list is written — used to draw nothing at all, because one edge carried one destination and picking an arm would have been a guess. Every arm is now its own transition, labelled with the path THAT arm takes, so a store's paginated addresses are on the map instead of sitting there unreachable. The same goes for `redirect(307, user ? \`/profile/@${user.username}\` : '/login')` in a loader, and for `router.push(cond ? '/a' : '/b')`. Arms that name the same route still make one transition, and an arm nothing can read still contributes nothing.
+
+- **API endpoints are no longer drawn on the Screens tab.** A store's thirty Express endpoints sat beside its nineteen pages as boxes nothing navigates to and nothing leaves — in a picture that is only about navigation — and they stretched the row of unreachable pages hundreds of boxes wide. A screen is now a route named by its path alone; a route named with the method that reaches it (`GET /api/orders`, `POST /api/users/login`, `ANY /api/users`) is a request, not somewhere a user can be. Every route still appears on Entry points, which is the list of everything a request or a user can arrive at.
+
+- **A screen you could reach but never leave.** Three separate things left a page's own navigation off the map, and a store's home and sign-in pages showed nothing leaving them. **One component, several addresses:** a screen rendered at more than one route — a listing page that is also the search and the paginated results — handed all of its navigation to whichever route happened to be written first, and the rest were drawn as dead ends; every address it serves now gets it. **A link written as a choice:** `<Link to={redirect ? \`/register?redirect=${redirect}\` : '/register'}>` is how a link that carries state is written, and markup was read by a weaker reader than calls were, so it saw nothing; both now use the same one. **A destination whose other half is unknowable:** `const redirect = location.search ? location.search.split('=')[1] : '/'` followed by `history.push(redirect)` is how every app sends a user on after signing in — the `/` is where it lands by default, and reading neither half lost the whole transition. Where both halves ARE readable and disagree, it is still a fork and still nothing.
+
+- **A conditional inside a conditional no longer reads the wrong arm.** A paginator written `!isAdmin ? keyword ? '/search/…' : '/page/…' : '/admin/…'` was split at the first `:` rather than the matching one, so an admin's page links pointed at the storefront's pagination. The arms are paired properly now, and a three-way choice — which is more destinations than one link can name — is left alone.
+
+- **In a repository with several apps, a link no longer points into a different one.** Every app has a `/` and most have a `/login`, and the route table was built for the whole repository at once, so whichever app was indexed first claimed each address — a `<Link to="/posts">` in one app resolved to another app's `/posts`. Measured on a monorepo of 477 apps: **82% of navigations pointed at a route belonging to a different app**, and all of them now point within their own. Screens transitions are also no longer attributed to an unrelated page when several routes are declared in one file, as a code-based route tree or an Express router file is.
+
+- **A SvelteKit layout is no longer a second screen at a page's address.** `+layout.svelte` and `+error.svelte` sit at the same path as the `+page.svelte` beside them and were each indexed as a route, so one address appeared in the index two and three times over. Only a page is a route now.
+
+- **A framework whose package lives in a subfolder is detected again.** In a project that keeps its dependencies one level down — a `frontend/` and a `backend/`, or an `apps/web/` — the framework check ran once before any file had been indexed, found no folders to look in, and remembered that empty answer for the rest of the run. Every React, React Router and Next.js behaviour that depends on knowing the framework is there silently did nothing for those projects.
+
 - **A server action, or any handler written inside a wrapper, draws what it really does.** `const signIn = validatedAction(schema, async (data) => { … })` showed one call out of nine, because the constant held a reference to its schema and that counted as having a body of its own. Its picture is now whole — the lookup, the early returns, the `Promise.all`, the redirect.
 
 - **A call is no longer followed to a same-named method of its own class.** `crypto.createHash('sha256').update(…)` inside a service that happens to have an `update` method was followed into that method, so a login endpoint read as though it updated the user, extra replies and all. A method of your own class is written `this.update(…)`; a receiver that is not `this` now ends the walk instead of guessing.

+ 1 - 0
CLAUDE.md

@@ -289,6 +289,7 @@ publish actions on shared state. Write the files, hand the user the commands.
 
 - The `0.7.x` line is in active multi-agent rollout. Any change to `src/installer/` (especially `targets/`) needs corresponding test coverage and a CHANGELOG entry — installer regressions break every new install silently.
 - When changing what the MCP tools do or how agents should use them, edit `src/mcp/server-instructions.ts` — it is the **single source of truth** for agent-facing tool guidance (issue #529). The installer no longer writes a duplicate instructions block into `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering, so there's nothing to keep in sync anymore. (The repo's own checked-in `.cursor/rules/codegraph.mdc` is dogfooding config — update it too if you use Cursor on this repo, but it ships nowhere.)
+- **Before adding or extending a router, a web framework, or a language's `WHEN` rules, read `docs/design/framework-coverage.md`.** It is the standing answer to "what is supported and what is left" across the three axes (route nodes → Entry points, `navigates` edges → Screens, branch-guard rules → the `WHEN` labels), with what each remaining item needs, the traps that have already cost debugging time, and the queries to re-verify it. Update it in the same change that moves a row.
 - CodeGraph provides **code context**, not product requirements. For new features, ask the user about UX, edge cases, and acceptance criteria — the graph won't tell you.
 - **When the user references issues, PR comments, or external reports, anchor them to a date and version before drawing conclusions.** Check the comment's `createdAt` against:
   - The **last released version** — `grep -m1 '^## \[' CHANGELOG.md` shows the top-of-file version (older releases follow). A comment dated before the latest `## [X.Y.Z] - YYYY-MM-DD` is reacting to *released* state — work that's only on `main` or on an unmerged branch doesn't apply.

+ 15 - 3
README.md

@@ -392,11 +392,23 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
 | **Axum / actix / Rocket** | `.route("/x", get(handler))` |
 | **ASP.NET** | `[HttpGet("/x")]` attributes on action methods |
 | **Vapor** | `app.get("x", use: handler)` |
-| **React Router** / **SvelteKit** | Route component nodes |
-| **Expo Router** | Every screen file under `app/` (`app/item/[id].tsx` → `/item/[id]`, groups stripped) becomes a route node bound to its default-export component; `router.push/replace/navigate('/path')`, template hrefs, and `{ pathname }` objects become `navigates` edges to the screen — so "where does tapping this go" is one hop in the graph |
-| **Vue Router** / **Nuxt** | `pages/` file-based routes, `server/api/` endpoints, route middleware |
 | **Astro** | `src/pages/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) |
 
+### Routers — routes *and* the navigation between them
+
+These frameworks additionally emit **`navigates`** edges: the function that sends a user somewhere is linked to the screen it names, so "where does tapping this go" is one hop in the graph rather than a search. Each reads a literal destination — a computed one, or a path no route serves, is left unresolved rather than guessed — and a link written in markup is marked as inferred.
+
+| Router | Routes from | Navigation from |
+|---|---|---|
+| **Expo Router** | Every screen file under `app/` (`app/item/[id].tsx` → `/item/[id]`, groups stripped), bound to its default-export component | `router.push` / `replace` / `navigate`, template hrefs, `{ pathname }` objects, and a helper's returned href |
+| **Next.js** | App Router `app/**/page.tsx` and Pages Router pages (`(group)` stripped, `[slug]` → `:slug`); `app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens | `router.push` / `replace` / `prefetch`, `redirect()` / `permanentRedirect()` in a server action or page, `NextResponse.redirect(new URL(…))` in middleware, `<Link href>` and internal `<a href>` |
+| **React Router** | `<Route path component/element>` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `<Link to>` / `<NavLink to>` / `<Navigate to>` / react-router-bootstrap's `<LinkContainer to>` |
+| **TanStack Router** | `createFileRoute('/posts/$postId')` (file-based) and `createRoute({ path, getParentRoute })` composed up its parent chain (code-based); `_pathless` segments, `(group)` folders, `__root` and `<Outlet/>` layouts are not addresses | `navigate({ to })`, a thrown `redirect({ to })`, `<Link to>` / `<Navigate to>` — where `to` is the route PATTERN and the values ride beside it in `params` |
+| **Vue Router** / **Nuxt** | `createRouter({ routes: [...] })` with the view each entry names, plus Nuxt `pages/` file-based routes, `server/api/` endpoints and route middleware | `router.push` / `replace`, `$router.push`, Nuxt's `navigateTo`, `<router-link>` / `<RouterLink>` / `<NuxtLink>` — **by route name** (`push({ name: 'profile' })`) as well as by path |
+| **SvelteKit** | `src/routes/**/+page.svelte` (`[slug]` → `:slug`, `[[opt]]` → `:opt?`), joined to the `+page.server.js` beside it so a loader's guard belongs to its page | `goto('/x')`, `redirect(status, '/x')` from a load or form action, and the plain `<a href>` that is a link in a SvelteKit app |
+
+In a repository holding several apps, each app's routes are matched only against navigation written inside that app.
+
 ---
 
 ## Mixed iOS / React Native / Expo bridging

+ 31 - 7
__tests__/expo-router.test.ts

@@ -140,11 +140,30 @@ describe('expo-router: readHrefArgument', () => {
     const r = read(src, 'navigate');
     expect(r?.path).toBe('/sheets/create-detection-item');
     expect(r?.display).toBe('/sheets/create-detection-item?folderId=${…}');
-    expect(r?.alternate?.path).toBe('/sheets/create-detection-item');
+    expect(r?.alternates?.map((a) => a.path)).toEqual(['/sheets/create-detection-item']);
   });
 
-  it('returns null when one arm of a conditional is not a literal', () => {
-    expect(read("router.push(ready ? '/home' : fallback)")).toBeNull();
+  it('reads the literal arm when the other is not one — a place the code demonstrably goes', () => {
+    // Both arms readable is a fork, and `pageForHref` resolves it only when
+    // they name the same route. One arm readable is not a fork: `/home` is
+    // somewhere this call goes, and reporting it is not a guess. Dropping it
+    // cost every react-router app its post-login transition, which is written
+    // `const redirect = search ? search.split('=')[1] : '/'`.
+    const r = read("router.push(ready ? '/home' : fallback)");
+    expect(r?.path).toBe('/home');
+    expect(r?.alternate).toBeUndefined();
+    expect(read("router.push(ready ? fallback : '/home')")?.path).toBe('/home');
+    // Neither arm readable is still nothing.
+    expect(read('router.push(ready ? a : b)')).toBeNull();
+  });
+
+  it('pairs the arms of a NESTED conditional, and keeps all three', () => {
+    // Taking the first `:` split this between `keyword` and '/page', reading
+    // '/page' — a real path, from the wrong arm of the wrong conditional. Paired
+    // properly it is a paginator that goes to one of three places, and the
+    // picture draws all three rather than none.
+    const r = read("router.push(!isAdmin ? keyword ? '/search' : '/page' : '/admin')");
+    expect([r?.path, ...(r?.alternates ?? []).map((a) => a.path)]).toEqual(['/search', '/page', '/admin']);
   });
 
   it('reads only the first argument', () => {
@@ -190,7 +209,7 @@ describe('expo-router: readHrefViaLocal', () => {
       '  }\n}';
     const r = viaLocal(src);
     expect(r?.path).toBe('/barcode-scan');
-    expect(r?.alternate?.path).toBe('/barcode-scan');
+    expect(r?.alternates?.map((a) => a.path)).toEqual(['/barcode-scan']);
   });
 
   it('reads a typed declaration and an Href object initializer', () => {
@@ -369,9 +388,14 @@ describe('expo-router: resolve', () => {
     expect(expoRouterResolver.resolve(ref('list.push', 12, 32), context)?.targetNodeId).toBe(routes[2]!.id);
   });
 
-  it('binds a conditional whose arms name the same screen, refuses one that forks', () => {
-    expect(expoRouterResolver.resolve(ref('router.push', 14, 33), context)?.targetNodeId).toBe(routes[2]!.id);
-    expect(expoRouterResolver.resolve(ref('router.push', 13, 27), context)).toBeNull();
+  it('binds a conditional whose arms name the same screen, and draws BOTH when they fork', () => {
+    const same = expoRouterResolver.resolve(ref('router.push', 14, 33), context);
+    expect(same?.targetNodeId).toBe(routes[2]!.id);
+    expect(same?.alsoTargets).toBeUndefined();
+    // A fork reaches both screens, and each becomes an edge of its own.
+    const forked = expoRouterResolver.resolve(ref('router.push', 13, 27), context);
+    expect(forked).not.toBeNull();
+    expect([forked!.targetNodeId, ...(forked!.alsoTargets ?? []).map((t) => t.targetNodeId)]).toHaveLength(2);
   });
 
   it('ignores refs that are not calls or not JS/TS', () => {

+ 11 - 1
__tests__/nextjs.test.ts

@@ -263,7 +263,9 @@ describe('nextjs: end to end', () => {
     const link = screens.links.find((l) => l.from === home.id && l.to === users.id)!;
     expect(link.via).toEqual([]);
     expect(link.synthesized).toBe(true);
-    expect(link.sites[0]).toMatchObject({ href: '/users', method: 'return' });
+    // Markup, not a return value: the destination is written right there, so
+    // the site keeps its own verb rather than reading as a helper's return.
+    expect(link.sites[0]).toMatchObject({ href: '/users', method: 'link' });
     const push = screens.links.find((l) => l.from === users.id && l.to === user.id)!;
     expect(push).toBeDefined();
     expect(push.via.map((v) => v.name)).toEqual(['NewUserForm', 'handleSubmit']);
@@ -274,6 +276,14 @@ describe('nextjs: end to end', () => {
     expect(screens.dropped).toBe(0);
   });
 
+  it('an endpoint is not a screen — the Screens tab is pages, Entry points is every route', async () => {
+    const screens = await buildScreens(cg, tmpDir);
+    // `GET /api/users` and `POST /api/users` are routes, and they are on the
+    // Entry points list — but a request is not somewhere a user can be.
+    expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/login', '/users', '/users/:id']);
+    expect(cg.getNodesByKind('route').some((r) => r.name === 'POST /api/users')).toBe(true);
+  });
+
   it('a page’s Steps picture fires from its load, crosses to the server action, and draws the pages it leads to as boundaries', async () => {
     const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/users').id }));
     expect(p.project).toBe('web');

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

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

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

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

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

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

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

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

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

@@ -451,6 +451,38 @@ pages only, from files under a Next app's root; `<Link href>` and an internal `<
 reads them and draws a dashed `navigates` edge from the component (`synthesizedBy: 'next-link'`, the site as `registeredAt`).
 `app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens (`POST /api/users`, `ANY /api/users`), so the same
 index is a web app: pages on this tab, endpoints in Entry points, and a page's Steps picture firing from its load.
+React Router (`frameworks/react.ts` reads the routes, `frameworks/react-router.ts` the navigation, `react-router-synthesizer.ts`
+the markup): `<Route path component/element>` (v5 and v6) and `createBrowserRouter([{ path, element }])` are the routes, already
+named `:param` the way this table wants them; `history.push` / `.replace`, `useNavigate`'s `navigate`, a data router's
+`router.navigate` and a loader's `redirect` read their argument with the same Expo readers, and `<Link to>` / `<NavLink to>` /
+`<Navigate to>` / `<LinkContainer to>` are markup a synthesizer reads (`synthesizedBy: 'react-router-link'`). Two things are the
+app's, not the router's: the receiver has to name a router, because an unqualified `push` is an array's; and the app ROOT a call
+is read from is everything before the declaring file's `src/` (proshop's routes are in `frontend/src/App.js`, its screens in
+`frontend/src/screens/`). An optional parameter is registered twice — `/cart/:id?` answers `/cart` and `/cart/5` — because the
+matcher pairs a route with an href of the same length. A nested route's relative path and a splat are not destinations.
+Vue Router (`frameworks/vue-router.ts`, `vue-router-synthesizer.ts`): the routes are `createRouter({ routes: [...] })`, walked as
+objects rather than pattern-matched — a `name` is written ABOVE the `path` it belongs to, so a window around each `path` hands an
+entry its predecessor's name — and bound to the view each names by a `calls` edge, because a `references` candidate list is
+filtered to the ref's own language family and a `.js` router config can never name a `.vue` component. Navigation is usually a
+NAME (`router.push({ name: 'profile' })`, `:to="{ name }"`), which no other framework here does, so the table carries a `byName`
+index built by re-reading the config files its own route nodes came from; a `{ path }` object and a bare string fall back to the
+shared href readers. SvelteKit (`frameworks/sveltekit-router.ts`, `sveltekit-synthesizer.ts`): only a `+page.svelte` is a route
+(a `+layout` and a `+error` sit at a page's address without being one); `goto` takes its path first and `redirect(status, path)`
+second, the one framework here that does; `<a href>` is the link component. A route is joined to the `+page.svelte`
+that serves it (`sveltekit-page`), because a SvelteKit route is derived from a file PATH and its component has no name of its own
+to reference — every page file's component is called `+page` — so the match is the file, not a name; without it a page had no
+body and opened as a lone box. The page is in turn joined to the `+page.server.js` beside it by `callback-synthesizer.ts`'s
+`svelteKitLoadEdges` — a `calls` edge to its load and to each form action, because the framework joins those two by the file
+system and not by a call, and a page's own auth guard is written in its loader.
+TanStack Router (`frameworks/tanstack-router.ts`, `tanstack-router-synthesizer.ts`): routes come from `createFileRoute('/x/$id')`
+(the whole path as a literal) and from `createRoute({ path, getParentRoute })` composed up its parent chain within the file;
+`$id` normalises to `:id`, a `_pathless` segment and a `(group)` are not in the URL, and neither a `__root` route nor a file that
+renders an `<Outlet/>` is a page — the index beside it is. Its `to` is the route PATTERN with the values in `params`, so a
+destination is normalised the way a route NAME is rather than read as a URL, and `navigate` / `redirect` take it under a `to`
+key. **Every table above is per-app** (`RootedRouteTable`, `routesForFile`): one table for a whole repository is wrong the moment
+it holds two apps, because each has a `/` and a `/login` and the first indexed claims the address — measured at 82% of
+navigations pointing into a different app on a 477-app monorepo. The `roots` list decides only whether to resolve; the per-root
+split decides which app's routes to match, longest root first.
 
 ### 3.13 Steps (`#/steps?anchor=<id>` | `?symbol=<name>`, `&depth=`)
 What happens from an anchor — a screen, a handler, any symbol — drawn with the Screens view's machinery (§3.12's

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

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

+ 5 - 4
scripts/try-repo.sh

@@ -24,20 +24,21 @@ export CODEGRAPH_TELEMETRY=0 DO_NOT_TRACK=1
 
 # name|url|what to look at (hash URLs relative to the viewer)
 PRESETS='
-proshop|https://github.com/bradtraversy/proshop_mern.git|Express + React (MERN). Steps: #/steps?symbol=login&through=1 — login → ⇢ POST /api/users/login → User.findOne → 401 rows; #/steps?symbol=ProductScreen&through=1; Entry points: 30 endpoints + 19 pages; the project reads as a web app.
+proshop|https://github.com/bradtraversy/proshop_mern.git|Express + React Router (MERN). Steps: #/steps?symbol=login&through=1 — login → ⇢ POST /api/users/login → User.findOne → 401 rows; #/steps?symbol=/payment — the bounce to /shipping WHEN !shippingAddress.address, the push to /placeorder, and the checkout nav tabs, each under the prop that enables it. Screens: #/screens — 19 pages wired by history.push and <LinkContainer to>. Entry points: 30 endpoints + 19 pages; the project reads as a web app.
 express-realworld|https://github.com/gothinkster/node-express-realworld-example-app.git|Express + Prisma (TypeScript). Steps: #/steps?symbol=POST%20/api/users/login — request → handler → prisma → response rows with their status codes.
 nest-samples|https://github.com/nestjs/nest.git|NestJS samples. Steps: #/steps?symbol=POST%20/audio/transcode (sample/26-queues: the job lands on @Process("transcode") as ⇠ transcode); the event emitter sample (30) pairs emit("order.created") with its @OnEvent listener; sample/02-gateways for @SubscribeMessage.
 nest-boilerplate|https://github.com/brocoders/nestjs-boilerplate.git|NestJS + TypeORM. Steps: #/steps?symbol=POST%20/api/v1/auth/email/login — guards on the class and method (FIRES FROM … after UseGuards), DI followed by declared type into the service, repository saves as data calls, thrown exceptions as response rows.
+tanstack|https://github.com/TanStack/router.git|TanStack Router: 477 example and e2e apps in ONE index — the app-root gating under load, where a link resolves within its own app and never into another. Look at examples/react/kitchen-sink-file-based (file-based: /profile from _auth.profile.tsx, /route-group from a (group) folder) and examples/react/basic (code-based: /posts/:postId composed through getParentRoute).
 next-saas-starter|https://github.com/leerob/next-saas-starter.git|Next.js App Router + server actions. Screens: #/screens — /sign-in → /dashboard via Login > signIn WHEN …, <Link>s, redirect(), NextResponse.redirect; Steps: #/steps?symbol=/dashboard&through=1 — FIRES FROM page load, handlers, useSWR("/api/team") → ⇢ GET /api/team.
 spring-petclinic|https://github.com/spring-projects/spring-petclinic.git|Spring (Java). Steps: #/steps?symbol=POST%20/owners/new — PreAuthorize-style guards, OwnerRepository owners → owners.save as the database, ResponseEntity / view replies with WHEN rows. Kotlin twin: spring-petclinic-kotlin.
 spring-petclinic-kotlin|https://github.com/spring-petclinic/spring-petclinic-kotlin.git|Spring (Kotlin). Same picture as spring-petclinic with Kotlin guards (if expressions, when).
-fastapi-template|https://github.com/fastapi/full-stack-fastapi-template.git|FastAPI. Entry points: 23 routes named by path (APIRouter prefixes composed); Steps: #/steps?symbol=POST%20/items — Depends(...) as the chain, session.add/commit as the database, HTTPException rows with status_code. (settings.API_V1_STR is a computed prefix and is left off, on purpose.)
+fastapi-template|https://github.com/fastapi/full-stack-fastapi-template.git|FastAPI + a TanStack Router frontend. Screens: #/screens — 8 frontend pages with their guards (/login and /signup bounce to / WHEN isLoggedIn(), /admin WHEN NOT user.is_superuser). Entry points: 23 routes named by path (APIRouter prefixes composed); Steps: #/steps?symbol=POST%20/items — Depends(...) as the chain, session.add/commit as the database, HTTPException rows with status_code. (settings.API_V1_STR is a computed prefix and is left off, on purpose.)
 dispatch|https://github.com/Netflix/dispatch.git|FastAPI, large. Steps on any router endpoint; expect guards and arguments for Python.
 clean-architecture|https://github.com/jasontaylordev/CleanArchitecture.git|ASP.NET Minimal API endpoint groups (C#). Entry points: 10 routes (POST /api/TodoItems, PUT /api/TodoItems/{id} …); Steps: #/steps?symbol=PUT%20/api/TodoItems/{id} — TypedResults replies as 204 · 400 rows with WHEN.
 eshoponweb|https://github.com/dotnet-architecture/eShopOnWeb.git|ASP.NET MVC + Minimal API (C#). Steps on a controller action or a MapGet endpoint; C# guards and arguments.
 bookstack|https://github.com/BookStackApp/BookStack.git|Laravel (PHP). Entry points: routes/web.php → controller methods; Steps draws handlers and effects (Eloquent, responses) but PHP has no WHEN / arguments / trigger rules yet — expect boxes without conditions.
-sveltekit-realworld|https://github.com/sveltejs/realworld.git|SvelteKit. Entry points: file routes with load() edges; Steps on a load or an action; the Screens tab stays hidden — goto() / <a href> navigation is not modelled yet.
-vue-realworld|https://github.com/gothinkster/vue-realworld-example-app.git|Vue. Steps on a handler: template @click bindings, Pinia/Vuex channels; the Screens tab stays hidden — router.push / <router-link> is not modelled yet.
+sveltekit-realworld|https://github.com/sveltejs/realworld.git|SvelteKit. Screens: #/screens — 10 pages wired by <a href>, goto() and redirect(status, path); /settings and /editor guard themselves in their +page.server.js loaders, drawn WHEN !locals.user. Entry points: file routes with load() edges; Steps on a load or an action.
+vue-realworld|https://github.com/gothinkster/vue-realworld-example-app.git|Vue Router. Screens: #/screens — 10 routes read from src/router/index.js, wired by router.push({ name }) and <router-link :to>, which navigate by route NAME rather than by path. Steps on a handler: template @click bindings, Pinia/Vuex channels.
 icecubes|https://github.com/Dimillian/IceCubesApp.git|SwiftUI (Swift). Steps on a view model method: Swift guards, network / storage effects; SwiftUI navigation is not a Screens picture yet.
 nowinandroid|https://github.com/android/nowinandroid.git|Jetpack Compose (Kotlin). Steps on a ViewModel method: Kotlin guards, DataStore / network effects; Compose navigation is not a Screens picture yet.
 '

+ 16 - 2
src/resolution/callback-synthesizer.ts

@@ -30,6 +30,10 @@ import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
 import { goframeRouteEdges } from './goframe-synthesizer';
 import { expoRouterReturnEdges } from './expo-router-synthesizer';
 import { nextLinkEdges } from './next-router-synthesizer';
+import { reactRouterLinkEdges } from './react-router-synthesizer';
+import { tanstackLinkEdges } from './tanstack-router-synthesizer';
+import { vueRouterLinkEdges } from './vue-router-synthesizer';
+import { svelteKitLinkEdges, svelteKitPageComponentEdges } from './sveltekit-synthesizer';
 import { createYielder, type MaybeYield } from './cooperative-yield';
 import { crossTierEdges } from './tier-synthesizer';
 import { enclosingFn, makeLineAt } from './synth-utils';
@@ -2050,11 +2054,16 @@ async function svelteKitLoadEdges(ctx: ResolutionContext, onYield: MaybeYield):
       const loaderFile = `${dir}${prefix}${ext}`;
       if (!allFiles.has(loaderFile)) continue;
       for (const hook of ctx.getNodesInFile(loaderFile)) {
-        if (!HOOK_KINDS.has(hook.kind) || !HOOKS.has(hook.name)) continue;
+        // `load` and `actions` by name, and every function the loader file
+        // declares — a form action is an arrow inside `actions`, and it is a
+        // node of its own (`default`, `logout`), where the redirect that ends
+        // the submission is actually written.
+        const named = HOOK_KINDS.has(hook.kind) && HOOKS.has(hook.name);
+        if (!named && hook.kind !== 'function' && hook.kind !== 'method') continue;
         edges.push({
           source: page.id,
           target: hook.id,
-          kind: 'references',
+          kind: 'calls',
           line: page.startLine,
           provenance: 'heuristic',
           metadata: {
@@ -3604,6 +3613,11 @@ export const SYNTH_PASSES: SynthPassDef[] = [
   { name: 'expoRouterReturnEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => expoRouterReturnEdges(c, y) },
   // `<Link href="/x">` / an internal `<a href>` — markup, not a call; the component navigates.
   { name: 'nextLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => nextLinkEdges(c, y) },
+  { name: 'reactRouterLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => reactRouterLinkEdges(c, y) },
+  { name: 'tanstackLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => tanstackLinkEdges(c, y) },
+  { name: 'vueRouterLinkEdges', gate: (has) => has('vue', ...JS_FAMILY), run: (_q, c, y) => vueRouterLinkEdges(c, y) },
+  { name: 'svelteKitPageEdges', gate: (has) => has('svelte'), run: (_q, c, y) => svelteKitPageComponentEdges(c, y) },
+  { name: 'svelteKitLinkEdges', gate: (has) => has('svelte'), run: (_q, c, y) => svelteKitLinkEdges(c, y) },
   { name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) },
 ];
 

+ 156 - 17
src/resolution/frameworks/expo-router.ts

@@ -264,8 +264,17 @@ export interface HrefLiteral {
   path: string;
   /** The literal as written, holes rendered as `${…}` — for the edge metadata. */
   display: string;
-  /** The other arm of a `cond ? a : b` argument, when the argument was one. */
-  alternate?: HrefLiteral;
+  /**
+   * The OTHER destinations, when the argument was a conditional. A link
+   * written `!isAdmin ? keyword ? '/search/…' : '/page/…' : '/admin/…'` names
+   * three places a user can end up, and each is drawn.
+   */
+  alternates?: HrefLiteral[];
+}
+
+/** Every destination an href names — itself first, then its other arms. */
+export function hrefArms(href: HrefLiteral): HrefLiteral[] {
+  return href.alternates?.length ? [href, ...href.alternates] : [href];
 }
 
 /** Index of the first `ch` at bracket depth 0 and outside strings, or -1. */
@@ -307,6 +316,34 @@ export function toHref(literal: string | null): HrefLiteral | null {
  * with a literal `pathname`, or a conditional whose two arms are each one of
  * those (`cond ? \`/x?id=${id}\` : '/x'`). Anything else is not static.
  */
+/**
+ * The `:` that closes the ternary opened at `q`, honouring nested ones.
+ *
+ * Taking the FIRST `:` splits `a ? b ? '/x' : '/y' : '/z'` between `b` and
+ * `'/y'`, which reads as `'/y'` — a real path, from the wrong arm. A paginator
+ * written that way (`!isAdmin ? keyword ? … : '/page/…' : '/admin/…'`) then
+ * pointed an admin's page links at the storefront's pagination. With the arms
+ * paired correctly the expression is a three-way fork, and a fork is nothing.
+ */
+function ternaryColon(s: string, q: number): number {
+  let depth = 0;
+  let i = q + 1;
+  for (let steps = 0; steps < 64; steps++) {
+    const nextQ = indexAtDepth0(s, '?', i);
+    const nextColon = indexAtDepth0(s, ':', i);
+    if (nextColon < 0) return -1;
+    if (nextQ >= 0 && nextQ < nextColon) {
+      depth++;
+      i = nextQ + 1;
+      continue;
+    }
+    if (depth === 0) return nextColon;
+    depth--;
+    i = nextColon + 1;
+  }
+  return -1;
+}
+
 export function parseHrefExpression(expr: string): HrefLiteral | null {
   // `expr as any` / `expr satisfies Href` — a cast says nothing about the value.
   let args = expr.trim().replace(/\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/, '');
@@ -317,12 +354,20 @@ export function parseHrefExpression(expr: string): HrefLiteral | null {
   if (args.length === 0) return null;
   const q = indexAtDepth0(args, '?', 0);
   if (q > 0) {
-    const colon = indexAtDepth0(args, ':', q + 1);
+    const colon = ternaryColon(args, q);
     if (colon > q) {
       const yes = parseHrefExpression(args.slice(q + 1, colon));
       const no = parseHrefExpression(args.slice(colon + 1));
-      if (yes && no) return { ...yes, alternate: no };
-      return null;
+      // Every arm is a destination, flattened — an arm that is itself a
+      // conditional contributes its own arms rather than being reduced to one.
+      // `const redirect = location.search ? location.search.split('=')[1] : '/'`
+      // then `history.push(redirect)` contributes just the `/`, which is where
+      // that lands by default; reading neither arm lost the whole transition.
+      const arms = [...(yes ? hrefArms(yes) : []), ...(no ? hrefArms(no) : [])];
+      const head = arms[0];
+      if (!head) return null;
+      const rest = arms.slice(1);
+      return rest.length ? { path: head.path, display: head.display, alternates: rest } : { path: head.path, display: head.display };
     }
   }
   if (args[0] === '{') {
@@ -356,6 +401,25 @@ export function firstArgumentText(
   line: number,
   column: number,
   method: string
+): string | null {
+  return nthArgumentText(lines, line, column, method, 0);
+}
+
+/**
+ * The source text of a call's nth argument (0-based), or null when there is
+ * no call there or it has too few arguments.
+ *
+ * Most navigation calls put the destination first; SvelteKit's
+ * `redirect(303, '/login')` puts the status there, so the reader has to be
+ * able to take the second. A `,` at depth 0 separates arguments — inside
+ * parens, brackets, braces or a template it is part of one.
+ */
+export function nthArgumentText(
+  lines: readonly string[],
+  line: number,
+  column: number,
+  method: string,
+  index: number
 ): string | null {
   const first = line - 1;
   if (first < 0 || first >= lines.length) return null;
@@ -366,8 +430,12 @@ export function firstArgumentText(
   while (open < text.length && /\s/.test(text[open]!)) open++;
   if (text[open] !== '(') return null;
   const close = matchParen(text, open);
-  const args = text.slice(open + 1, close < 0 ? undefined : close);
-  // Only the first argument: a `,` at depth 0 ends it (`push(href, opts)`).
+  let args = text.slice(open + 1, close < 0 ? undefined : close);
+  for (let i = 0; i < index; i++) {
+    const comma = indexAtDepth0(args, ',', 0);
+    if (comma < 0) return null;
+    args = args.slice(comma + 1);
+  }
   const comma = indexAtDepth0(args, ',', 0);
   return comma < 0 ? args : args.slice(0, comma);
 }
@@ -439,6 +507,69 @@ function balanced(s: string): boolean {
   return depth <= 0;
 }
 
+/**
+ * A route table split by the app each route belongs to.
+ *
+ * One table for a whole repository is wrong the moment the repository holds
+ * more than one app: every app has a `/`, most have a `/login`, and a global
+ * `exact` map keeps whichever was indexed first — so a `<Link to="/posts">`
+ * in one app resolves to another app's `/posts`. Measured on the TanStack
+ * Router monorepo (477 apps in one index): **82% of navigations pointed at a
+ * route belonging to a different app.** Gating on the roots decides only
+ * WHETHER to resolve; the table has to decide WHICH app's routes to match.
+ */
+export interface RootedRouteTable<T extends RouteTable = RouteTable> {
+  /** Identity of the node array the table was built from — rebuild when it changes. */
+  source: readonly Node[];
+  /** App root (`apps/web/`, `''`) → the routes that app serves. */
+  byRoot: Map<string, T>;
+}
+
+/**
+ * The routes of the app `filePath` belongs to, or null when it is under none.
+ *
+ * Longest root wins, so an app nested inside another resolves to the nested
+ * one; a root of `''` is a single-app repo, and covers every file.
+ */
+export function routesForFile<T extends RouteTable>(
+  table: RootedRouteTable<T>,
+  filePath: string
+): T | null {
+  let best: T | null = null;
+  let bestLen = -1;
+  for (const [root, routes] of table.byRoot) {
+    if (root.length > bestLen && filePath.startsWith(root)) {
+      best = routes;
+      bestLen = root.length;
+    }
+  }
+  return best;
+}
+
+/** Register `path` → `node` in one app's table. The first route to claim an address keeps it. */
+export function addRouteTo(table: RouteTable, path: string, node: Node): void {
+  if (!table.exact.has(path)) table.exact.set(path, node);
+  if (path.includes(':')) table.dynamic.push({ node, segs: path.split('/').slice(1) });
+}
+
+/**
+ * The directory the app owning `filePath` lives in — what a navigation call is
+ * gated on, so a `push` in one package of a monorepo cannot name another
+ * package's routes.
+ *
+ * The first conventional source directory ends it: proshop keeps its routes in
+ * `frontend/src/App.js` and its screens in `frontend/src/screens/`, so the root
+ * is `frontend/`; `src/routes/login/+page.svelte` and `pages/index.vue` are
+ * both a repo-root app, whose root is `''` — every file, exactly as a Next app
+ * at the repo root is. A file under no such directory owns only its own folder.
+ */
+export function appRootFor(filePath: string): string {
+  const m = /^((?:[^/]+\/)*?)(?:src|pages|app|routes)\//.exec(filePath);
+  if (m) return m[1]!;
+  const slash = filePath.lastIndexOf('/');
+  return slash < 0 ? '' : filePath.slice(0, slash + 1);
+}
+
 // =============================================================================
 // Route table
 // =============================================================================
@@ -651,21 +782,29 @@ export const expoRouterResolver: FrameworkResolver = {
     }
     if (!href) return null;
     const table = routeTable(context);
-    const segs = normalizeHrefPath(href.path, ref.filePath);
-    if (segs === null) return null;
-    const target = matchRoute(segs, table);
-    if (!target) return null;
-    if (href.alternate) {
-      // `cond ? a : b` — one edge can carry one destination. Both arms
-      // reaching the same screen (a query-string difference, typically) is a
-      // confident bind; two different screens is a fork this ref can't record.
-      const altSegs = normalizeHrefPath(href.alternate.path, ref.filePath);
-      if (altSegs === null || matchRoute(altSegs, table)?.id !== target.id) return null;
+    // `cond ? a : b` names a screen per arm, and the user reaches every one of
+    // them; the extra arms ride along as `alsoTargets` and become edges of
+    // their own. A relative href is resolved against the screen it sits in,
+    // which is why this matches its own way rather than through `pagesForHref`.
+    const targets: Node[] = [];
+    const seen = new Set<string>();
+    for (const arm of hrefArms(href)) {
+      const segs = normalizeHrefPath(arm.path, ref.filePath);
+      if (segs === null) continue;
+      const hit = matchRoute(segs, table);
+      if (!hit || seen.has(hit.id)) continue;
+      seen.add(hit.id);
+      targets.push(hit);
     }
+    const target = targets[0];
+    if (!target) return null;
 
     return {
       original: ref,
       targetNodeId: target.id,
+      ...(targets.length > 1
+        ? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.id, metadata: { href: href.display, navMethod: method } })) }
+        : {}),
       confidence: 0.95,
       resolvedBy: 'framework',
       edgeKind: 'navigates',

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

@@ -12,6 +12,10 @@ import { expressResolver } from './express';
 import { nestjsResolver } from './nestjs';
 import { reactResolver } from './react';
 import { nextjsResolver } from './nextjs';
+import { reactRouterResolver } from './react-router';
+import { tanstackRouterResolver } from './tanstack-router';
+import { vueRouterResolver } from './vue-router';
+import { svelteKitRouterResolver } from './sveltekit-router';
 import { svelteResolver } from './svelte';
 import { vueResolver } from './vue';
 import { astroResolver } from './astro';
@@ -43,10 +47,18 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   expressResolver,
   nestjsResolver,
   reactResolver,
+  // React Router — `<Route path>` routes are `reactResolver`'s; `history.push('/x')` / `navigate('/x')` → navigates edges
+  reactRouterResolver,
+  // TanStack Router — `createFileRoute('/x')` / `createRoute({ path })` → route nodes; `navigate({ to })` → navigates edges
+  tanstackRouterResolver,
   // Next.js — `app/**/page.tsx` + `pages/**` → route nodes; `route.ts` exports → endpoints; `router.push('/x')` / `redirect('/x')` → navigates edges
   nextjsResolver,
   svelteResolver,
+  // SvelteKit — `src/routes/**/+page.svelte` routes are `svelteResolver`'s; `goto('/x')` / `redirect(303, '/x')` → navigates edges
+  svelteKitRouterResolver,
   vueResolver,
+  // Vue Router — `createRouter({ routes })` → route nodes; `router.push({ name })` / `router.push('/x')` → navigates edges
+  vueRouterResolver,
   astroResolver,
   // Python
   djangoResolver,
@@ -142,6 +154,10 @@ export { laravelResolver, FACADE_MAPPINGS } from './laravel';
 export { expressResolver } from './express';
 export { nestjsResolver } from './nestjs';
 export { reactResolver } from './react';
+export { reactRouterResolver } from './react-router';
+export { tanstackRouterResolver } from './tanstack-router';
+export { vueRouterResolver } from './vue-router';
+export { svelteKitRouterResolver } from './sveltekit-router';
 export { svelteResolver } from './svelte';
 export { vueResolver } from './vue';
 export { astroResolver } from './astro';

+ 42 - 13
src/resolution/frameworks/nextjs.ts

@@ -42,6 +42,7 @@ import { stripCommentsForRegex } from '../strip-comments';
 import { dependsOn } from './package-deps';
 import {
   HOLE,
+  hrefArms,
   defaultExportName,
   firstArgumentText,
   matchRoute,
@@ -164,17 +165,39 @@ function decode(s: string): string {
   }
 }
 
-/** The page an href names in this table, or null when none or several do (a fork), exactly as Expo Router decides. */
-export function pageForHref(href: HrefLiteral, table: RouteTable): Node | null {
-  const segs = hrefSegments(href);
-  if (segs === null) return null;
-  const target = matchRoute(segs, table);
-  if (!target) return null;
-  if (href.alternate) {
-    const alt = hrefSegments(href.alternate);
-    if (alt === null || matchRoute(alt, table)?.id !== target.id) return null;
+/** A route a destination names, with the arm that named it — so each edge says the path it took. */
+export interface HrefDestination {
+  node: Node;
+  /** The arm of the expression this route came from; its `display` is the edge's href. */
+  href: HrefLiteral;
+}
+
+/**
+ * Every route a destination names — one per arm of a conditional, deduped.
+ *
+ * Each carries its OWN arm, because an edge that says
+ * `/search/${…}/page/${…}` while pointing at `/admin/productlist/:pageNumber`
+ * names a path it did not take.
+ */
+export function destinationsForHref(href: HrefLiteral, table: RouteTable): HrefDestination[] {
+  const out: HrefDestination[] = [];
+  const seen = new Set<string>();
+  for (const arm of hrefArms(href)) {
+    const segs = hrefSegments(arm);
+    if (segs === null) continue;
+    const target = matchRoute(segs, table);
+    // An arm naming no route drops out; the arms that DO name one are still
+    // places this navigation goes.
+    if (!target || seen.has(target.id)) continue;
+    seen.add(target.id);
+    out.push({ node: target, href: arm });
   }
-  return target;
+  return out;
+}
+
+/** The single route an href names, or null when it names none. The first arm wins a fork. */
+export function pageForHref(href: HrefLiteral, table: RouteTable): Node | null {
+  return destinationsForHref(href, table)[0]?.node ?? null;
 }
 
 // =============================================================================
@@ -306,15 +329,21 @@ export const nextjsResolver: FrameworkResolver = {
       href = readHrefViaLocal(lines, ref.line, ref.column, callee, start);
     }
     if (!href) return null;
-    const target = pageForHref(href, table);
+    // Every arm of a conditional destination is somewhere this call goes; the
+    // first is this reference's resolution and the rest ride as `alsoTargets`.
+    const targets = destinationsForHref(href, table);
+    const target = targets[0];
     if (!target) return null;
     return {
       original: ref,
-      targetNodeId: target.id,
+      targetNodeId: target.node.id,
+      ...(targets.length > 1
+        ? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) }
+        : {}),
       confidence: 0.95,
       resolvedBy: 'framework',
       edgeKind: 'navigates',
-      metadata: { href: href.display, navMethod: verb },
+      metadata: { href: target.href.display, navMethod: verb },
     };
   },
 };

+ 123 - 0
src/resolution/frameworks/object-literal.ts

@@ -0,0 +1,123 @@
+/**
+ * Walking an object literal in source, for the framework resolvers whose route
+ * table IS an object literal.
+ *
+ * Vue's `routes: [{ name, path, component }]`, TanStack Router's
+ * `createRoute({ path, getParentRoute, component })` and its
+ * `createFileRoute('/x')({ component })` all state a route as a JavaScript
+ * object, and reading one field out of a WINDOW around another is wrong in a
+ * way that is silent: a Vue `name` is written above the `path` it belongs to,
+ * so a window around each path hands an entry its predecessor's name — every
+ * route in vue-realworld came out pointing one entry too far down.
+ *
+ * So each object is matched as a unit and only its own depth-1 fields are
+ * read; nested objects, arrays, calls, strings and templates are stepped over
+ * rather than searched. This is a scanner, not a parser: it knows brackets,
+ * quotes and template interpolation, and nothing else about JavaScript.
+ */
+
+/** The index of the bracket, brace or paren closing the one at `open`, or -1. */
+export function matchBracket(s: string, open: number): number {
+  let depth = 0;
+  for (let i = open; i < s.length; i++) {
+    const ch = s[i]!;
+    if (ch === '"' || ch === "'" || ch === '`') {
+      const end = skipString(s, i);
+      if (end < 0) return -1;
+      i = end;
+      continue;
+    }
+    if (ch === '[' || ch === '{' || ch === '(') depth++;
+    else if (ch === ']' || ch === '}' || ch === ')') {
+      depth--;
+      if (depth === 0) return i;
+    }
+  }
+  return -1;
+}
+
+/** The index of the quote closing the string or template opened at `at`, or -1. */
+export function skipString(s: string, at: number): number {
+  const quote = s[at]!;
+  for (let i = at + 1; i < s.length; i++) {
+    const ch = s[i]!;
+    if (ch === '\\') {
+      i++;
+      continue;
+    }
+    if (ch === quote) return i;
+    if (quote === '`' && ch === '$' && s[i + 1] === '{') {
+      const end = matchBracket(s, i + 1);
+      if (end < 0) return -1;
+      i = end;
+    }
+  }
+  return -1;
+}
+
+/** Each `{…}` written directly in `[from, to)`, as its own extent. */
+export function* topLevelObjects(s: string, from: number, to: number): Generator<{ start: number; end: number }> {
+  for (let i = from; i < to; i++) {
+    const ch = s[i]!;
+    if (ch === '"' || ch === "'" || ch === '`') {
+      const end = skipString(s, i);
+      if (end < 0) return;
+      i = end;
+      continue;
+    }
+    if (ch === '{') {
+      const end = matchBracket(s, i);
+      if (end < 0) return;
+      yield { start: i, end };
+      i = end;
+    }
+  }
+}
+
+/** An object's own fields — key to its value text and where the key sits. Nested structures are stepped over. */
+export function readFields(s: string, start: number, end: number): Map<string, { text: string; at: number }> {
+  const out = new Map<string, { text: string; at: number }>();
+  let i = start + 1;
+  while (i < end) {
+    const ch = s[i]!;
+    if (ch === '"' || ch === "'" || ch === '`') {
+      const close = skipString(s, i);
+      if (close < 0) return out;
+      i = close + 1;
+      continue;
+    }
+    if (ch === '{' || ch === '[' || ch === '(') {
+      const close = matchBracket(s, i);
+      if (close < 0) return out;
+      i = close + 1;
+      continue;
+    }
+    const key = /^([A-Za-z_$][\w$]*)\s*:/.exec(s.slice(i, i + 64));
+    if (!key) {
+      i++;
+      continue;
+    }
+    let j = i + key[0].length;
+    const valueAt = j;
+    while (j < end) {
+      const c = s[j]!;
+      if (c === '"' || c === "'" || c === '`') {
+        const close = skipString(s, j);
+        if (close < 0) return out;
+        j = close + 1;
+        continue;
+      }
+      if (c === '{' || c === '[' || c === '(') {
+        const close = matchBracket(s, j);
+        if (close < 0) return out;
+        j = close + 1;
+        continue;
+      }
+      if (c === ',') break;
+      j++;
+    }
+    if (!out.has(key[1]!)) out.set(key[1]!, { text: s.slice(valueAt, j), at: i });
+    i = j + 1;
+  }
+  return out;
+}

+ 17 - 4
src/resolution/frameworks/package-deps.ts

@@ -12,17 +12,30 @@ import type { ResolutionContext } from '../types';
 /** Nested manifests read per project, at most — a monorepo with hundreds of packages is sampled, not scanned. */
 const MAX_MANIFESTS = 24;
 
-const cache = new WeakMap<ResolutionContext, Set<string>>();
+/**
+ * Cached per context, keyed by how many files are indexed.
+ *
+ * The resolver is constructed — and every `detect()` runs once — BEFORE any
+ * file exists, so that first pass sees no directories to probe and reads only
+ * the root manifest. Caching that answer outright made the re-detect after
+ * indexing (`CodeGraph.indexAll`) a cache hit on the empty set, and every
+ * framework whose dependency lives one directory down stayed undetected: a
+ * proshop-shaped repo indexed its React Router routes (extraction is not
+ * gated on detection) and then resolved none of its navigation. Re-reading
+ * when the file count changes costs one manifest sweep per index.
+ */
+const cache = new WeakMap<ResolutionContext, { files: number; names: Set<string> }>();
 
 /** Every dependency name declared at the root or up to two directories down, de-duplicated. */
 export function declaredDependencies(context: ResolutionContext): Set<string> {
+  const files = context.getAllFiles();
   const cached = cache.get(context);
-  if (cached) return cached;
+  if (cached && cached.files === files.length) return cached.names;
   const names = new Set<string>();
   // The index lists source files, never manifests: the candidate directories
   // are the first one or two segments of what IS indexed, probed on disk.
   const dirs = new Set<string>();
-  for (const file of context.getAllFiles()) {
+  for (const file of files) {
     const segs = file.split('/');
     if (segs.length > 1) dirs.add(segs[0] + '/');
     if (segs.length > 2) dirs.add(segs[0] + '/' + segs[1] + '/');
@@ -45,7 +58,7 @@ export function declaredDependencies(context: ResolutionContext): Set<string> {
       // Not JSON — a template, a broken manifest; nothing to read.
     }
   }
-  cache.set(context, names);
+  cache.set(context, { files: files.length, names });
   return names;
 }
 

+ 202 - 0
src/resolution/frameworks/react-router.ts

@@ -0,0 +1,202 @@
+/**
+ * React Router — routes declared in markup, navigation written as a string.
+ *
+ * `frameworks/react.ts` already reads the route table out of the markup:
+ * `<Route path="/payment" component={PaymentScreen}/>` (v5),
+ * `<Route path="/payment" element={<PaymentScreen/>}/>` (v6) and
+ * `createBrowserRouter([{ path, element }])` (v6.4+) each become a `route`
+ * node named by its path, bound to the component that renders it. That is
+ * half of what "how does this app flow" means. This file is the other half.
+ *
+ * **Navigation is a string.** `history.push('/placeorder')` (v5, and the
+ * `useHistory` hook), `navigate('/placeorder')` (v6's `useNavigate`),
+ * `router.navigate(…)` on a data router, `redirect('/login')` from a loader
+ * or an action: the extractor records each as a call that resolves to
+ * nothing, because the target is a path, not a symbol. `resolve()` claims
+ * those refs, reads the argument off the source with the Expo Router readers
+ * (a string, a template with holes, a `{ pathname }` object, a conditional
+ * whose arms agree, a local `const href = …`), matches it against this
+ * framework's own route table, and returns a **`navigates`** edge carrying
+ * the href — the edge the Screens picture is drawn from and the step Steps
+ * draws as another page. `<Link to>` and `<Navigate to>` are JSX attributes
+ * rather than calls, so a synthesizer reads them instead
+ * (`react-router-synthesizer.ts`).
+ *
+ * Precision rests on the string naming a real route: a computed path, a path
+ * no route serves, and a conditional that forks are left unresolved rather
+ * than guessed. `push` and `replace` are two of the most common method names
+ * in JavaScript, so the receiver has to name a router — a bare `push` is an
+ * array's, and is never claimed.
+ *
+ * Known limits, both deliberate: a nested route's path is relative to its
+ * parent (`<Route path="team">` inside `<Route path="/dashboard">`), and the
+ * markup scan does not compose that tree, so only an absolute path is a
+ * destination an href can name; and a splat (`/admin/*`) matches anything, so
+ * it is never the answer to a concrete href.
+ */
+
+import type { Language, Node } from '../../types';
+import type { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
+import { dependsOn } from './package-deps';
+import {
+  addRouteTo,
+  appRootFor,
+  firstArgumentText,
+  parseHrefExpression,
+  readHrefViaLocal,
+  routesForFile,
+  type RootedRouteTable,
+  type RouteTable,
+} from './expo-router';
+// `pageForHref` is framework-agnostic — it takes any RouteTable and decides
+// which of its routes an href names (absolute URLs, holes, a conditional's
+// two arms). It lives in `nextjs.ts` because that is where it was first
+// needed; duplicating it here would be a second derivation of the same rule.
+import { destinationsForHref } from './nextjs';
+
+const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx'];
+
+// =============================================================================
+// Route table — the routes `frameworks/react.ts` read out of the markup
+// =============================================================================
+
+export type ReactRouterTable = RootedRouteTable;
+
+/** The app a route file belongs to — the shared rule (`appRootFor`). */
+export const reactRouterRoot = appRootFor;
+
+/**
+ * True for a route node `frameworks/react.ts` emitted, and no other.
+ *
+ * Its id is a verbatim reconstruction of the node's own fields, which no
+ * other framework's route id is: a server route carries its METHOD
+ * (`route:file:12:POST:/login`), a file-based page carries no line.
+ */
+function isReactRouterRoute(node: Node): boolean {
+  return (
+    (node.language === 'tsx' || node.language === 'jsx') &&
+    node.id === `route:${node.filePath}:${node.startLine}:${node.name}`
+  );
+}
+
+/** `:id?` — a parameter React Router serves the route with or without. */
+function isOptionalParam(seg: string): boolean {
+  return seg.startsWith(':') && seg.endsWith('?');
+}
+
+const tables = new WeakMap<ResolutionContext, ReactRouterTable>();
+
+export function reactRouterTable(context: ResolutionContext): ReactRouterTable {
+  const all = context.getNodesByKind('route');
+  const cached = tables.get(context);
+  if (cached && cached.source === all) return cached;
+  const byRoot = new Map<string, RouteTable>();
+  const shortened: { root: string; path: string; node: Node }[] = [];
+  const tableAt = (root: string): RouteTable => {
+    let t = byRoot.get(root);
+    if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] }));
+    return t;
+  };
+  for (const node of all) {
+    if (!isReactRouterRoute(node)) continue;
+    // A nested route's path is relative to its parent; without the tree it is
+    // not a destination. A splat matches everything, so it answers nothing.
+    if (!node.name.startsWith('/') || node.name.endsWith('*')) continue;
+    const root = reactRouterRoot(node.filePath);
+    const path = node.name.length > 1 && node.name.endsWith('/') ? node.name.slice(0, -1) : node.name;
+    addRouteTo(tableAt(root), path, node);
+    // React Router's optional parameter: `/cart/:id?` is the screen for
+    // `/cart/5` AND for a bare `/cart`, which the navbar's cart icon links
+    // to. The matcher pairs a route with an href of the same length, so the
+    // shorter form is its own entry — collected now, registered after every
+    // literal path, so a route someone actually wrote always wins.
+    let segs = path.split('/').slice(1);
+    while (segs.length > 1 && isOptionalParam(segs[segs.length - 1]!)) {
+      segs = segs.slice(0, -1);
+      shortened.push({ root, path: '/' + segs.join('/'), node });
+    }
+  }
+  for (const s of shortened) {
+    const t = byRoot.get(s.root);
+    if (t && !t.exact.has(s.path)) addRouteTo(t, s.path, s.node);
+  }
+  const table: ReactRouterTable = { source: all, byRoot };
+  tables.set(context, table);
+  return table;
+}
+
+// =============================================================================
+// Navigation calls
+// =============================================================================
+
+/**
+ * `history.push` / `.replace` (v5, `useHistory`), `navigate(…)` (v6,
+ * `useNavigate`), `router.navigate(…)` (a data router), `redirect(…)` (a
+ * loader or an action).
+ *
+ * The receiver is required for `push` / `replace`: an unqualified `push` is
+ * an array's, and claiming it would put every `paths.push('/tmp/x')` in the
+ * repo one string-match away from a route.
+ */
+const NAV_CALL = /^(?:history|navigate|router)\.(?:push|replace|navigate)$|^(?:navigate|redirect)$/;
+
+/** The verb a navigation call name stands for, or null. */
+export function reactRouterNavVerb(name: string): string | null {
+  if (!NAV_CALL.test(name)) return null;
+  const dot = name.lastIndexOf('.');
+  return dot < 0 ? name : name.slice(dot + 1);
+}
+
+// =============================================================================
+// The resolver
+// =============================================================================
+
+export const reactRouterResolver: FrameworkResolver = {
+  name: 'react-router',
+  languages: [...ROUTE_LANGUAGES],
+
+  detect(context: ResolutionContext): boolean {
+    return dependsOn(context, 'react-router', 'react-router-dom', 'react-router-native');
+  },
+
+  claimsReference(name: string): boolean {
+    return NAV_CALL.test(name);
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    if (ref.referenceKind !== 'calls') return null;
+    const verb = reactRouterNavVerb(ref.referenceName);
+    if (!verb) return null;
+    if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
+    const routes = routesForFile(reactRouterTable(context), ref.filePath);
+    if (!routes || routes.exact.size === 0) return null;
+    const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
+    if (!lines) return null;
+
+    const arg = firstArgumentText(lines, ref.line, ref.column, verb);
+    if (arg === null) return null;
+    let href = parseHrefExpression(arg);
+    if (!href) {
+      const enclosing = context.getNodeById?.(ref.fromNodeId);
+      const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40);
+      href = readHrefViaLocal(lines, ref.line, ref.column, verb, start);
+    }
+    if (!href) return null;
+    // Every arm of a conditional destination is somewhere this call goes; the
+    // first is this reference's resolution and the rest ride as `alsoTargets`.
+    const targets = destinationsForHref(href, routes);
+    const target = targets[0];
+    if (!target) return null;
+    return {
+      original: ref,
+      targetNodeId: target.node.id,
+      ...(targets.length > 1
+        ? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) }
+        : {}),
+      confidence: 0.95,
+      resolvedBy: 'framework',
+      edgeKind: 'navigates',
+      metadata: { href: target.href.display, navMethod: verb },
+    };
+  },
+};

+ 6 - 1
src/resolution/frameworks/svelte.ts

@@ -153,7 +153,12 @@ export const svelteResolver: FrameworkResolver = {
     const fileName = filePath.split(/[/\\]/).pop() || '';
     const routeMatch = getSvelteKitRouteInfo(fileName);
 
-    if (routeMatch) {
+    // Only a `+page.svelte` is a URL. A `+layout.svelte` and a `+error.svelte`
+    // sit at the same path as the page beside them, so emitting a route for
+    // them put the same address in the index two and three times over — one
+    // `/` for the page, one for the layout, one for the error page — which the
+    // Screens picture then drew as three separate screens.
+    if (routeMatch === 'page') {
       // Extract route path from directory structure
       // e.g., src/routes/blog/[slug]/+page.svelte -> /blog/:slug
       const routePath = filePathToSvelteKitRoute(filePath);

+ 169 - 0
src/resolution/frameworks/sveltekit-router.ts

@@ -0,0 +1,169 @@
+/**
+ * SvelteKit — pages are directories, navigation is a string.
+ *
+ * `frameworks/svelte.ts` already reads the route table out of the file tree:
+ * `src/routes/article/[slug]/+page.svelte` is `/article/:slug`,
+ * `[[optional]]` is `:optional?` and `[...rest]` is `*rest`. This file is the
+ * navigation half — without it a SvelteKit project's screens are drawn as
+ * islands and the Screens tab stays hidden, because it is a picture of
+ * `navigates` edges and there were none.
+ *
+ * Two calls carry a user from one page to another, and they do not agree on
+ * where the path goes:
+ *
+ *   goto('/login')                     // $app/navigation, in the browser
+ *   redirect(303, '/article/' + slug)  // @sveltejs/kit, from a load or an action
+ *
+ * `redirect` takes the status FIRST, so the destination is its second
+ * argument — the one difference from every other framework here. Both are
+ * read with the Expo Router readers (a string, a template with holes, a
+ * conditional whose arms agree, a local `const href = …`) and matched against
+ * this framework's own routes. `<a href="/login">` is markup rather than a
+ * call, so a synthesizer reads it (`sveltekit-link-synthesizer.ts`).
+ *
+ * Only `+page.svelte` is a screen. `+layout.svelte` and `+error.svelte` sit at
+ * the same path and would be a second screen for one URL; `+server.ts` is an
+ * endpoint, not a page. A computed destination, a path no page serves, and an
+ * external URL are left unresolved rather than guessed.
+ */
+
+import type { Language, Node } from '../../types';
+import type { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
+import { dependsOn } from './package-deps';
+import {
+  addRouteTo,
+  appRootFor,
+  nthArgumentText,
+  parseHrefExpression,
+  readHrefViaLocal,
+  routesForFile,
+  type RootedRouteTable,
+  type RouteTable,
+} from './expo-router';
+import { destinationsForHref } from './nextjs';
+
+const NAV_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'svelte'];
+
+// =============================================================================
+// Route table — the `+page.svelte` files `frameworks/svelte.ts` named
+// =============================================================================
+
+export type SvelteKitTable = RootedRouteTable;
+
+/** True for a page route node `frameworks/svelte.ts` emitted, and no other. */
+function isSvelteKitPage(node: Node): boolean {
+  return (
+    node.language === 'svelte' &&
+    node.filePath.endsWith('/+page.svelte') &&
+    node.id === `route:${node.filePath}:${node.name}:1`
+  );
+}
+
+/** `:id?` — a parameter SvelteKit serves the route with or without. */
+function isOptionalParam(seg: string): boolean {
+  return seg.startsWith(':') && seg.endsWith('?');
+}
+
+const tables = new WeakMap<ResolutionContext, SvelteKitTable>();
+
+export function svelteKitTable(context: ResolutionContext): SvelteKitTable {
+  const all = context.getNodesByKind('route');
+  const cached = tables.get(context);
+  if (cached && cached.source === all) return cached;
+  const byRoot = new Map<string, RouteTable>();
+  const shortened: { root: string; path: string; node: Node }[] = [];
+  const tableAt = (root: string): RouteTable => {
+    let t = byRoot.get(root);
+    if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] }));
+    return t;
+  };
+  for (const node of all) {
+    if (!isSvelteKitPage(node)) continue;
+    // `[...rest]` becomes `*rest`, which matches anything — never an answer.
+    if (!node.name.startsWith('/') || node.name.includes('*')) continue;
+    const root = appRootFor(node.filePath);
+    addRouteTo(tableAt(root), node.name, node);
+    // `[[optional]]` is `:x?`: the route serves the path with and without it.
+    let segs = node.name.split('/').slice(1);
+    while (segs.length > 1 && isOptionalParam(segs[segs.length - 1]!)) {
+      segs = segs.slice(0, -1);
+      shortened.push({ root, path: '/' + segs.join('/'), node });
+    }
+  }
+  for (const s of shortened) {
+    const t = byRoot.get(s.root);
+    if (t && !t.exact.has(s.path)) addRouteTo(t, s.path, s.node);
+  }
+  const table: SvelteKitTable = { source: all, byRoot };
+  tables.set(context, table);
+  return table;
+}
+
+// =============================================================================
+// Navigation calls
+// =============================================================================
+
+/** `goto('/x')` in the browser; `redirect(303, '/x')` from a load or an action. */
+const NAV_CALL = /^(goto|redirect)$/;
+
+/** Which argument of a navigation call is the destination — `redirect` puts the status first. */
+export function svelteKitHrefArgument(name: string): 0 | 1 | null {
+  if (name === 'goto') return 0;
+  if (name === 'redirect') return 1;
+  return null;
+}
+
+// =============================================================================
+// The resolver
+// =============================================================================
+
+export const svelteKitRouterResolver: FrameworkResolver = {
+  name: 'sveltekit-router',
+  languages: [...NAV_LANGUAGES],
+
+  detect(context: ResolutionContext): boolean {
+    return dependsOn(context, '@sveltejs/kit');
+  },
+
+  claimsReference(name: string): boolean {
+    return NAV_CALL.test(name);
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    // `import { redirect } from '@sveltejs/kit'` is not a navigation.
+    if (ref.referenceKind !== 'calls') return null;
+    const argIndex = svelteKitHrefArgument(ref.referenceName);
+    if (argIndex === null) return null;
+    if (!NAV_LANGUAGES.includes(ref.language)) return null;
+    const routes = routesForFile(svelteKitTable(context), ref.filePath);
+    if (!routes || routes.exact.size === 0) return null;
+    const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
+    if (!lines) return null;
+
+    const arg = nthArgumentText(lines, ref.line, ref.column, ref.referenceName, argIndex);
+    if (arg === null) return null;
+    let href = parseHrefExpression(arg);
+    if (!href && argIndex === 0) {
+      const enclosing = context.getNodeById?.(ref.fromNodeId);
+      const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40);
+      href = readHrefViaLocal(lines, ref.line, ref.column, ref.referenceName, start);
+    }
+    if (!href) return null;
+    // Every arm of a conditional destination is somewhere this call goes; the
+    // first is this reference's resolution and the rest ride as `alsoTargets`.
+    const targets = destinationsForHref(href, routes);
+    const target = targets[0];
+    if (!target) return null;
+    return {
+      original: ref,
+      targetNodeId: target.node.id,
+      ...(targets.length > 1
+        ? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: ref.referenceName } })) }
+        : {}),
+      confidence: 0.95,
+      resolvedBy: 'framework',
+      edgeKind: 'navigates',
+      metadata: { href: target.href.display, navMethod: ref.referenceName },
+    };
+  },
+};

+ 470 - 0
src/resolution/frameworks/tanstack-router.ts

@@ -0,0 +1,470 @@
+/**
+ * TanStack Router — the path is a literal, and so is the destination.
+ *
+ * Routes are declared two ways, and this reads both:
+ *
+ *   // file-based (the plugin's default): the full path is the argument
+ *   export const Route = createFileRoute('/dashboard/invoices/$invoiceId')({
+ *     component: InvoiceComponent,
+ *   })
+ *
+ *   // code-based: a path per route, composed through its parent
+ *   const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts' })
+ *   const postRoute  = createRoute({ getParentRoute: () => postsRoute, path: '$postId' })
+ *
+ * Three things are TanStack's own, and each one decides whether the picture is
+ * right:
+ *
+ * 1. **A parameter is `$id`, not `:id`.** Route names are normalised to the
+ *    `:id` every other framework here uses, so one matcher serves them all.
+ * 2. **`to` is the route PATTERN, not a filled URL.** `<Link to="/posts/$postId"
+ *    params={{ postId }}>` names the route and passes the values beside it —
+ *    where React Router would write `/posts/5`. So a destination is normalised
+ *    the same way a route name is, and then matches it exactly.
+ * 3. **A destination is an object.** `navigate({ to: '/' })`,
+ *    `throw redirect({ to: '/login' })` — the path is under a `to` key, and a
+ *    `navigate({ search: … })` with no `to` stays on the page it is on.
+ *
+ * Not every route file is a page. A segment written `_auth` is a pathless
+ * layout — it does not appear in the URL, and the file that declares it renders
+ * an outlet rather than a screen; a `(group)` segment is likewise invisible; a
+ * `dashboard.route.tsx` is the layout for the `/dashboard` subtree while
+ * `dashboard.index.tsx` — whose literal carries a trailing slash — is the page
+ * AT `/dashboard`. Drawing both would put one address on the map twice.
+ *
+ * Left unresolved rather than guessed: a computed `to`, a path no route serves,
+ * and a code-based route whose parent is declared in another file (the chain is
+ * composed within a file, which is where a route tree is written).
+ */
+
+import type { Language, Node } from '../../types';
+import type {
+  FrameworkExtractionResult,
+  FrameworkResolver,
+  ResolutionContext,
+  ResolvedRef,
+  UnresolvedRef,
+} from '../types';
+import { stripCommentsForRegex } from '../strip-comments';
+import { dependsOn } from './package-deps';
+import { matchBracket, readFields } from './object-literal';
+import {
+  addRouteTo,
+  appRootFor,
+  firstArgumentText,
+  parseHrefExpression,
+  readHrefViaLocal,
+  readStringAt,
+  routesForFile,
+  toHref,
+  type HrefLiteral,
+  type RootedRouteTable,
+  type RouteTable,
+} from './expo-router';
+import { destinationsForHref } from './nextjs';
+
+const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx'];
+
+// =============================================================================
+// Paths
+// =============================================================================
+
+/**
+ * A TanStack path in the form every other framework's routes take.
+ *
+ * `$invoiceId` is `:invoiceId` and a bare `$` is a splat; a `_auth` segment is
+ * a pathless layout and a `(group)` segment is a route group, neither of which
+ * appears in the URL; a trailing `_` un-nests without changing the segment.
+ * Returns null for a path that names no address at all.
+ */
+export function tanstackPath(raw: string): string | null {
+  if (!raw.startsWith('/')) return null;
+  const segs: string[] = [];
+  for (const seg of raw.split('/')) {
+    if (seg.length === 0) continue;
+    if (seg.startsWith('_')) continue; // pathless layout
+    if (seg.startsWith('(') && seg.endsWith(')')) continue; // route group
+    const bare = seg.endsWith('_') ? seg.slice(0, -1) : seg;
+    if (bare === '$') {
+      segs.push(':splat*');
+      continue;
+    }
+    segs.push(bare.startsWith('$') ? ':' + bare.slice(1) : bare);
+  }
+  return '/' + segs.join('/');
+}
+
+/**
+ * True when the literal names a pathless layout rather than a page.
+ *
+ * `'/_auth'` is the layout file itself — it renders an outlet, at no address
+ * of its own. `'/_auth/'` is the INDEX route inside that layout, and its
+ * address is whatever the layout sits at: `_layout/index.tsx` is a project's
+ * home page, and reading it as a layout dropped `/` from the map entirely.
+ */
+function isPathlessLayout(raw: string): boolean {
+  if (raw.length > 1 && raw.endsWith('/')) return false; // an index route, not the layout
+  const segs = raw.split('/').filter((s) => s.length > 0);
+  const last = segs[segs.length - 1];
+  return last !== undefined && last.startsWith('_');
+}
+
+/**
+ * True for a file that wraps a subtree rather than rendering a page at its own
+ * address.
+ *
+ * `<Outlet />` is where children render, so a route file that has one is the
+ * layout AROUND an address and the index route beside it is the page AT it —
+ * `_auth.invoices.tsx` and `_auth.invoices.index.tsx` both say `/invoices`,
+ * and drawing both puts one address on the map twice. The name `route.tsx`
+ * declares the same thing by convention, whether or not it draws an outlet.
+ *
+ * This is per-file on purpose: the alternative — a path that is a prefix of
+ * another route's — is only knowable once every file has been read, and by
+ * then the extra screen is already in the index.
+ */
+function isLayoutFile(filePath: string, content: string): boolean {
+  const base = filePath.slice(filePath.lastIndexOf('/') + 1);
+  if (/(?:^|\.)route\.(?:tsx|ts|jsx|js)$/.test(base)) return true;
+  return /<Outlet\b/.test(content);
+}
+
+function languageForFile(filePath: string): Language {
+  if (filePath.endsWith('.tsx')) return 'tsx';
+  if (filePath.endsWith('.jsx')) return 'jsx';
+  if (/\.(?:ts|mts|cts)$/.test(filePath)) return 'typescript';
+  return 'javascript';
+}
+
+// =============================================================================
+// Reading the routes
+// =============================================================================
+
+export interface TanstackRouteEntry {
+  /** `/dashboard/invoices/:invoiceId` — normalised the way the table wants it. */
+  path: string;
+  /** The component the route renders, when it names one. */
+  component: string | null;
+  /** True for the index route AT an address, which outranks the layout that wraps it. */
+  index: boolean;
+  /** True when the route came from `createFileRoute` — one route per file, so the file's own shape describes it. */
+  fileBased: boolean;
+  line: number;
+}
+
+/** The calls that declare a route — the cheap gate before parsing anything. */
+const ROUTE_FACTORY = /\bcreate(?:File|Lazy(?:File)?|Root)?Route\s*\(/;
+
+/**
+ * Every route a file declares, file-based and code-based alike.
+ *
+ * A code-based route's own `path` is a fragment (`posts`, `$postId`, `/`), so
+ * the chain of `getParentRoute: () => parent` is followed to compose the full
+ * address — within the file, which is where a route tree is written. A route
+ * that is another route's parent is the layout for that subtree, and the
+ * address belongs to the index route under it.
+ */
+export function parseTanstackRoutes(content: string): TanstackRouteEntry[] {
+  if (!ROUTE_FACTORY.test(content)) return [];
+  const safe = stripCommentsForRegex(content, 'typescript');
+  const out: TanstackRouteEntry[] = [];
+  const lineOf = (index: number): number => safe.slice(0, index).split('\n').length;
+
+  // ---- file-based: the path is the first argument, the options follow ----
+  const fileRoutes = /\bcreate(?:Lazy)?FileRoute\s*\(/g;
+  let f: RegExpExecArray | null;
+  while ((f = fileRoutes.exec(safe)) !== null) {
+    const open = f.index + f[0].length - 1;
+    const close = matchBracket(safe, open);
+    if (close < 0) continue;
+    const raw = readStringAt(safe.slice(open + 1, close).trimStart(), 0);
+    if (raw === null) continue;
+    const path = tanstackPath(raw);
+    if (path === null || isPathlessLayout(raw)) continue;
+    out.push({
+      path,
+      component: componentIn(chainAfter(safe, close + 1)),
+      // `createFileRoute('/dashboard/')` is the index page AT `/dashboard`;
+      // `createFileRoute('/dashboard')` is the layout around it.
+      index: raw.length > 1 && raw.endsWith('/'),
+      fileBased: true,
+      line: lineOf(f.index),
+    });
+  }
+
+  // ---- code-based: a fragment per route, composed through its parent ----
+  const decls = new Map<string, { path: string | null; parent: string | null; component: string | null; root: boolean; index: number }>();
+  const named = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]*?)?=\s*create(Root)?Route\s*\(\s*\{/g;
+  let d: RegExpExecArray | null;
+  while ((d = named.exec(safe)) !== null) {
+    const brace = safe.indexOf('{', d.index + d[0].length - 1);
+    const end = matchBracket(safe, brace);
+    if (end < 0) continue;
+    const fields = readFields(safe, brace, end);
+    const pathField = fields.get('path');
+    const path = d[2] ? '/' : pathField ? readStringAt(pathField.text.trimStart(), 0) : null;
+    const parentField = fields.get('getParentRoute');
+    const parent = parentField ? (/=>\s*([A-Za-z_$][\w$]*)/.exec(parentField.text)?.[1] ?? null) : null;
+    const componentField = fields.get('component');
+    decls.set(d[1]!, {
+      path,
+      parent,
+      component: componentField ? componentIn(componentField.text) : null,
+      root: d[2] !== undefined,
+      index: d.index,
+    });
+  }
+  // A route with an index child is the LAYOUT around that address; the child
+  // with `path: '/'` is what renders there. A parent with no index child still
+  // is the page at its own address — its outlet is simply empty.
+  const wrapsAnIndex = new Set(
+    [...decls.values()].filter((r) => r.path === '/' && r.parent !== null).map((r) => r.parent!)
+  );
+  for (const [name, decl] of decls) {
+    if (decl.path === null) continue; // a pathless layout contributes no address
+    // `createRootRoute` is the outermost layout — every page renders inside it,
+    // and the index route beside it is what renders at `/`. A `__root.tsx` that
+    // counted as a page put a second `/` on every file-based project's map.
+    if (decl.root) continue;
+    if (wrapsAnIndex.has(name)) continue;
+    const full = composePath(name, decls);
+    if (full === null) continue;
+    const path = tanstackPath(full);
+    if (path === null) continue;
+    out.push({ path, component: decl.component, index: decl.path === '/', fileBased: false, line: lineOf(decl.index) });
+  }
+  return out;
+}
+
+/** The address a code-based route sits at, following `getParentRoute` up. */
+function composePath(
+  name: string,
+  decls: Map<string, { path: string | null; parent: string | null }>
+): string | null {
+  const segs: string[] = [];
+  let cur: string | null = name;
+  for (let hops = 0; cur !== null && hops < 24; hops++) {
+    const decl: { path: string | null; parent: string | null } | undefined = decls.get(cur);
+    if (!decl) return null; // a parent declared in another file — not composed
+    if (decl.path !== null) {
+      const own = decl.path.split('/').filter((s) => s.length > 0);
+      segs.unshift(...own);
+    }
+    cur = decl.parent;
+  }
+  return '/' + segs.join('/');
+}
+
+/**
+ * The text of the call chain starting at `at` — `({ … })`, and any `.update({ … })`
+ * or `.lazy(…)` after it, which is where a route's component may be written.
+ */
+function chainAfter(s: string, at: number): string {
+  let i = at;
+  const start = i;
+  for (let steps = 0; steps < 8; steps++) {
+    while (i < s.length && /\s/.test(s[i]!)) i++;
+    if (s[i] === '.') {
+      i++;
+      while (i < s.length && /[\w$]/.test(s[i]!)) i++;
+      while (i < s.length && /\s/.test(s[i]!)) i++;
+    }
+    if (s[i] !== '(') break;
+    const close = matchBracket(s, i);
+    if (close < 0) break;
+    i = close + 1;
+  }
+  return s.slice(start, i);
+}
+
+/** The component a route names: an identifier, or the file a lazy import names. */
+function componentIn(text: string): string | null {
+  const lazy = /\bimport\s*\(\s*['"`]([^'"`]+)['"`]/.exec(text);
+  if (lazy) return (lazy[1]!.split('/').pop() ?? '').replace(/\.\w+$/, '') || null;
+  return /(?:^|[^\w$])component\s*:\s*([A-Z][A-Za-z0-9_]*)/.exec(text)?.[1] ?? /^\s*([A-Z][A-Za-z0-9_]*)\s*$/.exec(text)?.[1] ?? null;
+}
+
+/** The id a TanStack route carries — a verbatim reconstruction, so the table can recognise its own. */
+function routeId(filePath: string, line: number, path: string): string {
+  return `route:${filePath}:${line}:${path}:tanstack`;
+}
+
+// =============================================================================
+// Route table
+// =============================================================================
+
+export type TanstackTable = RootedRouteTable;
+
+/** True for a route node this resolver emitted, and no other. */
+function isTanstackRoute(node: Node): boolean {
+  return node.id === routeId(node.filePath, node.startLine, node.name);
+}
+
+const tables = new WeakMap<ResolutionContext, TanstackTable>();
+
+export function tanstackTable(context: ResolutionContext): TanstackTable {
+  const all = context.getNodesByKind('route');
+  const cached = tables.get(context);
+  if (cached && cached.source === all) return cached;
+  const byRoot = new Map<string, RouteTable>();
+  for (const node of all) {
+    if (!isTanstackRoute(node)) continue;
+    const root = appRootFor(node.filePath);
+    let t = byRoot.get(root);
+    if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] }));
+    addRouteTo(t, node.name, node);
+  }
+  const table: TanstackTable = { source: all, byRoot };
+  tables.set(context, table);
+  return table;
+}
+
+// =============================================================================
+// Navigation calls
+// =============================================================================
+
+/** `navigate({ to })` from `useNavigate`, `router.navigate({ to })`, and a thrown `redirect({ to })`. */
+const NAV_CALL = /^(?:navigate|redirect)$|^(?:router|Route)\.navigate$/;
+
+/** The verb a navigation call name stands for, or null. */
+export function tanstackNavVerb(name: string): string | null {
+  if (!NAV_CALL.test(name)) return null;
+  const dot = name.lastIndexOf('.');
+  return dot < 0 ? name : name.slice(dot + 1);
+}
+
+/**
+ * The destination in a TanStack navigation: `{ to: '/posts/$postId' }`.
+ *
+ * `to` is the route pattern, so it is normalised exactly as a route name is
+ * and then names that route. A `navigate({ search: … })` with no `to` is a
+ * change of search parameters on the page the user is already on.
+ */
+export function tanstackDestination(expr: string): HrefLiteral | null {
+  const args = expr.trim();
+  const literal = args[0] === '{' ? toKeyOf(args) : readStringAt(args, 0);
+  if (literal === null) return null;
+  const path = tanstackPath(literal);
+  return path === null ? parseHrefExpression(args) : toHref(path);
+}
+
+/** The `to:` value of an object destination, or null when it has none or it is computed. */
+function toKeyOf(args: string): string | null {
+  const end = matchBracket(args, 0);
+  if (end < 0) return null;
+  const field = readFields(args, 0, end).get('to');
+  return field ? readStringAt(field.text.trimStart(), 0) : null;
+}
+
+// =============================================================================
+// The resolver
+// =============================================================================
+
+export const tanstackRouterResolver: FrameworkResolver = {
+  name: 'tanstack-router',
+  languages: [...ROUTE_LANGUAGES],
+
+  detect(context: ResolutionContext): boolean {
+    return dependsOn(
+      context,
+      '@tanstack/react-router',
+      '@tanstack/solid-router',
+      '@tanstack/router',
+      '@tanstack/react-start',
+      '@tanstack/start'
+    );
+  },
+
+  claimsReference(name: string): boolean {
+    return NAV_CALL.test(name);
+  },
+
+  extract(filePath: string, content: string): FrameworkExtractionResult {
+    // A file-based route file describes ONE route, so the file's own shape says
+    // whether that route is a page. A file holding a code-based route TREE
+    // describes many, and its root component draws the outlet they render into
+    // — judging that file by the same rule would drop every route in it.
+    const layout = isLayoutFile(filePath, content);
+    const entries = parseTanstackRoutes(content).filter((e) => !(e.fileBased && layout));
+    if (entries.length === 0) return { nodes: [], references: [] };
+    const language = languageForFile(filePath);
+    const now = Date.now();
+    const nodes: Node[] = [];
+    const references: UnresolvedRef[] = [];
+    // An index route is the page AT its address; a layout at the same address
+    // wraps it. One address, one screen — the index wins it.
+    const byPath = new Map<string, TanstackRouteEntry>();
+    for (const entry of entries) {
+      const held = byPath.get(entry.path);
+      if (!held || (entry.index && !held.index)) byPath.set(entry.path, entry);
+    }
+    for (const entry of byPath.values()) {
+      const node: Node = {
+        id: routeId(filePath, entry.line, entry.path),
+        kind: 'route',
+        name: entry.path,
+        qualifiedName: `${filePath}::route:${entry.path}`,
+        filePath,
+        startLine: entry.line,
+        endLine: entry.line,
+        startColumn: 0,
+        endColumn: 0,
+        language,
+        updatedAt: now,
+      };
+      nodes.push(node);
+      if (entry.component) {
+        // `calls`, as every component-backed screen binds: a `references`
+        // candidate list is filtered to the ref's own language family.
+        references.push({
+          fromNodeId: node.id,
+          referenceName: entry.component,
+          referenceKind: 'calls',
+          line: entry.line,
+          column: 0,
+          filePath,
+          language,
+          candidates: [entry.component],
+        });
+      }
+    }
+    return { nodes, references };
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    if (ref.referenceKind !== 'calls') return null;
+    const verb = tanstackNavVerb(ref.referenceName);
+    if (!verb) return null;
+    if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
+    const routes = routesForFile(tanstackTable(context), ref.filePath);
+    if (!routes || routes.exact.size === 0) return null;
+    const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
+    if (!lines) return null;
+
+    const arg = firstArgumentText(lines, ref.line, ref.column, verb);
+    if (arg === null) return null;
+    let href = tanstackDestination(arg);
+    if (!href) {
+      const enclosing = context.getNodeById?.(ref.fromNodeId);
+      const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40);
+      href = readHrefViaLocal(lines, ref.line, ref.column, verb, start);
+    }
+    if (!href) return null;
+    // Every arm of a conditional destination is somewhere this call goes; the
+    // first is this reference's resolution and the rest ride as `alsoTargets`.
+    const targets = destinationsForHref(href, routes);
+    const target = targets[0];
+    if (!target) return null;
+    return {
+      original: ref,
+      targetNodeId: target.node.id,
+      ...(targets.length > 1
+        ? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) }
+        : {}),
+      confidence: 0.95,
+      resolvedBy: 'framework',
+      edgeKind: 'navigates',
+      metadata: { href: target.href.display, navMethod: verb },
+    };
+  },
+};

+ 411 - 0
src/resolution/frameworks/vue-router.ts

@@ -0,0 +1,411 @@
+/**
+ * Vue Router — routes declared in a config object, navigation often by NAME.
+ *
+ * `frameworks/vue.ts` reads Nuxt's file convention (`pages/about.vue` is
+ * `/about`), which is half the Vue world. The other half — every plain Vue 3
+ * app — declares its routes in one object:
+ *
+ *   const router = createRouter({
+ *     history: createWebHistory(),
+ *     routes: [
+ *       { name: 'login',   path: '/login',             component: () => import('@/views/Login') },
+ *       { name: 'profile', path: '/profile/:username', component: Profile },
+ *     ],
+ *   })
+ *
+ * `extract()` reads that array into one `route` node per entry, named by its
+ * path the way every other framework's routes are, bound to the component it
+ * names — an identifier, or the last segment of a lazy `() => import(…)`,
+ * which is the `.vue` file's own name.
+ *
+ * **Navigation is usually a name, not a path.** This is what makes Vue
+ * different from React Router and Next.js, where the destination is always a
+ * URL:
+ *
+ *   router.push({ name: 'login' })        // by name — the common idiom
+ *   router.push('/')                      // by path
+ *   router.push({ path: '/', query })     // by path, with extras
+ *   navigateTo('/dashboard')              // Nuxt
+ *
+ * So `resolve()` reads the argument as a name FIRST and falls back to the
+ * path readers every other framework shares. A route's name lives only in the
+ * source — node metadata is not persisted — so the table re-reads the config
+ * files its own route nodes came from, with the same parser `extract` used.
+ * `<router-link to>` / `<RouterLink to>` / `<NuxtLink to>` are markup rather
+ * than calls, so a synthesizer reads them (`vue-router-synthesizer.ts`).
+ *
+ * Left unresolved rather than guessed: a computed destination
+ * (`router.push(postAuthRoute.value)`), a name or path nothing declares, and
+ * a nested `children:` route, whose path is relative to its parent.
+ */
+
+import type { Language, Node } from '../../types';
+import type {
+  FrameworkExtractionResult,
+  FrameworkResolver,
+  ResolutionContext,
+  ResolvedRef,
+  UnresolvedRef,
+} from '../types';
+import { stripCommentsForRegex } from '../strip-comments';
+import { matchBracket, readFields, topLevelObjects } from './object-literal';
+import { dependsOn } from './package-deps';
+import {
+  addRouteTo,
+  appRootFor,
+  firstArgumentText,
+  parseHrefExpression,
+  readHrefViaLocal,
+  readStringAt,
+  routesForFile,
+  toHref,
+  type HrefLiteral,
+  type RootedRouteTable,
+  type RouteTable,
+} from './expo-router';
+import { destinationsForHref } from './nextjs';
+
+const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'vue'];
+
+// =============================================================================
+// Reading the routes array
+// =============================================================================
+
+export interface VueRouteEntry {
+  /** `/profile/:username` — the path, in the form every other framework's routes use. */
+  path: string;
+  /** `profile` — what `router.push({ name })` names, when the entry has one. */
+  name: string | null;
+  /** The component the entry names, by identifier or by the tail of its lazy import. */
+  component: string | null;
+  line: number;
+}
+
+/** A file that builds a router — the cheap gate before parsing anything. */
+const ROUTER_FACTORY = /\b(?:createRouter|createWebHistory|createWebHashHistory|createMemoryHistory)\s*\(|\bnew\s+VueRouter\s*\(/;
+
+/** `routes: [` / `routes = [` — the array itself, for a file that only holds the table. */
+const ROUTES_ARRAY = /\broutes\s*[:=]\s*\[/;
+
+/**
+ * Every top-level entry of a `routes: [...]` array.
+ *
+ * The array is walked, not pattern-matched: a `name` is written ABOVE the
+ * `path` it belongs to, so reading fields out of a window around each `path`
+ * hands an entry its PREDECESSOR's name — vue-realworld's `login` came out as
+ * `/register`, silently, for every route in the file. So each top-level `{…}`
+ * is matched as a unit and only its own depth-1 fields are read; a nested
+ * `children:` array, a `meta: {…}` and a lazy `component: () => import(…)`
+ * are stepped over rather than searched.
+ *
+ * An entry whose path does not start with `/` is a child route, relative to a
+ * parent this does not compose, and is not a destination on its own.
+ */
+export function parseVueRoutes(content: string): VueRouteEntry[] {
+  if (!ROUTER_FACTORY.test(content) && !ROUTES_ARRAY.test(content)) return [];
+  const safe = stripCommentsForRegex(content, 'typescript');
+  const out: VueRouteEntry[] = [];
+  const seen = new Set<string>();
+  const arrays = /\broutes\s*[:=]\s*\[/g;
+  let a: RegExpExecArray | null;
+  while ((a = arrays.exec(safe)) !== null) {
+    const open = a.index + a[0].length - 1;
+    const close = matchBracket(safe, open);
+    if (close < 0) continue;
+    for (const obj of topLevelObjects(safe, open + 1, close)) {
+      const fields = readFields(safe, obj.start, obj.end);
+      const pathField = fields.get('path');
+      if (!pathField) continue;
+      const path = readStringAt(pathField.text.trimStart(), 0);
+      if (path === null || !path.startsWith('/')) continue;
+      const componentField = fields.get('component') ?? fields.get('components');
+      if (!componentField) continue; // no component in the entry → not a route object
+      const component = componentName(componentField.text);
+      if (!component) continue;
+      const nameField = fields.get('name');
+      const name = nameField ? readStringAt(nameField.text.trimStart(), 0) : null;
+      const line = safe.slice(0, pathField.at).split('\n').length;
+      const key = `${path} ${name ?? ''}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      out.push({ path, name, component, line });
+    }
+    arrays.lastIndex = close;
+  }
+  return out;
+}
+
+/** The component an entry names: an identifier, or the file a lazy import names. */
+function componentName(value: string): string | null {
+  const lazy = /\bimport\s*\(\s*['"`]([^'"`]+)['"`]/.exec(value);
+  if (lazy) return (lazy[1]!.split('/').pop() ?? '').replace(/\.\w+$/, '') || null;
+  const ident = /^\s*([A-Z][A-Za-z0-9_]*)\s*$/.exec(value);
+  return ident?.[1] ?? null;
+}
+
+function languageForFile(filePath: string): Language {
+  if (filePath.endsWith('.vue')) return 'vue';
+  if (/\.(?:ts|mts|cts)$/.test(filePath)) return 'typescript';
+  return 'javascript';
+}
+
+/** The id a config-declared route carries — a verbatim reconstruction, so the table can recognise its own. */
+function routeId(filePath: string, line: number, path: string): string {
+  return `route:${filePath}:${line}:${path}:vue`;
+}
+
+// =============================================================================
+// Route table — by path, and by name
+// =============================================================================
+
+/** One app's routes, by path and — Vue's own idiom — by name. */
+export interface VueAppRoutes extends RouteTable {
+  /** `login` → the route node, for `router.push({ name: 'login' })`. */
+  byName: Map<string, Node>;
+}
+
+export type VueRouteTable = RootedRouteTable<VueAppRoutes>;
+
+/** True for a route node this resolver emitted, and no other. */
+function isVueConfigRoute(node: Node): boolean {
+  return node.id === routeId(node.filePath, node.startLine, node.name);
+}
+
+/** True for a Nuxt page route `frameworks/vue.ts` emitted. */
+function isNuxtPage(node: Node): boolean {
+  return (
+    node.language === 'vue' &&
+    node.filePath.includes('/pages/') &&
+    node.id === `route:${node.filePath}:${node.name}:1`
+  );
+}
+
+const tables = new WeakMap<ResolutionContext, VueRouteTable>();
+
+export function vueRouteTable(context: ResolutionContext): VueRouteTable {
+  const all = context.getNodesByKind('route');
+  const cached = tables.get(context);
+  if (cached && cached.source === all) return cached;
+  const byRoot = new Map<string, VueAppRoutes>();
+  const configFiles = new Map<string, { root: string; nodes: Node[] }>();
+  const tableAt = (root: string): VueAppRoutes => {
+    let t = byRoot.get(root);
+    if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [], byName: new Map() }));
+    return t;
+  };
+  for (const node of all) {
+    const config = isVueConfigRoute(node);
+    if (!config && !isNuxtPage(node)) continue;
+    if (!node.name.startsWith('/')) continue;
+    const root = appRootFor(node.filePath);
+    addRouteTo(tableAt(root), node.name, node);
+    if (config) {
+      const group = configFiles.get(node.filePath);
+      if (group) group.nodes.push(node);
+      else configFiles.set(node.filePath, { root, nodes: [node] });
+    }
+  }
+  // A route's NAME is not persisted on the node, so the config files its own
+  // route nodes came from are re-read with the same parser `extract` used.
+  for (const [filePath, group] of configFiles) {
+    const content = context.readFile(filePath);
+    if (!content) continue;
+    const byName = tableAt(group.root).byName;
+    const byPath = new Map(group.nodes.map((n) => [n.name, n]));
+    for (const entry of parseVueRoutes(content)) {
+      if (!entry.name) continue;
+      const node = byPath.get(entry.path);
+      if (node && !byName.has(entry.name)) byName.set(entry.name, node);
+    }
+  }
+  const table: VueRouteTable = { source: all, byRoot };
+  tables.set(context, table);
+  return table;
+}
+
+// =============================================================================
+// Navigation calls
+// =============================================================================
+
+/**
+ * `router.push` / `.replace` (the Composition API), `$router.push` /
+ * `.replace` (the Options API and templates), and Nuxt's `navigateTo`.
+ *
+ * As everywhere else, `push` and `replace` need a receiver that names a
+ * router: an unqualified `push` is an array's.
+ */
+const NAV_CALL = /^\$?router\.(?:push|replace)$|^navigateTo$/;
+
+/** The verb a navigation call name stands for, or null. */
+export function vueNavVerb(name: string): string | null {
+  if (!NAV_CALL.test(name)) return null;
+  const dot = name.lastIndexOf('.');
+  return dot < 0 ? name : name.slice(dot + 1);
+}
+
+/** The route name in a `{ name: 'login' }` destination, or null for anything else. */
+export function routeNameInExpression(expr: string): string | null {
+  const args = expr.trim();
+  if (args[0] !== '{') return null;
+  const key = /\bname\s*:\s*['"`]/.exec(args);
+  if (!key) return null;
+  return readStringAt(args, key.index + key[0].length - 1);
+}
+
+/** `{ path: '/', query }` — Vue's object destination, whose key is `path`, not `pathname`. */
+export function parseVuePathObject(expr: string): HrefLiteral | null {
+  const args = expr.trim();
+  if (args[0] !== '{') return null;
+  const key = /\bpath\s*:\s*['"`]/.exec(args);
+  if (!key) return null;
+  return toHref(readStringAt(args, key.index + key[0].length - 1));
+}
+
+/** True for the `calls` ref this resolver's `extract` emitted from a route to its component. */
+function isVueRouteRef(ref: UnresolvedRef): boolean {
+  return ref.fromNodeId.startsWith('route:') && ref.fromNodeId.endsWith(':vue');
+}
+
+/**
+ * The component a route names — a `.vue` file's own component node, or a
+ * component declared in a plain script. Nearest app root first; an ambiguous
+ * name resolves to nothing rather than to an arbitrary one of several.
+ */
+function vueComponentNamed(name: string, fromFile: string, context: ResolutionContext): Node | null {
+  const candidates = context
+    .getNodesByName(name)
+    .filter((n) => n.kind === 'component' || (n.kind === 'function' && n.filePath.endsWith('.vue')));
+  if (candidates.length === 0) return null;
+  if (candidates.length === 1) return candidates[0]!;
+  const root = appRootFor(fromFile);
+  const near = candidates.filter((n) => n.filePath.startsWith(root));
+  return near.length === 1 ? near[0]! : null;
+}
+
+// =============================================================================
+// The resolver
+// =============================================================================
+
+export const vueRouterResolver: FrameworkResolver = {
+  name: 'vue-router',
+  languages: [...ROUTE_LANGUAGES],
+
+  detect(context: ResolutionContext): boolean {
+    return dependsOn(context, 'vue-router', 'nuxt', 'nuxt3');
+  },
+
+  claimsReference(name: string): boolean {
+    return NAV_CALL.test(name);
+  },
+
+  extract(filePath: string, content: string): FrameworkExtractionResult {
+    const entries = parseVueRoutes(content);
+    if (entries.length === 0) return { nodes: [], references: [] };
+    const language = languageForFile(filePath);
+    const now = Date.now();
+    const nodes: Node[] = [];
+    const references: UnresolvedRef[] = [];
+    for (const entry of entries) {
+      const node: Node = {
+        id: routeId(filePath, entry.line, entry.path),
+        kind: 'route',
+        name: entry.path,
+        qualifiedName: `${filePath}::route:${entry.path}`,
+        filePath,
+        startLine: entry.line,
+        endLine: entry.line,
+        startColumn: 0,
+        endColumn: 0,
+        language,
+        updatedAt: now,
+      };
+      nodes.push(node);
+      if (entry.component) {
+        // `calls`, not `references`, for the same reason Next.js binds a page
+        // that way: a `references` candidate list is filtered to the ref's own
+        // language family, and a router config is `.js` while the component it
+        // names is `.vue` — so the right component was dropped and a same-named
+        // `.js` function in a store was picked instead. `route-roots.ts` reads
+        // a `calls` edge to a component as the page a screen renders.
+        references.push({
+          fromNodeId: node.id,
+          referenceName: entry.component,
+          referenceKind: 'calls',
+          line: entry.line,
+          column: 0,
+          filePath,
+          language,
+          candidates: [entry.component],
+        });
+      }
+    }
+    return { nodes, references };
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    if (ref.referenceKind !== 'calls') return null;
+
+    // A route naming the component it renders — this resolver's own reference,
+    // bound here rather than by name alone: a Vue app usually has a `.vue`
+    // `Login` view AND a `login` action in a store, and only one of them is
+    // the screen.
+    if (isVueRouteRef(ref)) {
+      const component = vueComponentNamed(ref.referenceName, ref.filePath, context);
+      return component
+        ? { original: ref, targetNodeId: component.id, confidence: 0.95, resolvedBy: 'framework' }
+        : null;
+    }
+
+    const verb = vueNavVerb(ref.referenceName);
+    if (!verb) return null;
+    if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
+    const routes = routesForFile(vueRouteTable(context), ref.filePath);
+    if (!routes || routes.exact.size === 0) return null;
+    const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
+    if (!lines) return null;
+
+    const arg = firstArgumentText(lines, ref.line, ref.column, verb);
+    if (arg === null) return null;
+
+    // By name first — `{ name: 'login' }` is the idiom Vue apps are written in.
+    const named = routeNameInExpression(arg);
+    if (named !== null) {
+      const target = routes.byName.get(named);
+      return target
+        ? {
+            original: ref,
+            targetNodeId: target.id,
+            confidence: 0.95,
+            resolvedBy: 'framework',
+            edgeKind: 'navigates',
+            metadata: { href: named, navMethod: verb, by: 'name' },
+          }
+        : null;
+    }
+
+    // Otherwise a path, read exactly as every other framework reads one.
+    let href = parseHrefExpression(arg) ?? parseVuePathObject(arg);
+    if (!href) {
+      const enclosing = context.getNodeById?.(ref.fromNodeId);
+      const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40);
+      href = readHrefViaLocal(lines, ref.line, ref.column, verb, start);
+    }
+    if (!href) return null;
+    // Every arm of a conditional destination is somewhere this call goes; the
+    // first is this reference's resolution and the rest ride as `alsoTargets`.
+    const targets = destinationsForHref(href, routes);
+    const target = targets[0];
+    if (!target) return null;
+    return {
+      original: ref,
+      targetNodeId: target.node.id,
+      ...(targets.length > 1
+        ? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) }
+        : {}),
+      confidence: 0.95,
+      resolvedBy: 'framework',
+      edgeKind: 'navigates',
+      metadata: { href: target.href.display, navMethod: verb },
+    };
+  },
+};

+ 12 - 5
src/resolution/index.ts

@@ -1061,7 +1061,7 @@ export class ReferenceResolver {
    * Create edges from resolved references
    */
   createEdges(resolved: ResolvedRef[]): Edge[] {
-    return resolved.map((ref) => {
+    return resolved.flatMap((ref) => {
       // `function_ref` (#756) is internal-only: it persists as a `references`
       // edge (the registration site depends on the callback), distinguishable
       // by metadata.resolvedBy === 'function-ref'. callers/impact already
@@ -1097,14 +1097,21 @@ export class ReferenceResolver {
         }
       }
 
-      return {
+      // One reference can name several targets — a navigation whose
+      // destination is a conditional reaches every arm. Each becomes its own
+      // edge, sharing this resolution's kind and confidence.
+      const targets = [
+        { targetNodeId: ref.targetNodeId, metadata: ref.metadata },
+        ...(ref.alsoTargets ?? []),
+      ];
+      return targets.map((t) => ({
         source: ref.original.fromNodeId,
-        target: ref.targetNodeId,
+        target: t.targetNodeId,
         kind,
         line: ref.original.line,
         column: ref.original.column,
         metadata: {
-          ...(ref.metadata ?? {}),
+          ...(t.metadata ?? {}),
           confidence: ref.confidence,
           resolvedBy: ref.resolvedBy,
           // The ORIGINAL reference text (and kind, when edge-kind promotion
@@ -1125,7 +1132,7 @@ export class ReferenceResolver {
           // exactly the edges this feature added.
           ...(ref.original.referenceKind === 'function_ref' ? { fnRef: true } : {}),
         },
-      };
+      }));
     });
   }
 

+ 19 - 17
src/resolution/next-router-synthesizer.ts

@@ -27,7 +27,7 @@ import type { MaybeYield } from './cooperative-yield';
 import { stripCommentsForRegex } from './strip-comments';
 import { isTestPath } from '../search/query-utils';
 import { readStringAt, toHref } from './frameworks/expo-router';
-import { nextRouteTable, pageForHref } from './frameworks/nextjs';
+import { nextRouteTable, destinationsForHref } from './frameworks/nextjs';
 import { enclosingFn, makeLineAt } from './synth-utils';
 
 const JSX_FILE = /\.(?:[cm]?[jt]sx?|mdx)$/;
@@ -80,22 +80,24 @@ export async function nextLinkEdges(ctx: ResolutionContext, onYield: MaybeYield)
       const line = lineOf(m.index);
       const component = enclosingFn(nodes, line);
       if (!component) continue;
-      const page = pageForHref(href, table);
-      if (!page) continue;
-      const key = `${component.id}>${page.id}`;
-      if (seen.has(key)) continue;
-      const count = (perComponent.get(component.id) ?? 0) + 1;
-      perComponent.set(component.id, count);
-      if (count > MAX_LINKS_PER_COMPONENT) continue;
-      seen.add(key);
-      edges.push({
-        source: component.id,
-        target: page.id,
-        kind: 'navigates',
-        line,
-        provenance: 'heuristic',
-        metadata: { synthesizedBy: 'next-link', href: href.display, navMethod: tag === 'a' ? 'a' : 'link', registeredAt: `${file}:${line}` },
-      });
+      // A destination written as a choice names one route per arm, and the
+      // user reaches every one of them — each is drawn.
+      for (const { node: page, href: arm } of destinationsForHref(href, table)) {
+        const key = `${component.id}>${page.id}`;
+        if (seen.has(key)) continue;
+        const count = (perComponent.get(component.id) ?? 0) + 1;
+        perComponent.set(component.id, count);
+        if (count > MAX_LINKS_PER_COMPONENT) continue;
+        seen.add(key);
+        edges.push({
+          source: component.id,
+          target: page.id,
+          kind: 'navigates',
+          line,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'next-link', href: arm.display, navMethod: tag === 'a' ? 'a' : 'link', registeredAt: `${file}:${line}` },
+        });
+      }
     }
   }
   return edges;

+ 120 - 0
src/resolution/react-router-synthesizer.ts

@@ -0,0 +1,120 @@
+/**
+ * React Router — navigation written as markup.
+ *
+ *   <Link to="/placeorder">Continue</Link>
+ *   <NavLink to="/profile">Profile</NavLink>
+ *   <Navigate to="/login" replace />
+ *   <LinkContainer to="/payment">…</LinkContainer>   // react-router-bootstrap
+ *   <Link to={{ pathname: '/shipping' }}>…</Link>    // v5's object form
+ *
+ * A JSX attribute is not a call, so the extractor records no reference for it
+ * and the resolver in `frameworks/react-router.ts` — which binds
+ * `history.push` and `navigate` — never sees it. This pass reads every `to`
+ * attribute out of the source, attributes it to the component (the innermost
+ * function) it is written in, matches it against the React Router route
+ * table, and synthesizes one `navigates` edge from the component to the
+ * route. That is the edge the Screens view walks back from, so a screen's
+ * links are its transitions exactly as its pushes are.
+ *
+ * Edges are `provenance:'heuristic'`, `synthesizedBy:'react-router-link'`,
+ * with the path as written and `registeredAt` = the JSX site. A computed
+ * target (`to={next}`) is nothing; a path no route serves is nothing; a
+ * relative `to` is nothing, because it is resolved against a nesting this
+ * scan does not read. Nothing here runs on a project with no React Router
+ * routes.
+ *
+ * This is `next-router-synthesizer.ts`'s twin — the same shape over the other
+ * attribute (`to`, not `href`) and the other table.
+ */
+
+import type { Edge } from '../types';
+import type { ResolutionContext } from './types';
+import type { MaybeYield } from './cooperative-yield';
+import { stripCommentsForRegex } from './strip-comments';
+import { isTestPath } from '../search/query-utils';
+import { parseHrefExpression, routesForFile, toHref, type HrefLiteral } from './frameworks/expo-router';
+import { matchBracket } from './frameworks/object-literal';
+import { destinationsForHref } from './frameworks/nextjs';
+import { reactRouterTable } from './frameworks/react-router';
+import { enclosingFn, makeLineAt } from './synth-utils';
+
+const JSX_FILE = /\.(?:[cm]?[jt]sx?|mdx)$/;
+
+/** The tags that carry a route as a `to` attribute, the attribute anywhere in the tag. */
+const LINK_TAG = /<(Link|NavLink|Navigate|LinkContainer|IndexLinkContainer)\b([^>]*?)\bto\s*=\s*(?:"([^"]*)"|'([^']*)'|(?=\{))/g;
+
+/** A tag this pass could possibly match — the cheap prefilter before stripping comments. */
+const HAS_LINK_TAG = /<(?:Link|NavLink|Navigate|LinkContainer|IndexLinkContainer)\b/;
+
+/** Links a single component may carry before it is a navigation menu, not a decision. */
+const MAX_LINKS_PER_COMPONENT = 24;
+
+export async function reactRouterLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
+  const table = reactRouterTable(ctx);
+  if (table.byRoot.size === 0) return [];
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const perComponent = new Map<string, number>();
+  let scanned = 0;
+  for (const file of ctx.getAllFiles()) {
+    if (!JSX_FILE.test(file) || isTestPath(file)) continue;
+    const routes = routesForFile(table, file);
+    if (!routes || routes.exact.size === 0) continue;
+    if ((++scanned & 63) === 0) await onYield();
+    const source = ctx.readFile(file);
+    if (!source || !HAS_LINK_TAG.test(source)) continue;
+    const safe = stripCommentsForRegex(source, 'typescript');
+    const nodes = ctx.getNodesInFile(file);
+    const lineOf = makeLineAt(safe, 1);
+    LINK_TAG.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = LINK_TAG.exec(safe)) !== null) {
+      const tag = m[1]!;
+      const quoted: string | null = m[3] ?? m[4] ?? null;
+      let href: HrefLiteral | null;
+      if (quoted !== null) href = toHref(quoted);
+      else {
+        // `to={…}` holds an EXPRESSION, and it is read with the same reader
+        // the `history.push(…)` path uses — a string, a template, a
+        // `{ pathname }` object, or a conditional whose arms agree
+        // (`to={redirect ? `/register?redirect=${redirect}` : '/register'}`,
+        // which is how react-router apps write a link that carries state).
+        // Peeking at the first character instead missed every one of those.
+        const at = m.index + m[0].length;
+        const close = matchBracket(safe, at);
+        if (close < 0) continue;
+        href = parseHrefExpression(safe.slice(at + 1, close));
+      }
+      // A relative `to` is resolved against the route this markup renders
+      // under — a nesting this scan does not read, so it is not a destination.
+      if (!href || !href.path.startsWith('/')) continue;
+      const line = lineOf(m.index);
+      const component = enclosingFn(nodes, line);
+      if (!component) continue;
+      // A destination written as a choice names one route per arm, and the
+      // user reaches every one of them — each is drawn.
+      for (const { node: route, href: arm } of destinationsForHref(href, routes)) {
+        const key = `${component.id}>${route.id}`;
+        if (seen.has(key)) continue;
+        const count = (perComponent.get(component.id) ?? 0) + 1;
+        perComponent.set(component.id, count);
+        if (count > MAX_LINKS_PER_COMPONENT) continue;
+        seen.add(key);
+        edges.push({
+          source: component.id,
+          target: route.id,
+          kind: 'navigates',
+          line,
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'react-router-link',
+            href: arm.display,
+            navMethod: tag === 'Navigate' ? 'navigate' : 'link',
+            registeredAt: `${file}:${line}`,
+          },
+        });
+      }
+    }
+  }
+  return edges;
+}

+ 163 - 0
src/resolution/sveltekit-synthesizer.ts

@@ -0,0 +1,163 @@
+/**
+ * SvelteKit — navigation written as markup.
+ *
+ *   <a href="/login">Sign in</a>
+ *   <a href="/profile/@{user.username}">…</a>
+ *   <a href={`/article/${slug}`}>…</a>
+ *
+ * SvelteKit has no link component: an ordinary `<a href>` IS the navigation,
+ * intercepted by the router. So a page's outgoing links are plain markup, the
+ * extractor records no reference for them, and the resolver in
+ * `frameworks/sveltekit-router.ts` — which binds `goto` and `redirect` —
+ * never sees them. This pass reads every internal `<a href>` out of the
+ * source, attributes it to the component (the innermost function) it is
+ * written in, matches it against the SvelteKit route table, and synthesizes
+ * one `navigates` edge from the component to the page.
+ *
+ * Edges are `provenance:'heuristic'`, `synthesizedBy:'sveltekit-link'`, with
+ * the href as written and `registeredAt` = the markup site. An external href
+ * is a link out of the site, not a transition; a computed one is nothing; a
+ * path no page serves is nothing. Nothing here runs on a project with no
+ * SvelteKit pages.
+ *
+ * This is `next-router-synthesizer.ts`'s twin over `<a href>` alone — Next
+ * reads `<Link href>` too, and Svelte has no such component.
+ *
+ * A second pass here binds a route to the `+page.svelte` that serves it
+ * (`svelteKitPageComponentEdges`): the route node and the component sit in the
+ * same file, but nothing joined them, so a SvelteKit page had no body for the
+ * Steps picture to walk and opened as a lone box.
+ *
+ * The other half of the join — a page and the `+page.server.js` beside it — is
+ * `callback-synthesizer.ts`'s `svelteKitLoadEdges`, which already existed: a
+ * SvelteKit page and its loader are two halves of one route joined by the file
+ * system rather than by a call, and without that join a page's own auth guard
+ * (`redirect(302, '/login')` in its loader) belongs to no screen at all.
+ */
+
+import type { Edge, Node } from '../types';
+import type { ResolutionContext } from './types';
+import type { MaybeYield } from './cooperative-yield';
+import { isTestPath } from '../search/query-utils';
+import { HOLE, readStringAt, routesForFile, toHref } from './frameworks/expo-router';
+import { destinationsForHref } from './frameworks/nextjs';
+import { svelteKitTable } from './frameworks/sveltekit-router';
+import { enclosingFn, makeLineAt } from './synth-utils';
+
+const MARKUP_FILE = /\.svelte$/;
+
+/** `<a … href=…`, the attribute anywhere in the tag, quoted or bound. */
+const LINK_TAG = /<a\b([^>]*?)\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*)/g;
+
+/** Links a single component may carry before it is a navigation menu, not a decision. */
+const MAX_LINKS_PER_COMPONENT = 24;
+
+export async function svelteKitLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
+  const table = svelteKitTable(ctx);
+  if (table.byRoot.size === 0) return [];
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const perComponent = new Map<string, number>();
+  let scanned = 0;
+  for (const file of ctx.getAllFiles()) {
+    if (!MARKUP_FILE.test(file) || isTestPath(file)) continue;
+    const routes = routesForFile(table, file);
+    if (!routes || routes.exact.size === 0) continue;
+    if ((++scanned & 63) === 0) await onYield();
+    const source = ctx.readFile(file);
+    if (!source || !source.includes('href')) continue;
+    const nodes = ctx.getNodesInFile(file);
+    const lineOf = makeLineAt(source, 1);
+    LINK_TAG.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = LINK_TAG.exec(source)) !== null) {
+      let literal: string | null = m[2] ?? m[3] ?? null;
+      if (literal === null) {
+        // `href={…}`: a string or a template with holes.
+        const at = m.index + m[0].length;
+        const ch = source[at];
+        if (ch === '"' || ch === "'" || ch === '`') literal = readStringAt(source, at);
+      }
+      if (literal === null) continue;
+      // An external href is a link out of the site, not a transition. A
+      // Svelte `{expr}` inside a quoted attribute is an interpolation, so it
+      // becomes the same hole a template literal's `${…}` does — which is how
+      // `/profile/@{user.username}` reaches the `/profile/@:user` page.
+      if (!literal.startsWith('/')) continue;
+      const href = toHref(literal.replace(/\{[^}]*\}/g, HOLE));
+      if (!href) continue;
+      const line = lineOf(m.index);
+      const component = enclosingFn(nodes, line);
+      if (!component) continue;
+      // A destination written as a choice names one route per arm, and the
+      // user reaches every one of them — each is drawn.
+      for (const { node: page, href: arm } of destinationsForHref(href, routes)) {
+        const key = `${component.id}>${page.id}`;
+        if (seen.has(key)) continue;
+        const count = (perComponent.get(component.id) ?? 0) + 1;
+        perComponent.set(component.id, count);
+        if (count > MAX_LINKS_PER_COMPONENT) continue;
+        seen.add(key);
+        edges.push({
+          source: component.id,
+          target: page.id,
+          kind: 'navigates',
+          line,
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'sveltekit-link',
+            href: arm.display,
+            navMethod: 'a',
+            registeredAt: `${file}:${line}`,
+          },
+        });
+      }
+    }
+  }
+  return edges;
+}
+
+
+
+// =============================================================================
+// A route and the page that serves it
+// =============================================================================
+
+/**
+ * One `calls` edge from each `+page.svelte` route to the component in its own
+ * file — the page that renders when a navigation lands there.
+ *
+ * Every other framework's resolver names this at extraction: a Next page route
+ * points at the file's default export, a React Router route at the component
+ * the markup named. SvelteKit's route is derived from the file's PATH, and its
+ * component has no name of its own to reference (every page file's component
+ * is called `+page`), so the two are joined here, where both are already in
+ * hand and the match is the file itself rather than a name.
+ *
+ * With it, `route-roots.ts` reads the page as the route's root: the Steps
+ * picture starts at the page instead of at an empty box, and the Screens walk
+ * attributes a navigation to the screen whose component holds it rather than
+ * falling back to the file it was written in.
+ */
+export async function svelteKitPageComponentEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
+  const table = svelteKitTable(ctx);
+  if (table.byRoot.size === 0) return [];
+  const edges: Edge[] = [];
+  let scanned = 0;
+  const pages = new Set<Node>();
+  for (const routes of table.byRoot.values()) for (const page of routes.exact.values()) pages.add(page);
+  for (const page of pages) {
+    if ((++scanned & 31) === 0) await onYield();
+    const component = ctx.getNodesInFile(page.filePath).find((n) => n.kind === 'component');
+    if (!component) continue;
+    edges.push({
+      source: page.id,
+      target: component.id,
+      kind: 'calls',
+      line: component.startLine,
+      provenance: 'heuristic',
+      metadata: { synthesizedBy: 'sveltekit-page', registeredAt: page.filePath },
+    });
+  }
+  return edges;
+}

+ 108 - 0
src/resolution/tanstack-router-synthesizer.ts

@@ -0,0 +1,108 @@
+/**
+ * TanStack Router — navigation written as markup.
+ *
+ *   <Link to="/dashboard/invoices/$invoiceId" params={{ invoiceId: 3 }}>…</Link>
+ *   <Link to="/login">Sign in</Link>
+ *   <Navigate to="/dashboard" />
+ *
+ * A JSX attribute is not a call, so the extractor records no reference for it
+ * and the resolver in `frameworks/tanstack-router.ts` — which binds
+ * `navigate({ to })` and `redirect({ to })` — never sees it. This pass reads
+ * every `to` out of the source, attributes it to the component (the innermost
+ * function) it is written in, matches it against the TanStack route table, and
+ * synthesizes one `navigates` edge from the component to the route.
+ *
+ * What makes this different from React Router's identical-looking `<Link to>`:
+ * TanStack's `to` is the route PATTERN and the values ride beside it in
+ * `params`, so `to="/posts/$postId"` names the route rather than an address —
+ * and it is normalised the same way a route name is instead of being read as a
+ * URL. A `<Link from=…>` with no `to` is a relative link within the route it
+ * is already on, and names no destination of its own.
+ *
+ * Edges are `provenance:'heuristic'`, `synthesizedBy:'tanstack-link'`, with the
+ * destination as written and `registeredAt` = the JSX site. A computed `to` is
+ * nothing; a pattern no route serves is nothing. Nothing here runs on a project
+ * with no TanStack routes.
+ */
+
+import type { Edge } from '../types';
+import type { ResolutionContext } from './types';
+import type { MaybeYield } from './cooperative-yield';
+import { stripCommentsForRegex } from './strip-comments';
+import { isTestPath } from '../search/query-utils';
+import { readStringAt, routesForFile } from './frameworks/expo-router';
+import { destinationsForHref } from './frameworks/nextjs';
+import { tanstackDestination, tanstackTable } from './frameworks/tanstack-router';
+import { enclosingFn, makeLineAt } from './synth-utils';
+
+const JSX_FILE = /\.(?:[cm]?[jt]sx?)$/;
+
+/** `<Link … to=` / `<Navigate … to=`, the attribute anywhere in the tag. */
+const LINK_TAG = /<(Link|Navigate)\b([^>]*?)\bto\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*)/g;
+
+/** A tag this pass could possibly match — the cheap prefilter. */
+const HAS_LINK_TAG = /<(?:Link|Navigate)\b/;
+
+/** Links a single component may carry before it is a navigation menu, not a decision. */
+const MAX_LINKS_PER_COMPONENT = 24;
+
+export async function tanstackLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
+  const table = tanstackTable(ctx);
+  if (table.byRoot.size === 0) return [];
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const perComponent = new Map<string, number>();
+  let scanned = 0;
+  for (const file of ctx.getAllFiles()) {
+    if (!JSX_FILE.test(file) || isTestPath(file)) continue;
+    const routes = routesForFile(table, file);
+    if (!routes || routes.exact.size === 0) continue;
+    if ((++scanned & 63) === 0) await onYield();
+    const source = ctx.readFile(file);
+    if (!source || !HAS_LINK_TAG.test(source)) continue;
+    const safe = stripCommentsForRegex(source, 'typescript');
+    const nodes = ctx.getNodesInFile(file);
+    const lineOf = makeLineAt(safe, 1);
+    LINK_TAG.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = LINK_TAG.exec(safe)) !== null) {
+      let literal: string | null = m[3] ?? m[4] ?? null;
+      if (literal === null) {
+        // `to={…}`: a string or a template.
+        const at = m.index + m[0].length;
+        const ch = safe[at];
+        if (ch === '"' || ch === "'" || ch === '`') literal = readStringAt(safe, at);
+      }
+      if (literal === null) continue;
+      const href = tanstackDestination(JSON.stringify(literal));
+      if (!href) continue;
+      const line = lineOf(m.index);
+      const component = enclosingFn(nodes, line);
+      if (!component) continue;
+      // A destination written as a choice names one route per arm, and the
+      // user reaches every one of them — each is drawn.
+      for (const { node: route, href: arm } of destinationsForHref(href, routes)) {
+        const key = `${component.id}>${route.id}`;
+        if (seen.has(key)) continue;
+        const count = (perComponent.get(component.id) ?? 0) + 1;
+        perComponent.set(component.id, count);
+        if (count > MAX_LINKS_PER_COMPONENT) continue;
+        seen.add(key);
+        edges.push({
+          source: component.id,
+          target: route.id,
+          kind: 'navigates',
+          line,
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'tanstack-link',
+            href: arm.display,
+            navMethod: m[1] === 'Navigate' ? 'navigate' : 'link',
+            registeredAt: `${file}:${line}`,
+          },
+        });
+      }
+    }
+  }
+  return edges;
+}

+ 13 - 0
src/resolution/types.ts

@@ -52,6 +52,19 @@ export interface ResolvedRef {
   edgeKind?: EdgeKind;
   /** Extra metadata the strategy wants persisted on the edge (`href`, …). */
   metadata?: Record<string, unknown>;
+  /**
+   * The OTHER targets, when one reference names several.
+   *
+   * A navigation whose destination is a conditional reaches every arm —
+   * `!isAdmin ? keyword ? '/search/…' : '/page/…' : '/admin/…'` is one call
+   * and three screens — and drawing only the first would hide two places the
+   * code goes. `createEdges` fans these out into an edge apiece, sharing this
+   * resolution's kind and confidence; each carries its own metadata.
+   *
+   * The reference itself still resolves ONCE, so the resolution pipeline's
+   * bookkeeping — cleanup by row id, counts, re-resolution — is unchanged.
+   */
+  alsoTargets?: { targetNodeId: string; metadata?: Record<string, unknown> }[];
 }
 
 /**

+ 109 - 0
src/resolution/vue-router-synthesizer.ts

@@ -0,0 +1,109 @@
+/**
+ * Vue Router — navigation written as markup.
+ *
+ *   <router-link to="/login">Sign in</router-link>
+ *   <RouterLink :to="{ name: 'profile', params: { username } }">…</RouterLink>
+ *   <NuxtLink to="/dashboard">…</NuxtLink>          // Nuxt
+ *   <router-link :to="`/article/${slug}`">…</router-link>
+ *
+ * A template attribute is not a call, so the extractor records no reference
+ * for it and the resolver in `frameworks/vue-router.ts` — which binds
+ * `router.push` and `navigateTo` — never sees it. This pass reads every `to`
+ * out of the source, attributes it to the component (the innermost function)
+ * it is written in, matches it against the Vue route table by NAME or by
+ * path, and synthesizes one `navigates` edge from the component to the route.
+ *
+ * The bound form (`:to`) is what carries an object or a template, and it is
+ * the common one in a Vue template — so both spellings are read, and both a
+ * `{ name: … }` and a `{ path: … }` destination resolve, exactly as they do
+ * from a `router.push`.
+ *
+ * Edges are `provenance:'heuristic'`, `synthesizedBy:'vue-router-link'`, with
+ * the destination as written and `registeredAt` = the template site. A
+ * computed `:to="target"` is nothing; a name or path nothing declares is
+ * nothing. Nothing here runs on a project with no Vue routes.
+ */
+
+import type { Edge, Node } from '../types';
+import type { ResolutionContext } from './types';
+import type { MaybeYield } from './cooperative-yield';
+import { isTestPath } from '../search/query-utils';
+import { readStringAt, routesForFile, toHref } from './frameworks/expo-router';
+import { destinationsForHref } from './frameworks/nextjs';
+import { parseVuePathObject, routeNameInExpression, vueRouteTable } from './frameworks/vue-router';
+import { enclosingFn, makeLineAt } from './synth-utils';
+
+const TEMPLATE_FILE = /\.(?:vue|[cm]?[jt]sx?)$/;
+
+/** `<router-link … to=` / `<RouterLink … :to=` / `<NuxtLink … to=`, the attribute anywhere in the tag. */
+const LINK_TAG = /<(router-link|RouterLink|NuxtLink|nuxt-link)\b([^>]*?)\s:?to\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
+
+/** A tag this pass could possibly match — the cheap prefilter. */
+const HAS_LINK_TAG = /<(?:router-link|RouterLink|NuxtLink|nuxt-link)\b/;
+
+/** Links a single component may carry before it is a navigation menu, not a decision. */
+const MAX_LINKS_PER_COMPONENT = 24;
+
+export async function vueRouterLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
+  const table = vueRouteTable(ctx);
+  if (table.byRoot.size === 0) return [];
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const perComponent = new Map<string, number>();
+  let scanned = 0;
+  for (const file of ctx.getAllFiles()) {
+    if (!TEMPLATE_FILE.test(file) || isTestPath(file)) continue;
+    const routes = routesForFile(table, file);
+    if (!routes || routes.exact.size === 0) continue;
+    if ((++scanned & 63) === 0) await onYield();
+    const source = ctx.readFile(file);
+    if (!source || !HAS_LINK_TAG.test(source)) continue;
+    const nodes = ctx.getNodesInFile(file);
+    const lineOf = makeLineAt(source, 1);
+    LINK_TAG.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = LINK_TAG.exec(source)) !== null) {
+      // A bound `:to` holds an expression; a plain `to` holds a literal path.
+      const value = (m[3] ?? m[4] ?? '').trim();
+      if (value.length === 0) continue;
+      const line = lineOf(m.index);
+      const component = enclosingFn(nodes, line);
+      if (!component) continue;
+      const bound = m[0].includes(':to');
+      const named = bound ? routeNameInExpression(value) : null;
+      const byName = named === null ? undefined : routes.byName.get(named);
+      // A `{ name }` destination names exactly one route; a path may be
+      // written as a choice, and then every arm is drawn.
+      let destinations: { node: Node; display: string }[];
+      if (byName && named !== null) destinations = [{ node: byName, display: named }];
+      else {
+        const href = bound ? (parseVuePathObject(value) ?? toHref(readStringAt(value, 0))) : toHref(value);
+        if (!href || !href.path.startsWith('/')) continue;
+        destinations = destinationsForHref(href, routes).map((d) => ({ node: d.node, display: d.href.display }));
+      }
+      for (const { node: target, display } of destinations) {
+        const key = `${component.id}>${target.id}`;
+        if (seen.has(key)) continue;
+        const count = (perComponent.get(component.id) ?? 0) + 1;
+        perComponent.set(component.id, count);
+        if (count > MAX_LINKS_PER_COMPONENT) continue;
+        seen.add(key);
+        edges.push({
+          source: component.id,
+          target: target.id,
+          kind: 'navigates',
+          line,
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'vue-router-link',
+            href: display,
+            navMethod: 'link',
+            ...(named !== null && routes.byName.has(named) ? { by: 'name' } : {}),
+            registeredAt: `${file}:${line}`,
+          },
+        });
+      }
+    }
+  }
+  return edges;
+}

+ 68 - 13
src/ui-server/api/screens.ts

@@ -157,12 +157,38 @@ const SHARED_CHROME_MIN = 3;
 // The endpoint
 // =============================================================================
 
+/** True when the edge's destination is written at the line the edge points to. */
+function writtenHere(edge: Edge, holder: Node): boolean {
+  const at = (edge.metadata as Record<string, unknown> | undefined)?.registeredAt;
+  if (typeof at !== 'string') return edge.provenance !== 'heuristic';
+  return at === `${holder.filePath}:${edge.line}`;
+}
+
+/**
+ * A route a user can be ON, as opposed to one a request goes to.
+ *
+ * Every server framework names its routes with the HTTP method that reaches
+ * them — `GET /api/orders`, `POST /api/users/login`, `USE /api/products`,
+ * `ANY /api/users`, `GET *` — while a screen is named by its path alone.
+ * Nuxt is the one framework that names an endpoint like a page, so its
+ * `server/api/` files are excluded by path instead.
+ *
+ * Without this the tab drew a store's thirty Express endpoints beside its
+ * nineteen pages: boxes nothing can navigate to and nothing leaves, in a
+ * picture that is only about navigation, pushing the pages that ARE
+ * unreachable into a row hundreds of boxes wide. Every route still appears on
+ * Entry points, which is the list of what a request or a user can arrive at.
+ */
+function isScreenRoute(route: Node): boolean {
+  return route.name.startsWith('/') && !route.filePath.includes('/server/api/');
+}
+
 export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<WireScreensPayload> {
   const started = Date.now();
   const stats = cg.getStats();
   const index = { lastIndexedAt: cg.getLastIndexedAt() ?? null, edges: stats.edgeCount, files: stats.fileCount };
 
-  const routes = cg.getNodesByKind('route');
+  const routes = cg.getNodesByKind('route').filter(isScreenRoute);
   const routeIds = routes.map((r) => r.id);
   const navEdges = routeIds.length === 0 ? [] : cg.getIncomingEdgesTo(routeIds, ['navigates']);
   if (navEdges.length === 0) {
@@ -183,14 +209,31 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
   // A route standing in for its own inline handler binds to nothing here — a
   // walk back from a navigation cannot land on a registration site.
   const routeById = new Map(routes.map((r) => [r.id, r]));
-  const routeByFile = new Map(routes.map((r) => [r.filePath, r.id]));
+  // A file that declares exactly ONE route, for the fallback that says a
+  // component belongs to the screen whose file defines it. A file holding
+  // SEVERAL routes says nothing about which one a navigation belongs to —
+  // an Express router file, or the `main.tsx` a code-based route tree is
+  // written in, would otherwise hand every navigation in it to whichever
+  // route happened to be declared last, and draw a root nav bar's links as
+  // transitions out of an unrelated page.
+  const routesPerFile = new Map<string, number>();
+  for (const r of routes) routesPerFile.set(r.filePath, (routesPerFile.get(r.filePath) ?? 0) + 1);
+  const routeByFile = new Map(routes.filter((r) => routesPerFile.get(r.filePath) === 1).map((r) => [r.filePath, r.id]));
   const roots = routeRoots(cg, routes);
   const componentOf = new Map<string, Node>();
-  const screenOfComponent = new Map<string, string>();
+  // Component → EVERY route it serves, not one of them. proshop renders
+  // `HomeScreen` at `/`, `/search/:keyword`, `/page/:pageNumber` and
+  // `/search/:keyword/page/:pageNumber`; keeping only the first route to claim
+  // the component gave all four addresses' navigation to whichever `<Route>`
+  // happened to be written first, and drew the home page as a screen you can
+  // get to but never leave.
+  const screenOfComponent = new Map<string, string[]>();
   for (const [routeId, root] of roots) {
     if (root.inline) continue;
     componentOf.set(routeId, root.node);
-    if (!screenOfComponent.has(root.node.id)) screenOfComponent.set(root.node.id, routeId);
+    const serves = screenOfComponent.get(root.node.id);
+    if (serves) serves.push(routeId);
+    else screenOfComponent.set(root.node.id, [routeId]);
   }
   const nodesById = cg.getNodesByIds([...componentOf.values()].map((n) => n.id).concat(navEdges.map((e) => e.source)));
 
@@ -226,8 +269,14 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
       file: toPosix(holder.filePath),
       line: nav.line ?? holder.startLine,
       href: typeof meta.href === 'string' ? meta.href : target.name,
-      method: nav.provenance === 'heuristic' ? 'return' : typeof meta.navMethod === 'string' ? meta.navMethod : 'push',
-      when: nav.provenance === 'heuristic' ? '' : await whenAt(holder, nav),
+      // How the destination got here. A synthesized edge whose `registeredAt`
+      // is its OWN line had the destination written right there — a
+      // `<Link to='/shipping'>` is markup, not a return value — so it keeps
+      // its own verb. Only an edge whose destination came from somewhere else
+      // (`expo-router-return`, where a helper returns the href and the push is
+      // in another file) reads as `return`.
+      method: writtenHere(nav, holder) && typeof meta.navMethod === 'string' ? meta.navMethod : nav.provenance === 'heuristic' ? 'return' : 'push',
+      when: await whenAt(holder, nav),
     };
 
     let starts = await attribute(cg, projectRoot, holder, screenOfComponent, routeByFile, nodesById);
@@ -352,13 +401,14 @@ async function attribute(
   cg: CodeGraph,
   projectRoot: string,
   holder: Node,
-  screenOfComponent: Map<string, string>,
+  screenOfComponent: Map<string, string[]>,
   routeByFile: Map<string, string>,
   known: Map<string, Node>
 ): Promise<Attribution[] | null> {
-  // The holder IS a screen component: the transition starts on that screen.
+  // The holder IS a screen component: the transition starts on that screen —
+  // on each of them, when one component is rendered at several addresses.
   const own = screenOfComponent.get(holder.id);
-  if (own) return [{ screenId: own, path: [{ node: holder, edge: null }] }];
+  if (own) return own.map((screenId) => ({ screenId, path: [{ node: holder, edge: null }] }));
 
   const parent = new Map<string, { prev: string | null; edge: Edge | null }>();
   parent.set(holder.id, { prev: null, edge: null });
@@ -410,9 +460,10 @@ async function attribute(
         if (!caller || caller.kind === 'file' || caller.kind === 'route') continue;
         parent.set(e.source, { prev: e.target, edge: e });
         nodes.set(e.source, caller);
-        const screen = screenOfComponent.get(caller.id);
-        if (screen) {
-          found.push({ screenId: screen, path: pathFrom(caller.id, parent, nodes) });
+        const screens = screenOfComponent.get(caller.id);
+        if (screens) {
+          const path = pathFrom(caller.id, parent, nodes);
+          for (const screenId of screens) found.push({ screenId, path });
           continue; // a screen is where the walk stops
         }
         nextIds.push(e.source);
@@ -455,7 +506,11 @@ function collapseSharedChrome(starts: Attribution[], origins: Map<string, WireSc
   const out: Attribution[] = [];
   const collapsed = new Set<Attribution>();
   for (const [, group] of byFirstHop) {
-    const screens = new Set(group.map((g) => g.screenId));
+    // Counted by the screen COMPONENT the chain starts at, not by the address:
+    // a top bar rendered by twelve different screens is chrome, while one
+    // component serving four routes is one screen with four addresses, and
+    // collapsing that would take the navigation away from all of them.
+    const screens = new Set(group.map((g) => g.path[0]!.node.id));
     if (screens.size < SHARED_CHROME_MIN) continue;
     const head = group[0]!.path[1]!.node;
     const existing = origins.get(head.id);