From d348d301c18f90a8e0ec4ae356db010b38fad46e Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 09:16:49 +0000 Subject: [PATCH] fix(sync): a navigation call links its route when the route appears later (#2425) A router's navigation call (`history.push('/login')`, `navigate('/login')`, `router.push('/x')`, `goto('/x')`) names its route by path and its reference by the router's method, so sync never noticed a route that changed in another file. A synced index then kept answers a fresh index of the same files does not have (CG-33): - A call parked as failed while its route was missing never matched the #1240 retry, which keys on the names the synced files define. - A call bound to a catch-all, a parameter route or the other arm of a conditional kept that binding. - A route renamed in place by runPostExtract kept the calls bound to it. - Template links from a routes file that matches no synthesis trigger were never redrawn. When a navigation router is detected, sync now reads the route nodes at its first file change and again after runPostExtract. For each route on one side only, it puts back the calls the routers' new `navigation` hook (method tails and app scope) says that route can answer: the failed ones, and the `navigates` edges a resolver made. The orchestrator's orphan sweep resolves them, under the same 500-per-name ceiling, and the sync refreshes synthesis. This covers re-extracted and removed files, postExtract renames and cross-file table routes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/react-router.test.ts | 3 +- __tests__/sync-navigation-retry.test.ts | 671 ++++++++++++++++++ docs/benchmarks/index-drift-cg33.md | 63 ++ docs/design/framework-coverage.md | 18 +- src/db/queries.ts | 64 ++ src/index.ts | 31 + src/resolution/frameworks/angular-router.ts | 35 +- src/resolution/frameworks/expo-router.ts | 22 +- src/resolution/frameworks/nextjs.ts | 17 +- src/resolution/frameworks/react-router.ts | 20 +- src/resolution/frameworks/sveltekit-router.ts | 18 +- src/resolution/frameworks/tanstack-router.ts | 6 + src/resolution/frameworks/vue-router.ts | 17 +- src/resolution/index.ts | 99 +++ src/resolution/types.ts | 30 + 16 files changed, 1093 insertions(+), 22 deletions(-) create mode 100644 __tests__/sync-navigation-retry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a00c789..23a188536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, an annotation is no longer lost when a comment sits between it and the declaration, like `@override` followed by `// ignore: must_call_super`, a `///` comment written below the annotations, or `@riverpod` followed by an `// expect_lint: …` line. Before, the comment hid the annotation, so a provider written that way was missing from the annotation's `callers` and impact. Re-index Dart projects after upgrading. - In Dart, a call written through an import prefix, like `http.get(…)` after `import 'package:http/http.dart' as http;`, or `utils.Listener()` and `kit.Box.create()` through a prefix of your own, now links to what the library imported with that prefix exports, following its `export` chains, `show` and `hide`, and to nothing when that library is a package outside your project. Before, the prefix was taken for a variable, so `http.get(…)` could link to an unrelated `Http` class's `get` method, and calls made through a prefix into your own libraries were missing from `callers` and impact. Re-index Dart projects after upgrading. - `codegraph sync` and the file watcher now link an import to the file it names when that file is added after the importing file was indexed, or comes back after being deleted: `import 'package:app/b.dart';` in Dart, `import './side'` in JavaScript or TypeScript, `#include "net/socket.h"` in C and C++, `require 'inc/db.php'` in PHP, `import pkg.mod` in Python, a Lua `require`, or a C# `using` of a namespace declared later. Before, the import stayed unlinked until the importing file itself changed or the project was re-indexed, so `codegraph affected` and the files listed as using the new file missed it. Re-index after upgrading. +- `codegraph sync` and the file watcher now link a navigation call to its page when the page or route is added after the call was indexed, or comes back after being removed: `history.push('/login')` and `navigate('/login')` with React Router, `router.push('/login')` and `redirect('/login')` in Next.js, Expo Router, Vue Router, TanStack Router and SvelteKit (`goto('/login')`), and `this.router.navigate(['/login'])` and `routerLink="/login"` in Angular. Before, the call stayed unlinked, or linked to a catch-all or `:id` page that answered it in the meantime, until its own file changed or the project was re-indexed, so the step from that call to the page it opens was missing or wrong. A route whose path changes without its own file changing, like one built from a path constant kept in another file, now also lets go of the calls that named its old path. Re-index after upgrading. - JSON files in a `templates` or `sections` folder are now read as Shopify Liquid only inside a Shopify theme, where the folder that contains `templates` or `sections` also has `layout/theme.liquid` or `config/settings_schema.json`. Before, all such JSON was, so a .NET project template's `template.json`, a schematic or a CMS's content showed up in the index as an empty Liquid file, and one that lists `sections` could be linked to an unrelated Liquid file of the same name. Shopify themes, including one kept in a subfolder of a repository, are indexed as before, and the next sync removes JSON that an earlier version indexed this way. - In a repository that holds more than one Shopify theme, a theme's `{% render %}`, `{% include %}` and `{% section %}` tags and the sections its JSON templates list now link only to that theme's own snippets and sections, which is where Shopify looks for them. Before, a section or snippet the theme doesn't have was linked to another theme's file of the same name, and a theme kept in a subfolder could link to the root theme's copy instead of its own. A reference the theme can't satisfy now links to nothing. Projects with a single theme are indexed as before. Re-index projects with several Shopify themes after upgrading. - `codegraph sync` and the file watcher now link a Shopify section or snippet to the files that name it when it is added after them, or comes back after being deleted, whether they name it in a `{% render %}`, `{% include %}` or `{% section %}` tag or as a section `type` in a JSON template. Before, the link appeared only once a file naming it changed or the project was re-indexed, even in a repository with a single theme, so the new section or snippet looked unused and `codegraph affected` missed the files that use it. Re-index Shopify themes after upgrading to pick up links an earlier version missed. diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts index 9c2cf86e1..419b1f7ad 100644 --- a/__tests__/react-router.test.ts +++ b/__tests__/react-router.test.ts @@ -1402,7 +1402,8 @@ export function NavMenu() { writeFiles(root, { 'src/App.js': MAPPED }); await cg.sync(); expect(routeNames(cg)).toEqual(['/', '/counter', '/fetch-data']); - expect(navigations(cg)).toContain('NavMenu -> /counter'); + // `navigate('/')` failed while `/` was gone; the routes coming back retry it. + expect(navigations(cg)).toEqual(['NavMenu -> /counter', 'goHome -> /']); }); it('deleting that file is a change too', async () => { diff --git a/__tests__/sync-navigation-retry.test.ts b/__tests__/sync-navigation-retry.test.ts new file mode 100644 index 000000000..79070b1e2 --- /dev/null +++ b/__tests__/sync-navigation-retry.test.ts @@ -0,0 +1,671 @@ +/** + * A sync binds a navigation call to a route that appears after the call was + * indexed, for every router, as a full index binds it. + * + * `history.push('/login')`, `navigate('/login')`, `router.push('/x')`, + * `goto('/x')`: a navigation call names its route by path and its reference + * by the router's method. While the route did not exist, the call was parked + * as failed, and sync's retry — a failed ref's name tail against the names + * the synced files define — never found it, because no file that adds + * `/login` defines anything named `push`. The synced index kept a failure a + * fresh index of the same files does not have (CG-33). + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { QueryBuilder } from '../src/db/queries'; +import { referenceNameTail } from '../src/db/reference-tail'; +import { getAllFrameworkResolvers } from '../src/resolution/frameworks'; +import { changedRoutes, type ReferenceResolver } from '../src/resolution'; +import type { Node } from '../src/types'; + +interface Case { + name: string; + /** The project as it ends up. */ + files: Record; + /** The file the route comes from, and what it holds while the route does not exist (null: no such file yet). */ + route: { file: string; before: string | null }; + /** The file whose navigation call waits for the route. */ + from: string; + /** Where that call goes once the route exists: ` → `. */ + expected: string; +} + +const angularComponent = (name: string, body = '', template = '

x

') => `import { Component } from '@angular/core'; +import { Router } from '@angular/router'; +@Component({ selector: 'app-x', template: '${template}' }) +export class ${name} { + constructor(private readonly router: Router) {} +${body} +} +`; + +const CASES: Case[] = [ + { + name: 'React Router (a in the markup)', + files: { + 'package.json': JSON.stringify({ name: 'shop', dependencies: { react: '18', 'react-router-dom': '5' } }), + 'src/App.js': + "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + + "import LoginScreen from './screens/LoginScreen'\n" + + "import CartScreen from './screens/CartScreen'\n" + + 'const App = () => (\n' + + ' \n' + + " \n" + + " \n" + + ' \n' + + ')\n' + + 'export default App\n', + 'src/screens/LoginScreen.js': 'const LoginScreen = () =>
Login
\nexport default LoginScreen\n', + 'src/screens/CartScreen.js': + 'const CartScreen = ({ history }) => {\n' + + ' const checkout = () => {\n' + + " history.push('/login?redirect=shipping')\n" + + ' }\n' + + ' return \n' + + '}\n' + + 'export default CartScreen\n', + }, + route: { + file: 'src/App.js', + before: + "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + + "import LoginScreen from './screens/LoginScreen'\n" + + "import CartScreen from './screens/CartScreen'\n" + + 'const App = () => (\n' + + ' \n' + + " \n" + + ' \n' + + ')\n' + + 'export default App\n', + }, + from: 'src/screens/CartScreen.js', + expected: '/login?redirect=shipping → /login', + }, + { + name: 'React Router (a path built from a constant another file holds)', + files: { + 'package.json': JSON.stringify({ name: 'web', dependencies: { react: '18', 'react-router': '7' } }), + 'src/config/paths.ts': "export const paths = {\n auth: { login: { path: '/auth/login' } },\n} as const;\n", + 'src/app/router.tsx': + "import { createBrowserRouter } from 'react-router';\n" + + "import { paths } from '../config/paths';\n" + + "import { LoginRoute } from './routes/login';\n" + + 'export const router = createBrowserRouter([\n' + + ' { path: paths.auth.login.path, element: },\n' + + ']);\n', + 'src/app/routes/login.tsx': 'export function LoginRoute() { return null; }\n', + 'src/lib/auth.tsx': + "import { useNavigate } from 'react-router';\n" + + 'export function Logout() {\n' + + ' const navigate = useNavigate();\n' + + " return ;\n" + + '}\n', + }, + // The route's own file never changes: the constant it reads does. + route: { file: 'src/config/paths.ts', before: "export const paths = {\n auth: { login: { path: '/auth/sign-in' } },\n} as const;\n" }, + from: 'src/lib/auth.tsx', + expected: '/auth/login → /auth/login', + }, + { + // The routes live in a file that never changes: they exist only while + // another file hands that table to the router. + name: 'React Router (a table another file hands the router)', + files: { + 'package.json': JSON.stringify({ name: 'app', dependencies: { react: '18', 'react-router-dom': '6' } }), + 'src/AppRoutes.js': + "import { Home } from './Home';\n" + + "import { Counter } from './Counter';\n" + + 'const AppRoutes = [\n' + + ' { index: true, element: },\n' + + " { path: '/counter', element: },\n" + + '];\n' + + 'export default AppRoutes;\n', + 'src/App.js': + "import { Route, Routes } from 'react-router-dom';\n" + + "import AppRoutes from './AppRoutes';\n" + + 'export default function App() {\n' + + ' return {AppRoutes.map(({ element, ...rest }, index) => )};\n' + + '}\n', + 'src/Home.js': 'export function Home() { return null; }\n', + 'src/Counter.js': 'export function Counter() { return null; }\n', + 'src/NavMenu.js': + "import { Link, useNavigate } from 'react-router-dom';\n" + + 'export function NavMenu() {\n' + + ' const navigate = useNavigate();\n' + + " const goHome = () => navigate('/');\n" + + ' return ;\n' + + '}\n', + }, + route: { + file: 'src/App.js', + before: + "import { Route, Routes } from 'react-router-dom';\n" + + "import { Counter } from './Counter';\n" + + 'export default function App() {\n' + + ' return } />;\n' + + '}\n', + }, + from: 'src/NavMenu.js', + expected: '/ → /', + }, + { + name: 'React Router (one arm of a conditional destination)', + files: { + 'package.json': JSON.stringify({ name: 'shop', dependencies: { react: '18', 'react-router-dom': '5' } }), + 'src/App.js': + "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + + "import LoginScreen from './screens/LoginScreen'\n" + + "import CartScreen from './screens/CartScreen'\n" + + 'const App = () => (\n' + + ' \n' + + " \n" + + " \n" + + ' \n' + + ')\n' + + 'export default App\n', + 'src/screens/LoginScreen.js': 'const LoginScreen = () =>
Login
\nexport default LoginScreen\n', + // While `/login` is missing the call still resolves, through its other + // arm: it is not parked as failed, it lost a destination. + 'src/screens/CartScreen.js': + 'const CartScreen = ({ history, user }) => {\n' + + ' const checkout = () => {\n' + + " history.push(user ? '/cart' : '/login')\n" + + ' }\n' + + ' return \n' + + '}\n' + + 'export default CartScreen\n', + }, + route: { + file: 'src/App.js', + before: + "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + + "import CartScreen from './screens/CartScreen'\n" + + 'const App = () => (\n' + + ' \n' + + " \n" + + ' \n' + + ')\n' + + 'export default App\n', + }, + from: 'src/screens/CartScreen.js', + expected: '/login → /login', + }, + { + name: 'React Router (a parameter route that took the call meanwhile)', + files: { + 'package.json': JSON.stringify({ name: 'admin', dependencies: { react: '18', 'react-router-dom': '6' } }), + 'src/App.jsx': + "import { Routes, Route } from 'react-router-dom'\n" + + "import { User, NewUser } from './User'\n" + + 'export const App = () => (\n' + + ' \n' + + ' } />\n' + + ' } />\n' + + ' \n' + + ')\n', + 'src/User.jsx': 'export const User = () =>
User
\nexport const NewUser = () =>
New
\n', + 'src/Admin.jsx': + "import { useNavigate } from 'react-router-dom'\n" + + 'export const Admin = () => {\n' + + ' const navigate = useNavigate()\n' + + " const add = () => navigate('/users/new')\n" + + ' return \n' + + '}\n', + }, + route: { + file: 'src/App.jsx', + before: + "import { Routes, Route } from 'react-router-dom'\n" + + "import { User } from './User'\n" + + 'export const App = () => (\n' + + ' \n' + + ' } />\n' + + ' \n' + + ')\n', + }, + from: 'src/Admin.jsx', + expected: '/users/new → /users/new', + }, + { + name: 'Next.js', + files: { + 'package.json': JSON.stringify({ name: 'site', dependencies: { next: '15', react: '19' } }), + 'app/page.tsx': 'export default function Home() {\n return null\n}\n', + 'app/login/page.tsx': 'export default function LoginPage() {\n return null\n}\n', + 'components/logout-button.tsx': + "'use client'\n" + + "import { useRouter } from 'next/navigation'\n" + + 'export function LogoutButton() {\n' + + ' const router = useRouter()\n' + + " const out = () => router.push('/login')\n" + + ' return \n' + + '}\n', + }, + route: { file: 'app/login/page.tsx', before: null }, + from: 'components/logout-button.tsx', + expected: '/login → /login', + }, + { + name: 'Expo Router', + files: { + 'package.json': JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', react: '18' } }), + 'src/app/_layout.tsx': 'export default function Layout() { return null }\n', + 'src/app/index.tsx': 'export default function Home() { return null }\n', + 'src/app/settings.tsx': 'export default function Settings() { return null }\n', + 'src/services/nav.ts': "import { router } from 'expo-router'\nexport function openSettings() {\n router.push('/settings')\n}\n", + }, + route: { file: 'src/app/settings.tsx', before: null }, + from: 'src/services/nav.ts', + expected: '/settings → /settings', + }, + { + name: 'Expo Router (a catch-all screen that took the call meanwhile)', + files: { + 'package.json': JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', react: '18' } }), + 'src/app/_layout.tsx': 'export default function Layout() { return null }\n', + 'src/app/index.tsx': 'export default function Home() { return null }\n', + 'src/app/[...missing].tsx': 'export default function NotFound() { return null }\n', + 'src/app/login.tsx': 'export default function Login() { return null }\n', + 'src/services/session.ts': "import { router } from 'expo-router'\nexport function signOut() {\n router.replace('/login')\n}\n", + }, + route: { file: 'src/app/login.tsx', before: null }, + from: 'src/services/session.ts', + expected: '/login → /login', + }, + { + name: 'Vue Router', + files: { + 'package.json': JSON.stringify({ name: 'conduit', dependencies: { vue: '3', 'vue-router': '4' } }), + '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' + + ' ]\n' + + '})\n' + + 'export default router\n', + 'src/views/Home.vue': + '\n' + + '\n', + 'src/views/Login.vue': '\n\n', + }, + route: { + file: 'src/router/index.js', + before: + 'import { createRouter, createWebHistory } from "vue-router"\n' + + 'const router = createRouter({\n' + + ' history: createWebHistory(),\n' + + ' routes: [\n' + + ' { name: "home", path: "/", component: () => import("@/views/Home") },\n' + + ' ]\n' + + '})\n' + + 'export default router\n', + }, + from: 'src/views/Home.vue', + expected: '/login → /login', + }, + { + name: 'Angular', + files: { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '19', '@angular/router': '19' } }), + 'src/app/app.routes.ts': + "import { Routes } from '@angular/router';\n" + + "import { HomeComponent } from './home.component';\n" + + "import { LoginComponent } from './login.component';\n" + + 'export const routes: Routes = [\n' + + " { path: 'home', component: HomeComponent },\n" + + " { path: 'login', component: LoginComponent },\n" + + '];\n', + 'src/app/home.component.ts': angularComponent('HomeComponent', " signIn() { this.router.navigate(['/login']); }"), + 'src/app/login.component.ts': angularComponent('LoginComponent'), + }, + route: { + file: 'src/app/app.routes.ts', + before: + "import { Routes } from '@angular/router';\n" + + "import { HomeComponent } from './home.component';\n" + + 'export const routes: Routes = [\n' + + " { path: 'home', component: HomeComponent },\n" + + '];\n', + }, + from: 'src/app/home.component.ts', + expected: '/login → /login', + }, + { + // A link in a template is a synthesizer's edge, and a routes file holds + // nothing that sends a sync to the synthesizers on its own. + name: 'Angular (a routerLink, the routes file added later)', + files: { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '19', '@angular/router': '19' } }), + 'src/app/app.routes.ts': + "import { Routes } from '@angular/router';\n" + + "import { HomeComponent } from './home.component';\n" + + "import { LoginComponent } from './login.component';\n" + + 'export const routes: Routes = [\n' + + " { path: 'home', component: HomeComponent },\n" + + " { path: 'login', component: LoginComponent },\n" + + '];\n', + 'src/app/home.component.ts': angularComponent('HomeComponent', '', 'Sign in'), + 'src/app/login.component.ts': angularComponent('LoginComponent'), + }, + route: { file: 'src/app/app.routes.ts', before: null }, + from: 'src/app/home.component.ts', + expected: '/login → /login', + }, + { + name: 'TanStack Router', + files: { + 'package.json': JSON.stringify({ name: 'app', dependencies: { react: '19', '@tanstack/react-router': '1' } }), + 'src/routes/index.tsx': + "import { createFileRoute, useNavigate } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/')({ component: IndexComponent })\n" + + 'function IndexComponent() {\n' + + ' const navigate = useNavigate()\n' + + " const signIn = () => navigate({ to: '/login' })\n" + + ' return \n' + + '}\n', + 'src/routes/login.tsx': + "import { createFileRoute } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/login')({ component: LoginComponent })\n" + + 'function LoginComponent() {\n return
Login
\n}\n', + }, + route: { file: 'src/routes/login.tsx', before: null }, + from: 'src/routes/index.tsx', + expected: '/login → /login', + }, + { + name: 'SvelteKit', + files: { + 'package.json': JSON.stringify({ name: 'conduit', devDependencies: { '@sveltejs/kit': '2', svelte: '5' } }), + 'src/routes/+page.svelte': '

