From a50104c89532d025711aa9821afcbbdef02f0c56 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 18:31:31 +0000 Subject: [PATCH 01/28] fix(react-router): read every table a route-building function returns, and the one behind useMemo (#2672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route-table pass (`returnedTable`) took the first `return [` anywhere in a route-building function's body for its table: - An early guard's `[]` (`if (!user) return [];`) hid the real table. - A callback's `[]` did too: devanshthakker15/BANKFORM1's `generateRoutes` returns `[]` from a `.forEach` callback before its own table. - A menu that a helper inside the function returns could be read as routes. - Of `if (admin) return [ … ]; return [ … ]`, only the first branch was read. A function that returns another function's table (`return buildRoutes()`) was not followed, and neither was `useMemo`, which hooks-based routers wrap their table in. Now `returnedTables` reads what the function's expression body gives, or what each of its own `return`s gives (`ownReturns`). It steps over the bodies of arrows, `function`s and methods written inside it, found by a brace matcher tolerant of JSX text: in `blockEnd` and `opensString`, a quote right after a word is an apostrophe. `heldTable` reads parentheses around a value, and reads `useMemo(() => getRoutes(user), [user])` and `useMemo(getRoutes, [])` as the function's tables. `tableNamed`, `exportedTable` and `declaredTable` now return every table they find. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../react-router-returned-tables.test.ts | 210 ++++++++++++++ docs/design/framework-coverage.md | 12 +- src/resolution/frameworks/react.ts | 259 ++++++++++++++---- 4 files changed, 428 insertions(+), 54 deletions(-) create mode 100644 __tests__/react-router-returned-tables.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e91b435ba..4fbf0745e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now link a lazily loaded React Router route to its page when the page appears, or gains the export the route reads, in a folder below the one the route loads, like `src/pages/Home/index.tsx` behind `import('@/pages')`. Before, such a route kept linking nothing until the folder's own `index.ts` changed or the project was re-indexed, although a fresh index linked it. - React Router routes are now indexed when the router gets its table from a function through a variable, as in Medusa's admin dashboard, which writes `const routes = getRouteMap({ … })` and then `createBrowserRouter(routes)`. The same goes for `export const routes = buildRoutes()` or `export default buildRoutes()` imported into `useRoutes(routes)`, and for `const routes = await loadRoutes()`. Before, such a variable was read only when it held the table itself, so these apps had no routes: their pages had no route leading to them, and their `navigate('/orders/…')` calls and `` links led nowhere. Routes the function is handed by its caller, like the plugin routes Medusa spreads in, are still not read, and when two functions in one file each keep a `routes` variable, the router reads its own and never the other's, which may be a menu. Re-index React projects after upgrading. - A React Router route written as `element: ` or `Component: Outlet`, or whose `lazy` loader hands back `{ Component: Outlet }`, now counts as rendering nothing of its own, as a route with no element does and as `}>` already did. Before, `Outlet` was taken for the route's page and for the layout of the routes inside it, which linked nothing, and a loader returning `{ Component: Outlet }` made the module it loads for its `loader` the layout of the routes inside: in Medusa's admin dashboard, a product's detail page was drawn around its variants' pages. Such a route is still a route at its own address, inside the layouts around it, and a page inside it at the same address still takes the address over. Re-index React projects after upgrading. +- React Router routes are now read from every table a route-building function returns, an early `if (!user) return [];` and each branch alike, and never from a list that a function written inside it returns, like a `.forEach` callback's `return []` or a helper's menu. A function that returns another's table, as in `return buildRoutes()`, is followed too, and so is a table behind `useMemo`, like `useMemo(() => getRoutes(user), [user])` or `useMemo(getRoutes, [])`. Before, the first `return [` anywhere in the function was taken for the table, so an empty guard or a callback's list could hide an app's routes entirely, or a menu could be read in their place. Re-index React projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. - In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. - In React, a tag a component only mentions in a comment or a string no longer links it to that component: a JSDoc example, markup commented out like `{/* */}`, or an error message like react-router's "You cannot render a inside another " or shadcn's "useCarousel must be used within a ". Nothing in a TypeScript `.ts` file, which can't hold JSX, is read as a tag either, like the XML in outline's OpenSearch template. Before, these counted as renders, so react-router's hooks showed up among the callers of `Router`, `Routes` and `Route`, and `codegraph_explore`, callers and impact followed renders that never happen. A tag after an apostrophe, a URL or a backtick in a component's text, like `

Don't have an account? `, still links as before. Re-index React projects after upgrading. diff --git a/__tests__/react-router-returned-tables.test.ts b/__tests__/react-router-returned-tables.test.ts new file mode 100644 index 000000000..153175263 --- /dev/null +++ b/__tests__/react-router-returned-tables.test.ts @@ -0,0 +1,210 @@ +/** + * What a route-building function returns, when the router is handed its + * result: every `return` of its own, never one in a function written inside + * it. Before, the first `return [` anywhere in the body was the table: the + * empty array an early guard (`if (!user) return [];`) or a `.forEach` + * callback hands back (devanshthakker15/BANKFORM1's `generateRoutes`), so + * the real table was never read; a menu a helper inside returns could be read + * instead; and only the first of two branches' tables. A function that + * returns another's result (`return buildRoutes()`) was not followed, and + * neither was a table behind `useMemo` (`useMemo(() => getRoutes(user), + * [user])`, `useMemo(getRoutes, [])`), which hooks-based routers write. + */ +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'; + +const pages = ['Home', 'Login', 'Orders', 'OrderDetail', 'Admin', 'Account', 'Reports', 'Help', 'Faq', 'Settings', 'Dashboard', 'Landing', 'NotFound']; + +const FILES: Record = { + 'package.json': JSON.stringify({ name: 'apps', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'src/pages.jsx': pages.map((p) => `export function ${p}() {\n return

