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; } /**