mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
fix(sync): a lazy route relinks a page that appears below its barrel (#2560)
A lazy React Router route whose module is a barrel, and whose page sits in a folder below the barrel's, stayed unlinked after `codegraph sync` added the page or gave it its export, though a fresh index linked it. In DigiShop.Frontend, `@/pages` is `src/pages/index.ts`, which imports `./Home`, `src/pages/Home/index.tsx`, and exports it again. While the page is missing, the route's reference fails and waits under the key of the module it names, `module:pages` (#2422). The retry looks failed references up by the keys of the changed files, and a file in `pages/Home/` gives only `module:Home` and its own stem. The limit, which the `export *` barrels of #2436 share, was recorded with #2558. After the failed-ref retry, which makes the barrel's import edge into an added file, sync now walks up the import edges from each file it added, or gave a definition, to the barrels that pass it on: `export … from`, or an import exported again (`import { X } from './x'; export { X }`, `export default X`), as deep as the resolver chases a name. It re-opens, for the sweep, the failed references waiting under those barrels' keys. Only keys a failed reference waits under are looked up, in one DISTINCT over the failed rows parked under module keys, so a sync with none reads nothing, and a body-only edit seeds nothing. The ImportReader that the added-files reopen (#2512) uses is shared, and gains exportedBindings, behind a regex pre-check, since most importers export nothing. defaultExportBinding moves to alias-binding.ts for this walk and for react.ts's exportsBinding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- A React Router route that lazily loads a folder, like `lazy: () => import('@/pages/invocation_page')`, now links to the page the folder's `index.ts` passes on, whether it writes `export * from './invocation_page'`, `export { default } from './page'` or `export { Page as Component } from './page'`, even through another `index.ts`. A route module that names its page with `export { Page as Component }` links to it as well, and so does a page loaded with `lazy(() => import(…))` through such an `index.ts`. Before, these routes linked to nothing, so their pages had no route leading to them. Re-index React projects after upgrading.
|
||||
- A React Router route whose `lazy` loader picks its page from the module it loads now links to that page: `lazy: async () => { const { TestTab } = await import('./tabs'); return { Component: TestTab }; }`, `const { default: Component } = await import('./pages/Login')`, `import('./reports').then((m) => ({ Component: m.ReportList }))`, `({ Component: (await import('./reports')).ReportPage })`, React Router 7's `lazy: { Component: async () => (await import('./show')).Show }`, and a page the loader shows inside a guard, like `element: <AgeGate><FireworksPage /></AgeGate>`. Before, such a route linked to the module's default export, else its `Component` export, which is a different page or none at all, as with luci-go's tabs loaded from a folder that forwards them. A loader written as a method, `async lazy() { … }`, is now read too, so its route is no longer missing, and a loader that hands React Router only a `loader` no longer makes the module's default export the route's page or the layout of the routes inside it. Re-index React projects after upgrading.
|
||||
- A React Router route, or a page loaded with `lazy(() => import(…))`, now links to its page when the folder it loads passes the page on through an import of its own, like `import { UserLayout } from './UserLayout'; export { UserLayout }` or `import Login from './Login'; export default Login`. Before, the folder's `index.ts` was searched for a page declared in it, which it doesn't have, so the route linked nothing. A default export that wraps the page in a call, like `export default withAuth(Page)`, still links neither the wrapper nor a guess. Re-index React projects after upgrading.
|
||||
- `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.
|
||||
- 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 `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <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 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.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* A lazy route whose module is a barrel, and whose page sits in a folder below
|
||||
* the barrel's (DigiShop.Frontend: `@/pages` is `src/pages/index.ts`, which
|
||||
* imports `./Home`, which is `src/pages/Home/index.tsx`). While the page is
|
||||
* missing the route's reference fails, and it waits under the key of the
|
||||
* module it names, `module:pages`. A file in `src/pages/Home/` gives its own
|
||||
* folder's and stem's keys, so `codegraph sync` never revisited the route when
|
||||
* the page appeared, though a fresh index links it.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
type Files = Record<string, string | null>;
|
||||
|
||||
const ROUTER: Files = {
|
||||
'package.json': JSON.stringify({ name: 'shop', private: true, dependencies: { react: '^18', 'react-router': '^7' } }),
|
||||
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }),
|
||||
'src/router.tsx': `import { createBrowserRouter } from 'react-router';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
lazy: async () => {
|
||||
const { HomePage } = await import('@/pages');
|
||||
return { Component: HomePage };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/cart',
|
||||
lazy: async () => {
|
||||
const { CartPage } = await import('@/shop');
|
||||
return { Component: CartPage };
|
||||
},
|
||||
},
|
||||
{ path: '/admin', lazy: () => import('@/admin') },
|
||||
]);
|
||||
`,
|
||||
// \`export … from\` into a folder below.
|
||||
'src/pages/index.ts': "export * from './home';\n",
|
||||
'src/pages/home/index.ts': "export { HomePage } from './HomePage';\n",
|
||||
// An import the barrel exports again.
|
||||
'src/shop/index.ts': "import { CartPage } from './cart';\n\nexport { CartPage };\n",
|
||||
// Two barrels below the one the route loads.
|
||||
'src/admin/index.ts': "export * from './dashboard';\n",
|
||||
'src/admin/dashboard/index.ts': "export * from './panel';\n",
|
||||
'src/admin/dashboard/panel/index.ts': "export * from './Dashboard';\n",
|
||||
};
|
||||
|
||||
const PAGES: Files = {
|
||||
'src/pages/home/HomePage.tsx': 'export function HomePage() {\n return null;\n}\n',
|
||||
'src/shop/cart/index.tsx': 'export function CartPage() {\n return null;\n}\n',
|
||||
'src/admin/dashboard/panel/Dashboard.tsx': 'export function Component() {\n return null;\n}\n',
|
||||
};
|
||||
|
||||
const LINKED = {
|
||||
'/': ['HomePage@src/pages/home/HomePage.tsx'],
|
||||
'/cart': ['CartPage@src/shop/cart/index.tsx'],
|
||||
'/admin': ['Component@src/admin/dashboard/panel/Dashboard.tsx'],
|
||||
};
|
||||
|
||||
let dirs: string[] = [];
|
||||
let graphs: CodeGraph[] = [];
|
||||
afterEach(() => {
|
||||
for (const graph of graphs) graph.close();
|
||||
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
||||
graphs = [];
|
||||
dirs = [];
|
||||
});
|
||||
|
||||
const write = (dir: string, files: Files): void => {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const file = path.join(dir, rel);
|
||||
if (content === null) {
|
||||
fs.rmSync(file, { force: true });
|
||||
continue;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
};
|
||||
/** A project of `files`, indexed from scratch in a folder of its own. */
|
||||
const indexed = async (files: Files): Promise<{ dir: string; graph: CodeGraph }> => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-nested-barrel-'));
|
||||
dirs.push(dir);
|
||||
write(dir, files);
|
||||
const graph = await CodeGraph.init(dir, { index: true });
|
||||
graphs.push(graph);
|
||||
return { dir, graph };
|
||||
};
|
||||
/** Each route's `references` edges, as `name@file`. */
|
||||
const routeLinks = (graph: CodeGraph): Record<string, string[]> => Object.fromEntries(
|
||||
graph.getNodesByKind('route').map((route) => [route.name, graph.getOutgoingEdges(route.id)
|
||||
.filter((e) => e.kind === 'references')
|
||||
.map((e) => graph.getNode(e.target)!)
|
||||
.map((n) => `${n.name}@${n.filePath}`)
|
||||
.sort()])
|
||||
);
|
||||
const NONE = { '/': [], '/cart': [], '/admin': [] };
|
||||
|
||||
describe('sync, for a lazy route whose page sits below its barrel', () => {
|
||||
it('links each page once it appears', async () => {
|
||||
const { dir, graph } = await indexed(ROUTER);
|
||||
expect(routeLinks(graph)).toEqual(NONE);
|
||||
write(dir, PAGES);
|
||||
expect((await graph.sync()).filesAdded).toBe(3);
|
||||
expect(routeLinks(graph)).toEqual(LINKED);
|
||||
expect(routeLinks((await indexed({ ...ROUTER, ...PAGES })).graph)).toEqual(LINKED);
|
||||
}, 60_000);
|
||||
|
||||
it('links each page once an edit gives it the export', async () => {
|
||||
const placeholders = Object.fromEntries(Object.keys(PAGES).map((file) => [file, 'export const placeholder = 1;\n']));
|
||||
const { dir, graph } = await indexed({ ...ROUTER, ...placeholders });
|
||||
expect(routeLinks(graph)).toEqual(NONE);
|
||||
write(dir, PAGES);
|
||||
expect((await graph.sync()).filesModified).toBe(3);
|
||||
expect(routeLinks(graph)).toEqual(LINKED);
|
||||
}, 60_000);
|
||||
|
||||
it('links a page again after it is deleted and comes back', async () => {
|
||||
const { dir, graph } = await indexed({ ...ROUTER, ...PAGES });
|
||||
expect(routeLinks(graph)).toEqual(LINKED);
|
||||
write(dir, Object.fromEntries(Object.keys(PAGES).map((file) => [file, null])));
|
||||
await graph.sync();
|
||||
expect(routeLinks(graph)).toEqual(NONE);
|
||||
write(dir, PAGES);
|
||||
await graph.sync();
|
||||
expect(routeLinks(graph)).toEqual(LINKED);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -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 `<Redirect>`), 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(… <Route {...rest}>)` 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); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve); a table `.map`ped inside `<Route path="/dashboard">` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). 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 `<Route element={<DashboardLayout/>}>` (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 `<Route>` (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, but its routes are not extracted: `getRouteMap()` returns the table), 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. Known limit, shared with #2436's `export *` barrels: a page in a folder below the barrel's (`pages/Home/index.tsx` behind `@/pages`) is linked by a fresh index but not by a sync that adds it later, because the route's failed reference waits under the barrel's module key (`module:pages`) |
|
||||
| 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 `<Redirect>`), 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(… <Route {...rest}>)` 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); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve); a table `.map`ped inside `<Route path="/dashboard">` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). 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 `<Route element={<DashboardLayout/>}>` (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 `<Route>` (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, but its routes are not extracted: `getRouteMap()` returns the table), 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 |
|
||||
| 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) |
|
||||
@@ -344,6 +344,13 @@ Each of these cost real debugging time; they are not hypothetical.
|
||||
route's reference back for its resolution sweep. The same holds for the
|
||||
JSX a component renders: a sync that adds a component redraws the
|
||||
`jsx-render` edges, since a tag in a file it never touched may name it.
|
||||
A route whose reference FAILED waits under the key of the module it names
|
||||
(`module:pages` for `@/pages`), and a page in a folder below that barrel
|
||||
(`pages/Home/index.tsx`) gives only its own folder's and stem's keys. So a
|
||||
sync that adds a file, or gives one a definition, walks up the import
|
||||
edges to the barrels that pass it on and re-opens what waits under their
|
||||
keys (`reopenReferencesThroughBarrels`, after the failed-ref retry made the
|
||||
barrel's import edge into the added file).
|
||||
18. **A lazy loader is code, and its page is what it hands over.** A React
|
||||
Router `lazy` resolves to the route's properties, so `const { TestTab } =
|
||||
await import('./tabs'); return { Component: TestTab }` renders `TestTab`,
|
||||
|
||||
@@ -4232,6 +4232,18 @@ export class QueryBuilder {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys failed references wait for a module under (`module:pages`), each
|
||||
* once. Reads only the failed rows parked under such keys: a range of
|
||||
* idx_unresolved_failed_tail, or the whole of
|
||||
* idx_unresolved_failed_module_name.
|
||||
*/
|
||||
getFailedModuleKeys(): string[] {
|
||||
return (this.db
|
||||
.prepare(`SELECT DISTINCT name_tail AS key FROM unresolved_refs WHERE status = 'failed' AND name_tail GLOB 'module:*'`)
|
||||
.all() as Array<{ key: string }>).map((row) => row.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
+29
-7
@@ -54,7 +54,7 @@ import {
|
||||
ResolutionResult,
|
||||
} from './resolution';
|
||||
import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer';
|
||||
import { createImportReader, reopenReferencesThroughAddedFiles } from './resolution/module-importers';
|
||||
import { createImportReader, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels } from './resolution/module-importers';
|
||||
import { GraphTraverser, GraphQueryManager } from './graph';
|
||||
import { findNamedCopybooks, type NamedCopybook } from './graph/cobol-copybooks';
|
||||
import { ContextBuilder, createContextBuilder } from './context';
|
||||
@@ -1112,18 +1112,40 @@ export class CodeGraph {
|
||||
// it bound by name in its absence, or failed, goes back to pending
|
||||
// for the sweep below, which binds it through the module as a fresh
|
||||
// index does. (The removal half runs before removed files are deleted.)
|
||||
if (result.addedFilePaths && result.addedFilePaths.length > 0) {
|
||||
const importReader = createImportReader(this.projectRoot);
|
||||
const added = (result.addedFilePaths ?? []).flatMap((filePath) => {
|
||||
const file = this.queries.getFileByPath(filePath);
|
||||
return file ? [{ path: filePath, language: file.language }] : [];
|
||||
});
|
||||
if (added.length > 0) {
|
||||
const tAdded = Date.now();
|
||||
const added = result.addedFilePaths.flatMap((filePath) => {
|
||||
const file = this.queries.getFileByPath(filePath);
|
||||
return file ? [{ path: filePath, language: file.language }] : [];
|
||||
});
|
||||
const reopened = reopenReferencesThroughAddedFiles(
|
||||
this.queries, createImportReader(this.projectRoot), added, edgesBeforeResolution,
|
||||
this.queries, importReader, added, edgesBeforeResolution,
|
||||
(specifier, fromFile, language) => this.resolver.resolveModuleFile(specifier, fromFile, language)
|
||||
);
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-added-module-importers: ${Date.now() - tAdded}ms (${reopened} refs re-opened)`);
|
||||
}
|
||||
|
||||
// A failed reference that waits for a module through a barrel —
|
||||
// React Router's `import:@/pages#HomePage`, parked under
|
||||
// `module:pages` — waits for a file below the barrel too
|
||||
// (`pages/Home/index.tsx`), whose own keys name its folder and
|
||||
// stem. Those parked under the keys of the barrels that pass on a
|
||||
// file this sync adds, or one whose definitions it changed, go
|
||||
// back to pending for the sweep; a body-only edit satisfies none.
|
||||
const tBarrels = Date.now();
|
||||
const delta = new Set(result.definitionDelta ?? []);
|
||||
const seeds = new Map<string, Language>(added.map((file) => [file.path, file.language]));
|
||||
const modified = result.changedFilePaths.filter((filePath) => !seeds.has(filePath));
|
||||
if (delta.size > 0 && modified.length > 0) {
|
||||
for (const node of this.queries.getNodesByFiles(modified)) {
|
||||
if (delta.has(node.name)) seeds.set(node.filePath, node.language);
|
||||
}
|
||||
}
|
||||
const throughBarrels = reopenReferencesThroughBarrels(
|
||||
this.queries, importReader, [...seeds].map(([path, language]) => ({ path, language }))
|
||||
);
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-barrel-modules: ${Date.now() - tBarrels}ms (${throughBarrels} refs re-opened)`);
|
||||
} else {
|
||||
// No git info — use batched resolution to avoid OOM
|
||||
const unresolvedCount = this.queries.getUnresolvedReferencesCount();
|
||||
|
||||
@@ -148,3 +148,14 @@ export function extractLocalExportAliases(content: string): Array<{ exportedName
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The binding a module's default export is as a whole: `export default
|
||||
* Page;`, `export { Page as default }`. Null for a declaration or an
|
||||
* expression: `export default withAuth(Page)` hands over a call, whose first
|
||||
* identifier is no binding of the module's.
|
||||
*/
|
||||
export function defaultExportBinding(content: string): string | null {
|
||||
return /(?:^|;)\s*export\s+default\s+([A-Za-z_$][\w$]*)\s*(?:;|$)/m.exec(content)?.[1] ??
|
||||
extractLocalExportAliases(content).find((a) => a.exportedName === 'default')?.localName ?? null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Language, Node } from '../../types';
|
||||
import { FrameworkExtractionResult, FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { dependsOn } from './package-deps';
|
||||
import { resolveImportPath } from '../import-resolver';
|
||||
import { extractLocalExportAliases } from '../alias-binding';
|
||||
import { defaultExportBinding, extractLocalExportAliases } from '../alias-binding';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
import { makeLineAt } from '../synth-utils';
|
||||
import { isTestPath } from '../../search/query-utils';
|
||||
@@ -550,8 +550,7 @@ function ownExport(source: string, name: string): string | null {
|
||||
|
||||
/** True when the module's default export is the binding `local` itself: `export default Page;`, `export { Page as default }`. */
|
||||
function exportsBinding(source: string, local: string): boolean {
|
||||
return new RegExp(String.raw`(?:^|;)\s*export\s+default\s+${local.replace(/\$/g, '\\$')}\s*(?:;|$)`, 'm').test(source) ||
|
||||
extractLocalExportAliases(source).some((a) => a.exportedName === 'default' && a.localName === local);
|
||||
return defaultExportBinding(source) === local;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,8 @@ import type { Edge, Language, UnresolvedReference } from '../types';
|
||||
import type { ImportMapping, ReExport } from './types';
|
||||
import { extractImportMappings, extractReExports, pythonModulePath, REEXPORT_MAX_DEPTH } from './import-resolver';
|
||||
import { resurrectRefFromDroppedEdge } from './resurrect-ref';
|
||||
import { defaultExportBinding, extractLocalExportAliases } from './alias-binding';
|
||||
import { moduleReferenceKeys } from '../db/reference-tail';
|
||||
|
||||
/**
|
||||
* Languages whose import bindings name what their references are written as:
|
||||
@@ -42,6 +44,9 @@ const BINDING_LANGUAGES = new Set<Language>([
|
||||
*/
|
||||
const reExportDepth = (language: Language): number => (language === 'python' ? 3 : REEXPORT_MAX_DEPTH);
|
||||
|
||||
/** An export clause, a default export, or CommonJS exports: a module that can hand over a binding it imports. */
|
||||
const EXPORTS_A_BINDING = /\bexport\s*\{|\bexport\s+default\s|\bexports\b/;
|
||||
|
||||
/** A Python star import, read the way the resolver reads one. */
|
||||
const PYTHON_STAR_IMPORT = /^\s*from\s+([\w.]+)\s+import\s+\*/gm;
|
||||
|
||||
@@ -75,10 +80,16 @@ export interface ImportReader {
|
||||
reExports(file: string, language: Language): ReExport[];
|
||||
/** The modules a Python file star-imports, which have no import mapping. */
|
||||
starImports(file: string, language: Language): string[];
|
||||
/**
|
||||
* The bindings an ES module's own exports hand over as they are: each local
|
||||
* an export clause names (`export { Page }`), and its default export when
|
||||
* that is a binding (`export default Page;`).
|
||||
*/
|
||||
exportedBindings(file: string, language: Language): ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function createImportReader(rootDir: string): ImportReader {
|
||||
const memo = new Map<string, { mappings: ImportMapping[]; reExports: ReExport[]; starImports: string[] }>();
|
||||
const memo = new Map<string, { content: string; mappings: ImportMapping[]; reExports: ReExport[]; starImports: string[]; exported?: ReadonlySet<string> }>();
|
||||
const read = (file: string, language: Language) => {
|
||||
let entry = memo.get(file);
|
||||
if (!entry) {
|
||||
@@ -89,6 +100,7 @@ export function createImportReader(rootDir: string): ImportReader {
|
||||
// Gone or unreadable: it imports nothing.
|
||||
}
|
||||
entry = {
|
||||
content,
|
||||
mappings: content ? extractImportMappings(file, content, language) : [],
|
||||
reExports: content ? extractReExports(content, language) : [],
|
||||
starImports: language === 'python' ? [...content.matchAll(PYTHON_STAR_IMPORT)].map((m) => m[1]!) : [],
|
||||
@@ -101,6 +113,21 @@ export function createImportReader(rootDir: string): ImportReader {
|
||||
mappings: (file, language) => read(file, language).mappings,
|
||||
reExports: (file, language) => read(file, language).reExports,
|
||||
starImports: (file, language) => read(file, language).starImports,
|
||||
exportedBindings: (file, language) => {
|
||||
const entry = read(file, language);
|
||||
if (!entry.exported) {
|
||||
// Most importers export nothing they import; a commented-out clause
|
||||
// read as one only costs a key lookup.
|
||||
const exported = new Set<string>();
|
||||
if (EXPORTS_A_BINDING.test(entry.content)) {
|
||||
for (const alias of extractLocalExportAliases(entry.content)) exported.add(alias.localName);
|
||||
const binding = defaultExportBinding(entry.content);
|
||||
if (binding) exported.add(binding);
|
||||
}
|
||||
entry.exported = exported;
|
||||
}
|
||||
return entry.exported;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +179,66 @@ export function reopenReferencesThroughAddedFiles(
|
||||
reopenEdges(queries, bindings, (e) => e.edgeId <= resolvedUpToEdgeId) + queries.reopenFailedReferences(failed);
|
||||
}
|
||||
|
||||
/** The languages a barrel a lazy route reads through is written in: the ES-module family. */
|
||||
const BARREL_LANGUAGES = new Set<Language>(['typescript', 'tsx', 'javascript', 'jsx']);
|
||||
|
||||
/**
|
||||
* Re-open, for the sync's sweep, the failed references that wait for a module
|
||||
* through a barrel when a file below the barrel appears or gains a
|
||||
* definition. React Router's `import:@/pages#HomePage` waits under the key of
|
||||
* the module it names, `module:pages`. When `pages/index.ts` passes on
|
||||
* `pages/Home/index.tsx`, that file is what the reference waits for, but its
|
||||
* own keys name its folder and stem (`module:Home`), never the barrel's.
|
||||
*
|
||||
* The barrels are the modules that pass one of `files` on, through `export …
|
||||
* from` or an import they export again (`import { HomePage } from './Home';
|
||||
* export { HomePage }`), followed up as deep as the resolver chases a name.
|
||||
* Only keys a failed reference waits under are looked up, so a sync with none
|
||||
* reads nothing. Call after the failed-ref retry: an added file's importers
|
||||
* reach it through the import edges that retry made. Returns the number
|
||||
* re-opened.
|
||||
*/
|
||||
export function reopenReferencesThroughBarrels(
|
||||
queries: QueryBuilder,
|
||||
reader: ImportReader,
|
||||
files: ReadonlyArray<{ path: string; language: Language }>,
|
||||
): number {
|
||||
let level = files.filter((file) => BARREL_LANGUAGES.has(file.language));
|
||||
if (level.length === 0) return 0;
|
||||
const waiting = new Set(queries.getFailedModuleKeys());
|
||||
if (waiting.size === 0) return 0;
|
||||
const keys = new Set<string>();
|
||||
const seen = new Set(level.map((file) => file.path));
|
||||
for (let depth = 1; level.length > 0 && depth <= REEXPORT_MAX_DEPTH; depth++) {
|
||||
const next: Array<{ path: string; language: Language }> = [];
|
||||
for (const module of level) {
|
||||
for (const i of queries.getImportsInto(`file:${module.path}`, module.path)) {
|
||||
if (i.refName === null || seen.has(i.filePath) || !BARREL_LANGUAGES.has(i.language)) continue;
|
||||
if (!passesOn(i.filePath, i.language, i.refName, reader)) continue;
|
||||
seen.add(i.filePath);
|
||||
for (const key of moduleReferenceKeys(i.filePath)) if (waiting.has(key)) keys.add(key);
|
||||
next.push({ path: i.filePath, language: i.language });
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
if (keys.size === 0) return 0;
|
||||
const parked = queries.getRetryableFailedReferences([...keys]).flatMap((ref) => (ref.rowId === undefined ? [] : [ref.rowId]));
|
||||
return queries.reopenFailedReferences(parked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `importer` passes on what it imports from `source`: an `export …
|
||||
* from` of it, or a binding it imports from it and exports again — the forms
|
||||
* a lazy route's lookup follows through a barrel (`exportedComponent`).
|
||||
*/
|
||||
function passesOn(importer: string, language: Language, source: string, reader: ImportReader): boolean {
|
||||
if (reader.reExports(importer, language).some((rex) => rex.source === source)) return true;
|
||||
const exported = reader.exportedBindings(importer, language);
|
||||
return exported.size > 0 &&
|
||||
reader.mappings(importer, language).some((m) => m.source === source && !m.isNamespace && exported.has(m.localName));
|
||||
}
|
||||
|
||||
/** An import's module edge into a file that an added file now outranks. */
|
||||
interface OutrankedImport {
|
||||
edge: Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language };
|
||||
|
||||
Reference in New Issue
Block a user