diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d0ce55..8e069b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now also link the imports of an added or restored JavaScript or TypeScript file whose name hundreds of other imports share, like a project's own `utils.ts` among `jotai/utils` and `@scope/pkg/utils` imports, or `assert.ts` in a project full of `import * as assert from 'assert'`. Before, a name that common was skipped as noise no single file could answer, so in a large project the file's own `./utils` and `~/settings/utils` imports stayed unlinked until the importing files changed or the project was re-indexed. - `codegraph sync` and the file watcher now link an import of a folder two or more levels up, like `import { Excalidraw } from "../.."` in excalidraw's tests, to that folder's `index` file when the file is added after the importing file was indexed, or comes back after being deleted. Before, only an import of the file's own folder or the one above (`.`, `..`) was relinked; one reaching further up stayed unlinked until the importing file changed or the project was re-indexed. - `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. +- In Nuxt, a `navigateTo('/login')` call now links to the page it opens, as `router.push('/login')` does, whether it is written in a page, a component, a composable or route middleware. Before, every `navigateTo` call was recorded as the calling code calling itself, so a flow through a Nuxt redirect stopped there. A call whose destination is computed, names no page, or leaves the app (`{ external: true }`) is left unlinked, never tied to a project function that only shares the name. Re-index Nuxt projects after upgrading. Thanks @naashw for the report. (#2634) - 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__/nuxt-navigate-to.test.ts b/__tests__/nuxt-navigate-to.test.ts new file mode 100644 index 00000000..4580380c --- /dev/null +++ b/__tests__/nuxt-navigate-to.test.ts @@ -0,0 +1,157 @@ +/** + * Nuxt's `navigateTo('/x')` navigates to the page at `/x`, as `router.push` + * does (#2634). + * + * `navigateTo` was in the Vue resolver's list of Nuxt auto-imports, which + * answers each one with a sink, an edge from the calling node to itself. The + * Vue resolver runs before Vue Router's, so the navigation was never read: + * every `navigateTo` became a self-pointing `calls` edge, in pages and in + * route middleware alike, and no `navigates` edge was drawn. + * + * Now Vue Router reads the destination, and Nuxt owns what it can't read: a + * computed destination, a path no page serves or an external URL calls Nuxt's + * `navigateTo` all the same, so it is left unresolved rather than linked to a + * project function that only shares the name (elk declares one inside a + * plugin). A file that declares or imports its own `navigateTo` calls that one. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import type { Edge, Node } from '../src/types'; + +const NUXT_APP: Record = { + 'package.json': JSON.stringify({ name: 'site', private: true, dependencies: { nuxt: '^4.0.0', vue: '^3.5.0' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '~/*': ['./*'] } } }), + // The issue's pages. + 'pages/index.vue': "\n\n", + 'pages/push.vue': + '\n' + + '\n', + 'pages/target.vue': '\n', + 'pages/login.vue': '\n', + 'middleware/auth.global.ts': + "export default defineNuxtRouteMiddleware((to) => {\n if (to.path !== '/login') {\n return navigateTo('/login');\n }\n});\n", + 'pages/account.vue': + '\n' + + '\n', + 'composables/useNav.ts': 'export function useNav() {\n return { go: (to: string) => navigateTo(to) };\n}\n', + // Two project functions named navigateTo: a plugin's own, and a helper's. + 'plugins/keys.client.ts': + 'export default defineNuxtPlugin(() => {\n' + + ' const navigateTo = (to: string) => {\n console.log(to);\n };\n' + + " window.addEventListener('keydown', () => navigateTo('/target'));\n" + + '});\n', + 'lib/open.ts': 'export function navigateTo(url: string) {\n return window.open(url);\n}\n', + 'pages/docs.vue': + "\n" + + '\n', +}; + +const roots: string[] = []; +function indexProject(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nuxt-nav-')); + roots.push(root); + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + return CodeGraph.init(root, { index: true }); +} + +afterAll(() => { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Nuxt navigateTo', () => { + let cg: CodeGraph; + beforeAll(async () => { + cg = await indexProject(NUXT_APP); + }); + afterAll(() => cg?.close()); + + const route = (name: string): Node => cg.getNodesByKind('route').find((r) => r.name === name)!; + const sym = (name: string, file: string): Node => cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'import')!; + /** The edges a node makes from its `navigateTo` calls. */ + const navigateToEdges = (from: Node): Edge[] => + cg.getOutgoingEdges(from.id).filter((e) => (e.metadata as Record | undefined)?.refName === 'navigateTo'); + const destinations = (from: Node) => + navigateToEdges(from).map((e) => `${e.kind} ${(e.metadata as Record).href} -> ${cg.getNode(e.target)?.name}`); + + it('navigates from a page to the page it names, with no edge from the page to itself', () => { + expect(destinations(sym('index', 'pages/index.vue'))).toEqual(['navigates /target -> /target']); + expect(navigateToEdges(sym('index', 'pages/index.vue'))[0]!.target).toBe(route('/target').id); + // router.push in the same app, unchanged. + expect(cg.getOutgoingEdges(sym('go', 'pages/push.vue').id).filter((e) => e.kind === 'navigates').map((e) => e.target)).toEqual([ + route('/target').id, + ]); + }); + + it('navigates from route middleware', () => { + const file = cg.getNodesInFile('middleware/auth.global.ts').find((n) => n.kind === 'file')!; + expect(destinations(file)).toEqual(['navigates /login -> /login']); + }); + + it('reads an object destination and a call written in the template', () => { + expect(destinations(sym('goLogin', 'pages/account.vue'))).toEqual(['navigates /login -> /login']); + expect(destinations(sym('account', 'pages/account.vue'))).toEqual(['navigates /target -> /target']); + }); + + it('leaves a computed destination, a path no page serves and an external URL unresolved', () => { + for (const [name, file] of [ + ['goNext', 'pages/account.vue'], + ['goMissing', 'pages/account.vue'], + ['goDocs', 'pages/account.vue'], + ['useNav', 'composables/useNav.ts'], + ] as const) { + expect(navigateToEdges(sym(name, file)), name).toEqual([]); + } + // Neither project function named navigateTo is called by any of them, nor + // does any navigateTo call point at its own caller. + const namesakes = cg.getNodesByName('navigateTo').map((n) => n.id); + expect(namesakes).toHaveLength(2); + const intoNamesakes = cg.getIncomingEdgesTo(namesakes, ['calls']); + expect(intoNamesakes.map((e) => `${cg.getNode(e.source)?.name} -> ${cg.getNode(e.target)?.filePath}`)).toEqual([ + 'openDocs -> lib/open.ts', + ]); + }); + + it('calls the navigateTo a file imports, and navigates through the one a file declares', () => { + const docs = cg.getOutgoingEdges(sym('openDocs', 'pages/docs.vue').id).filter((e) => e.kind === 'calls'); + expect(docs.map((e) => cg.getNode(e.target)).map((n) => `${n?.name} ${n?.filePath}`)).toEqual(['navigateTo lib/open.ts']); + // The plugin's wrapper is called with a page's path: where it goes, as before. + const plugin = cg.getNodesInFile('plugins/keys.client.ts').find((n) => n.kind === 'file')!; + expect(destinations(plugin)).toEqual(['navigates /target -> /target']); + }); +}); + +describe('navigateTo outside Nuxt', () => { + it('is the project function a plain Vue app imports', async () => { + const cg = await indexProject({ + 'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3.5.0', 'vue-router': '^4.4.0' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } } }), + 'src/router/index.ts': + "import { createRouter, createWebHistory } from 'vue-router';\n" + + "export default createRouter({ history: createWebHistory(), routes: [{ path: '/login', component: () => import('@/views/Login.vue') }] });\n", + 'src/views/Login.vue': '\n', + 'src/utils/nav.ts': 'export function navigateTo(path: string) {\n window.location.assign(path);\n}\n', + 'src/views/Home.vue': + "\n" + + '\n', + }); + try { + const go = cg.getNodesInFile('src/views/Home.vue').find((n) => n.name === 'go')!; + const calls = cg.getOutgoingEdges(go.id).filter((e) => e.kind === 'calls'); + expect(calls.map((e) => cg.getNode(e.target)?.filePath)).toEqual(['src/utils/nav.ts']); + } finally { + cg.close(); + } + }); +}); diff --git a/__tests__/sync-navigation-retry.test.ts b/__tests__/sync-navigation-retry.test.ts index 79070b1e..1d3a05ca 100644 --- a/__tests__/sync-navigation-retry.test.ts +++ b/__tests__/sync-navigation-retry.test.ts @@ -315,6 +315,24 @@ const CASES: Case[] = [ from: 'src/views/Home.vue', expected: '/login → /login', }, + { + // Nuxt's auto-imported navigateTo, waiting for its page (#2634). + name: 'Nuxt (navigateTo)', + files: { + 'package.json': JSON.stringify({ name: 'site', dependencies: { nuxt: '4', vue: '3' } }), + 'pages/index.vue': + '\n' + + '\n', + 'pages/login.vue': '\n', + }, + route: { file: 'pages/login.vue', before: null }, + from: 'pages/index.vue', + expected: '/login → /login', + }, { name: 'Angular', files: { diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index e9457178..8421fb46 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -1,6 +1,6 @@ # Framework & language coverage — what is done, what is left -**Last verified: 2026-08-29** (Angular row: 2026-10-06; React Router row: 2026-10-19) against the build at that date. Re-verify with the +**Last verified: 2026-08-29** (Angular row: 2026-10-06; React Router row: 2026-10-19; Vue Router / Nuxt row: 2026-10-22) against the build at that date. Re-verify with the queries in [Checking this file is still true](#checking-this-file-is-still-true) before trusting a row; this is a snapshot, not a live view. @@ -40,7 +40,7 @@ guessed. | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | | React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… )` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16), and its `[...materialRoutes]` once the jsconfig `"baseUrl": "src"` import naming it resolved (7 → 21 routes, each bound through `lazy(() => import(…))` to its page, trap 20); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve; each bound to its lazily loaded page and its layout since their `baseUrl` / `"*"` imports resolve, where before they named the local `Loadable(lazy(…))` value); a table `.map`ped inside `` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). A table a function returns, handed over through a variable (`const routes = getRouteMap({ … })` in a class's `render()`, then `createBrowserRouter(routes)`; trap 13): Medusa's admin dashboard (0 → 221 routes, each bound to its page, 613 layout edges, 92 navigations newly linked: 60 `navigate()` calls and 32 ``s; all 821 edges an AST oracle on the TypeScript compiler expects, and no others), Mercur's admin panel (0 → 216; 775 of 775), serenedb's serene-ui (`buildRoutes = () => [...appRoutes, ...loaderRoutes]`: 0 → 4), 22 control repos byte-identical, and a late-file sync equal to a fresh index on both dashboards. Route objects that render `` (`element: `, `Component: Outlet`, a `lazy` loader handing back `{ Component: Outlet }`; trap 14): both dashboards and mercurjs/vendor-panel drop every `layout:Outlet` reference they made (93, 106 and 87 rows, none of which linked anything) and the 13, 5 and 5 layout edges that drew a product's detail page around its variants' pages; React Router's error-boundaries example's `/` becomes its `Layout` page instead of React Router's own `Outlet`; 21 other repos byte-identical, and 2,677 surveyed router files change only by dropping 40 such references. A table behind `useMemo` (trap 13): anotherIlnaz/tg-tt-bot-stage (`useMemo(() => getRoutes(personalAcc), [personalAcc])`: 0 → 12 routes, 16 navigations), dingtalkwukong/bewild-clone (`useMemo(() => buildRoutes(), [])`: 0 → 13, 4) and the-ctrlv/SpaceR (`useMemo(getRoutes, [])` through a `baseUrl` import: 0 → 10, 21), each route bound to the page its table names; EyeSeeTea/training-app, whose builder maps a config into routes, and BANKFORM1, whose route file imports `createBrowserRouter` and so was read already, unchanged, with 25 other repos byte-identical. JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels), four more apps (9 routes), 16 control repos byte-identical. Lazy loaders that pick their page (`lazyRouteReference` in `frameworks/react.ts`, trap 18): milo/ui's three tabs (`const { TestTab } = await import('…/tabs')`), 20 apps from a GitHub survey of `createBrowserRouter` + `lazy` (636 picked-page edges, each checked against the target file's own export: 467 by name, 169 as its `export default`; 42 routes written with `async lazy()` now indexed, among them React Router's own `lazy-loading-router-provider` example), an AST oracle on the TypeScript compiler agreeing with the text reader on all 2,538 loaders of 876 surveyed router files, a late-file sync equal to a fresh index on 8 of those apps, and 26 control repos byte-identical, among them ghostfolio, whose 30 Angular lazy routes stay Angular's with React detected in the same project. Barrels that pass on a page they import (`import { UserLayout } from './UserLayout'; export { UserLayout }`, `import Login from './Login'; export default Login`; `exportedComponent` follows the import): Arshia76/DigiShop.Frontend (0 → 27 route and layout edges), 49 other repos byte-identical; `export default withAuth(Page)` is followed nowhere. A page in a folder below the barrel's (`pages/Home/index.tsx` behind `@/pages`) whose route failed waits under the barrel's module key (`module:pages`), which the page's own keys never give: a sync that adds the page, or gives it a definition, walks up the import edges to the barrels that pass it on (`reopenReferencesThroughBarrels` in `resolution/module-importers.ts`) and re-opens what waits under theirs. A late-file sync on DigiShop then equals a fresh index (13 of its routes stayed unlinked before), and 27 other repos sync exactly as before. A name the route file declares itself (trap 19), such as a lazy page or layout, a `Loadable(lazy(…))` or an `observer(…)` value, binds to what that declaration renders wherever the file uses it, in routes, layouts and JSX alike: outline (+31 edges, 7 moved off another file's namesake or onto the screen a lazy declaration loads), mastodon (+20, 9 moved: `lazy(() => import(…).then(({ Emoji }) => …))` values and `let Emoji` locals had gone to other files' components), SigNoz (+7, 4 moved), excalidraw (+3, 1 moved: the Next.js example's `` was the docs site's), bulletproof-react (+6), material-kit-react and create-t3-turbo (+1 each), with matx, mantis, proshop, next-saas-starter and berry byte-identical | | TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | -| Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies | +| Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts`, `nuxt-navigate-to.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies. Nuxt's `navigateTo` (trap 21): mealie 13 of its 14 calls, nuxt.com 17 of 24, elk 9 of 10 outside the plugin that declares its own, atidone 2 of 2, pages and route middleware alike; the rest are computed, an API endpoint (`{ external: true }`), or a page of a Nuxt layer (`extends: ['./layers/nuxi']`), whose pages sit in a route table of their own. nuxt/ui's docs: 0 of 4, two computed and two whose only page is a catch-all (`[...slug].vue`, named `*slug`) or an optional parameter (`[[tag]].vue`, `:tag?`), which `matchRoute` doesn't read yet, for markup links either | | SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) | | Angular | `frameworks/angular-router.ts` | `angular-template-synthesizer.ts` | `angular-router.test.ts` | angular-realworld (31 edges, 18 renders, 32 bindings), Ghostfolio (189 edges, 170 renders, 215 bindings), ngx-admin (routes and renders; its menus are config; 13 bindings), CleanArchitecture's `src/Web/ClientApp` (signals; 12 bindings), angular-spotify (Nx libs behind barrels: 14 routes), jira-clone (class-constant paths, mount-only redirects), jhipster (60), ionic-conference (18), Angular-JumpStart (18) | @@ -435,6 +435,20 @@ Each of these cost real debugging time; they are not hypothetical. project keeps for it (`"*": ["./typings/*"]`, as tsc reads it). A route resolves its lazy module as TypeScript whatever the file is written in, which is why the TypeScript extension list probes `/index.jsx` last. +21. **A navigation call named like a function must not fall back to one.** + Nuxt auto-imports `navigateTo`, so a call to it is written bare, with no + import to say where it comes from. When Vue Router can't read where it goes + — a computed destination, a path no page serves, an external URL — the + reference would reach the name matcher, which links any project function + of that name: elk declares a `navigateTo` of its own inside a plugin. The + Vue resolver used to sink every `navigateTo` into an edge to its caller, + which hid the navigation too, and which sync never revisits (trap 16). + `FrameworkResolver.ownsReference` names the references a framework answers + alone: `nuxtResolver` owns a bare `navigateTo` that no declaration or + import in the file binds, and one no router links stays unresolved, so a + sync binds it when its page appears. An absolute URL is no path of the app: + Vue Router's call reader keeps only destinations that start with `/`, as + its markup reader does. --- diff --git a/src/resolution/frameworks/vue-router.ts b/src/resolution/frameworks/vue-router.ts index f84dad89..a62e0629 100644 --- a/src/resolution/frameworks/vue-router.ts +++ b/src/resolution/frameworks/vue-router.ts @@ -535,7 +535,10 @@ export const vueRouterResolver: FrameworkResolver = { if (!href) return null; // Every arm of a conditional destination is somewhere this call goes; the // first is this reference's resolution and the rest ride as `alsoTargets`. - const targets = destinationsForHref(href, routes); + // An arm that is no path of the app — an absolute URL, which Nuxt's + // `navigateTo(url, { external: true })` leaves the app for — opens none of + // its routes, as `` is read (vue-router-synthesizer). + const targets = destinationsForHref(href, routes).filter((t) => t.href.path.startsWith('/')); const target = targets[0]; if (!target) return null; return { diff --git a/src/resolution/frameworks/vue.ts b/src/resolution/frameworks/vue.ts index b8a5a3c2..257c536f 100644 --- a/src/resolution/frameworks/vue.ts +++ b/src/resolution/frameworks/vue.ts @@ -28,13 +28,15 @@ const VUE_COMPILER_MACROS = new Set([ ]); /** - * Nuxt auto-imported composables and utilities + * Nuxt auto-imported composables and utilities. `navigateTo` is not one of + * them here: `navigateTo('/x')` is a navigation, which Vue Router's resolver + * links to the page it opens, and Nuxt owns the call when it can't (see + * `nuxtResolver.ownsReference`, #2634). */ const NUXT_AUTO_IMPORTS = new Set([ // Routing 'useRoute', 'useRouter', - 'navigateTo', 'abortNavigation', // Data fetching 'useFetch', @@ -213,6 +215,25 @@ export const nuxtResolver: FrameworkResolver = { return resolvePageComponent(ref, context); }, + /** + * Nuxt's auto-imported `navigateTo` (#2634). Vue Router's resolver links a + * call to the page it opens; one it can't link — a computed destination, a + * path no page serves, an external URL — still calls Nuxt's, never a + * project function that shares the name, such as the `navigateTo` elk + * declares inside a plugin. A file that declares or imports a `navigateTo` + * calls that one, which the other strategies find (an import from `#app` + * links nothing, as any package's does). + */ + ownsReference(ref: UnresolvedRef, context: ResolutionContext): boolean { + return ( + ref.referenceKind === 'calls' && + ref.referenceName === 'navigateTo' && + VUE_SCRIPT_LANGUAGES.has(ref.language) && + !declaresOwnFunction(ref, context) && + !context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === ref.referenceName) + ); + }, + extract(filePath: string, _content: string) { const nodes: Node[] = []; const references: UnresolvedRef[] = []; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index e2e717af..5e3e46ea 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1713,6 +1713,14 @@ export class ReferenceResolver { } if (this.profileStages) this.stageAdd('frameworks', ref, fwEarly !== null, tFw); if (fwEarly) return fwEarly; + // A reference a framework answers alone ends with the frameworks' answer + // (see FrameworkResolver.ownsReference): Nuxt's `navigateTo(x)` with a + // destination no route serves is no namesake's call. + if (this.frameworksFor(ref.language).some((f) => f.ownsReference?.(ref, this.context))) { + return candidates.length > 0 + ? candidates.reduce((best, curr) => (curr.confidence > best.confidence ? curr : best)) + : null; + } // A retained untyped chain supplies effect/call-site evidence only. In // particular, importing its root does not make the root its call target. // A path through module namespaces is not untyped: `z.coerce.number()` diff --git a/src/resolution/types.ts b/src/resolution/types.ts index b4e5dae4..d5d5dcb6 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -373,6 +373,17 @@ export interface FrameworkResolver { * Asked only about references written in the languages `resolve()` sees. */ claimsReference?(name: string): boolean; + /** + * A reference this framework answers alone. When no framework's `resolve()` + * links it, it stays unresolved: no import or name strategy guesses past it, + * as they would for a project function that only shares its name. Nuxt's + * auto-imported `navigateTo('/x')` names a page, which Vue Router reads; + * one whose destination is computed, or a path no page serves, calls no + * function of the project — elk declares a `navigateTo` of its own inside a + * plugin. Unresolved, a sync can still revisit it when a route appears. + * Asked only about references written in the languages `resolve()` sees. + */ + ownsReference?(ref: UnresolvedRef, context: ResolutionContext): boolean; /** * Extract framework-specific nodes and references from a file. *