${p}
;\n}\n`).join(''), + + // BANKFORM1: dynamic routes built in a callback whose `return []` comes first. + 'src/permissions/routes.jsx': `import { Home, Login, NotFound, Orders, OrderDetail } from '../pages'; + +export const generateRoutes = (permissions) => { + const dynamicRoutes = []; + permissions.forEach((currentPerm) => { + if (!currentPerm.module) return []; + dynamicRoutes.push({ path: \`/\${currentPerm.module}\`, element: }); + }); + return [ + { path: '/login', element: }, + { + path: '/', + children: [{ path: '/', element: }, { path: '/orders/:id', element: }, ...dynamicRoutes], + }, + { path: '*', element: }, + ]; +}; +`, + 'src/permissions/App.jsx': `import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { generateRoutes } from './routes'; + +export default function App({ permissions }) { + const dynamicRoutes = generateRoutes(permissions); + const router = createBrowserRouter(dynamicRoutes); + return ; +} +`, + + // An early guard, and two branches that each return a table. + 'src/guarded/routes.jsx': `import { Account, Admin, Dashboard, Landing } from '../pages'; + +export function getRoutes(user) { + if (!user) return []; + if (user.isAdmin) { + return [{ path: '/admin', element: }]; + } + return [ + { path: '/dashboard', element: }, + { path: '/account', element: }, + ]; +} + +export const publicRoutes = (ready) => (ready ? [] : [{ path: '/welcome', element: }]); + +// Parameters with calls and arrows of their own in them. +export const meRoutes = (user = currentUser(), onLeave = () => null) => [{ path: '/me', element: }]; + +function currentUser() { + return null; +} +`, + 'src/guarded/App.jsx': `import { useRoutes } from 'react-router-dom'; +import { getRoutes } from './routes'; + +export default function App({ user }) { + return useRoutes(getRoutes(user)); +} +`, + + 'src/guarded/Me.jsx': `import { useRoutes } from 'react-router-dom'; +import { meRoutes } from './routes'; + +export default function Me() { + return useRoutes(meRoutes()); +} +`, + + // A table behind useMemo, written both ways. + 'src/memo/routes.jsx': `import { Reports, Settings } from '../pages'; + +export const getRoutes = (user) => [{ path: '/reports', element: }]; + +export default function settingsRoutes() { + return [{ path: '/settings', element: }]; +} +`, + 'src/memo/App.jsx': `import { useMemo } from 'react'; +import { useRoutes } from 'react-router-dom'; +import { getRoutes } from './routes'; + +export default function App({ user }) { + const routes = useMemo(() => getRoutes(user), [user]); + return useRoutes(routes); +} +`, + 'src/memo/Settings.jsx': `import { useMemo } from 'react'; +import { useRoutes } from 'react-router-dom'; +import settingsRoutes from './routes'; + +export default function SettingsApp() { + const routes = useMemo(settingsRoutes, []); + return useRoutes(routes); +} +`, + + // A function that returns another's table; functions written inside a + // builder return their own lists, which are no routes. + 'src/nested/build.jsx': `import { Faq, Help, Home } from '../pages'; + +export function buildRoutes() { + const note =

Don't panic

; + const menu = () => { + return [{ path: '/help', element: }]; + }; + function faqRoutes() { + return [{ path: '/faq', element: }]; + } + return [{ path: '/', element: , handle: { note, links: menu() }, loader() { return [faqRoutes()]; } }]; +} +`, + 'src/nested/routes.jsx': `import { buildRoutes } from './build'; + +export function appRoutes() { + return buildRoutes(); +} +`, + // Read straight from the builder too: before, the menu inside was the table. + 'src/nested/Direct.jsx': `import { useRoutes } from 'react-router-dom'; +import { buildRoutes } from './build'; + +export default function Direct() { + return useRoutes(buildRoutes()); +} +`, + 'src/nested/App.jsx': `import { useRoutes } from 'react-router-dom'; +import { appRoutes } from './routes'; + +export default function App() { + return useRoutes(appRoutes()); +} +`, +}; + +/** `path -> Component` for every route whose node sits under `dir`. */ +function routeBindings(cg: CodeGraph, dir: string): string[] { + return cg.getNodesByKind('route').filter((r) => r.filePath.startsWith(dir)).flatMap((r) => cg.getOutgoingEdges(r.id) + .filter((e) => e.kind === 'references') + .map((e) => `${r.name} ${(e.metadata as Record | undefined)?.layout ? '~>' : '->'} ${cg.getNode(e.target)?.name}`)) + .sort(); +} + +describe('react-router: the tables a route-building function returns', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-returns-')); + 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); + } + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("reads the function's own table, past the `return []` of a callback inside it", () => { + expect(routeBindings(cg, 'src/permissions/')).toEqual([ + '/ -> Home', + '/login -> Login', + '/orders/:id -> OrderDetail', + '/* -> NotFound', + ].sort()); + }); + + it('reads every table the function returns, past an early `return []`', () => { + expect(routeBindings(cg, 'src/guarded/')).toEqual([ + '/account -> Account', + '/admin -> Admin', + '/dashboard -> Dashboard', + '/me -> Account', + ]); + }); + + it('reads the table behind useMemo, a callback or a function handed over', () => { + expect(routeBindings(cg, 'src/memo/')).toEqual(['/reports -> Reports', '/settings -> Settings']); + }); + + it('follows a function that returns another function\'s table, and reads no list a function inside it returns', () => { + expect(routeBindings(cg, 'src/nested/')).toEqual(['/ -> Home']); + const all = cg.getNodesByKind('route').map((r) => r.name); + for (const p of ['/help', '/faq', '/welcome']) expect(all).not.toContain(p); + }); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 4201748e3..5739b73d7 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -38,7 +38,7 @@ guessed. |---|---|---|---|---| | Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — | | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | -| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), 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. 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 | +| 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 | | 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) | @@ -292,7 +292,15 @@ Each of these cost real debugging time; they are not hypothetical. function-local declaration has no node, so the declarations are read from the text and only the scope comes from the graph. What the function's parameters bring in (Medusa's `...coreRoutes`, from plugins) is the - caller's to fill and stays unread. + caller's to fill and stays unread. What a function returns is every + `return` of its own, an early `if (!user) return [];` and each branch + alike, and never one of a function written inside it: the first `return + [` anywhere in the body was a `.forEach` callback's `[]` in + devanshthakker15/BANKFORM1's `generateRoutes`, and could be a helper's + menu. A function that returns another's table (`return buildRoutes()`) is + followed, and so is `useMemo` (`useMemo(() => getRoutes(user), [user])`, + `useMemo(getRoutes, [])`). The body is scanned as text, so a quote right + after a word is JSX text's apostrophe (`

Don't

`), not a string. 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 diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index ee6712d22..09cb8fa69 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -1964,8 +1964,9 @@ function tableRoutes(context: ResolutionContext): FrameworkExtractionResult { if (!source || !CONSUMER_HINT.test(source)) continue; const scan = scanRoutes(source, allowsJsx(file), { objects: ROUTER_CALL.test(source), uses: true }); for (const use of scan.uses) { - const table = use.literal !== undefined ? { file, at: use.literal } : tableNamed(use.name!, file, context); - enqueue(table, pathValues(use.prefix, file, context), file, use.layouts); + const tables = use.literal !== undefined ? [{ file, at: use.literal }] : tableNamed(use.name!, file, context); + const prefix = pathValues(use.prefix, file, context); + for (const table of tables) enqueue(table, prefix, file, use.layouts); } } const made = new Set(); @@ -1975,7 +1976,10 @@ function tableRoutes(context: ResolutionContext): FrameworkExtractionResult { const source = context.readFile(file); if (!source) continue; const scan = scanRoutes(source, allowsJsx(file), { table: { at, prefix: prefix.map((lit) => ({ lit })), layouts } }); - for (const named of scan.names) enqueue(tableNamed(named, file, context), pathValues(named.prefix, file, context), file, named.layouts); + for (const named of scan.names) { + const prefix = pathValues(named.prefix, file, context); + for (const table of tableNamed(named, file, context)) enqueue(table, prefix, file, named.layouts); + } if (ROUTER_CALL.test(source)) continue; const lineAt = makeLineAt(source, 1); for (const route of scan.routes) { @@ -2016,40 +2020,50 @@ function scriptLanguage(filePath: string): Language { return /\.tsx$/.test(filePath) ? 'tsx' : /\.[cm]?ts$/.test(filePath) ? 'typescript' : /\.jsx$/.test(filePath) ? 'jsx' : 'javascript'; } +/** A table literal: the file it is written in, and where it opens. */ +interface TableLiteral { + file: string; + at: number; +} + /** - * The literal a table name stands for in `file`: declared there, or + * The literals a table name stands for in `file`: declared there, or * imported. A value that holds a call's result — Medusa's `const routes = - * getRouteMap({ … })` — stands for the table the function returns. + * getRouteMap({ … })` — stands for the tables the function returns. */ -function tableNamed(named: TableName, file: string, context: ResolutionContext, depth = 0): { file: string; at: number } | null { +function tableNamed(named: TableName, file: string, context: ResolutionContext, depth = 0): TableLiteral[] { const source = context.readFile(file); - if (!source) return null; + if (!source) return []; const safe = stripCommentsForRegex(source, 'typescript'); const local = declaredTable(safe, named.name, named.call, visibleAt(named.at, safe, file, context)); - if (typeof local === 'number') return { file, at: local }; - if (local) return depth > 3 ? null : tableNamed(local, file, context, depth + 1); + if (local.length > 0) return tablesAt(local, file, context, depth); const language = scriptLanguage(file); const mapping = context.getImportMappings(file, language).find((m) => m.localName === named.name); - if (!mapping || mapping.isNamespace || depth > 3) return null; + if (!mapping || mapping.isNamespace || depth > 3) return []; const target = resolveImportPath(mapping.source, file, language, context); - if (!target) return null; + if (!target) return []; return exportedTable(target, mapping.isDefault ? 'default' : mapping.exportedName, named.call, context, depth + 1); } -/** The literal `file` exports as `exported`: `export default [ … ]`, `export default AppRoutes`, `export const routes = [ … ]`, re-exports followed. */ -function exportedTable(file: string, exported: string, call: boolean, context: ResolutionContext, depth: number): { file: string; at: number } | null { +/** The literals `sites` in `file` stand for: each literal written there, and the tables each function named returns. */ +function tablesAt(sites: TableSite[], file: string, context: ResolutionContext, depth: number): TableLiteral[] { + return sites.flatMap((site) => + typeof site === 'number' ? [{ file, at: site }] : depth > 3 ? [] : tableNamed(site, file, context, depth + 1)); +} + +/** The literals `file` exports as `exported`: `export default [ … ]`, `export default AppRoutes`, `export const routes = [ … ]`, re-exports followed. */ +function exportedTable(file: string, exported: string, call: boolean, context: ResolutionContext, depth: number): TableLiteral[] { const source = context.readFile(file); - if (!source) return null; + if (!source) return []; const safe = stripCommentsForRegex(source, 'typescript'); let local = exported; if (exported === 'default') { const m = /\bexport\s+default\s+/.exec(safe); if (m) { const after = m.index + m[0].length; - const inline = call ? returnedTable(safe, after) : heldTable(safe, after); - if (typeof inline === 'number') return { file, at: inline }; - // `export default buildRoutes()` - if (inline) return depth > 3 ? null : tableNamed(inline, file, context, depth + 1); + // `export default [ … ]`, `export default buildRoutes()`, `export default function routes() { … }`. + const inline = call ? returnedTables(safe, after) : heldTable(safe, after); + if (inline.length > 0) return tablesAt(inline, file, context, depth); local = /^([A-Za-z_$][\w$]*)\s*(?:;|\n|$)/.exec(safe.slice(after))?.[1] ?? ''; } else local = /\bexport\s*\{[^}]*?\b([A-Za-z_$][\w$]*)\s+as\s+default\b/.exec(safe)?.[1] ?? ''; } else { @@ -2058,10 +2072,9 @@ function exportedTable(file: string, exported: string, call: boolean, context: R if (alias) local = alias[1]!; } // What a module exports is declared at its top level. - const at = local ? declaredTable(safe, local, call, visibleAt(undefined, safe, file, context)) : null; - if (typeof at === 'number') return { file, at }; - if (at) return depth > 3 ? null : tableNamed(at, file, context, depth + 1); - if (depth > 3) return null; + const declared = local ? declaredTable(safe, local, call, visibleAt(undefined, safe, file, context)) : []; + if (declared.length > 0) return tablesAt(declared, file, context, depth); + if (depth > 3) return []; // `export { routes } from './routes'`, `export { default as routes } from './routes'`, `export * from './routes'`. const language = scriptLanguage(file); for (const re of context.getReExports?.(file, language) ?? []) { @@ -2069,48 +2082,61 @@ function exportedTable(file: string, exported: string, call: boolean, context: R const target = resolveImportPath(re.source, file, language, context); if (!target) continue; const found = exportedTable(target, re.kind === 'named' ? re.originalName : exported, call, context, depth + 1); - if (found) return found; + if (found.length > 0) return found; } - return null; + return []; } /** - * Where `name`'s table literal opens, for `const name = [ … ]` (or a function - * returning one when `call`) — or, for `const name = getRouteMap({ … })`, the - * function whose returned table the value holds. Of several declarations of - * the name, the one `visible` picks. + * Where `name`'s table literal opens, for `const name = [ … ]` (or the tables + * a function returns when `call`) — or, for `const name = getRouteMap({ … + * })`, the function whose returned table the value holds. Of several + * declarations of the name, the one `visible` picks. */ function declaredTable( safe: string, name: string, call: boolean, visible: (keywords: number[]) => number | undefined -): number | TableName | null { +): TableSite[] { const id = name.replace(/\$/g, '\\$'); const decls = [...safe.matchAll(new RegExp(`(?:^|[^\\w$.])((?:const|let|var)\\s+${id}\\s*(?::[^=;]*)?=\\s*)`, 'g'))]; const chosen = decls.length > 0 ? visible(decls.map((d) => d.index + d[0].length - d[1]!.length)) : undefined; const decl = chosen === undefined ? undefined : decls[chosen]; if (decl) { const after = decl.index + decl[0].length; - if (call) return returnedTable(safe, after); - return heldTable(safe, after); + return call ? returnedTables(safe, after) : heldTable(safe, after); } - if (!call) return null; + if (!call) return []; const fn = new RegExp(`(?:^|[^\\w$.])function\\s+${id}\\s*\\(`).exec(safe); - return fn ? returnedTable(safe, fn.index + fn[0].indexOf('function')) : null; + return fn ? returnedTables(safe, fn.index + fn[0].indexOf('function')) : []; } +/** A table to read: where its literal opens in the file at hand, or a function whose returned tables to read. */ +type TableSite = number | TableName; + /** - * The table a value written at `at` gives: the literal there, or the + * The tables a value written at `at` gives: the literal there, or the * function a call there names — `getRouteMap({ … })`, `await loadRoutes()` — - * whose returned table it is. + * whose returned table it is, and through `useMemo` what its callback + * returns: `useMemo(() => getRoutes(user), [user])`, `useMemo(getRoutes, [])`. */ -function heldTable(safe: string, at: number): number | TableName | null { - if (safe[at] === '[' || safe[at] === '{') return at; +function heldTable(safe: string, at: number): TableSite[] { + // Parentheses around the value change nothing: `return ([ … ])`. + while (safe[at] === '(') at = skipSpace(safe, at + 1); + if (safe[at] === '[' || safe[at] === '{') return [at]; + const memo = /(?:React\s*\.\s*)?useMemo\s*(?:<[^()]*>)?\s*\(\s*/y; + memo.lastIndex = at; + if (memo.test(safe)) { + const handed = /([A-Za-z_$][\w$]*)\s*,/y; + handed.lastIndex = memo.lastIndex; + const fn = handed.exec(safe); + return fn ? [{ name: fn[1]!, call: true, at: memo.lastIndex }] : returnedTables(safe, memo.lastIndex); + } const call = /(await\s+)?([A-Za-z_$][\w$]*)\s*\(/y; call.lastIndex = at; const m = call.exec(safe); - return m ? { name: m[2]!, call: true, at: at + (m[1]?.length ?? 0) } : null; + return m ? [{ name: m[2]!, call: true, at: at + (m[1]?.length ?? 0) }] : []; } /** @@ -2149,22 +2175,151 @@ function visibleAt(use: number | undefined, safe: string, file: string, context: }; } -/** The table a function at `at` returns: `(user) => [ … ]`, `(user) => ([ … ])`, `function routes(user) { return [ … ]; }`. */ -function returnedTable(safe: string, at: number): number | null { - const head = /^(?:async\s+)?(?:function\b[^(]*\([^)]*\)|\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^={]*)?(=>)?\s*/.exec(safe.slice(at)); - if (!head) return null; - let i = at + head[0].length; - if (head[1]) { - while (safe[i] === '(') { - i++; - while (/\s/.test(safe[i] ?? '')) i++; - } - if (safe[i] === '[') return i; +/** + * The tables the function written at `at` returns: its expression body's + * (`(user) => [ … ]`, `() => buildRoutes()`), or what each `return` of its + * own hands back, an early guard (`if (!user) return [];`) and every branch + * alike. A `return` in a function written inside it (`.map((p) => { return + * [ … ]; })`, a method's) is that function's. + */ +function returnedTables(safe: string, at: number): TableSite[] { + const body = functionBody(safe, at); + if (!body) return []; + if (!body.block) return heldTable(safe, body.start); + return ownReturns(safe, body.start, body.end).flatMap((value) => heldTable(safe, value)); +} + +/** + * Where the function written at `at` keeps its body: a block, from its `{` to + * its `}`, or an arrow's expression, at `start`. Null for no function. + */ +function functionBody(safe: string, at: number): { block: boolean; start: number; end: number } | null { + let i = skipSpace(safe, at); + const keyword = (word: string): boolean => { + const re = new RegExp(`${word}\\b`, 'y'); + re.lastIndex = i; + if (!re.test(safe)) return false; + i = skipSpace(safe, re.lastIndex); + return true; + }; + keyword('async'); + const arrow = !keyword('function'); + const name = /\*?\s*[A-Za-z_$][\w$]*/y; + if (!arrow) { + name.lastIndex = i; + if (name.test(safe)) i = skipSpace(safe, name.lastIndex); + } + if (safe[i] === '(') { + const close = matchBracket(safe, i); + if (close < 0) return null; + i = skipSpace(safe, close + 1); + } else { + // `user => [ … ]` + const param = /[A-Za-z_$][\w$]*/y; + param.lastIndex = i; + if (!arrow || !param.test(safe)) return null; + i = skipSpace(safe, param.lastIndex); + } + // A return type, up to the arrow or the body: `(): RouteObject[] => [ … ]`. + if (safe[i] === ':') { + const stop = safe.indexOf(arrow ? '=>' : '{', i); + if (stop < 0) return null; + i = stop; + } + if (arrow) { + if (!safe.startsWith('=>', i)) return null; + i = skipSpace(safe, i + 2); + if (safe[i] !== '{') return { block: false, start: i, end: i }; } if (safe[i] !== '{') return null; - const close = matchBracket(safe, i); - const ret = /\breturn\s*(?:\(\s*)?\[/.exec(safe.slice(i, close < 0 ? safe.length : close)); - return ret ? i + ret.index + ret[0].length - 1 : null; + const end = blockEnd(safe, i); + return end < 0 ? null : { block: true, start: i, end }; +} + +/** The statements that start a block of their own after a parenthesized head: never a method's. */ +const BLOCK_HEADS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with']); + +/** + * Where each `return` of the block opening at `open` hands back its value: + * the block's own, past the functions written inside it — an arrow's block, + * a `function`, a method like `lazy() { … }`. + */ +function ownReturns(safe: string, open: number, close: number): number[] { + const values: number[] = []; + /** Past the body of a function whose parameters open at `paren`; -1 when there is none. */ + const pastBody = (paren: number): number => { + const params = paren < 0 ? -1 : matchBracket(safe, paren); + if (params < 0) return -1; + let i = skipSpace(safe, params + 1); + if (safe[i] === ':') i = safe.indexOf('{', i); + return i >= 0 && safe[i] === '{' ? blockEnd(safe, i) : -1; + }; + for (let i = open + 1; i < close; i++) { + const ch = safe[i]!; + if (opensString(safe, i)) { + const end = skipString(safe, i); + if (end < 0) break; + i = end; + } else if (ch === '=' && safe[i + 1] === '>') { + const body = skipSpace(safe, i + 2); + const end = safe[body] === '{' ? blockEnd(safe, body) : -1; + i = end < 0 ? i + 1 : end; + } else if (/[A-Za-z_$]/.test(ch) && !/[\w$.]/.test(safe[i - 1] ?? '')) { + const word = /[A-Za-z_$][\w$]*/y; + word.lastIndex = i; + word.test(safe); + const name = safe.slice(i, word.lastIndex); + const next = skipSpace(safe, word.lastIndex); + if (name === 'return') { + values.push(next); + } else if (name === 'function') { + const end = pastBody(safe.indexOf('(', next)); + if (end > 0) { + i = end; + continue; + } + } else if (safe[next] === '(' && !BLOCK_HEADS.has(name)) { + const end = pastBody(next); + if (end > 0) { + i = end; + continue; + } + } + i = word.lastIndex - 1; + } + } + return values; +} + +/** + * The index of the brace closing the block at `open`, or -1. A quote right + * after a word is an apostrophe in JSX text (`

Don't

`), not a string. + */ +function blockEnd(safe: string, open: number): number { + let depth = 0; + for (let i = open; i < safe.length; i++) { + if (opensString(safe, i)) { + const end = skipString(safe, i); + if (end < 0) return -1; + i = end; + continue; + } + const ch = safe[i]!; + if (ch === '{' || ch === '(' || ch === '[') depth++; + else if ((ch === '}' || ch === ')' || ch === ']') && --depth === 0) return i; + } + return -1; +} + +/** True for a quote that opens a string or template, not an apostrophe in JSX text. */ +function opensString(safe: string, i: number): boolean { + const ch = safe[i]; + return ch === '`' || ((ch === '"' || ch === "'") && !/[\w$]/.test(safe[i - 1] ?? '')); +} + +function skipSpace(safe: string, i: number): number { + while (/\s/.test(safe[i] ?? '')) i++; + return i; } /** From ab6ec218ff5d1442109420e41bc2b8d32e203347 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 18:43:16 +0000 Subject: [PATCH 02/28] fix(js,ts): a function value imported from the project links only to what its import binds (#2673) A function passed as a value whose name the file imports from its own project still fell back to the project's one function of that name whenever the import resolver bound the name to something that is not a function: outline's `isCloudHosted` constant landed on the server's `isCloudHosted()` check, its `Star` model class on the `` icon, `import * as Slack` on the Slack settings component, and topcoder's `import TCO21 from './themes/TCO21.scss'` on a route component. matchFunctionRef's unique cross-file fallback now also declines when a project import binds the name to something else (importBindsOther): a namespace import, an asset import (stylesheet, image, GraphQL, JSON, font, media; judged by the specifier's extension), or an import the import resolver binds to a node that is not a function or method. A wrapper declared beside the namesake (`export default styled(Disclosure)`) and a lazy loader (`lazy(() => import(...))`) still lead to it, and an import the resolver can't follow at all keeps the fallback. Follow-up to #2671. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../function-ref-project-imports.test.ts | 225 ++++++++++++++++++ src/resolution/name-matcher.ts | 31 ++- 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 __tests__/function-ref-project-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fbf0745e..0a4534055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React, a tag a component only mentions in a comment or a string no longer links it to that component: a JSDoc example, markup commented out like `{/* */}`, or an error message like react-router's "You cannot render a inside another " or shadcn's "useCarousel must be used within a ". Nothing in a TypeScript `.ts` file, which can't hold JSX, is read as a tag either, like the XML in outline's OpenSearch template. Before, these counted as renders, so react-router's hooks showed up among the callers of `Router`, `Routes` and `Route`, and `codegraph_explore`, callers and impact followed renders that never happen. A tag after an apostrophe, a URL or a backtick in a component's text, like `

Don't have an account? `, still links as before. Re-index React projects after upgrading. - In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading. - In JavaScript and TypeScript, a function the file imports from a package and passes along as a value, like react-router's `Link` in `styled(Link)`, lodash's `noop` in `createContext({ onClose: noop })` or chart.js's `Legend` in `Chart.register(Legend)`, no longer links to a project function that only shares its name. Before, whenever the project had exactly one function of that name, every such value linked to it: outline's breadcrumbs, menus and document lists showed up among the callers of an email template's `Link`, and a test's own `noop` stub in cord-field among the code the app's contexts use, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A function the file declares itself, including one that hides the import inside a function, and one imported from a file of your project link as before. Re-index JavaScript and TypeScript projects after upgrading. +- In JavaScript and TypeScript, a value the file imports from your own project and passes along now links only to what it imports: a constant like `isCloudHosted`, a class like a `Star` model, a whole module (`import * as Slack from '../slack'`), or a stylesheet, image or GraphQL document (`import TCO21 from './themes/TCO21.scss'`) no longer links to a function elsewhere that only shares its name. Before, when the project had exactly one function of that name, such a value linked to it: outline's settings screens showed up among the callers of the server's `isCloudHosted()` check, and its stores among those of the `` icon, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A component that wraps the function declared beside it, like `export default styled(Disclosure)`, and a page loaded lazily, like `lazy(() => import('../pages/Home'))`, still lead to that function. Re-index JavaScript and TypeScript projects after upgrading. - `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named. - In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. - In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading. diff --git a/__tests__/function-ref-project-imports.test.ts b/__tests__/function-ref-project-imports.test.ts new file mode 100644 index 000000000..866280dea --- /dev/null +++ b/__tests__/function-ref-project-imports.test.ts @@ -0,0 +1,225 @@ +/** + * A function passed as a value whose name the file imports from its own + * project names what that import binds. When the import resolver binds it to + * something that is not a function, the function-as-value matcher still fell + * back to the one project function of that name elsewhere: + * - outline's `import isCloudHosted from '~/utils/isCloudHosted'` (a boolean + * constant) landed on the server's `isCloudHosted()` policy helper, and its + * `Star` model class on the `` icon component; + * - outline's `import * as Slack from '../slack'` (a module object) landed on + * the Slack plugin's settings component; + * - topcoder's `import TCO21 from './themes/TCO21.scss'` landed on the TCO21 + * community's routes, and cord-field's `.graphql` document on a component. + * + * A wrapper declared beside the function it wraps (`export default + * styled(Disclosure)`) and a lazy loader (`lazy(() => import('../pages/Home'))`) + * still lead to the namesake, as does an import the import resolver can't + * follow at all (a barrel it doesn't read). + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const roots: string[] = []; +afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +async function indexProject(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-project-')); + roots.push(root); + 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); + } + return CodeGraph.init(root, { index: true }); +} + +/** ` -> :` for each function-as-value edge leaving `file`. */ +function fnRefsFrom(cg: CodeGraph, file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return [...new Set(cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.metadata?.fnRef === true) + .map((e) => { + const target = cg.getNode(e.target); + return `${String(e.metadata?.refName)} -> ${target?.filePath}:${target?.qualifiedName}`; + }))] + .sort(); +} + +const PACKAGE_JSON = JSON.stringify({ name: 'wiki', dependencies: { react: '^18.2.0', 'styled-components': '^6.1.0' } }); +const TSCONFIG = JSON.stringify({ compilerOptions: { baseUrl: '.', jsx: 'react-jsx' } }); + +describe('a function value the file imports from its own project', () => { + it('names the constant, class or module the import binds, not a function elsewhere', async () => { + const cg = await indexProject({ + 'package.json': PACKAGE_JSON, + 'tsconfig.json': TSCONFIG, + // What the imports bind: a constant, a model class and a module. + 'app/utils/isCloudHosted.ts': `export const isCloudHosted = window.location.host.endsWith('.example.com'); +`, + 'app/models/Star.ts': `export default class Star { + id = ''; +} +`, + 'plugins/slack/server/slack.ts': `export function post(text: string): string { return text; } +`, + // The project's only functions of those names, somewhere else. + 'server/policies/utils.ts': `export function isCloudHosted(): boolean { return true; } +`, + 'app/components/Star.tsx': `export function Star() { + return ; +} +`, + 'plugins/slack/client/Settings.tsx': `export function Slack() { + return

; +} +`, + 'app/scenes/Settings.ts': `import { isCloudHosted } from '../utils/isCloudHosted'; +import Star from '../models/Star'; + +export function settings(register: (value: unknown) => void): void { + register(isCloudHosted); + register(Star); +} +`, + 'plugins/slack/server/hooks.test.ts': `import * as Slack from './slack'; + +export function spyOn(spy: (module: unknown) => void): void { + spy(Slack); +} +`, + }); + try { + expect({ + settings: fnRefsFrom(cg, 'app/scenes/Settings.ts'), + hooks: fnRefsFrom(cg, 'plugins/slack/server/hooks.test.ts'), + }).toEqual({ settings: [], hooks: [] }); + } finally { + cg.close(); + } + }, 60_000); + + it('never names a function when it imports a stylesheet, an image or a GraphQL document', async () => { + const cg = await indexProject({ + 'package.json': PACKAGE_JSON, + 'tsconfig.json': TSCONFIG, + 'src/components/Countdown/themes/TCO21.scss': `.countdown { color: red; } +`, + 'src/assets/img-QA.png': '', + 'src/assets/visibility.svg': ` +`, + 'src/scenes/Story/Story.graphql': `mutation CreateStory { createStory { id } } +`, + // The project's only functions of those names. + 'src/routes/TCO21/Routes.tsx': `export function TCO21() { + return
; +} +`, + 'src/routes/QA/Routes.tsx': `export function QA() { + return
; +} +`, + 'src/components/VisibilityIcon.tsx': `export function VisibilityIcon() { + return ; +} +`, + 'src/scenes/Story/CreateStory.tsx': `export function CreateStory() { + return
; +} +`, + 'src/components/Countdown/index.tsx': `import TCO21 from './themes/TCO21.scss'; +import QA from '../../assets/img-QA.png'; +import VisibilityIcon from '../../assets/visibility.svg?react'; +import { CreateStoryDocument as CreateStory } from '../../scenes/Story/Story.graphql'; + +export function themes(pick: (value: unknown) => void): void { + pick(TCO21); + pick(QA); + pick(VisibilityIcon); + pick(CreateStory); +} +`, + }); + try { + expect(fnRefsFrom(cg, 'src/components/Countdown/index.tsx')).toEqual([]); + } finally { + cg.close(); + } + }, 60_000); + + it('keeps a wrapper beside its function, a lazy loader, a barrel and a plain import', async () => { + const cg = await indexProject({ + 'package.json': PACKAGE_JSON, + 'tsconfig.json': TSCONFIG, + // The default export wraps the function declared beside it. + 'app/components/Disclosure.tsx': `import styled from 'styled-components'; + +function Disclosure(props: { open: boolean }) { + return