mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
fix(react-router): read route tables another file hands the router (#2400)
A route table kept in a file of its own produced no routes: the ASP.NET
Core React template's AppRoutes array, which App.js maps into
<Route {...rest}>, a routes array imported into useRoutes(routes) or
createBrowserRouter(routes), and codedthemes' one-object-per-file
[MainRoutes, LoginRoutes]. Route objects were read only from a file that
itself names a data router, so these apps had no routes and their
navigate()/<Link to> navigation reached nothing.
The table's own file cannot tell it from a menu's { path, element } list,
so the evidence is the file that hands it over: a useRoutes /
create*Router argument, or a .map whose callback renders a <Route> from
the item's own fields ({...route}, {...rest}, path={route.path}). A new
FrameworkResolver.crossFileNodes hook keeps those routes, reconciled in
runPostExtract on every index and sync (removal-only syncs included):
new ones are inserted with pending references, ones no longer wanted are
removed with their incoming resolution edges resurrected, unchanged ones
keep their edges. The React pass starts from the files that import React
Router, follows the import to the table and the tables it names
(...ApiAuthorizationRoutes, children: x), each under its mount, and owns
its routes by id (route:<file>:<line>:table:<path>).
Route objects (tables and data routers alike): an index: true route is
the page at its parent's address; a route object around others is their
layout (a references edge marked layout: true, as Angular's), and is not
a page of its own where a child claims its address. A route renders past
a *Wrapper or *Route guard, and through a same-file
lazy(() => import(...)) to the module it loads, never a same-named
component in another app. useRoutes joins the file-level gate.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading.
|
||||
- In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading.
|
||||
- In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading.
|
||||
- React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into `<Route {...rest}>`, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `<Link to="/counter">` links led nowhere. Each route links to the page its `element` renders, past a guard like `<ProtectedRoute>` or `<AdminWrapper>`, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading.
|
||||
|
||||
## [1.6.2] - 2026-10-03
|
||||
|
||||
|
||||
@@ -75,11 +75,14 @@ afterAll(() => {
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const renders = (routeName: string): string[] => {
|
||||
const bindings = (routeName: string, layout: boolean): string[] => {
|
||||
const route = cg.getNodesByKind('route').find((r) => r.name === routeName);
|
||||
if (!route) return [];
|
||||
return cg.getOutgoingEdges(route.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.name);
|
||||
return cg.getOutgoingEdges(route.id)
|
||||
.filter((e) => e.kind === 'references' && Boolean((e.metadata as Record<string, unknown> | undefined)?.layout) === layout)
|
||||
.map((e) => cg.getNode(e.target)!.name);
|
||||
};
|
||||
const renders = (routeName: string): string[] => bindings(routeName, false);
|
||||
|
||||
describe('nested, lazy and constant React Router routes', () => {
|
||||
it('compose their paths and render their modules', () => {
|
||||
@@ -90,6 +93,11 @@ describe('nested, lazy and constant React Router routes', () => {
|
||||
expect(renders('/app/discussions')).toEqual(['DiscussionsRoute']);
|
||||
});
|
||||
|
||||
it('a child route renders inside its parent’s component, the layout around it', () => {
|
||||
expect(bindings('/app/discussions', true)).toEqual(['AppRoot']);
|
||||
expect(bindings('/', true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves an app’s own tsconfig alias', () => {
|
||||
const router = cg.getNodesInFile('apps/web/src/app/router.tsx').map((n) => n.id);
|
||||
const imported = cg.getOutgoingEdgesFrom(router).filter((e) => e.kind === 'imports').map((e) => cg.getNode(e.target)!.filePath);
|
||||
|
||||
@@ -574,8 +574,9 @@ describe('react-router: route declaration boundaries (#1348)', () => {
|
||||
]);
|
||||
`, extension);
|
||||
expect(result).toEqual({
|
||||
paths: ['/', '/data/prefs', '/long', '/quoted', '/sibling'],
|
||||
bindings: ['/->DataSettings', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'],
|
||||
// `{ index: true, Component: DataIndex }` is the page at its parent's address.
|
||||
paths: ['/', '/data', '/data/prefs', '/long', '/quoted', '/sibling'],
|
||||
bindings: ['/->DataSettings', '/data->DataIndex', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -692,3 +693,571 @@ export default function FeaturePage() { return <h1>Features</h1>; }
|
||||
expect(navsFrom('PrivateRoute')).toEqual(['redirect /']);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Route tables another file hands the router
|
||||
// =============================================================================
|
||||
|
||||
function writeFiles(root: string, files: Record<string, string>): void {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
}
|
||||
|
||||
async function indexProject(files: Record<string, string>): Promise<{ root: string; cg: CodeGraph }> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-tables-'));
|
||||
writeFiles(root, files);
|
||||
return { root, cg: await CodeGraph.init(root, { index: true }) };
|
||||
}
|
||||
|
||||
/** `path -> Component` for what each route renders, `path ~> Layout` for each layout around it. */
|
||||
function routeBindings(cg: CodeGraph): string[] {
|
||||
return cg.getNodesByKind('route').flatMap((r) => cg.getOutgoingEdges(r.id)
|
||||
.filter((e) => e.kind === 'references')
|
||||
.map((e) => `${r.name} ${(e.metadata as Record<string, unknown> | undefined)?.layout ? '~>' : '->'} ${cg.getNode(e.target)?.name}`))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** `Source -> /path` for every navigation into a route. */
|
||||
function navigations(cg: CodeGraph): string[] {
|
||||
return cg.getNodesByKind('route').flatMap((r) => cg.getIncomingEdges(r.id)
|
||||
.filter((e) => e.kind === 'navigates')
|
||||
.map((e) => `${cg.getNode(e.source)?.name} -> ${r.name}`))
|
||||
.sort();
|
||||
}
|
||||
|
||||
const routeNames = (cg: CodeGraph): string[] => cg.getNodesByKind('route').map((r) => r.name).sort();
|
||||
|
||||
const component = (name: string): string => `export function ${name}() {\n return <div>${name}</div>;\n}\n`;
|
||||
|
||||
/** jasontaylordev/CleanArchitecture's ClientApp-React and the ASP.NET Core React template it comes from. */
|
||||
const ASPNET_TEMPLATE: Record<string, string> = {
|
||||
'src/Web/ClientApp/package.json': JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router-dom': '^6' } }),
|
||||
'src/Web/ClientApp/src/AppRoutes.js': `import ApiAuthorzationRoutes from './components/api-authorization/ApiAuthorizationRoutes';
|
||||
import { Counter } from "./components/Counter";
|
||||
import { FetchData } from "./components/FetchData";
|
||||
import { Home } from "./components/Home";
|
||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import AdminWrapper from "./components/AdminWrapper";
|
||||
import { Users } from "./components/Users";
|
||||
|
||||
const AppRoutes = [
|
||||
{
|
||||
index: true,
|
||||
element: <Home />
|
||||
},
|
||||
{
|
||||
path: '/counter',
|
||||
element: <Counter />
|
||||
},
|
||||
{
|
||||
path: '/fetch-data',
|
||||
requireAuth: true,
|
||||
element: <ProtectedRoute><FetchData /></ProtectedRoute>
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
element: <AdminWrapper><Users /></AdminWrapper>
|
||||
},
|
||||
...ApiAuthorzationRoutes
|
||||
];
|
||||
|
||||
export default AppRoutes;
|
||||
`,
|
||||
'src/Web/ClientApp/src/components/api-authorization/ApiAuthorizationRoutes.js': `import { Profile } from './Profile';
|
||||
import { Login } from './Login';
|
||||
import { ApplicationPaths } from './ApiAuthorizationConstants';
|
||||
|
||||
const ApiAuthorizationRoutes = [
|
||||
{
|
||||
path: '/authentication/profile',
|
||||
element: <Profile />
|
||||
},
|
||||
{
|
||||
path: ApplicationPaths.Login,
|
||||
element: loginAction('login')
|
||||
}
|
||||
];
|
||||
|
||||
function loginAction(name) {
|
||||
return <Login action={name}></Login>;
|
||||
}
|
||||
|
||||
export default ApiAuthorizationRoutes;
|
||||
`,
|
||||
'src/Web/ClientApp/src/components/api-authorization/ApiAuthorizationConstants.js':
|
||||
"const prefix = '/authentication';\nexport const ApplicationPaths = {\n Login: `${prefix}/login`,\n};\n",
|
||||
'src/Web/ClientApp/src/App.js': `import React, { Component } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import AuthorizeRoute from './components/api-authorization/AuthorizeRoute';
|
||||
import { Layout } from './components/Layout';
|
||||
|
||||
export default class App extends Component {
|
||||
static displayName = App.name;
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
{AppRoutes.map((route, index) => {
|
||||
const { element, requireAuth, ...rest } = route;
|
||||
return <Route key={index} {...rest} element={requireAuth ? <AuthorizeRoute {...rest} element={element} /> : element} />;
|
||||
})}
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/Web/ClientApp/src/components/NavMenu.js': `import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { menu } from '../menu';
|
||||
|
||||
export function NavMenu() {
|
||||
const navigate = useNavigate();
|
||||
const showProfile = () => {
|
||||
navigate('/authentication/profile');
|
||||
};
|
||||
return (
|
||||
<nav>
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/counter">Counter</Link>
|
||||
<Link to="/fetch-data">Fetch data</Link>
|
||||
<button onClick={showProfile}>Profile</button>
|
||||
{menu.map((item) => <NavLink key={item.path} to={item.path}>{item.element}</NavLink>)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
`,
|
||||
// `{ path, element }` lists that are not route tables: a menu rendered as
|
||||
// links, a list nothing renders, and a table mapped into a path it does not
|
||||
// hold (`layout + path`).
|
||||
'src/Web/ClientApp/src/menu.js': `import { HelpIcon, SettingsIcon } from './icons';
|
||||
export const menu = [
|
||||
{ path: '/help', element: <HelpIcon /> },
|
||||
{ path: '/settings', element: <SettingsIcon /> },
|
||||
];
|
||||
`,
|
||||
'src/Web/ClientApp/src/breadcrumbs.js': `import { Crumb } from './icons';
|
||||
export const crumbs = [{ path: '/history', element: <Crumb /> }];
|
||||
`,
|
||||
'src/Web/ClientApp/src/dashboard.js': `import { Route, Routes } from 'react-router-dom';
|
||||
import { Stats } from './icons';
|
||||
const dashboardRoutes = [{ path: '/stats', layout: '/admin', element: <Stats /> }];
|
||||
export function Dashboard() {
|
||||
return <Routes>{dashboardRoutes.map((r) => <Route key={r.path} path={r.layout + r.path} element={r.element} />)}</Routes>;
|
||||
}
|
||||
`,
|
||||
'src/Web/ClientApp/src/icons.js': component('HelpIcon') + component('SettingsIcon') + component('Crumb') + component('Stats'),
|
||||
'src/Web/ClientApp/src/components/Home.js': component('Home'),
|
||||
'src/Web/ClientApp/src/components/Counter.js': component('Counter'),
|
||||
'src/Web/ClientApp/src/components/FetchData.js': component('FetchData'),
|
||||
'src/Web/ClientApp/src/components/ProtectedRoute.js': 'export function ProtectedRoute({ children }) {\n return children;\n}\n',
|
||||
'src/Web/ClientApp/src/components/AdminWrapper.js': 'export default function AdminWrapper({ children }) {\n return children;\n}\n',
|
||||
'src/Web/ClientApp/src/components/Users.js': component('Users'),
|
||||
'src/Web/ClientApp/src/components/Layout.js':
|
||||
"import { NavMenu } from './NavMenu';\nexport function Layout({ children }) {\n return <div><NavMenu />{children}</div>;\n}\n",
|
||||
'src/Web/ClientApp/src/components/api-authorization/Profile.js': component('Profile'),
|
||||
'src/Web/ClientApp/src/components/api-authorization/Login.js': component('Login'),
|
||||
'src/Web/ClientApp/src/components/api-authorization/AuthorizeRoute.js':
|
||||
'export default function AuthorizeRoute({ element }) {\n return element;\n}\n',
|
||||
};
|
||||
|
||||
describe('react-router: a route table another file maps into <Route> (the ASP.NET Core React template)', () => {
|
||||
let root: string;
|
||||
let cg: CodeGraph;
|
||||
beforeAll(async () => {
|
||||
({ root, cg } = await indexProject(ASPNET_TEMPLATE));
|
||||
});
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the table App.js spreads into <Route {...rest}>, and the table it spreads in turn', () => {
|
||||
expect(routeBindings(cg)).toEqual([
|
||||
// `index: true` at the top of the table is the app's `/`.
|
||||
'/ -> Home',
|
||||
// `<AdminWrapper><Users /></AdminWrapper>` and
|
||||
// `<ProtectedRoute><FetchData /></ProtectedRoute>` render what they wrap.
|
||||
'/admin/users -> Users',
|
||||
'/authentication/profile -> Profile',
|
||||
'/counter -> Counter',
|
||||
'/fetch-data -> FetchData',
|
||||
]);
|
||||
// An entry that renders a call's result names no component, so it is no route.
|
||||
expect(routeNames(cg)).toEqual(['/', '/admin/users', '/authentication/profile', '/counter', '/fetch-data']);
|
||||
});
|
||||
|
||||
it('keeps each route at the line of its own object in the table', () => {
|
||||
const counter = cg.getNodesByKind('route').find((r) => r.name === '/counter')!;
|
||||
expect(counter.filePath).toBe('src/Web/ClientApp/src/AppRoutes.js');
|
||||
expect(counter.startLine).toBe(15);
|
||||
});
|
||||
|
||||
it('makes no route of a menu, a list nothing hands the router, or a path the table does not hold', () => {
|
||||
for (const p of ['/help', '/settings', '/history', '/stats', '/admin/stats']) expect(routeNames(cg)).not.toContain(p);
|
||||
});
|
||||
|
||||
it('gives the navigation that names those routes somewhere to go', () => {
|
||||
expect(navigations(cg)).toEqual([
|
||||
'NavMenu -> /',
|
||||
'NavMenu -> /counter',
|
||||
'NavMenu -> /fetch-data',
|
||||
'showProfile -> /authentication/profile',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lands on the Screens tab', async () => {
|
||||
const screens = await buildScreens(cg, root);
|
||||
expect(screens.routed).toBe(true);
|
||||
expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/admin/users', '/authentication/profile', '/counter', '/fetch-data']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('react-router: a table handed to useRoutes from another file, an index route under a layout', () => {
|
||||
let root: string;
|
||||
let cg: CodeGraph;
|
||||
beforeAll(async () => {
|
||||
({ root, cg } = await indexProject({
|
||||
'package.json': JSON.stringify({ name: 'kit', dependencies: { react: '^18', 'react-router-dom': '^6' } }),
|
||||
// A table written as a function of the login state, the way many admin kits write it.
|
||||
'src/routes.jsx': `import { Navigate } from 'react-router-dom';
|
||||
import DashboardLayout from './layouts/DashboardLayout';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import { Account, Dashboard, Landing, Login } from './pages';
|
||||
|
||||
const routes = (isLoggedIn) => [
|
||||
{
|
||||
path: 'app',
|
||||
element: isLoggedIn ? <DashboardLayout /> : <Navigate to="/login" />,
|
||||
children: [
|
||||
{ path: 'dashboard', element: <Dashboard /> },
|
||||
{ path: 'account', element: <Account /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Landing /> },
|
||||
{ path: 'login', element: <Login /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
`,
|
||||
'src/App.jsx': `import { useRoutes } from 'react-router-dom';
|
||||
import routes from './routes';
|
||||
|
||||
export default function App({ isLoggedIn }) {
|
||||
return useRoutes(routes(isLoggedIn));
|
||||
}
|
||||
`,
|
||||
'src/layouts/MainLayout.jsx': `import { Link, Outlet } from 'react-router-dom';
|
||||
export default function MainLayout() {
|
||||
return <div><Link to="/app/dashboard">Dashboard</Link><Outlet /></div>;
|
||||
}
|
||||
`,
|
||||
'src/layouts/DashboardLayout.jsx': `import { Outlet } from 'react-router-dom';
|
||||
export default function DashboardLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
`,
|
||||
'src/pages.jsx': `import { useNavigate } from 'react-router-dom';
|
||||
export function Dashboard() { return <div>Dashboard</div>; }
|
||||
export function Account() { return <div>Account</div>; }
|
||||
export function Landing() { return <div>Landing</div>; }
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const onSubmit = () => navigate('/app/account');
|
||||
return <form onSubmit={onSubmit} />;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
});
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the table the function returns, nested paths composed', () => {
|
||||
expect(routeBindings(cg)).toEqual([
|
||||
// The index route is the page at `/`; MainLayout is the layout around it, not a second `/`.
|
||||
'/ -> Landing',
|
||||
'/ ~> MainLayout',
|
||||
// `app`'s element is a choice made at runtime: a path to sit under, no layout.
|
||||
'/app/account -> Account',
|
||||
'/app/dashboard -> Dashboard',
|
||||
'/login -> Login',
|
||||
'/login ~> MainLayout',
|
||||
]);
|
||||
});
|
||||
|
||||
it("draws a layout's links on every screen inside it", async () => {
|
||||
expect(navigations(cg)).toEqual([
|
||||
'MainLayout -> /app/dashboard',
|
||||
'onSubmit -> /app/account',
|
||||
// The table's own guard: `isLoggedIn ? <DashboardLayout /> : <Navigate to="/login" />`.
|
||||
'routes -> /login',
|
||||
]);
|
||||
const screens = await buildScreens(cg, root);
|
||||
expect(screens.routed).toBe(true);
|
||||
const at = (p: string) => screens.screens.find((s) => s.path === p)!.id;
|
||||
for (const from of ['/', '/login']) {
|
||||
expect(screens.links.find((l) => l.from === at(from) && l.to === at('/app/dashboard'))).toBeDefined();
|
||||
}
|
||||
expect(screens.links.find((l) => l.from === at('/login') && l.to === at('/app/account'))).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("react-router: route objects in their own files, handed to createBrowserRouter (codedthemes' admin templates)", () => {
|
||||
let root: string;
|
||||
let cg: CodeGraph;
|
||||
beforeAll(async () => {
|
||||
({ root, cg } = await indexProject({
|
||||
'package.json': JSON.stringify({ name: 'berry', dependencies: { react: '^18', 'react-router-dom': '^7' } }),
|
||||
'src/routes/index.jsx': `import { createBrowserRouter } from 'react-router-dom';
|
||||
|
||||
// routes
|
||||
import AuthenticationRoutes from './AuthenticationRoutes';
|
||||
import MainRoutes from './MainRoutes';
|
||||
|
||||
const router = createBrowserRouter([MainRoutes, AuthenticationRoutes], {
|
||||
basename: import.meta.env.VITE_APP_BASE_NAME
|
||||
});
|
||||
|
||||
export default router;
|
||||
`,
|
||||
'src/routes/MainRoutes.jsx': `import { lazy } from 'react';
|
||||
import MainLayout from '../layout/MainLayout';
|
||||
import Loadable from '../ui-component/Loadable';
|
||||
|
||||
const DashboardDefault = Loadable(lazy(() => import('../views/DashboardDefault')));
|
||||
// An import only the app's own build resolves (\`baseUrl\`).
|
||||
const SamplePage = Loadable(lazy(() => import('views/sample-page')));
|
||||
|
||||
const MainRoutes = {
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <DashboardDefault />
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'default',
|
||||
element: <DashboardDefault />
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/sample-page',
|
||||
element: <SamplePage />
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default MainRoutes;
|
||||
`,
|
||||
'src/routes/AuthenticationRoutes.jsx': `import MinimalLayout from '../layout/MinimalLayout';
|
||||
import LoginPage from '../views/LoginPage';
|
||||
|
||||
const AuthenticationRoutes = {
|
||||
path: '/',
|
||||
element: <MinimalLayout />,
|
||||
children: [
|
||||
{
|
||||
path: '/pages/login',
|
||||
element: <LoginPage />
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default AuthenticationRoutes;
|
||||
`,
|
||||
'src/layout/MainLayout.jsx': 'export default function MainLayout() {\n return <main />;\n}\n',
|
||||
'src/layout/MinimalLayout.jsx': 'export default function MinimalLayout() {\n return <main />;\n}\n',
|
||||
'src/views/DashboardDefault.jsx': 'export default function DashboardDefault() {\n return <div />;\n}\n',
|
||||
'src/views/LoginPage.jsx': 'export default function LoginPage() {\n return <div />;\n}\n',
|
||||
'src/ui-component/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => <Component {...props} />;\n}\n',
|
||||
// The repository's other app, with pages of the same names.
|
||||
'next/package.json': JSON.stringify({ name: 'berry-next', dependencies: { next: '^15', react: '^18' } }),
|
||||
'next/src/views/sample-page.jsx': 'export default function SamplePage() {\n return <div />;\n}\n',
|
||||
'next/src/views/default.jsx': 'export default function DashboardDefault() {\n return <div />;\n}\n',
|
||||
}));
|
||||
});
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads each route object the array names, its children under its path', () => {
|
||||
expect(routeBindings(cg)).toEqual([
|
||||
// MainRoutes' `/` child claims its layout's address.
|
||||
'/ -> DashboardDefault',
|
||||
'/ ~> MainLayout',
|
||||
// No child of the login layout claims `/`, so it is a page there of its
|
||||
// own — the rule Angular and Vue routes follow.
|
||||
'/ -> MinimalLayout',
|
||||
'/dashboard/default -> DashboardDefault',
|
||||
'/dashboard/default ~> MainLayout',
|
||||
'/pages/login -> LoginPage',
|
||||
'/pages/login ~> MinimalLayout',
|
||||
'/sample-page -> SamplePage',
|
||||
'/sample-page ~> MainLayout',
|
||||
].sort());
|
||||
});
|
||||
|
||||
it('binds a page the route file loads lazily to the module it loads, and never to a same-named page of another app', () => {
|
||||
const rendered = (p: string): string[] => cg.getNodesByKind('route').filter((r) => r.name === p).flatMap((r) => cg.getOutgoingEdges(r.id)
|
||||
.filter((e) => e.kind === 'references' && !(e.metadata as Record<string, unknown> | undefined)?.layout)
|
||||
.map((e) => { const n = cg.getNode(e.target)!; return `${n.kind} ${n.name} ${n.filePath}`; }));
|
||||
expect(rendered('/dashboard/default')).toEqual(['function DashboardDefault src/views/DashboardDefault.jsx']);
|
||||
// Its module is out of reach, so the page is the declaration itself.
|
||||
expect(rendered('/sample-page')).toEqual(['constant SamplePage src/routes/MainRoutes.jsx']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('react-router: a table mapped inside <Route path> in the same file', () => {
|
||||
let root: string;
|
||||
let cg: CodeGraph;
|
||||
beforeAll(async () => {
|
||||
({ root, cg } = await indexProject({
|
||||
'ClientApp/package.json': JSON.stringify({ name: 'client', dependencies: { react: '^18', 'react-router-dom': '^6' } }),
|
||||
'ClientApp/src/routes.js': `import { Routes, Route } from 'react-router-dom';
|
||||
import DashboardLayout from './layouts/dashboard';
|
||||
import DashboardApp from './pages/DashboardApp';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import TicketDetail from './pages/TicketDetail';
|
||||
|
||||
const DashboardRoutes = [
|
||||
{
|
||||
path: 'app',
|
||||
requireAuth: true,
|
||||
element: <DashboardApp/>
|
||||
},
|
||||
{
|
||||
path: 'tickets/:id',
|
||||
requireAuth: true,
|
||||
element: <TicketDetail/>
|
||||
}
|
||||
];
|
||||
|
||||
export default function Router() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="landing" element={<LandingPage/>}/>
|
||||
<Route path="/dashboard" element={<DashboardLayout/>}>
|
||||
{DashboardRoutes.map((route, index) => {
|
||||
const {element, requireAuth, ...rest} = route;
|
||||
return <Route key={index} {...rest} element={element}/>;
|
||||
})}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
`,
|
||||
'ClientApp/src/layouts/dashboard.js': 'export default function DashboardLayout() {\n return <div />;\n}\n',
|
||||
'ClientApp/src/pages/DashboardApp.js': 'export default function DashboardApp() {\n return <div />;\n}\n',
|
||||
'ClientApp/src/pages/LandingPage.js': 'export default function LandingPage() {\n return <div />;\n}\n',
|
||||
'ClientApp/src/pages/TicketDetail.js': 'export default function TicketDetail() {\n return <div />;\n}\n',
|
||||
}));
|
||||
});
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("composes the table's relative paths onto the <Route path> it is mapped inside", () => {
|
||||
expect(routeBindings(cg)).toEqual([
|
||||
'/dashboard -> DashboardLayout',
|
||||
'/dashboard/app -> DashboardApp',
|
||||
'/dashboard/tickets/:id -> TicketDetail',
|
||||
'/landing -> LandingPage',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('react-router: a route table as the files around it change', () => {
|
||||
let root: string;
|
||||
let cg: CodeGraph;
|
||||
const app = (body: string) => `import { Route, Routes } from 'react-router-dom';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { Counter } from './Counter';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
${body}
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
`;
|
||||
const MAPPED = app('{AppRoutes.map(({ element, ...rest }, index) => <Route key={index} {...rest} element={element} />)}');
|
||||
const table = (extra: string) => `import { Counter } from './Counter';
|
||||
import { Home } from './Home';
|
||||
import { FetchData } from './FetchData';
|
||||
|
||||
const AppRoutes = [${extra}
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: '/counter', element: <Counter /> },
|
||||
];
|
||||
|
||||
export default AppRoutes;
|
||||
`;
|
||||
beforeAll(async () => {
|
||||
({ root, cg } = await indexProject({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18', 'react-router-dom': '^6' } }),
|
||||
'src/AppRoutes.js': table(''),
|
||||
'src/App.js': MAPPED,
|
||||
'src/Home.js': component('Home'),
|
||||
'src/Counter.js': component('Counter'),
|
||||
'src/FetchData.js': component('FetchData'),
|
||||
'src/NavMenu.js': `import { Link, useNavigate } from 'react-router-dom';
|
||||
export function NavMenu() {
|
||||
const navigate = useNavigate();
|
||||
const goHome = () => navigate('/');
|
||||
return <nav><button onClick={goHome}>Home</button><Link to="/counter">Counter</Link></nav>;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
});
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('starts with the table read', () => {
|
||||
expect(routeNames(cg)).toEqual(['/', '/counter']);
|
||||
expect(navigations(cg)).toEqual(['NavMenu -> /counter', 'goHome -> /']);
|
||||
});
|
||||
|
||||
it('an edit to the table moves its routes, and what navigated to them still does', async () => {
|
||||
writeFiles(root, { 'src/AppRoutes.js': table("\n { path: '/fetch-data', element: <FetchData /> },") });
|
||||
await cg.sync();
|
||||
expect(routeBindings(cg)).toEqual(['/ -> Home', '/counter -> Counter', '/fetch-data -> FetchData']);
|
||||
expect(navigations(cg)).toEqual(['NavMenu -> /counter', 'goHome -> /']);
|
||||
});
|
||||
|
||||
it('a file that stops handing the table over takes its routes with it, and putting it back brings them back', async () => {
|
||||
writeFiles(root, { 'src/App.js': app('<Route path="/static" element={<Counter />} />') });
|
||||
await cg.sync();
|
||||
expect(routeNames(cg)).toEqual(['/static']);
|
||||
writeFiles(root, { 'src/App.js': MAPPED });
|
||||
await cg.sync();
|
||||
expect(routeNames(cg)).toEqual(['/', '/counter', '/fetch-data']);
|
||||
expect(navigations(cg)).toContain('NavMenu -> /counter');
|
||||
});
|
||||
|
||||
it('deleting that file is a change too', async () => {
|
||||
fs.rmSync(path.join(root, 'src/App.js'));
|
||||
await cg.sync();
|
||||
expect(routeNames(cg)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Framework & language coverage — what is done, what is left
|
||||
|
||||
**Last verified: 2026-08-29** (Angular row: 2026-10-06) against the build at that date. Re-verify with the
|
||||
**Last verified: 2026-08-29** (Angular row: 2026-10-06; React Router row: 2026-10-06) against the build at that date. Re-verify with the
|
||||
queries in [Checking this file is still true](#checking-this-file-is-still-true)
|
||||
before trusting a row; this is a snapshot, not a live view.
|
||||
|
||||
@@ -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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -263,6 +263,26 @@ Each of these cost real debugging time; they are not hypothetical.
|
||||
template-bound handler there was listed as dead code. A new detector that
|
||||
gates on a dependency should ask `dependsOn`, not read a `package.json`
|
||||
itself, so it sees the same manifests every other detector does.
|
||||
13. **A route table can be named only by another file.** The ASP.NET Core
|
||||
React template keeps its routes in `AppRoutes.js`, a file that never names
|
||||
the router, and renders them from `App.js` with `AppRoutes.map(({ element,
|
||||
...rest }) => <Route {...rest} element={element} />)`; codedthemes' admin
|
||||
kits put one route object per file and list them in
|
||||
`createBrowserRouter([MainRoutes, LoginRoutes])`. Read on its own, such a
|
||||
file cannot be told from a menu's `{ path, element }` list, and per-file
|
||||
extraction cannot see the file that hands it over, nor run again when that
|
||||
file changes. So React keeps those routes in the cross-file pass
|
||||
(`FrameworkResolver.crossFileNodes`, reconciled in `runPostExtract` on every
|
||||
index and sync, removals included): it starts from the files that import
|
||||
React Router, takes only a `useRoutes` / `create*Router` argument or a
|
||||
`.map` whose callback renders a `<Route>` from the item's own fields
|
||||
(`{...route}`, `{...rest}`, `path={route.path}` — never
|
||||
`path={r.layout + r.path}`), follows the import to the table and the tables
|
||||
it names in turn, and owns its routes by id (`…:table:<path>`). Two limits
|
||||
it shares with every router: a table rendered by a component that is
|
||||
itself mounted under another file's `<Route path="/admin/*">` reads as if
|
||||
at the root, and a navigation that failed while its route was gone is not
|
||||
retried until a full index.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel
|
||||
|
||||
- **A server action written through a wrapper starts its transition on the right page.** `export const signIn = validatedAction(schema, async (data) => { … redirect('/dashboard') })` — the arrow inside is no symbol of its own — now belongs to `signIn` on the Screens tab, and `signIn` is attributed to the page whose component hands it to `useActionState(signIn, …)`, read from the source when the graph holds no such edge.
|
||||
|
||||
- **React Router apps that keep their routes in a table get a Screens picture.** The ASP.NET Core React template's `AppRoutes` array, a `routes` array handed to `useRoutes` or `createBrowserRouter` from another file, and admin kits' `[MainRoutes, LoginRoutes]` route objects drew no screens, because their routes were never read. They are now, with the links and `navigate(…)` calls between them. A parent route object's component is drawn as the layout around the screens inside it: its own links count from each of those screens, and when an `index: true` child claims its address, that child is the screen there. Re-index React projects after upgrading.
|
||||
|
||||
## Fixes — Steps — what a call does, and when
|
||||
|
||||
- **A Steps picture with nothing past its anchor says why.** Starting Steps from a helper that only computes drew one box and no explanation. The panel now says that nothing it sets in motion is a step the picture draws, explains that calls between plain functions fold into the lines, and links to the symbol's callers and callees.
|
||||
|
||||
+1
-33
@@ -17,7 +17,6 @@ import {
|
||||
ExtractionError,
|
||||
Edge,
|
||||
UnresolvedReference,
|
||||
ReferenceKind,
|
||||
} from '../types';
|
||||
import { QueryBuilder, NodeIdentity } from '../db/queries';
|
||||
import { extractFromSource } from './tree-sitter';
|
||||
@@ -35,6 +34,7 @@ import { detectFrameworks, getFrameworkResolver } from '../resolution/frameworks
|
||||
import { declaredDependencies } from '../resolution/frameworks/package-deps';
|
||||
import type { ResolutionContext } from '../resolution/types';
|
||||
import { createYielder, type MaybeYield } from '../resolution/cooperative-yield';
|
||||
import { resurrectRefFromDroppedEdge } from '../resolution/resurrect-ref';
|
||||
import { MAX_SOURCE_FILE_SIZE_BYTES, oversizeStamp, readBoundedSource, readBoundedSourceSync } from '../file-limits';
|
||||
export { oversizeStamp };
|
||||
|
||||
@@ -1771,38 +1771,6 @@ function scanDirectoryWalk(
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resurrect a resolution edge that is about to be dropped (its target symbol
|
||||
* was removed, renamed, or its whole file deleted) as the ORIGINAL unresolved
|
||||
* reference that created it, read from the refName/refKind stamp
|
||||
* `createEdges` writes into edge metadata. Inserted as status='pending', the
|
||||
* ref is consumed by the same sync's resolution sweep: it rebinds to an
|
||||
* alternative definition if one exists, or parks as status='failed' where the
|
||||
* #1240 retry finds it if the symbol later reappears.
|
||||
*
|
||||
* Returns null — drop silently, the pre-#1240 behavior — for edges without a
|
||||
* refName stamp (created before the stamp existed, or synthesized): rebuilding
|
||||
* a ref from the target's plain node name would strip the receiver/qualifier
|
||||
* context the original text carried (`h.greet` → `greet`) and could rebind
|
||||
* somewhere a full re-index never would. Silent beats wrong.
|
||||
*/
|
||||
function resurrectRefFromDroppedEdge(
|
||||
e: Edge & { sourceFilePath: string; sourceLanguage: Language }
|
||||
): UnresolvedReference | null {
|
||||
const refName = e.metadata?.refName;
|
||||
if (typeof refName !== 'string' || refName.length === 0) return null;
|
||||
const refKind = typeof e.metadata?.refKind === 'string' ? (e.metadata.refKind as ReferenceKind) : e.kind;
|
||||
return {
|
||||
fromNodeId: e.source,
|
||||
referenceName: refName,
|
||||
referenceKind: refKind,
|
||||
line: e.line ?? 0,
|
||||
column: e.column ?? 0,
|
||||
filePath: e.sourceFilePath,
|
||||
language: e.sourceLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What tells a file's nodes apart across a re-index, coarsest first. (kind,
|
||||
* name) is the #899 key and settles every name a file defines once; a name it
|
||||
|
||||
+9
-11
@@ -940,18 +940,16 @@ export class CodeGraph {
|
||||
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Run on
|
||||
// every sync that touched files so edits to `app.module.ts` propagate
|
||||
// to controllers in unchanged files. The pass is idempotent and cheap
|
||||
// (regex over *.module.ts only).
|
||||
if (result.filesAdded > 0 || result.filesModified > 0) {
|
||||
// (regex over *.module.ts only). A removal counts too: deleting the
|
||||
// file that hands a React Router table to the router leaves the
|
||||
// table's routes behind unless this pass runs to take them away.
|
||||
// (A pure-removal sync still resolves refs below — the deletion path
|
||||
// resurrects the removed file's incoming edges as pending refs, #1240
|
||||
// removal case — and runPostExtract starts by dropping the resolver's
|
||||
// name caches, which a long-lived daemon warmed against the
|
||||
// pre-removal graph.)
|
||||
if (result.filesAdded > 0 || result.filesModified > 0 || result.filesRemoved > 0) {
|
||||
this.resolver.runPostExtract();
|
||||
} else if (result.filesRemoved > 0) {
|
||||
// A pure-removal sync still resolves refs below — the deletion path
|
||||
// resurrects the removed file's incoming edges as pending refs
|
||||
// (#1240 removal case) and the orphan sweep consumes them. In a
|
||||
// long-lived process (daemon) the resolver's name caches were
|
||||
// warmed against the pre-removal graph; drop them so resolution
|
||||
// sees the post-removal state. (runPostExtract above clears caches
|
||||
// itself, so the changed-files branch is already covered.)
|
||||
this.resolver.clearCaches();
|
||||
}
|
||||
|
||||
// Resolve references if files were updated
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
* `<Route path="/payment" component={PaymentScreen}/>` (v5),
|
||||
* `<Route path="/payment" element={<PaymentScreen/>}/>` (v6) and
|
||||
* `createBrowserRouter([{ path, element }])` (v6.4+) each become a `route`
|
||||
* node named by its path, bound to the component that renders it. That is
|
||||
* half of what "how does this app flow" means. This file is the other half.
|
||||
* node named by its path, bound to the component that renders it — and so
|
||||
* does a table another file hands the router (`AppRoutes.map(… <Route
|
||||
* {...rest}>)`, `useRoutes(routes)` with `routes` imported), read once every
|
||||
* file is indexed. That is half of what "how does this app flow" means. This
|
||||
* file is the other half.
|
||||
*
|
||||
* **Navigation is a string.** `history.push('/placeorder')` (v5, and the
|
||||
* `useHistory` hook), `navigate('/placeorder')` (v6's `useNavigate`),
|
||||
@@ -32,7 +35,9 @@
|
||||
* inside `<Route path="/dashboard">`, a data router's `children`); the markup
|
||||
* scan composes that tree, a constant path (`paths.app.root.path`) included,
|
||||
* and a `lazy: () => import('./routes/x')` route renders that module's default
|
||||
* export (`frameworks/react.ts`). Known limit, deliberate: a splat
|
||||
* export (`frameworks/react.ts`). An `index: true` route is the page at its
|
||||
* parent's address, and a route object around others is the layout they
|
||||
* render inside (a `references` edge marked `layout: true`). Known limit, deliberate: a splat
|
||||
* (`/admin/*`) matches anything, so it is never the answer to a concrete href.
|
||||
*/
|
||||
|
||||
@@ -54,7 +59,7 @@ import {
|
||||
// two arms). It lives in `nextjs.ts` because that is where it was first
|
||||
// needed; duplicating it here would be a second derivation of the same rule.
|
||||
import { destinationsForHref } from './nextjs';
|
||||
import { configHrefExpression } from './react';
|
||||
import { configHrefExpression, isTableRoute } from './react';
|
||||
|
||||
const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx'];
|
||||
|
||||
@@ -73,11 +78,13 @@ export const reactRouterRoot = appRootFor;
|
||||
* Its id is a verbatim reconstruction of the node's own fields, which no
|
||||
* other framework's route id is: a server route carries its METHOD
|
||||
* (`route:file:12:POST:/login`), a file-based page carries no line. A path
|
||||
* built from a constant keeps the id it was extracted with.
|
||||
* built from a constant keeps the id it was extracted with, and a route read
|
||||
* from a table another file hands the router says so (`…:12:table:/login`).
|
||||
*/
|
||||
function isReactRouterRoute(node: Node): boolean {
|
||||
const prefix = `route:${node.filePath}:${node.startLine}:`;
|
||||
if ((node.language !== 'tsx' && node.language !== 'jsx') || !node.id.startsWith(prefix)) return false;
|
||||
if (isTableRoute(node)) return true;
|
||||
// Its name, or — for a path built from a constant, renamed after extraction
|
||||
// — the path as the file wrote it; never a server route's `METHOD:`.
|
||||
const rest = node.id.slice(prefix.length);
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
* Next.js pages, route handlers and navigation are `nextjs.ts`'s.
|
||||
*/
|
||||
|
||||
import { Node } from '../../types';
|
||||
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { Language, Node } from '../../types';
|
||||
import { FrameworkExtractionResult, FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { dependsOn } from './package-deps';
|
||||
import { resolveImportPath } from '../import-resolver';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
import { makeLineAt } from '../synth-utils';
|
||||
import { isTestPath } from '../../search/query-utils';
|
||||
import { matchBracket, skipString } from './object-literal';
|
||||
|
||||
/** The languages React components, hooks and contexts are written and used in. */
|
||||
const REACT_SCRIPT_LANGUAGES: ReadonlySet<string> = new Set(['typescript', 'javascript', 'tsx', 'jsx']);
|
||||
@@ -31,9 +35,10 @@ export const reactResolver: FrameworkResolver = {
|
||||
return allFiles.some((f) => f.endsWith('.jsx') || f.endsWith('.tsx'));
|
||||
},
|
||||
|
||||
// A data-router `lazy: () => import('./routes/x')` route names a module, not a symbol.
|
||||
// A data-router `lazy: () => import('./routes/x')` route names a module, not
|
||||
// a symbol; a route's `layout:MainLayout` names the component around it.
|
||||
claimsReference(name: string): boolean {
|
||||
return name.startsWith(LAZY_ROUTE_PREFIX);
|
||||
return name.startsWith(LAZY_ROUTE_PREFIX) || name.startsWith(LAYOUT_PREFIX);
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
@@ -44,6 +49,12 @@ export const reactResolver: FrameworkResolver = {
|
||||
const target = lazyRouteComponent(ref.referenceName.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context);
|
||||
return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null;
|
||||
}
|
||||
// The layout a route renders inside: what happens in it — its header's
|
||||
// links, its logout — happens on the route's screen too.
|
||||
if (ref.referenceName.startsWith(LAYOUT_PREFIX)) {
|
||||
const target = layoutComponent(ref.referenceName.slice(LAYOUT_PREFIX.length), ref, context);
|
||||
return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework', metadata: { layout: true } } : null;
|
||||
}
|
||||
// A component, hook or context the file IMPORTS is the import's: the
|
||||
// package's (`useQuery` from `@tanstack/react-query`, `<Button>` from a UI
|
||||
// kit), or the module the import names, which import resolution finds.
|
||||
@@ -66,6 +77,16 @@ export const reactResolver: FrameworkResolver = {
|
||||
isPascalCase(ref.referenceName) &&
|
||||
!isBuiltInType(ref.referenceName)
|
||||
) {
|
||||
// What a route renders, when its own file declares it: the module a
|
||||
// `const RegisterPage = Loadable(lazy(() => import('pages/auth/Register')))`
|
||||
// loads, or else that declaration, which name matching binds — never a
|
||||
// same-named component in another app of the repository.
|
||||
if (ref.fromNodeId.startsWith(`route:${ref.filePath}:`)) {
|
||||
const own = declaredComponent(ref.referenceName, ref.filePath, context);
|
||||
if (own !== undefined) {
|
||||
return own ? { original: ref, targetNodeId: own, confidence: 0.9, resolvedBy: 'framework' } : null;
|
||||
}
|
||||
}
|
||||
const result = resolveComponent(ref.referenceName, ref.filePath, context);
|
||||
if (result) {
|
||||
return {
|
||||
@@ -124,8 +145,9 @@ export const reactResolver: FrameworkResolver = {
|
||||
// routes live in JSX files and were previously skipped entirely.
|
||||
|
||||
// Read only each opening tag's own attributes, including expression values.
|
||||
const declarations = scanRouteDeclarations(content, !/\.(?:ts|mts|cts)$/.test(filePath));
|
||||
for (const { path: routePath, parts, component, lazy, at } of declarations) {
|
||||
const declarations = scanRouteDeclarations(content, allowsJsx(filePath));
|
||||
for (const route of declarations) {
|
||||
const { path: routePath, parts, at } = route;
|
||||
const line = content.slice(0, at).split('\n').length;
|
||||
const routeNode: Node = {
|
||||
id: `route:${filePath}:${line}:${routePath}`,
|
||||
@@ -143,18 +165,7 @@ export const reactResolver: FrameworkResolver = {
|
||||
...(parts.some((p) => p.expr) ? { signature: ROUTE_PARTS_PREFIX + JSON.stringify(parts) } : {}),
|
||||
};
|
||||
nodes.push(routeNode);
|
||||
const target = component ?? (lazy ? LAZY_ROUTE_PREFIX + lazy : undefined);
|
||||
if (target) {
|
||||
references.push({
|
||||
fromNodeId: routeNode.id,
|
||||
referenceName: target,
|
||||
referenceKind: 'references',
|
||||
line,
|
||||
column: 0,
|
||||
filePath,
|
||||
language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
|
||||
});
|
||||
}
|
||||
references.push(...routeReferences(route, routeNode.id, filePath, line));
|
||||
}
|
||||
|
||||
// Next.js pages and route handlers are `frameworks/nextjs.ts`'s.
|
||||
@@ -185,10 +196,16 @@ export const reactResolver: FrameworkResolver = {
|
||||
}
|
||||
return updates;
|
||||
},
|
||||
|
||||
/** The routes of tables another file hands the router (`tableRoutes`). */
|
||||
crossFileNodes(context: ResolutionContext) {
|
||||
return { kind: 'route', owns: isTableRoute, ...tableRoutes(context) };
|
||||
},
|
||||
};
|
||||
|
||||
const LAZY_ROUTE_PREFIX = 'lazy-import:';
|
||||
const ROUTE_PARTS_PREFIX = 'route-parts:';
|
||||
const LAYOUT_PREFIX = 'layout:';
|
||||
|
||||
/** One segment of a nested route's path: a literal, or a constant's member expression. */
|
||||
interface RoutePart {
|
||||
@@ -394,6 +411,42 @@ function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionC
|
||||
return node?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component `filePath` holds in a value of its own, for a route there that
|
||||
* renders it: the component of the module a lazy declaration loads (`lazy(()
|
||||
* => import('./x'))`, wrapped or not), null for a value that loads none it
|
||||
* can find — the value is then the binding, by name — and undefined when the
|
||||
* file declares no such value (a function or class component is found the
|
||||
* usual way).
|
||||
*/
|
||||
function declaredComponent(name: string, filePath: string, context: ResolutionContext): string | null | undefined {
|
||||
const own = (context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name))
|
||||
.find((n) => n.kind === 'constant' || n.kind === 'variable');
|
||||
if (!own) return undefined;
|
||||
const lines = context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? [];
|
||||
const text = lines.slice(own.startLine - 1, own.endLine).join('\n');
|
||||
const spec = /=>\s*import\s*\(\s*["']([^"']+)["']\s*\)/.exec(text)?.[1];
|
||||
return spec ? lazyRouteComponent(spec, filePath, context) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The component a route's `layout:` reference names, found the way the
|
||||
* route's own component is: through the route file's import of it, else
|
||||
* declared in reach — or a lazily loaded layout module's component.
|
||||
*/
|
||||
function layoutComponent(spec: string, ref: UnresolvedRef, context: ResolutionContext): string | null {
|
||||
if (spec.startsWith(LAZY_ROUTE_PREFIX)) return lazyRouteComponent(spec.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context);
|
||||
const mapping = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === spec);
|
||||
if (!mapping) return resolveComponent(spec, ref.filePath, context);
|
||||
const viaImport = context.resolveImport?.({ ...ref, referenceName: spec })?.targetNodeId;
|
||||
if (viaImport) return viaImport;
|
||||
// `import { default as AppRoot } from './routes/app/root'`: read the module the
|
||||
// import names — never a same-named component somewhere else.
|
||||
if (mapping.isDefault || mapping.exportedName === 'default') return lazyRouteComponent(mapping.source, ref.filePath, context);
|
||||
const file = resolveImportPath(mapping.source, ref.filePath, ref.language, context);
|
||||
return file ? context.getNodesInFile(file).find((n) => n.name === mapping.exportedName && COMPONENT_KINDS.has(n.kind))?.id ?? null : null;
|
||||
}
|
||||
|
||||
interface RouteDeclaration {
|
||||
/** The route's full path as far as the file tells: nesting composed, a constant part shown as `{expr}`. */
|
||||
path: string;
|
||||
@@ -402,33 +455,108 @@ interface RouteDeclaration {
|
||||
component?: string;
|
||||
/** A `lazy: () => import('…')` module. */
|
||||
lazy?: string;
|
||||
/**
|
||||
* The route objects around it that render something, outermost first: the
|
||||
* layouts it renders inside, each a component name or `lazy-import:…`.
|
||||
*/
|
||||
layouts: string[];
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** A path-bearing route object or `<Route>` element, with the extent its children sit in. */
|
||||
interface RouteScope {
|
||||
part: RoutePart;
|
||||
/** Where the route is named: its `path` (or `index`) property, or its `<Route` tag. */
|
||||
at: number;
|
||||
/** Its `{` or `<`: the scope holds every scope that opens after this and closes by `end`. */
|
||||
start: number;
|
||||
end: number;
|
||||
component?: string;
|
||||
lazy?: string;
|
||||
/** A `<Route path>` element: a route even with nothing to render (a layout's path). */
|
||||
jsx?: boolean;
|
||||
/** `{ element: <Shell/>, children }`, with neither `path` nor `index`: a layout at no address of its own. */
|
||||
pathless?: boolean;
|
||||
}
|
||||
|
||||
/** Structural scanner: strings, comments, JSX and balanced expressions are units. */
|
||||
/**
|
||||
* An identifier written where React Router reads routes — `[MainRoutes,
|
||||
* LoginRoutes]`, `...ApiAuthorizationRoutes`, `children: adminRoutes`,
|
||||
* `useRoutes(routes(isLoggedIn))` — naming a table declared elsewhere.
|
||||
*/
|
||||
interface TableName {
|
||||
name: string;
|
||||
/** `routes(isLoggedIn)`: the table is what the function returns. */
|
||||
call: boolean;
|
||||
/** Where it is written; inside a table, the route objects around it are its mount. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** A place a file hands routes to React Router. */
|
||||
interface TableUse {
|
||||
/** The table as the site names it; absent when the literal is written there (`useRoutes([…])`). */
|
||||
name?: TableName;
|
||||
/** The literal written there. */
|
||||
literal?: number;
|
||||
/** The routes around the site, outermost first: where the table is mounted. */
|
||||
prefix: RoutePart[];
|
||||
}
|
||||
|
||||
interface RouteScan {
|
||||
routes: RouteDeclaration[];
|
||||
/** The tables a table names as more of its routes, each with the path parts it sits under. */
|
||||
names: Array<TableName & { prefix: RoutePart[] }>;
|
||||
/** Where the file hands a table to the router. */
|
||||
uses: TableUse[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The calls that hand React Router its routes: a data router, JSX routes made
|
||||
* into one, or the `useRoutes` hook. Every route object in a file that makes
|
||||
* one is a route.
|
||||
*/
|
||||
const ROUTER_CALL = /\b(?:createBrowserRouter|createHashRouter|createMemoryRouter|createRoutesFromElements|useRoutes)\b/;
|
||||
|
||||
/** A route list's entry that names more routes: `MainRoutes`, `...authRoutes`, `...routes(user)`. */
|
||||
const NAMED_ENTRY = /^(?:\.\.\.\s*)?([A-Za-z_$][\w$]*)\s*(\([\s\S]*\))?$/;
|
||||
|
||||
/** The routes a file's own text declares: its `<Route>` elements and, in a file that makes a router, its route objects. */
|
||||
function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclaration[] {
|
||||
const routes: RouteDeclaration[] = [];
|
||||
const objects = ROUTER_CALL.test(source);
|
||||
if (!objects && !/<Route\b/.test(source)) return [];
|
||||
return scanRoutes(source, allowJsx, { objects }).routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural scanner: strings, comments, JSX and balanced expressions are units.
|
||||
*
|
||||
* With `objects`, every route object in the file is read. With `table`, only
|
||||
* the literal at that offset is — a table another file hands the router —
|
||||
* its routes sitting under `table.prefix`, and the identifiers it names as
|
||||
* more routes come back in `names`. With `uses`, the places the file hands
|
||||
* a table to the router come back too. A route object whose children render
|
||||
* something is the layout around them, and the address is theirs when one
|
||||
* of them claims it: an `index` route, or a `''` path.
|
||||
*/
|
||||
function scanRoutes(
|
||||
source: string,
|
||||
allowJsx: boolean,
|
||||
options: { objects?: boolean; table?: { at: number; prefix: RoutePart[] }; uses?: boolean }
|
||||
): RouteScan {
|
||||
const scopes: RouteScope[] = [];
|
||||
const dataRouter = /\b(?:createBrowserRouter|createHashRouter|createMemoryRouter|createRoutesFromElements)\b/.test(source);
|
||||
if (!dataRouter && !/<Route\b/.test(source)) return routes;
|
||||
const names: TableName[] = [];
|
||||
const sites: Array<{ name?: TableName; literal?: number; at: number }> = [];
|
||||
/** Every `<Route>` tag: where it is, the props it spreads, its `path` expression. */
|
||||
const routeTags: Array<{ at: number; spreads: string[]; path?: string }> = [];
|
||||
const objects = Boolean(options.objects || options.table);
|
||||
const literal = (value: string): string | undefined => {
|
||||
const match = /^(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')$/.exec(value.trim());
|
||||
return match ? match[1] ?? match[2] : undefined;
|
||||
};
|
||||
// A guard or boundary wrapping the screen (`<ProtectedRoute><AppRoot/></ProtectedRoute>`,
|
||||
// `<Suspense>`) is not what the route renders; the first element inside it is.
|
||||
const WRAPPER = /^(?:Suspense|ErrorBoundary|\w*(?:Guard|Provider)|(?:Protected|Private|Auth|Require\w*)\w*)$/;
|
||||
// `<AdminWrapper><Users/></AdminWrapper>`, `<Suspense>`) is not what the route
|
||||
// renders; the first element inside it is.
|
||||
const WRAPPER = /^(?:Suspense|ErrorBoundary|\w*(?:Guard|Provider|Wrapper|Route)|(?:Protected|Private|Auth|Require\w*)\w*)$/;
|
||||
const componentName = (value: string | undefined, jsx: boolean): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
if (!jsx) return /^\s*([A-Z][\w]*)\s*$/.exec(value)?.[1];
|
||||
@@ -457,7 +585,38 @@ function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclarat
|
||||
}
|
||||
return at;
|
||||
};
|
||||
function unit(at: number): number {
|
||||
// `useRoutes(…)` / `createBrowserRouter(…)` hand the router a table; so does
|
||||
// `AppRoutes.map(…)` when its callback renders a `<Route>` from each item.
|
||||
const routerCall = (at: number): { map?: TableName } | null => {
|
||||
if (!options.uses) return null;
|
||||
let j = at - 1;
|
||||
while (j >= 0 && /\s/.test(source[j]!)) j--;
|
||||
if (source[j] !== 's' && source[j] !== 'r' && source[j] !== 'p') return null;
|
||||
const before = source.slice(Math.max(0, at - 64), at);
|
||||
if (/(?:^|[^\w$.])(?:useRoutes|create(?:Browser|Hash|Memory)Router)\s*$/.test(before)) return {};
|
||||
const map = /(?:^|[^\w$.])([A-Za-z_$][\w$]*)\s*\??\.\s*map\s*$/.exec(before);
|
||||
if (!map) return null;
|
||||
return { map: { name: map[1]!, call: false, at: at - before.length + map.index + map[0].indexOf(map[1]!) } };
|
||||
};
|
||||
const settle = (call: { map?: TableName }, open: number, close: number): void => {
|
||||
const argAt = trivia(open + 1);
|
||||
if (argAt >= close) return;
|
||||
if (call.map) {
|
||||
const tags = routeTags.filter((t) => t.at > argAt && t.at < close);
|
||||
if (tags.length > 0 && mapsRouteFields(source.slice(argAt, close), tags)) sites.push({ name: call.map, at: open });
|
||||
return;
|
||||
}
|
||||
if (source[argAt] === '[' || source[argAt] === '{') {
|
||||
sites.push({ literal: argAt, at: open });
|
||||
return;
|
||||
}
|
||||
const id = /^[A-Za-z_$][\w$]*/.exec(source.slice(argAt, close));
|
||||
if (!id || /^createRoutesFrom(?:Elements|Children)$/.test(id[0])) return;
|
||||
const next = trivia(argAt + id[0].length);
|
||||
if (next === close || source[next] === ',') sites.push({ name: { name: id[0], call: false, at: argAt }, at: open });
|
||||
else if (source[next] === '(') sites.push({ name: { name: id[0], call: true, at: argAt }, at: open });
|
||||
};
|
||||
function unit(at: number, routeList = false): number {
|
||||
const ch = source[at];
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
let i = at + 1;
|
||||
@@ -493,47 +652,76 @@ function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclarat
|
||||
}
|
||||
const close = ch === '{' ? '}' : ch === '[' ? ']' : ch === '(' ? ')' : undefined;
|
||||
if (!close) return at + 1;
|
||||
const call = ch === '(' ? routerCall(at) : null;
|
||||
let i = at + 1;
|
||||
const fields = new Map<string, { value: string; at: number }>();
|
||||
while ((i = trivia(i)) < source.length && source[i] !== close) {
|
||||
// A property must start at an object entry, never inside its value.
|
||||
const key = ch === '{' ? /^(?:([A-Za-z_$][\w$]*)|["']([^"']+)["'])\s*:/.exec(source.slice(i)) : null;
|
||||
if (key) {
|
||||
const name = key[1] ?? key[2]!;
|
||||
const start = i;
|
||||
const valueAt = trivia(i + key[0].length);
|
||||
// A route's children are more routes, written in place or named.
|
||||
const children = objects && name === 'children';
|
||||
i = valueAt;
|
||||
let valueEnd = i;
|
||||
while ((i = trivia(i)) < source.length && source[i] !== ',' && source[i] !== close) {
|
||||
i = unit(i);
|
||||
i = unit(i, children);
|
||||
valueEnd = i;
|
||||
}
|
||||
fields.set(key[1] ?? key[2]!, { value: source.slice(valueAt, valueEnd).trim(), at: start });
|
||||
const value = source.slice(valueAt, valueEnd).trim();
|
||||
fields.set(name, { value, at: start });
|
||||
if (children && /^[A-Za-z_$][\w$]*$/.test(value)) names.push({ name: value, call: false, at: valueAt });
|
||||
} else {
|
||||
// Skip a whole entry (spread, method, shorthand), but still visit nested units.
|
||||
const entry = i;
|
||||
while ((i = trivia(i)) < source.length && source[i] !== ',' && source[i] !== close) i = unit(i);
|
||||
if (routeList && ch === '[') {
|
||||
const text = source.slice(entry, i).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '').trim();
|
||||
const named = NAMED_ENTRY.exec(text);
|
||||
const args = named?.[2] ? text.length - named[2].length : -1;
|
||||
if (named && (args < 0 || matchBracket(text, args) === text.length - 1)) {
|
||||
names.push({ name: named[1]!, call: args >= 0, at: entry });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (source[i] === ',') i++;
|
||||
}
|
||||
if (dataRouter && ch === '{' && source[i] === close) {
|
||||
if (objects && ch === '{' && source[i] === close) {
|
||||
const pathField = fields.get('path');
|
||||
const path = pathField && literal(pathField.value);
|
||||
const expr = pathField && path === undefined && /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(pathField.value) ? pathField.value : undefined;
|
||||
const component = componentName(fields.get('element')?.value, true)
|
||||
?? componentName(fields.get('Component')?.value, false);
|
||||
const lazy = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/.exec(fields.get('lazy')?.value ?? '')?.[1];
|
||||
const index = fields.get('index');
|
||||
if (pathField && (path !== undefined || expr)) {
|
||||
scopes.push({ part: path !== undefined ? { lit: path } : { expr }, at: pathField.at, end: i, component, lazy });
|
||||
scopes.push({ part: path !== undefined ? { lit: path } : { expr }, at: pathField.at, start: at, end: i, component, lazy });
|
||||
} else if (!pathField && index?.value === 'true') {
|
||||
// `{ index: true, element: <Home /> }` is the page at its parent's address.
|
||||
scopes.push({ part: { lit: '' }, at: index.at, start: at, end: i, component, lazy });
|
||||
} else if (!pathField && (component || lazy) && fields.has('children')) {
|
||||
scopes.push({ part: { lit: '' }, at, start: at, end: i, component, lazy, pathless: true });
|
||||
}
|
||||
}
|
||||
if (call && i < source.length) settle(call, at, i);
|
||||
return i < source.length ? i + 1 : i;
|
||||
}
|
||||
function jsx(at: number): number {
|
||||
const tag = /^<([\w.:-]*)/.exec(source.slice(at))!;
|
||||
let i = at + tag[0].length;
|
||||
const attrs = new Map<string, string>();
|
||||
const spreads: string[] = [];
|
||||
while ((i = trivia(i)) < source.length && source[i] !== '>' && !source.startsWith('/>', i)) {
|
||||
const attr = /^[\w:-]+/.exec(source.slice(i));
|
||||
if (!attr) { i = unit(i); continue; }
|
||||
if (!attr) {
|
||||
const from = i;
|
||||
i = unit(i);
|
||||
const spread = /^\{\s*\.\.\.\s*([A-Za-z_$][\w$]*)\s*\}$/.exec(source.slice(from, i));
|
||||
if (spread) spreads.push(spread[1]!);
|
||||
continue;
|
||||
}
|
||||
i = trivia(i + attr[0].length);
|
||||
if (source[i] !== '=') continue;
|
||||
i = trivia(i + 1);
|
||||
@@ -545,10 +733,11 @@ function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclarat
|
||||
if (tag[1] === 'Route' && i < source.length) {
|
||||
const path = literal(attrs.get('path') ?? '');
|
||||
const expression = (name: string) => attrs.get(name)?.replace(/^\{([\s\S]*)\}$/, '$1');
|
||||
routeTags.push({ at, spreads, path: expression('path')?.trim() });
|
||||
const component = componentName(expression('component'), false) ?? componentName(expression('element'), true);
|
||||
const lazy = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/.exec(expression('lazy') ?? '')?.[1];
|
||||
if (path) {
|
||||
scope = { part: { lit: path }, at, end: source.length, component, lazy, jsx: true };
|
||||
scope = { part: { lit: path }, at, start: at, end: source.length, component, lazy, jsx: true };
|
||||
scopes.push(scope);
|
||||
}
|
||||
}
|
||||
@@ -571,20 +760,314 @@ function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclarat
|
||||
if (scope) scope.end = i;
|
||||
return i;
|
||||
}
|
||||
let at = 0;
|
||||
while ((at = trivia(at)) < source.length) at = unit(at);
|
||||
if (options.table) unit(trivia(options.table.at), true);
|
||||
else {
|
||||
let at = 0;
|
||||
while ((at = trivia(at)) < source.length) at = unit(at);
|
||||
}
|
||||
// A child route's path is relative to the routes around it: compose each
|
||||
// rendering route's path from the path-bearing scopes that contain it.
|
||||
const prefix = options.table?.prefix ?? [];
|
||||
const renders = (s: RouteScope): boolean => Boolean(s.component || s.lazy);
|
||||
const holds = (outer: RouteScope, start: number, end: number): boolean => outer.start < start && outer.end >= end;
|
||||
const around = (start: number, end: number): RouteScope[] =>
|
||||
scopes.filter((outer) => holds(outer, start, end)).sort((a, b) => a.start - b.start);
|
||||
const shown = (p: RoutePart): string => p.lit ?? `{${p.expr}}`;
|
||||
const partsOf = new Map<RouteScope, RoutePart[]>();
|
||||
const pathOf = new Map<RouteScope, string>();
|
||||
for (const scope of scopes) {
|
||||
if (!scope.component && !scope.lazy && !scope.jsx) continue;
|
||||
const chain = scopes
|
||||
.filter((outer) => outer !== scope && outer.at < scope.at && outer.end >= scope.end)
|
||||
.sort((a, b) => a.at - b.at);
|
||||
const parts = [...chain.map((c) => c.part), scope.part];
|
||||
const shown = parts.map((p) => p.lit ?? `{${p.expr}}`);
|
||||
routes.push({ path: composeRoutePath(shown), parts, component: scope.component, lazy: scope.lazy, at: scope.at });
|
||||
const parts = [...prefix, ...around(scope.start, scope.end).map((c) => c.part), scope.part];
|
||||
partsOf.set(scope, parts);
|
||||
pathOf.set(scope, composeRoutePath(parts.map(shown)));
|
||||
}
|
||||
return routes.sort((a, b) => a.at - b.at);
|
||||
const routes: RouteDeclaration[] = [];
|
||||
for (const scope of scopes) {
|
||||
if (scope.pathless || (!renders(scope) && !scope.jsx)) continue;
|
||||
const path = pathOf.get(scope)!;
|
||||
// A route object around others is the layout they render inside; when one
|
||||
// of them claims its address, the page there is that one, not the layout.
|
||||
if (!scope.jsx && renders(scope) && scopes.some((inner) =>
|
||||
!inner.jsx && !inner.pathless && renders(inner) && holds(scope, inner.start, inner.end) && pathOf.get(inner) === path)) continue;
|
||||
const layouts = around(scope.start, scope.end)
|
||||
.filter((c) => !c.jsx && renders(c))
|
||||
.map((c) => c.component ?? LAZY_ROUTE_PREFIX + c.lazy);
|
||||
routes.push({ path, parts: partsOf.get(scope)!, component: scope.component, lazy: scope.lazy, layouts, at: scope.at });
|
||||
}
|
||||
return {
|
||||
routes: routes.sort((a, b) => a.at - b.at),
|
||||
names: names.map((n) => ({ ...n, prefix: [...prefix, ...around(n.at, n.at).map((c) => c.part)] })),
|
||||
uses: sites.map((s) => ({ name: s.name, literal: s.literal, prefix: around(s.at, s.at).map((c) => c.part) })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a `.map` callback renders a `<Route>` from the item's own route
|
||||
* fields — `{...route}`, `{...rest}` destructured from it, `path={route.path}`
|
||||
* or a `path` destructured from it — so the array it maps is a route table.
|
||||
* A `<Route path={item.layout + item.path}>` composes a path the table does
|
||||
* not hold, and a callback that renders links or menu entries maps a menu.
|
||||
*/
|
||||
function mapsRouteFields(callback: string, tags: ReadonlyArray<{ spreads: string[]; path?: string }>): boolean {
|
||||
const head = /^(?:async\s+)?(?:function\b[^(]*)?\(\s*/.exec(callback);
|
||||
let param: string | undefined;
|
||||
const rest = new Set<string>();
|
||||
const paths = new Set<string>();
|
||||
const bind = (pattern: string): void => {
|
||||
for (const entry of topLevelEntries(pattern.slice(1, -1))) {
|
||||
const spread = /^\.\.\.\s*([A-Za-z_$][\w$]*)$/.exec(entry);
|
||||
if (spread) rest.add(spread[1]!);
|
||||
const path = /^path\s*(?::\s*([A-Za-z_$][\w$]*))?\s*(?:=[\s\S]*)?$/.exec(entry);
|
||||
if (path) paths.add(path[1] ?? 'path');
|
||||
}
|
||||
};
|
||||
if (head) {
|
||||
const at = head[0].length;
|
||||
if (callback[at] === '{') {
|
||||
const close = matchBracket(callback, at);
|
||||
if (close < 0) return false;
|
||||
bind(callback.slice(at, close + 1));
|
||||
} else param = /^[A-Za-z_$][\w$]*/.exec(callback.slice(at))?.[0];
|
||||
} else {
|
||||
param = /^(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>/.exec(callback)?.[1];
|
||||
}
|
||||
if (param) {
|
||||
// `const { element, ...rest } = route;` in the body.
|
||||
const destructured = new RegExp(`\\b(?:const|let|var)\\s*(\\{[^{}]*\\})\\s*=\\s*${param.replace(/\$/g, '\\$')}\\s*[;\\n]`, 'g');
|
||||
for (const m of callback.matchAll(destructured)) bind(m[1]!);
|
||||
}
|
||||
const ownPath = param ? new RegExp(`^${param.replace(/\$/g, '\\$')}\\s*\\??\\.\\s*path$`) : null;
|
||||
return tags.some((t) =>
|
||||
t.spreads.some((s) => s === param || rest.has(s)) ||
|
||||
(t.path !== undefined && (ownPath?.test(t.path) || paths.has(t.path))));
|
||||
}
|
||||
|
||||
/** The comma-separated entries of a list's inside, nested brackets kept whole. */
|
||||
function topLevelEntries(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let from = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
const end = skipString(text, i);
|
||||
if (end < 0) break;
|
||||
i = end;
|
||||
} else if (ch === '{' || ch === '[' || ch === '(') depth++;
|
||||
else if (ch === '}' || ch === ']' || ch === ')') depth--;
|
||||
else if (ch === ',' && depth === 0) {
|
||||
out.push(text.slice(from, i).trim());
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(from).trim());
|
||||
return out.filter(Boolean);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Route tables another file hands the router
|
||||
// =============================================================================
|
||||
|
||||
/** The packages React Router's components and hooks are imported from. */
|
||||
const ROUTER_PACKAGES = ['react-router-dom', 'react-router', 'react-router-native'];
|
||||
|
||||
/** At most this many tables are read from one project's consumers — a bound, not a budget anyone reaches. */
|
||||
const MAX_TABLES = 512;
|
||||
|
||||
/** What a file that hands the router a table has to say: `useRoutes`, a data router, or a `<Route>` to map into. */
|
||||
const CONSUMER_HINT = /\buseRoutes\b|\bcreate(?:Browser|Hash|Memory)Router\b|<Route\b/;
|
||||
|
||||
/** True for a route the table pass made — `route:<file>:<line>:table:<path>` — and for no route extraction makes. */
|
||||
export function isTableRoute(node: Node): boolean {
|
||||
return node.kind === 'route' && node.id.startsWith(`route:${node.filePath}:${node.startLine}:table:`);
|
||||
}
|
||||
|
||||
/** The references that bind a route: what it renders, and the layouts around it. */
|
||||
function routeReferences(route: RouteDeclaration, fromNodeId: string, filePath: string, line: number): UnresolvedRef[] {
|
||||
const language = filePath.endsWith('.tsx') ? 'tsx' : 'jsx';
|
||||
const ref = (referenceName: string): UnresolvedRef =>
|
||||
({ fromNodeId, referenceName, referenceKind: 'references', line, column: 0, filePath, language });
|
||||
const refs: UnresolvedRef[] = [];
|
||||
const target = route.component ?? (route.lazy ? LAZY_ROUTE_PREFIX + route.lazy : undefined);
|
||||
if (target) refs.push(ref(target));
|
||||
for (const layout of route.layouts) refs.push(ref(LAYOUT_PREFIX + layout));
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes written in a table that another file hands to the router.
|
||||
*
|
||||
* The ASP.NET Core React template keeps them in `AppRoutes.js` —
|
||||
* `[{ index: true, element: <Home /> }, { path: '/counter', element:
|
||||
* <Counter /> }, …]` — and renders them from `App.js` with
|
||||
* `AppRoutes.map(({ element, ...rest }) => <Route {...rest} element={element} />)`;
|
||||
* codedthemes' admin templates write one route object per file and pass
|
||||
* `[MainRoutes, LoginRoutes]` to `createBrowserRouter`; many apps import
|
||||
* `routes` into `useRoutes(routes)`. The table's own file never names the
|
||||
* router, so its extraction cannot tell it from a menu's `{ path, element }`
|
||||
* list. The evidence is the file that hands it over: a `useRoutes` or
|
||||
* `create*Router` argument, or a `.map` whose callback renders a `<Route>`
|
||||
* from the item's own fields. From there the import leads to the table, which
|
||||
* is read the way a data router's is, and the tables it names in turn
|
||||
* (`...ApiAuthorizationRoutes`, `children: adminRoutes`) are read under the
|
||||
* path they sit at. A file that makes a router itself had its route objects
|
||||
* read when it was extracted; only the tables it names are new here.
|
||||
*/
|
||||
function tableRoutes(context: ResolutionContext): FrameworkExtractionResult {
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
const consumers = new Set<string>();
|
||||
for (const pkg of ROUTER_PACKAGES) {
|
||||
for (const n of context.getNodesByName(pkg)) {
|
||||
if (n.kind === 'import' && !isTestPath(n.filePath)) consumers.add(n.filePath);
|
||||
}
|
||||
}
|
||||
if (consumers.size === 0) return { nodes, references };
|
||||
const queue: Array<{ file: string; at: number; prefix: string[] }> = [];
|
||||
const queued = new Set<string>();
|
||||
const enqueue = (table: { file: string; at: number } | null, prefix: string[]): void => {
|
||||
if (!table || queued.size >= MAX_TABLES) return;
|
||||
const key = `${table.file}\0${table.at}\0${prefix.join('\0')}`;
|
||||
if (queued.has(key)) return;
|
||||
queued.add(key);
|
||||
queue.push({ ...table, prefix });
|
||||
};
|
||||
for (const file of [...consumers].sort()) {
|
||||
const source = context.readFile(file);
|
||||
// Most of the files that import React Router only link or navigate.
|
||||
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));
|
||||
}
|
||||
}
|
||||
const made = new Set<string>();
|
||||
const now = Date.now();
|
||||
for (let next = queue.shift(); next; next = queue.shift()) {
|
||||
const { file, at, prefix } = next;
|
||||
const source = context.readFile(file);
|
||||
if (!source) continue;
|
||||
const scan = scanRoutes(source, allowsJsx(file), { table: { at, prefix: prefix.map((lit) => ({ lit })) } });
|
||||
for (const named of scan.names) enqueue(tableNamed(named, file, context), pathValues(named.prefix, file, context));
|
||||
if (ROUTER_CALL.test(source)) continue;
|
||||
const lineAt = makeLineAt(source, 1);
|
||||
for (const route of scan.routes) {
|
||||
const line = lineAt(route.at);
|
||||
const id = `route:${file}:${line}:table:${route.path}`;
|
||||
if (made.has(id)) continue;
|
||||
made.add(id);
|
||||
const values = route.parts.map((p) => p.lit ?? (p.expr ? constantPathValue(p.expr, file, context) : null));
|
||||
nodes.push({
|
||||
id,
|
||||
kind: 'route',
|
||||
name: values.every((v) => v !== null) ? composeRoutePath(values as string[]) : route.path,
|
||||
qualifiedName: `${file}::route:${route.path}`,
|
||||
filePath: file,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
language: file.endsWith('.tsx') ? 'tsx' : 'jsx',
|
||||
updatedAt: now,
|
||||
});
|
||||
references.push(...routeReferences(route, id, file, line));
|
||||
}
|
||||
}
|
||||
return { nodes, references };
|
||||
}
|
||||
|
||||
/** Path parts as text: a constant read from where it is declared, or shown as `{expr}` when it cannot be. */
|
||||
function pathValues(parts: readonly RoutePart[], fromFile: string, context: ResolutionContext): string[] {
|
||||
return parts.map((p) => p.lit ?? (p.expr ? constantPathValue(p.expr, fromFile, context) : null) ?? `{${p.expr}}`);
|
||||
}
|
||||
|
||||
function allowsJsx(filePath: string): boolean {
|
||||
return !/\.(?:ts|mts|cts)$/.test(filePath);
|
||||
}
|
||||
|
||||
function scriptLanguage(filePath: string): Language {
|
||||
return /\.tsx$/.test(filePath) ? 'tsx' : /\.[cm]?ts$/.test(filePath) ? 'typescript' : /\.jsx$/.test(filePath) ? 'jsx' : 'javascript';
|
||||
}
|
||||
|
||||
/** The literal a table name stands for in `file`: declared there, or imported. */
|
||||
function tableNamed(named: TableName, file: string, context: ResolutionContext, depth = 0): { file: string; at: number } | null {
|
||||
const source = context.readFile(file);
|
||||
if (!source) return null;
|
||||
const safe = stripCommentsForRegex(source, 'typescript');
|
||||
const local = declaredTable(safe, named.name, named.call);
|
||||
if (local !== null) return { file, at: local };
|
||||
const language = scriptLanguage(file);
|
||||
const mapping = context.getImportMappings(file, language).find((m) => m.localName === named.name);
|
||||
if (!mapping || mapping.isNamespace || depth > 3) return null;
|
||||
const target = resolveImportPath(mapping.source, file, language, context);
|
||||
if (!target) return null;
|
||||
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 {
|
||||
const source = context.readFile(file);
|
||||
if (!source) return null;
|
||||
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) : safe[after] === '[' || safe[after] === '{' ? after : null;
|
||||
if (inline !== null) return { file, at: inline };
|
||||
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 {
|
||||
// `export { appRoutes as routes }`
|
||||
const alias = new RegExp(`\\bexport\\s*\\{[^}]*?\\b([A-Za-z_$][\\w$]*)\\s+as\\s+${exported.replace(/\$/g, '\\$')}\\b`).exec(safe);
|
||||
if (alias) local = alias[1]!;
|
||||
}
|
||||
const at = local ? declaredTable(safe, local, call) : null;
|
||||
if (at !== null) return { file, at };
|
||||
if (depth > 3) return null;
|
||||
// `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) ?? []) {
|
||||
if (re.kind === 'named' && re.exportedName !== exported) continue;
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Where `name`'s table literal opens, for `const name = [ … ]` (or a function returning one when `call`). */
|
||||
function declaredTable(safe: string, name: string, call: boolean): number | null {
|
||||
const id = name.replace(/\$/g, '\\$');
|
||||
const decl = new RegExp(`(?:^|[^\\w$.])(?:const|let|var)\\s+${id}\\s*(?::[^=;]*)?=\\s*`).exec(safe);
|
||||
if (decl) {
|
||||
const after = decl.index + decl[0].length;
|
||||
if (call) return returnedTable(safe, after);
|
||||
return safe[after] === '[' || safe[after] === '{' ? after : null;
|
||||
}
|
||||
if (!call) return null;
|
||||
const fn = new RegExp(`(?:^|[^\\w$.])function\\s+${id}\\s*\\(`).exec(safe);
|
||||
return fn ? returnedTable(safe, fn.index + fn[0].indexOf('function')) : null;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+55
-1
@@ -43,7 +43,8 @@ import { findGoModuleForImport, goModulePackageDir, loadGoModule, type GoModule
|
||||
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
|
||||
import { logDebug } from '../errors';
|
||||
import { lexicalPathWithinRoot } from '../utils';
|
||||
import type { ReExport } from './types';
|
||||
import type { CrossFileNodes, ReExport } from './types';
|
||||
import { resurrectRefFromDroppedEdge } from './resurrect-ref';
|
||||
import { LRUCache } from './lru-cache';
|
||||
import { JS_BUILT_INS } from './js-builtins';
|
||||
import { builtinModules } from 'module';
|
||||
@@ -385,10 +386,63 @@ export class ReferenceResolver {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const fw of this.frameworks) {
|
||||
if (!fw.crossFileNodes) continue;
|
||||
try {
|
||||
updated += this.reconcileCrossFileNodes(fw.crossFileNodes(this.context));
|
||||
} catch (err) {
|
||||
logDebug(`Framework '${fw.name}' crossFileNodes failed`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (updated > 0) this.clearCaches();
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a framework's cross-file nodes in line with what it wants now:
|
||||
* insert the new ones with their references (pending, for the resolution
|
||||
* that follows), rename the ones whose name changed, and remove the ones it
|
||||
* no longer wants. A removed node's incoming resolution edges go back to
|
||||
* being the references that made them, exactly as when a file is deleted,
|
||||
* so they rebind elsewhere or park as failed. Unchanged nodes are not
|
||||
* touched, so their edges survive. Returns the number of nodes changed.
|
||||
*/
|
||||
private reconcileCrossFileNodes(result: CrossFileNodes): number {
|
||||
const wanted = new Map<string, Node>();
|
||||
for (const node of result.nodes) if (result.owns(node)) wanted.set(node.id, node);
|
||||
const existing = new Map<string, Node>();
|
||||
for (const node of this.queries.getNodesByKind(result.kind)) if (result.owns(node)) existing.set(node.id, node);
|
||||
let changed = 0;
|
||||
for (const [id, old] of existing) {
|
||||
if (wanted.has(id)) continue;
|
||||
const refs = this.queries.getCrossFileIncomingEdgesWithTarget(old.filePath)
|
||||
.filter((e) => e.target === id)
|
||||
.map((e) => resurrectRefFromDroppedEdge(e))
|
||||
.filter((r): r is UnresolvedReference => r !== null);
|
||||
if (refs.length > 0) this.queries.insertUnresolvedRefsBatch(refs);
|
||||
this.queries.deleteNode(id);
|
||||
changed++;
|
||||
}
|
||||
const added: Node[] = [];
|
||||
for (const [id, node] of wanted) {
|
||||
const old = existing.get(id);
|
||||
if (!old) added.push(node);
|
||||
else if (old.name !== node.name || old.qualifiedName !== node.qualifiedName) {
|
||||
this.queries.updateNode(node);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
if (added.length > 0) {
|
||||
this.queries.insertNodes(added);
|
||||
const ids = new Set(added.map((n) => n.id));
|
||||
this.queries.insertUnresolvedRefsBatch(result.references.filter((r) => ids.has(r.fromNodeId)));
|
||||
changed += added.length;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-build lightweight caches for resolution.
|
||||
* Node lookups are now handled by indexed SQLite queries instead of
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The reference behind a resolution edge, for an edge whose target is going
|
||||
* away — shared by every path that removes nodes other files point at.
|
||||
*/
|
||||
|
||||
import type { Edge, Language, ReferenceKind, UnresolvedReference } from '../types';
|
||||
|
||||
/**
|
||||
* Resurrect a resolution edge that is about to be dropped (its target symbol
|
||||
* was removed, renamed, or its whole file deleted) as the ORIGINAL unresolved
|
||||
* reference that created it, read from the refName/refKind stamp
|
||||
* `createEdges` writes into edge metadata. Inserted as status='pending', the
|
||||
* ref is consumed by the same sync's resolution sweep: it rebinds to an
|
||||
* alternative definition if one exists, or parks as status='failed' where the
|
||||
* #1240 retry finds it if the symbol later reappears.
|
||||
*
|
||||
* Returns null — drop silently, the pre-#1240 behavior — for edges without a
|
||||
* refName stamp (created before the stamp existed, or synthesized): rebuilding
|
||||
* a ref from the target's plain node name would strip the receiver/qualifier
|
||||
* context the original text carried (`h.greet` → `greet`) and could rebind
|
||||
* somewhere a full re-index never would. Silent beats wrong.
|
||||
*/
|
||||
export function resurrectRefFromDroppedEdge(
|
||||
e: Edge & { sourceFilePath: string; sourceLanguage: Language }
|
||||
): UnresolvedReference | null {
|
||||
const refName = e.metadata?.refName;
|
||||
if (typeof refName !== 'string' || refName.length === 0) return null;
|
||||
const refKind = typeof e.metadata?.refKind === 'string' ? (e.metadata.refKind as ReferenceKind) : e.kind;
|
||||
return {
|
||||
fromNodeId: e.source,
|
||||
referenceName: refName,
|
||||
referenceKind: refKind,
|
||||
line: e.line ?? 0,
|
||||
column: e.column ?? 0,
|
||||
filePath: e.sourceFilePath,
|
||||
language: e.sourceLanguage,
|
||||
};
|
||||
}
|
||||
@@ -239,6 +239,18 @@ export interface FrameworkExtractionResult {
|
||||
references: UnresolvedRef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes a framework can name only from several files at once, with the
|
||||
* references that bind each — and how to recognise the ones an earlier run
|
||||
* produced, so a run can remove those it no longer wants.
|
||||
*/
|
||||
export interface CrossFileNodes extends FrameworkExtractionResult {
|
||||
/** The kind every one of these nodes has. */
|
||||
kind: Node['kind'];
|
||||
/** True for a node this pass produces, and for no node extraction does. */
|
||||
owns(node: Node): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-specific resolver
|
||||
*/
|
||||
@@ -292,6 +304,19 @@ export interface FrameworkResolver {
|
||||
* second run can recover the original in-file form from `qualifiedName`.
|
||||
*/
|
||||
postExtract?(context: ResolutionContext): Node[];
|
||||
/**
|
||||
* Nodes no single file's `extract()` can decide on — a React Router route
|
||||
* table written in one file is a table of routes only because another
|
||||
* file hands it to the router. Called after `postExtract` on every index
|
||||
* and every sync, it returns the COMPLETE set the framework wants now. The
|
||||
* orchestrator inserts the new ones (their references pending, for the
|
||||
* resolution that follows), removes the ones an earlier run inserted that
|
||||
* are no longer wanted, renames the ones whose name changed, and leaves
|
||||
* the rest alone, so an unchanged node keeps its edges. A node lives in the
|
||||
* file it is written in, so re-extracting that file drops it until the
|
||||
* next run puts it back.
|
||||
*/
|
||||
crossFileNodes?(context: ResolutionContext): CrossFileNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user