Home

\n', + 'src/routes/settings/+page.svelte': '

Settings

\n', + 'src/routes/settings/+page.server.js': + "import { redirect } from '@sveltejs/kit'\n" + + 'export function load({ locals }) {\n' + + " if (!locals.user) redirect(302, '/login')\n" + + '}\n', + 'src/routes/login/+page.svelte': '

Sign in

\n', + }, + route: { file: 'src/routes/login/+page.svelte', before: null }, + from: 'src/routes/settings/+page.server.js', + expected: '/login → /login', + }, +]; + +const roots: string[] = []; +const graphs: CodeGraph[] = []; + +afterEach(() => { + for (const graph of graphs.splice(0)) graph.close(); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-nav-')); + roots.push(root); + return root; +} + +function write(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } +} + +/** Set a file's content, or remove the file for null. */ +function put(root: string, rel: string, content: string | null): void { + if (content === null) fs.rmSync(path.join(root, rel)); + else write(root, { [rel]: content }); +} + +async function index(root: string): Promise { + const graph = await CodeGraph.init(root, { index: true }); + graphs.push(graph); + return graph; +} + +/** ` → ` for every navigation out of `file`. */ +function navigations(graph: CodeGraph, file: string): string[] { + const ids = graph.getNodesInFile(file).map((n) => n.id); + return graph + .getOutgoingEdgesFrom(ids, ['navigates']) + .map((edge) => `${String(edge.metadata?.href)} → ${graph.getNode(edge.target)?.name}`) + .sort(); +} + +/** The same files, indexed from scratch in a folder of their own. */ +async function freshNavigations(files: Record, file: string): Promise { + const root = tempRoot(); + write(root, files); + return navigations(await index(root), file); +} + +function internals(graph: CodeGraph): { queries: QueryBuilder; resolver: ReferenceResolver } { + return graph as unknown as { queries: QueryBuilder; resolver: ReferenceResolver }; +} + +describe('sync binds a navigation call to a route that appears later', () => { + it.each(CASES)('$name', async (c) => { + const root = tempRoot(); + const without = { ...c.files }; + if (c.route.before === null) delete without[c.route.file]; + else without[c.route.file] = c.route.before; + write(root, without); + const graph = await index(root); + const missing = navigations(graph, c.from); + expect(missing).not.toContain(c.expected); + const fresh = await freshNavigations(c.files, c.from); + expect(fresh).toContain(c.expected); + + // The route appears; the file with the call does not change. + put(root, c.route.file, c.files[c.route.file]!); + await graph.sync(); + expect(navigations(graph, c.from)).toEqual(fresh); + expect(graph.getPendingReferenceCount()).toBe(0); + + // Gone again, and back: the call parks while the route is missing and + // binds again when it returns, as a full index of each state has it. + put(root, c.route.file, c.route.before); + await graph.sync(); + expect(navigations(graph, c.from)).toEqual(missing); + put(root, c.route.file, c.files[c.route.file]!); + await graph.sync(); + expect(navigations(graph, c.from)).toEqual(fresh); + expect(graph.getPendingReferenceCount()).toBe(0); + }, 120_000); +}); + +describe('a route that goes away', () => { + it('lets a call two routes tied for bind to the one left', async () => { + const files = { + 'package.json': JSON.stringify({ name: 'admin', dependencies: { react: '18', 'react-router-dom': '6' } }), + 'src/App.jsx': + "import { Routes, Route } from 'react-router-dom'\n" + + "import { User } from './User'\n" + + 'export const App = () => (\n' + + ' \n' + + ' } />\n' + + ' \n' + + ')\n', + 'src/User.jsx': 'export const User = () =>
User
\n', + 'src/Admin.jsx': + "import { useNavigate } from 'react-router-dom'\n" + + 'export const Admin = () => {\n' + + ' const navigate = useNavigate()\n' + + " const open = () => navigate('/users/5')\n" + + ' return \n' + + '}\n', + }; + const root = tempRoot(); + write(root, files); + // A second route of the same shape: `/users/5` matches both, and a tie names nothing. + put(root, 'src/App.jsx', files['src/App.jsx'].replace(' ', ' } />\n ')); + const graph = await index(root); + expect(navigations(graph, 'src/Admin.jsx')).toEqual([]); + + put(root, 'src/App.jsx', files['src/App.jsx']); + await graph.sync(); + expect(navigations(graph, 'src/Admin.jsx')).toEqual(['/users/5 → /users/:id']); + expect(navigations(graph, 'src/Admin.jsx')).toEqual(await freshNavigations(files, 'src/Admin.jsx')); + }, 60_000); +}); + +describe('reopenNavigationsFor', () => { + const app = (name: string) => ({ + [`apps/${name}/package.json`]: JSON.stringify({ name, dependencies: { react: '18', 'react-router-dom': '6' } }), + [`apps/${name}/src/App.jsx`]: + "import { Routes, Route } from 'react-router-dom'\n" + + "import { Home } from './Home'\n" + + 'export const App = () => (\n \n } />\n \n)\n', + // Two calls to routes the app does not have, and one to its `/`. + [`apps/${name}/src/Home.jsx`]: + "import { useNavigate } from 'react-router-dom'\n" + + 'export const Home = () => {\n' + + ' const navigate = useNavigate()\n' + + " const go = () => navigate('/login')\n" + + " const back = () => navigate('/back')\n" + + " const home = () => navigate('/')\n" + + ' return \n' + + '}\n', + }); + + async function monorepo(): Promise { + const root = tempRoot(); + write(root, { 'package.json': JSON.stringify({ name: 'mono', private: true, workspaces: ['apps/*'] }), ...app('web'), ...app('admin') }); + return index(root); + } + + /** ` failed` per failed `navigate` call, ` → ` per navigation. */ + const navigationState = (graph: CodeGraph): string[] => [ + ...internals(graph).queries.getFailedCallsByTail(['navigate']).map((ref) => `${ref.filePath} failed`), + ...internals(graph).queries.getResolvedNavigations().map((e) => `${e.sourceFilePath} → ${graph.getNode(e.target)?.name}`), + ].sort(); + + /** The app's `/` route, moved to `path` — a route of that app the sync added. */ + const routeAt = (route: Node, path: string): Node => ({ ...route, id: route.id.replace(/:\/$/, `:${path}`), name: path }); + + it('re-opens the calls in the app the route belongs to, failed and resolved, and no others', async () => { + const graph = await monorepo(); + const { queries, resolver } = internals(graph); + const admin = ['apps/admin/src/Home.jsx failed', 'apps/admin/src/Home.jsx failed', 'apps/admin/src/Home.jsx → /']; + expect(navigationState(graph)).toEqual([...admin, 'apps/web/src/Home.jsx failed', 'apps/web/src/Home.jsx failed', 'apps/web/src/Home.jsx → /']); + const web = queries.getNodesByKind('route').find((r) => r.filePath === 'apps/web/src/App.jsx')!; + + expect(resolver.reopenNavigationsFor([routeAt(web, '/login')])).toBe(3); + expect(navigationState(graph)).toEqual(admin); + expect(graph.getPendingReferenceCount()).toBe(3); + }, 60_000); + + it('leaves alone the calls in a file the sync just resolved', async () => { + const graph = await monorepo(); + const { queries, resolver } = internals(graph); + const web = queries.getNodesByKind('route').find((r) => r.filePath === 'apps/web/src/App.jsx')!; + expect(resolver.reopenNavigationsFor([routeAt(web, '/login')], ['apps/web/src/Home.jsx'])).toBe(0); + expect(graph.getPendingReferenceCount()).toBe(0); + }, 60_000); + + it('skips a name more calls share than the ceiling', async () => { + const graph = await monorepo(); + const { queries, resolver } = internals(graph); + // Both apps gain a route: six `navigate` calls between them. + const added = queries.getNodesByKind('route').map((r) => routeAt(r, '/login')); + expect(resolver.reopenNavigationsFor(added, [], 5)).toBe(0); + expect(resolver.reopenNavigationsFor(added, [], 6)).toBe(6); + }, 60_000); + + it('passes over a route no router navigates to', async () => { + const graph = await monorepo(); + const { queries, resolver } = internals(graph); + // A nested route's relative path is not a destination of its own. + const added = queries.getNodesByKind('route').map((r) => routeAt(r, 'login')); + const lookup = vi.spyOn(QueryBuilder.prototype, 'getFailedCallsByTail'); + expect(resolver.reopenNavigationsFor(added)).toBe(0); + expect(lookup).not.toHaveBeenCalled(); + }, 60_000); +}); + +describe('changedRoutes', () => { + const route = (filePath: string, name: string, id = `route:${filePath}:1:${name}`): Node => ({ + id, kind: 'route', name, qualifiedName: `${filePath}::route:${name}`, filePath, language: 'jsx', + startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0, + }); + + it('is the routes on one side only, by file and path', () => { + const kept = route('src/App.jsx', '/'); + const moved = route('src/App.jsx', '/cart'); + const gone = route('src/App.jsx', '/login'); + const added = route('src/Admin.jsx', '/admin'); + // A body edit moves a route's line, and with it its id: still the same route. + const after = [{ ...kept, id: 'route:src/App.jsx:2:/', startLine: 2 }, moved, added]; + expect(changedRoutes([kept, moved, gone], after)).toEqual([gone, added]); + }); + + it('counts a route renamed in place as its old path and its new one', () => { + const old = route('src/router.tsx', '/auth/sign-in', 'route:src/router.tsx:5:paths.auth.login.path'); + const renamed = { ...old, name: '/auth/login' }; + expect(changedRoutes([old], [renamed])).toEqual([old, renamed]); + }); +}); + +describe('a sync that changes no route', () => { + it('looks up no failed navigation', async () => { + const c = CASES[0]!; + const root = tempRoot(); + write(root, c.files); + const graph = await index(root); + const lookup = vi.spyOn(QueryBuilder.prototype, 'getFailedCallsByTail'); + // A body edit to the file the routes are declared in, and to an unrelated one. + put(root, c.route.file, c.files[c.route.file]!.replace('export default App', 'export default App // routes')); + put(root, 'src/screens/LoginScreen.js', 'const LoginScreen = () =>
Sign in
\nexport default LoginScreen\n'); + const result = await graph.sync(); + expect(result.filesModified).toBe(2); + expect(lookup).not.toHaveBeenCalled(); + }, 60_000); + + it('takes no route snapshot without a router that navigates', async () => { + const root = tempRoot(); + write(root, { 'package.json': JSON.stringify({ name: 'lib' }), 'src/a.ts': 'export function a() { return 1; }\n' }); + const graph = await index(root); + const reopen = vi.spyOn(internals(graph).resolver, 'reopenNavigationsFor'); + put(root, 'src/a.ts', 'export function a() { return 2; }\n'); + expect((await graph.sync()).filesModified).toBe(1); + expect(reopen).not.toHaveBeenCalled(); + }, 60_000); +}); + +describe('FrameworkResolver.navigation', () => { + // Navigation calls each router's own tests resolve. + const CALLS: Record = { + 'react-router': ['history.push', 'history.replace', 'navigate', 'navigate.push', 'router.navigate', 'redirect'], + nextjs: ['router.push', 'router.replace', 'router.prefetch', 'redirect', 'permanentRedirect', 'NextResponse.redirect'], + 'expo-router': ['router.push', 'router.replace', 'router.navigate', 'router.dismissTo', 'nav.push'], + 'vue-router': ['router.push', 'this.$router.push', '$router.replace', 'navigateTo'], + 'angular-router': ['this.router.navigate', 'router.navigateByUrl', 'this._router.createUrlTree', 'Router.parseUrl'], + 'tanstack-router': ['navigate', 'redirect', 'router.navigate', 'Route.navigate'], + 'sveltekit-router': ['goto', 'redirect'], + }; + + it('is set by every router that binds navigation calls, and finds each by its tail', () => { + const routers = getAllFrameworkResolvers().filter((r) => r.navigation); + expect(routers.map((r) => r.name).sort()).toEqual(Object.keys(CALLS).sort()); + for (const router of routers) { + for (const call of CALLS[router.name]!) { + expect(router.claimsReference?.(call), `${router.name} claims ${call}`).toBe(true); + expect(router.navigation!.tails, `${router.name} ${call}`).toContain(referenceNameTail(call, 'calls')); + } + } + }); +}); diff --git a/docs/benchmarks/index-drift-cg33.md b/docs/benchmarks/index-drift-cg33.md index 58fdce46a..2778beaf0 100644 --- a/docs/benchmarks/index-drift-cg33.md +++ b/docs/benchmarks/index-drift-cg33.md @@ -125,6 +125,69 @@ named `push`, or a Rust method named `join`. **The full rebuild is the wrong one here** — converging would mean teaching sync to manufacture thousands of wrong edges. Left as is, deliberately. +### Navigation waits on a route, not a name (2026-10) + +A router's navigation call — `history.push('/login')`, `navigate('/login')`, +`router.push('/x')`, `goto('/x')` — names its route by path and its reference +by the router's method, so a route that changes in another file is invisible +to every pass above: + +- **A call parked while its route was missing.** The #1240 retry looks failed + refs up by the names the synced files define, and a call's name tail is + `push`, never a path. On proshop_mern, deleting and restoring + `` left six `history.push('/login')` calls failed and + their six `navigates` edges missing. +- **A call that bound elsewhere meanwhile.** It resolved, so nothing revisits + it: to a catch-all screen (Obytes' Expo starter: `router.replace('/login')` + stayed on `[...messing].tsx` after `login.tsx` came back), to a parameter + route (`/users/new` on `/users/:id`), or to the other arm of a conditional + (sveltekit-realworld's `redirect` lost its `/login` arm for good). +- **A route renamed in place.** `runPostExtract` renames a route whose path is + built from a constant another file holds, an Angular route under its mount, + or a React Router table route, and keeps its node, so the calls bound to it + keep pointing at a path it no longer has. `definitionDelta` is read before + that pass. +- **A link in markup.** ``, `routerLink` and the like are a + synthesizer's edges, and a routes file that matches none of the synthesis + triggers (an Angular `Routes` array, Ghostfolio's `home-page.routes.ts`) + does not send a sync to the synthesizers. + +Sync now reads every route node at its first file change, only when a router +that resolves navigation calls is detected, and again after `runPostExtract`. +When a route is on one side only (`changedRoutes`), the calls that routers' +`navigation` hooks say it can answer go back to the pending set for the +orphan sweep: the failed ones, found by their method tails through +`idx_unresolved_failed_tail`, and the resolved ones, whose `navigates` edges +are turned back into the references that made them, as the rebind does. A +call counts when the router's `claimsReference` accepts its whole name and its +file is in an app whose table holds the route, and a name more such calls +share than the same 500 ceiling is skipped. The sync also refreshes synthesis. +A sync that changes no route reads the routes twice and stops there. + +Measured by deleting a route (or renaming it), syncing, restoring it and +syncing again, with both states diffed against a fresh index of the same tree. +On `main` each of ten scenarios drifted in one of the two states; with the fix +all twenty match: proshop_mern (``), next-saas-starter +(the `/sign-in` and `/pricing` pages), create-t3-turbo (`/`), vue-realworld +(`/login` in the router config), sveltekit-realworld (`login/+page.svelte`), +angular-realworld (`login` in `app.routes.ts`), Obytes' Expo starter +(`app/login.tsx`), Ghostfolio (`home-page.routes.ts`), and bulletproof-react +with its `/auth/login` path constant renamed and back — the one that drifts +while the route is renamed rather than after it returns. CleanArchitecture's +React client, whose routes are a table `App.jsx` maps into ``, drifted +when `App.jsx` stopped mapping it and started again: its two +`navigate('/login')` calls stayed failed, though `AppRoutes.jsx` itself never +changed. With the fix every step of that replay matches. A sync that changes +no route spends 0–3 ms on the two reads (proshop's 49 routes, Ghostfolio's +203); a route change on Ghostfolio spends 12–17 ms putting 40–50 calls back. + +Known limits, deliberate: a call bound by name to a method while its route +was missing (a `redirect` that matched a project method called `redirect`) is +not revisited, since only `navigates` edges are looked at; a project's own +wrapper (Expo's `safePush`) is not looked up, since its tail is its own name +and the failed-tail index cannot find a suffix; and a Vue route whose `name:` +alone changes changes no route node. + ### `codegraph status` — decided: no drift metric The issue asked whether `status` should surface divergence. Decision: **no**. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index b6f5e5648..1ac0eb989 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -278,11 +278,11 @@ Each of these cost real debugging time; they are not hypothetical. `.map` whose callback renders a `` from the item's own fields (`{...route}`, `{...rest}`, `path={route.path}` — never `path={r.layout + r.path}`), follows the import to the table and the tables - it names in turn, and owns its routes by id (`…:table:`). Two limits + it names in turn, and owns its routes by id (`…:table:`). One limit it shares with every router: a table rendered by a component that is itself mounted under another file's `` reads as if - at the root, and a navigation that failed while its route was gone is not - retried until a full index. + at the root. A sync that changes such a route resolves the calls it can + answer again (16), as for any other route. 14. **An index route is at its parent's address, so it needs that address written down.** `} />` inside `}>` is the page at `/`, and Layout is the @@ -315,6 +315,18 @@ Each of these cost real debugging time; they are not hypothetical. modules, ASP.NET's Razor `@model` — because widening `languages` also runs `extract()` on those files. `claimsReference()` is still asked of every detected framework. +16. **A navigation call waits for a route, not for a name.** A call indexed + before its route existed is parked as failed (or bound to a catch-all, a + parameter route, the other arm of a conditional), and sync revisits a + failed reference only by matching its last name segment against the names + the synced files define — never `push` or `navigate`. Set the resolver's + `navigation`: `tails`, the method names its calls end in, and `scope`, the + apps whose calls one of its routes can answer (its own app's table, or every + file for a table the whole project shares). A sync that adds, removes or + renames a route — a cross-file table route included — then resolves those + calls again, failed or not; without it the synced index keeps an answer a + full index does not have. `sync-navigation-retry.test.ts` runs all seven + routers through it, and asserts each router's calls end in a declared tail. --- diff --git a/src/db/queries.ts b/src/db/queries.ts index 2e18bace9..f1a49cb36 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3939,6 +3939,70 @@ export class QueryBuilder { })); } + /** + * Failed `calls` refs whose name tail is one of `tails` — the navigation + * calls (`history.push`, `navigate`) a sync looks through after a route + * appeared or went away. Read through the failed-tail index; the caller + * decides on each whole name. + */ + getFailedCallsByTail(tails: string[]): Array<{ rowId: number; referenceName: string; filePath: string }> { + const out: Array<{ rowId: number; referenceName: string; filePath: string }> = []; + const unique = [...new Set(tails)]; + for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare( + `SELECT id, reference_name, file_path FROM unresolved_refs + WHERE status = 'failed' AND reference_kind = 'calls' AND name_tail IN (${placeholders})` + ) + .all(...chunk) as Array<{ id: number; reference_name: string; file_path: string }>; + for (const row of rows) out.push({ rowId: row.id, referenceName: row.reference_name, filePath: row.file_path }); + } + return out; + } + + /** + * The `navigates` edges a router's resolver made (not a synthesizer's), with + * the source file and language a resurrection needs — the navigation calls + * a sync re-resolves after a route appeared or went away. + */ + getResolvedNavigations(): Array { + const rows = this.db + .prepare( + `SELECT e.*, src.file_path AS source_file_path, src.language AS source_language + FROM edges e + JOIN nodes src ON src.id = e.source + WHERE e.kind = 'navigates' AND (e.provenance IS NULL OR e.provenance != 'heuristic')` + ) + .all() as Array; + return rows.map((row) => ({ + ...rowToEdge(row), + edgeId: row.id, + sourceFilePath: row.source_file_path, + sourceLanguage: row.source_language, + })); + } + + /** + * Put failed refs back in the pending set, for the next resolution pass — + * the sync's orphan sweep — to try again. Returns the number re-opened. + */ + reopenFailedReferences(rowIds: number[]): number { + if (rowIds.length === 0) return 0; + let changed = 0; + this.db.transaction(() => { + for (let i = 0; i < rowIds.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = rowIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + changed += this.db + .prepare(`UPDATE unresolved_refs SET status = 'pending' WHERE status = 'failed' AND id IN (${placeholders})`) + .run(...chunk).changes; + } + })(); + return changed; + } + /** * Resolution edges whose TARGET symbol is named one of `names` — the edges a * sync must re-resolve after `names` gained or lost a definition (CG-33). diff --git a/src/index.ts b/src/index.ts index a789730ee..127d9cf8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ import { import { detectLanguage, hasGrammarLoadFailure, isFileLevelOnlyLanguage } from './extraction/grammars'; import { ReferenceResolver, + changedRoutes, createResolver, ResolutionResult, } from './resolution'; @@ -922,8 +923,15 @@ export class CodeGraph { let refreshSynthesis = this.queries.getMetadata('synthesis_pending') === '1' || this.queries.getUnresolvedReferencesCount() > 0; if (refreshSynthesis) this.queries.setMetadata('synthesis_pending', '1'); + // The route nodes as they were before this sync replaced or removed a + // file — read at the first change, so a sync that changes nothing + // reads nothing, and only where a router binds navigation calls to + // routes. Compared with the routes after runPostExtract below. + const watchRoutes = this.resolver.hasNavigationRouters(); + let routesBefore = null as Node[] | null; const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure, (filePath, content) => { + if (watchRoutes && routesBefore === null) routesBefore = this.queries.getNodesByKind('route'); if (!refreshSynthesis && (this.queries.hasSynthesizedEdgesTouchingFile(filePath) || this.queries.wasSynthesisInput(filePath) || (content !== undefined && hasSynthesisPattern(filePath, content)))) { @@ -952,6 +960,29 @@ export class CodeGraph { this.resolver.runPostExtract(); } + // A route that appeared, went away or was renamed changes what a + // navigation call in an UNCHANGED file resolves to: `history.push( + // '/login')` binds again once `/login` is back. While the route was + // missing the call was parked as failed, or bound to a catch-all, and + // nothing below revisits it: the retry keys on the names this sync's + // files define, which a call named for the router's method (`push`) + // never matches. Put those calls back in the pending set for the sweep + // below, and let the synthesizers redraw the links markup makes to + // routes. The route table is final here: renames and table routes are + // runPostExtract's. + if (routesBefore) { + const tNav = Date.now(); + const routes = changedRoutes(routesBefore, this.queries.getNodesByKind('route')); + const reopened = this.resolver.reopenNavigationsFor(routes, result.changedFilePaths ?? []); + if (routes.length > 0 && !refreshSynthesis) { + refreshSynthesis = true; + this.queries.setMetadata('synthesis_pending', '1'); + } + if (process.env.CODEGRAPH_SYNTH_TIMINGS) { + console.error(`[phase-timing] sync-navigation-retry: ${Date.now() - tNav}ms (${routes.length} routes changed, ${reopened} refs re-opened)`); + } + } + // Resolve references if files were updated const filesChanged = result.filesAdded > 0 || result.filesModified > 0; if (filesChanged) { diff --git a/src/resolution/frameworks/angular-router.ts b/src/resolution/frameworks/angular-router.ts index 0980c4a5c..5d37951a9 100644 --- a/src/resolution/frameworks/angular-router.ts +++ b/src/resolution/frameworks/angular-router.ts @@ -693,6 +693,11 @@ function angularAppRoot(filePath: string, context: ResolutionContext): string { export type AngularRouteTable = RootedRouteTable; +/** True for a route the table holds: this resolver's, at an absolute path. */ +function inAngularTable(node: Node): boolean { + return isAngularRoute(node) && node.name.startsWith('/'); +} + const tables = new WeakMap(); export function angularRouteTable(context: ResolutionContext): AngularRouteTable { @@ -702,7 +707,7 @@ export function angularRouteTable(context: ResolutionContext): AngularRouteTable const byRoot = new Map(); const byFile = new Map(); for (const node of all) { - if (!isAngularRoute(node) || !node.name.startsWith('/')) continue; + if (!inAngularTable(node)) continue; const root = angularAppRoot(node.filePath, context); let t = byRoot.get(root); if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] })); @@ -760,6 +765,23 @@ export function angularRouteTable(context: ResolutionContext): AngularRouteTable return table; } +const appCounts = new WeakMap(); + +/** + * How many apps the table splits into — `byRoot.size` of + * {@link angularRouteTable}, counted from the routes alone: building the table + * reads every file that mounts or redirects. + */ +function angularAppCount(context: ResolutionContext): number { + const all = context.getNodesByKind('route'); + const cached = appCounts.get(context); + if (cached && cached.source === all) return cached.count; + const roots = new Set(); + for (const node of all) if (inAngularTable(node)) roots.add(angularAppRoot(node.filePath, context)); + appCounts.set(context, { source: all, count: roots.size }); + return roots.size; +} + /** * The routes a file navigates among: its app's, or — for a shared library * outside any app (an Nx `libs/ui`) — the one app's, when there is only one. @@ -970,6 +992,17 @@ export const angularRouterResolver: FrameworkResolver = { return NAV_CALL.test(name) || LAZY_COMPONENT_REF.test(name) || LAYOUT_REF.test(name); }, + navigation: { + tails: ['navigate', 'navigateByUrl', 'createUrlTree', 'parseUrl'], + // A call matches against its own app's table — or, in a workspace of one + // app, against that app's from anywhere, a shared library included + // (`angularRoutesFor`). + scope: (route, context) => { + if (!inAngularTable(route)) return null; + return angularAppCount(context) === 1 ? [''] : [angularAppRoot(route.filePath, context)]; + }, + }, + extract(filePath: string, content: string): FrameworkExtractionResult { if (!/\.[cm]?ts$/.test(filePath) || filePath.endsWith('.d.ts')) return { nodes: [], references: [] }; const { routes } = parseAngularRoutes(content); diff --git a/src/resolution/frameworks/expo-router.ts b/src/resolution/frameworks/expo-router.ts index ef6f44d81..e6641a460 100644 --- a/src/resolution/frameworks/expo-router.ts +++ b/src/resolution/frameworks/expo-router.ts @@ -610,6 +610,15 @@ export interface RouteTable { const tables = new Map(); +/** + * True for this framework's own route nodes: the ones whose name IS the path + * derived from their file. Express/SvelteKit routes in the same project name + * themselves differently and never match. + */ +function isScreenRoute(node: Node): boolean { + return routePathForFile(node.filePath) === node.name; +} + export function routeTable(context: ResolutionContext): RouteTable { const all = context.getNodesByKind('route'); const key = context.getProjectRoot(); @@ -618,10 +627,7 @@ export function routeTable(context: ResolutionContext): RouteTable { const exact = new Map(); const dynamic: RouteEntry[] = []; for (const node of all) { - // Only this framework's own route nodes: the ones whose name IS the path - // derived from their file. Express/SvelteKit routes in the same project - // name themselves differently and never match. - if (routePathForFile(node.filePath) !== node.name) continue; + if (!isScreenRoute(node)) continue; exact.set(node.name, node); if (node.name.includes('[')) dynamic.push({ node, segs: node.name.split('/').slice(1) }); } @@ -749,6 +755,14 @@ export const expoRouterResolver: FrameworkResolver = { return NAV_METHOD.test(name); }, + navigation: { + // A project's own wrapper (`safePush`) is left out: its tail is its own + // name, which the failed-tail index cannot find by suffix. + tails: ['push', 'replace', 'navigate', 'dismissTo'], + // One table for the whole project, and any file's call matches against it. + scope: (route) => (isScreenRoute(route) ? [''] : null), + }, + extract(filePath: string, content: string) { const apiPath = apiRoutePathForFile(filePath); if (apiPath !== null) { diff --git a/src/resolution/frameworks/nextjs.ts b/src/resolution/frameworks/nextjs.ts index 22b85f746..a2aba3d5d 100644 --- a/src/resolution/frameworks/nextjs.ts +++ b/src/resolution/frameworks/nextjs.ts @@ -125,6 +125,12 @@ interface NextTable extends RouteTable { const tables = new WeakMap(); +/** The page a route node is, or null for a node the table leaves out (a handler, another framework's route). */ +function tablePage(node: Node): NextRouteFile | null { + const file = nextRouteForFile(node.filePath); + return file && file.kind === 'page' && file.path === node.name ? file : null; +} + export function nextRouteTable(context: ResolutionContext): NextTable { const all = context.getNodesByKind('route'); const cached = tables.get(context); @@ -133,8 +139,8 @@ export function nextRouteTable(context: ResolutionContext): NextTable { const dynamic: RouteTable['dynamic'] = []; const roots = new Set(); for (const node of all) { - const file = nextRouteForFile(node.filePath); - if (!file || file.kind !== 'page' || file.path !== node.name) continue; + const file = tablePage(node); + if (!file) continue; exact.set(node.name, node); if (node.name.includes(':')) dynamic.push({ node, segs: node.name.split('/').slice(1) }); roots.add(file.root); @@ -235,6 +241,13 @@ export const nextjsResolver: FrameworkResolver = { return NAV_CALL.test(name); }, + navigation: { + tails: ['push', 'replace', 'prefetch', 'redirect', 'permanentRedirect'], + // One table holds every Next app's pages, and a call under any of them + // matches against all of it. + scope: (route, context) => (tablePage(route) ? nextRouteTable(context).roots : null), + }, + extract(filePath: string, content: string) { const file = nextRouteForFile(filePath); if (!file) return { nodes: [], references: [] }; diff --git a/src/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts index 59404a643..b13292272 100644 --- a/src/resolution/frameworks/react-router.ts +++ b/src/resolution/frameworks/react-router.ts @@ -95,6 +95,15 @@ function isReactRouterRoute(node: Node): boolean { return rest === node.name || (!/^[A-Z]+:/.test(rest) && Boolean(node.signature?.startsWith('route-parts:'))); } +/** + * True for a route the table holds. 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. + */ +function isDestination(node: Node): boolean { + return isReactRouterRoute(node) && node.name.startsWith('/') && !node.name.endsWith('*'); +} + /** `:id?` — a parameter React Router serves the route with or without. */ function isOptionalParam(seg: string): boolean { return seg.startsWith(':') && seg.endsWith('?'); @@ -114,10 +123,7 @@ export function reactRouterTable(context: ResolutionContext): ReactRouterTable { 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; + if (!isDestination(node)) 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); @@ -179,6 +185,12 @@ export const reactRouterResolver: FrameworkResolver = { return NAV_CALL.test(name); }, + navigation: { + tails: ['push', 'replace', 'navigate', 'redirect'], + // A call matches against the table of the app its file is in. + scope: (route) => (isDestination(route) ? [reactRouterRoot(route.filePath)] : null), + }, + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { if (ref.referenceKind !== 'calls') return null; const verb = reactRouterNavVerb(ref.referenceName); diff --git a/src/resolution/frameworks/sveltekit-router.ts b/src/resolution/frameworks/sveltekit-router.ts index bf105e9b8..95dcb0995 100644 --- a/src/resolution/frameworks/sveltekit-router.ts +++ b/src/resolution/frameworks/sveltekit-router.ts @@ -59,6 +59,14 @@ function isSvelteKitPage(node: Node): boolean { ); } +/** + * True for a page the table holds. `[...rest]` becomes `*rest`, which matches + * anything — never an answer. + */ +function inSvelteKitTable(node: Node): boolean { + return isSvelteKitPage(node) && node.name.startsWith('/') && !node.name.includes('*'); +} + /** `:id?` — a parameter SvelteKit serves the route with or without. */ function isOptionalParam(seg: string): boolean { return seg.startsWith(':') && seg.endsWith('?'); @@ -78,9 +86,7 @@ export function svelteKitTable(context: ResolutionContext): SvelteKitTable { 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; + if (!inSvelteKitTable(node)) continue; const root = appRootFor(node.filePath); addRouteTo(tableAt(root), node.name, node); // `[[optional]]` is `:x?`: the route serves the path with and without it. @@ -129,6 +135,12 @@ export const svelteKitRouterResolver: FrameworkResolver = { return NAV_CALL.test(name); }, + navigation: { + tails: ['goto', 'redirect'], + // A call matches against the table of the app its file is in. + scope: (route) => (inSvelteKitTable(route) ? [appRootFor(route.filePath)] : null), + }, + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // `import { redirect } from '@sveltejs/kit'` is not a navigation. if (ref.referenceKind !== 'calls') return null; diff --git a/src/resolution/frameworks/tanstack-router.ts b/src/resolution/frameworks/tanstack-router.ts index 844eefeff..935389fab 100644 --- a/src/resolution/frameworks/tanstack-router.ts +++ b/src/resolution/frameworks/tanstack-router.ts @@ -379,6 +379,12 @@ export const tanstackRouterResolver: FrameworkResolver = { return NAV_CALL.test(name); }, + navigation: { + tails: ['navigate', 'redirect'], + // A call matches against the table of the app its file is in. + scope: (route) => (isTanstackRoute(route) ? [appRootFor(route.filePath)] : null), + }, + 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 diff --git a/src/resolution/frameworks/vue-router.ts b/src/resolution/frameworks/vue-router.ts index 84a1a9beb..f84dad897 100644 --- a/src/resolution/frameworks/vue-router.ts +++ b/src/resolution/frameworks/vue-router.ts @@ -243,6 +243,11 @@ function isNuxtPage(node: Node): boolean { ); } +/** True for a route the table holds: a config route or a Nuxt page, at an absolute path. */ +function inVueTable(node: Node): boolean { + return (isVueConfigRoute(node) || isNuxtPage(node)) && node.name.startsWith('/'); +} + const tables = new WeakMap(); export function vueRouteTable(context: ResolutionContext): VueRouteTable { @@ -257,12 +262,10 @@ export function vueRouteTable(context: ResolutionContext): VueRouteTable { return t; }; for (const node of all) { - const config = isVueConfigRoute(node); - if (!config && !isNuxtPage(node)) continue; - if (!node.name.startsWith('/')) continue; + if (!inVueTable(node)) continue; const root = appRootFor(node.filePath); addRouteTo(tableAt(root), node.name, node); - if (config) { + if (isVueConfigRoute(node)) { const group = configFiles.get(node.filePath); if (group) group.nodes.push(node); else configFiles.set(node.filePath, { root, nodes: [node] }); @@ -412,6 +415,12 @@ export const vueRouterResolver: FrameworkResolver = { return NAV_CALL.test(name) || name.startsWith('import:') || name.startsWith('layout:'); }, + navigation: { + tails: ['push', 'replace', 'navigateTo'], + // A call matches against the table of the app its file is in. + scope: (route) => (inVueTable(route) ? [appRootFor(route.filePath)] : null), + }, + extract(filePath: string, content: string): FrameworkExtractionResult { const entries = parseVueRoutes(content, filePath); if (entries.length === 0) return { nodes: [], references: [] }; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 751ad9ccb..425a83f7a 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -2574,6 +2574,91 @@ export class ReferenceResolver { return this.frameworks.map((f) => f.name); } + /** True when a detected router binds navigation calls to its routes (see {@link reopenNavigationsFor}). */ + hasNavigationRouters(): boolean { + return this.frameworks.some((f) => f.navigation !== undefined); + } + + /** + * Put back in the pending set the navigation calls `routes` — route nodes a + * sync added, removed or renamed ({@link changedRoutes}) — may now resolve + * differently, for the sync's resolution sweep: the calls parked as failed, + * and the calls whose `navigates` edges a router's resolver made. Calls in + * `changedFilePaths` are left alone; the sync resolved them against the new + * routes already. Returns the number of references put back. + * + * A navigation call names its route by path (`navigate('/login')`) and its + * reference by the router's method (`navigate`, `history.push`), so a route + * table that changed in another file is invisible to the rest of sync. A + * call parked as failed while its route was missing is keyed on `push`, + * which no synced file defines, and a resolved call keeps the route it bound + * to while a better one was missing (a catch-all, a parameter route, one arm + * of a conditional) or a route renamed under it. A full index resolves every + * call against the final table. Each router says which calls a route of its + * own can answer (`FrameworkResolver.navigation`): the method tails, and the + * apps whose files match against that route's table. A name more of those + * calls share than `perNameCeiling` is skipped, as the symbol retry skips a + * common tail. + */ + reopenNavigationsFor(routes: readonly Node[], changedFilePaths: readonly string[] = [], perNameCeiling: number = 500): number { + if (routes.length === 0) return 0; + const plans: Array<{ router: FrameworkResolver; scopes: string[] }> = []; + const tails = new Set(); + for (const router of this.frameworks) { + if (!router.navigation) continue; + const scopes = new Set(); + for (const route of routes) { + for (const scope of router.navigation.scope(route, this.context) ?? []) scopes.add(scope); + } + if (scopes.size === 0) continue; + plans.push({ router, scopes: [...scopes] }); + for (const tail of router.navigation.tails) tails.add(tail); + } + if (plans.length === 0) return 0; + const claimed = (name: string, filePath: string): boolean => + plans.some(({ router, scopes }) => scopes.some((scope) => filePath.startsWith(scope)) && router.claimsReference?.(name) === true); + + const fresh = new Set(changedFilePaths); + const failed = new Map(); + for (const ref of this.queries.getFailedCallsByTail([...tails])) { + if (fresh.has(ref.filePath) || !claimed(ref.referenceName, ref.filePath)) continue; + const rowIds = failed.get(ref.referenceName); + if (rowIds) rowIds.push(ref.rowId); + else failed.set(ref.referenceName, [ref.rowId]); + } + const resolved = new Map>(); + for (const edge of this.queries.getResolvedNavigations()) { + const name = edge.metadata?.refName; + if (typeof name !== 'string' || fresh.has(edge.sourceFilePath) || !claimed(name, edge.sourceFilePath)) continue; + const edges = resolved.get(name); + if (edges) edges.push(edge); + else resolved.set(name, [edge]); + } + + const rowIds: number[] = []; + const edgeIds: number[] = []; + const refs: UnresolvedReference[] = []; + // A conditional's arms are edges of one call: it goes back as one reference. + const sites = new Set(); + for (const name of new Set([...failed.keys(), ...resolved.keys()])) { + const rows = failed.get(name) ?? []; + const edges = resolved.get(name) ?? []; + if (rows.length + edges.length > perNameCeiling) continue; + for (const rowId of rows) rowIds.push(rowId); + for (const edge of edges) { + const ref = resurrectRefFromDroppedEdge(edge); + if (!ref) continue; + edgeIds.push(edge.edgeId); + const site = `${ref.fromNodeId}\0${ref.referenceName}\0${ref.line}\0${ref.column}`; + if (sites.has(site)) continue; + sites.add(site); + refs.push(ref); + } + } + if (edgeIds.length > 0) this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs); + return this.queries.reopenFailedReferences(rowIds) + refs.length; + } + /** * True when `receiver` is a local name bound by an import that resolves to a * file IN THIS PROJECT — the only case where letting a python @@ -3306,6 +3391,20 @@ function goDirOf(p: string): string { return dir === '.' || dir === '/' ? '' : dir; } +/** + * The routes a sync added, removed or renamed: `before` is every route node + * before the sync changed a file, `after` every one after its post-extract + * pass, and a route whose file and path are on one side only changed. A route + * renamed in place — the same node under a new path — is in the result twice, + * as its old self and its new one. + */ +export function changedRoutes(before: readonly Node[], after: readonly Node[]): Node[] { + const key = (route: Node): string => `${route.filePath}\0${route.name}`; + const beforeKeys = new Set(before.map(key)); + const afterKeys = new Set(after.map(key)); + return [...before.filter((route) => !afterKeys.has(key(route))), ...after.filter((route) => !beforeKeys.has(key(route)))]; +} + /** * Create a reference resolver instance */ diff --git a/src/resolution/types.ts b/src/resolution/types.ts index b3ed71a5a..f394d5289 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -258,6 +258,26 @@ export interface CrossFileNodes extends FrameworkExtractionResult { owns(node: Node): boolean; } +/** + * The navigation calls a router binds to its routes — `navigate('/login')`, + * `router.push('/x')` — described so a sync can find the ones a changed route + * may answer (see `FrameworkResolver.navigation`). + */ +export interface NavigationCalls { + /** + * The name tails of the calls `claimsReference` accepts as navigation — + * `push` for `history.push` — which find them through the failed-tail + * index; `claimsReference` then decides on the whole name. + */ + tails: readonly string[]; + /** + * The files whose navigation calls can name `route`: the path prefixes of + * the apps whose table it is in (`''` for every file), or null when it is + * not one of this router's routes. + */ + scope(route: Node, context: ResolutionContext): readonly string[] | null; +} + /** * Framework-specific resolver */ @@ -337,6 +357,16 @@ export interface FrameworkResolver { * next run puts it back. */ crossFileNodes?(context: ResolutionContext): CrossFileNodes; + /** + * Set by a router whose `resolve()` binds a navigation call to one of its + * route nodes. A call whose route did not exist yet was parked as failed, or + * bound to whatever answered it then (a catch-all, a parameter route), and + * the retry that matches a failed ref's name tail against the names a sync + * adds (#1240) never finds it: its name is the router's method, never the + * route's path. So a sync that adds, removes or renames a route puts the + * calls this describes back for its resolution sweep. + */ + navigation?: NavigationCalls; } /**