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).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);
+ });
+});
diff --git a/__tests__/tanstack-router.test.ts b/__tests__/tanstack-router.test.ts
new file mode 100644
index 0000000..d222082
--- /dev/null
+++ b/__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' +
+ ' \n' +
+ ' \n' +
+ ' A post\n' +
+ ' \n' +
+ ' Sign in\n' +
+ '
\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 \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 Posts
\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 Post
\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 \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 All posts\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 \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).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 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);
+ });
+});
diff --git a/__tests__/vue-router.test.ts b/__tests__/vue-router.test.ts
new file mode 100644
index 0000000..abe0871
--- /dev/null
+++ b/__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, `` 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',
+ '\n' +
+ '
\n' +
+ '\n' +
+ '\n'
+ );
+ write(
+ 'src/views/Login.vue',
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\n'
+ );
+ write(
+ 'src/views/Register.vue',
+ '\n' +
+ ' Have an account?\n' +
+ '\n' +
+ '\n'
+ );
+ write(
+ 'src/views/Settings.vue',
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\n'
+ );
+ write(
+ 'src/views/Profile.vue',
+ '\n Profile
\n\n\n'
+ );
+ write(
+ 'src/components/TheHeader.vue',
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\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).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).by).toBeUndefined();
+ });
+
+ it('a 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).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);
+ });
+});
diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md
index eb4323d..2a09f7a 100644
--- a/docs/design/codegraph-ui-design-spec.md
+++ b/docs/design/codegraph-ui-design-spec.md
@@ -451,6 +451,38 @@ pages only, from files under a Next app's root; `` 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): `` (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 `` / `` /
+`` / `` 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; `` 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 `` 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=` | `?symbol=`, `&depth=`)
What happens from an anchor — a screen, a handler, any symbol — drawn with the Screens view's machinery (§3.12's
diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md
new file mode 100644
index 0000000..abf6a23
--- /dev/null
+++ b/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
+``, 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
+ `` read as *always* while the `push()` beside it carried its guard.
+ The reader works fine at a markup site — a JSX `{step1 ? : …}` 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 # 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
+```
diff --git a/scripts/try-repo.sh b/scripts/try-repo.sh
index db0c14a..ec7ab5a 100755
--- a/scripts/try-repo.sh
+++ b/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 . 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 …, 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() / 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 / is not modelled yet.
+sveltekit-realworld|https://github.com/sveltejs/realworld.git|SvelteKit. Screens: #/screens — 10 pages wired by , 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 , 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.
'
diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts
index de9aa9d..a4b82c2 100644
--- a/src/resolution/callback-synthesizer.ts
+++ b/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) },
// `` / an internal `` — 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) },
];
diff --git a/src/resolution/frameworks/expo-router.ts b/src/resolution/frameworks/expo-router.ts
index e6cf3bf..2f513e3 100644
--- a/src/resolution/frameworks/expo-router.ts
+++ b/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 ``
+ * 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 {
+ /** 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;
+}
+
+/**
+ * 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(
+ table: RootedRouteTable,
+ 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();
+ 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',
diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts
index bf7a88c..4c96e18 100644
--- a/src/resolution/frameworks/index.ts
+++ b/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 — `` 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';
diff --git a/src/resolution/frameworks/nextjs.ts b/src/resolution/frameworks/nextjs.ts
index 8a1a21a..7eb543d 100644
--- a/src/resolution/frameworks/nextjs.ts
+++ b/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();
+ 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 },
};
},
};
diff --git a/src/resolution/frameworks/object-literal.ts b/src/resolution/frameworks/object-literal.ts
new file mode 100644
index 0000000..b485e22
--- /dev/null
+++ b/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 {
+ const out = new Map();
+ 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;
+}
diff --git a/src/resolution/frameworks/package-deps.ts b/src/resolution/frameworks/package-deps.ts
index 22c0db9..1793848 100644
--- a/src/resolution/frameworks/package-deps.ts
+++ b/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>();
+/**
+ * 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 }>();
/** Every dependency name declared at the root or up to two directories down, de-duplicated. */
export function declaredDependencies(context: ResolutionContext): Set {
+ 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();
// 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();
- 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 {
// Not JSON — a template, a broken manifest; nothing to read.
}
}
- cache.set(context, names);
+ cache.set(context, { files: files.length, names });
return names;
}
diff --git a/src/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts
new file mode 100644
index 0000000..85db31e
--- /dev/null
+++ b/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:
+ * `` (v5),
+ * `}/>` (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. `` and `` 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 (`` inside ``), 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();
+
+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();
+ 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 },
+ };
+ },
+};
diff --git a/src/resolution/frameworks/svelte.ts b/src/resolution/frameworks/svelte.ts
index 6848529..d3271a6 100644
--- a/src/resolution/frameworks/svelte.ts
+++ b/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);
diff --git a/src/resolution/frameworks/sveltekit-router.ts b/src/resolution/frameworks/sveltekit-router.ts
new file mode 100644
index 0000000..bf105e9
--- /dev/null
+++ b/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. `` 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();
+
+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();
+ 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 },
+ };
+ },
+};
diff --git a/src/resolution/frameworks/tanstack-router.ts b/src/resolution/frameworks/tanstack-router.ts
new file mode 100644
index 0000000..844eefe
--- /dev/null
+++ b/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.** `` 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.
+ *
+ * `` 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 / 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();
+ 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 | 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();
+
+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();
+ 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();
+ 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 },
+ };
+ },
+};
diff --git a/src/resolution/frameworks/vue-router.ts b/src/resolution/frameworks/vue-router.ts
new file mode 100644
index 0000000..1a9884e
--- /dev/null
+++ b/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.
+ * `` / `` / `` 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();
+ 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;
+}
+
+export type VueRouteTable = RootedRouteTable;
+
+/** 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();
+
+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();
+ const configFiles = new Map();
+ 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 },
+ };
+ },
+};
diff --git a/src/resolution/index.ts b/src/resolution/index.ts
index e66631b..7988c1b 100644
--- a/src/resolution/index.ts
+++ b/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 } : {}),
},
- };
+ }));
});
}
diff --git a/src/resolution/next-router-synthesizer.ts b/src/resolution/next-router-synthesizer.ts
index 0589bee..026abac 100644
--- a/src/resolution/next-router-synthesizer.ts
+++ b/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;
diff --git a/src/resolution/react-router-synthesizer.ts b/src/resolution/react-router-synthesizer.ts
new file mode 100644
index 0000000..a070621
--- /dev/null
+++ b/src/resolution/react-router-synthesizer.ts
@@ -0,0 +1,120 @@
+/**
+ * React Router — navigation written as markup.
+ *
+ * Continue
+ * Profile
+ *
+ * … // react-router-bootstrap
+ * … // 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 {
+ const table = reactRouterTable(ctx);
+ if (table.byRoot.size === 0) return [];
+ const edges: Edge[] = [];
+ const seen = new Set();
+ const perComponent = new Map();
+ 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;
+}
diff --git a/src/resolution/sveltekit-synthesizer.ts b/src/resolution/sveltekit-synthesizer.ts
new file mode 100644
index 0000000..4bf3d5c
--- /dev/null
+++ b/src/resolution/sveltekit-synthesizer.ts
@@ -0,0 +1,163 @@
+/**
+ * SvelteKit — navigation written as markup.
+ *
+ * Sign in
+ * …
+ * …
+ *
+ * SvelteKit has no link component: an ordinary `` 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 `` 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 `` alone — Next
+ * reads `` 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$/;
+
+/** `]*?)\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 {
+ const table = svelteKitTable(ctx);
+ if (table.byRoot.size === 0) return [];
+ const edges: Edge[] = [];
+ const seen = new Set();
+ const perComponent = new Map();
+ 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 {
+ const table = svelteKitTable(ctx);
+ if (table.byRoot.size === 0) return [];
+ const edges: Edge[] = [];
+ let scanned = 0;
+ const pages = new Set();
+ 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;
+}
diff --git a/src/resolution/tanstack-router-synthesizer.ts b/src/resolution/tanstack-router-synthesizer.ts
new file mode 100644
index 0000000..9bdb0f1
--- /dev/null
+++ b/src/resolution/tanstack-router-synthesizer.ts
@@ -0,0 +1,108 @@
+/**
+ * TanStack Router — navigation written as markup.
+ *
+ * …
+ * Sign in
+ *
+ *
+ * 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 ``:
+ * 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 `` 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?)$/;
+
+/** `]*?)\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 {
+ const table = tanstackTable(ctx);
+ if (table.byRoot.size === 0) return [];
+ const edges: Edge[] = [];
+ const seen = new Set();
+ const perComponent = new Map();
+ 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;
+}
diff --git a/src/resolution/types.ts b/src/resolution/types.ts
index 0802b79..b3d76f6 100644
--- a/src/resolution/types.ts
+++ b/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;
+ /**
+ * 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 }[];
}
/**
diff --git a/src/resolution/vue-router-synthesizer.ts b/src/resolution/vue-router-synthesizer.ts
new file mode 100644
index 0000000..5dff169
--- /dev/null
+++ b/src/resolution/vue-router-synthesizer.ts
@@ -0,0 +1,109 @@
+/**
+ * Vue Router — navigation written as markup.
+ *
+ * Sign in
+ * …
+ * … // Nuxt
+ * …
+ *
+ * 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?)$/;
+
+/** `]*?)\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 {
+ const table = vueRouteTable(ctx);
+ if (table.byRoot.size === 0) return [];
+ const edges: Edge[] = [];
+ const seen = new Set();
+ const perComponent = new Map();
+ 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;
+}
diff --git a/src/ui-server/api/screens.ts b/src/ui-server/api/screens.ts
index 84ffb5b..0665a94 100644
--- a/src/ui-server/api/screens.ts
+++ b/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 | 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 {
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();
+ 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();
- const screenOfComponent = new Map();
+ // 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 ``
+ // happened to be written first, and drew the home page as a screen you can
+ // get to but never leave.
+ const screenOfComponent = new Map();
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
+ // `` 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,
+ screenOfComponent: Map,
routeByFile: Map,
known: Map
): Promise {
- // 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();
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();
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);