Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. 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.
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))` or a `dynamic(() => import(…))` component, links to the component its module exports, even through an `index` file that only passes it on or an `export default observer(Login)`; a wrapped component like `const Settings = observer(function Settings() {…})` links to the function it wraps; and an alias or styled component like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router routes and layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.

## [1.6.2] - 2026-10-03

Expand Down
239 changes: 239 additions & 0 deletions __tests__/jsx-child-disambiguation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,242 @@ export function Header() { return <h1><Badge /></h1>; }
expect(rendersFrom(db, 'Header')).toEqual(['only/badge.tsx']);
});
});

/**
* A tag the file declares itself as a VALUE — a lazily loaded page, a
* wrapped component, an alias — is that declaration, whatever else in the
* repository shares its name. A value is no component node, so the lookup by
* kind used to skip it and take a lone same-named function anywhere: on
* codedthemes' mantis (a vite app and a Next.js app in one repository) the
* vite app's `RegisterPage` went to the Next.js app's page, and excalidraw's
* Next.js example rendered the docs site's `Excalidraw`. Component resolution
* keeps the same rule for every other reference to such a name (the last case).
*/
describe('JSX child: a value the file declares itself', () => {
let dir: string;
let cg: any;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-own-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
});

afterEach(() => {
cg?.close?.();
fs.rmSync(dir, { recursive: true, force: true });
});

const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};

async function index(): Promise<void> {
cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
}

/** `kind name file` of each node a jsx-render edge out of `parent` points at. */
const renders = (parent: string): string[] =>
cg.db.db
.prepare(
`SELECT t.kind || ' ' || t.name || ' ' || t.file_path AS r FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
ORDER BY r`
)
.all(parent)
.map((row: any) => row.r);

const LOADABLE = 'export default function Loadable(Component) {\n return (props) => <Component {...props} />;\n}\n';

it('renders the module a lazy value loads, never another app’s same-named page', async () => {
write('next/src/app/(auth)/register/page.jsx', 'export default function RegisterPage() {\n return <div />;\n}\n');
write('vite/src/pages/auth/Register.jsx', 'export default function Register() {\n return <div />;\n}\n');
write('vite/src/components/Loadable.jsx', LOADABLE);
write(
'vite/src/layout/AuthShell.jsx',
`import { lazy } from 'react';
import Loadable from '../components/Loadable';

const RegisterPage = Loadable(lazy(() => import('../pages/auth/Register')));

export function AuthShell() {
return <main><RegisterPage /></main>;
}
`
);
await index();
expect(renders('AuthShell')).toEqual(['function Register vite/src/pages/auth/Register.jsx']);
});

it('renders the declaration itself when the module it loads is out of reach', async () => {
write('next/src/app/(auth)/login/page.jsx', 'export default function LoginPage() {\n return <div />;\n}\n');
write('vite/src/components/Loadable.jsx', LOADABLE);
write(
'vite/src/layout/AuthShell.jsx',
`import { lazy } from 'react';
import Loadable from '../components/Loadable';

// An import only the app's own build resolves (\`baseUrl\`).
const LoginPage = Loadable(lazy(() => import('pages/auth/Login')));

export function AuthShell() {
return <main>Sign in <LoginPage /></main>;
}
`
);
await index();
expect(renders('AuthShell')).toEqual(['constant LoginPage vite/src/layout/AuthShell.jsx']);
});

it('reads next/dynamic’s `(await import(…)).default` and a `.then` that picks a named export', async () => {
// excalidraw's docs site has a component of the same name.
write('dev-docs/src/theme/ReactLiveScope/index.js', 'const Excalidraw = React.forwardRef((props, ref) => <div ref={ref} />);\nexport default { Excalidraw };\n');
write('examples/with-nextjs/src/excalidrawWrapper.tsx', 'const ExcalidrawWrapper = () => {\n return <div />;\n};\nexport default ExcalidrawWrapper;\n');
write(
'examples/with-nextjs/src/pages/excalidraw-in-pages.tsx',
`import dynamic from 'next/dynamic';

const Excalidraw = dynamic(
async () => (await import('../excalidrawWrapper')).default,
{
ssr: false,
},
);

export default function Page() {
return <Excalidraw />;
}
`
);
write('a-decoy/line-chart.tsx', 'export function LineChart() {\n return <svg />;\n}\n');
write('charts/line-chart.tsx', 'export function LineChart() {\n return <svg />;\n}\n');
write('charts/index.ts', "export default function Fallback() {\n return null;\n}\nexport * from './line-chart';\n");
write(
'dashboard.tsx',
`import { lazy } from 'react';
const LineChart = lazy(() => import('./charts').then((m) => ({ default: m.LineChart })));
export function Dashboard() {
return <LineChart />;
}
`
);
await index();
expect(renders('Page')).toEqual(['function ExcalidrawWrapper examples/with-nextjs/src/excalidrawWrapper.tsx']);
expect(renders('Dashboard')).toEqual(['function LineChart charts/line-chart.tsx']);
});

it('follows a module that forwards its default export to the component it wraps', async () => {
// outline: `scenes/Login/index.ts` forwards `Login.tsx`'s `observer(Login)`.
write('a-decoy/Login.tsx', 'export function Login() {\n return <form />;\n}\n');
write(
'app/scenes/Login/Login.tsx',
"import { observer } from 'mobx-react';\nfunction Login() {\n return <form />;\n}\n\nexport default observer(Login);\n"
);
write('app/scenes/Login/index.ts', 'import Login from "./Login";\n\nexport default Login;\n');
write(
'app/scenes/Shared/index.tsx',
`import { lazy } from 'react';
const Login = lazy(() => import('../Login'));
export function SharedScene() {
return <Login />;
}
`
);
await index();
expect(renders('SharedScene')).toEqual(['function Login app/scenes/Login/Login.tsx']);
});

it('renders the function a value wraps, and an alias value itself', async () => {
write('a-decoy/portal.tsx', 'export function DialogPortal() {\n return <div />;\n}\n');
write(
'app/settings.tsx',
`import { observer } from 'mobx-react';
import * as DialogPrimitive from '@radix-ui/react-dialog';

const DialogPortal = DialogPrimitive.Portal;

const Application = observer(function Application() {
return <div />;
});

function TableViewInner() {
return <table />;
}

const TableView = observer(TableViewInner) as typeof TableViewInner;

export function Settings() {
return <DialogPortal><Application /><TableView /></DialogPortal>;
}
`
);
write('a-decoy/table-view.tsx', 'export class TableView {}\n');
await index();
expect(renders('Settings')).toEqual([
'constant DialogPortal app/settings.tsx',
'function Application app/settings.tsx',
'function TableViewInner app/settings.tsx',
]);
});

it('renders nothing for a type argument naming the value, nor for an import inside a function it wraps', async () => {
write('a-decoy/schema.tsx', 'export function Schema() {\n return <div />;\n}\n');
write('heavy.tsx', 'export default function Heavy() {\n return <div />;\n}\n');
write(
'form.tsx',
`import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { withFallback } from './fallback';

const Schema = z.object({ name: z.string() });
type Schema = z.infer<typeof Schema>;

const Dialog = withFallback('Dialog', (props) => {
const load = () => import('./heavy');
return <div onClick={load} />;
});

export function Form() {
const form = useForm<Schema>();
return <form><Dialog /></form>;
}
`
);
write('fallback.tsx', 'export function withFallback(name, render) {\n return render;\n}\n');
await index();
expect(renders('Form')).toEqual(['constant Dialog form.tsx']);
});

it('binds a TSX type named like a value the file declares to that file, never another app’s component', async () => {
// Component resolution saw the name as a component's and took the only
// one in the repository, in another app.
write('admin/src/components/User.tsx', 'export function User() {\n return <div />;\n}\n');
write(
'web/src/forms/profile-form.tsx',
`import { z } from 'zod';

const User = z.object({ name: z.string() });
type User = z.infer<typeof User>;

export function ProfileForm({ user }: { user: User }) {
return <form>{user.name}</form>;
}
`
);
await index();
const referenced = cg.db.db
.prepare(
`SELECT DISTINCT t.file_path AS f FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE s.name = 'ProfileForm' AND e.kind = 'references'`
)
.all()
.map((row: any) => row.f);
expect(referenced).toEqual(['web/src/forms/profile-form.tsx']);
});
});
51 changes: 51 additions & 0 deletions __tests__/react-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,57 @@ export default AuthenticationRoutes;
});
});

describe('react-router: a layout the route file loads lazily', () => {
let root: string;
let cg: CodeGraph;
beforeAll(async () => {
({ root, cg } = await indexProject({
'package.json': JSON.stringify({ name: 'mantis', private: true }),
'vite/package.json': JSON.stringify({ name: 'vite-app', dependencies: { react: '^18', 'react-router-dom': '^7' } }),
'vite/src/routes/index.jsx': `import { createBrowserRouter } from 'react-router-dom';
import MainRoutes from './MainRoutes';

const router = createBrowserRouter([MainRoutes]);

export default router;
`,
'vite/src/routes/MainRoutes.jsx': `import { lazy } from 'react';
import Loadable from '../components/Loadable';

const MainLayout = Loadable(lazy(() => import('../layout/MainLayout')));
const Dashboard = Loadable(lazy(() => import('../views/Dashboard')));

const MainRoutes = {
path: '/',
element: <MainLayout />,
children: [
{
path: 'dashboard',
element: <Dashboard />
}
]
};

export default MainRoutes;
`,
'vite/src/layout/MainLayout/index.jsx': 'export default function Layout() {\n return <main />;\n}\n',
'vite/src/views/Dashboard.jsx': 'export default function Dashboard() {\n return <div />;\n}\n',
'vite/src/components/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => <Component {...props} />;\n}\n',
// The repository's other app, with a layout of the same name.
'next/package.json': JSON.stringify({ name: 'next-app', dependencies: { next: '^15', react: '^18' } }),
'next/src/layout/MainLayout.jsx': 'export default function MainLayout({ children }) {\n return <main>{children}</main>;\n}\n',
}));
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

it('binds the layout to the module the route file loads, never to a same-named layout of another app', () => {
expect(routeBindings(cg)).toEqual(['/ -> Layout', '/dashboard -> Dashboard', '/dashboard ~> Layout']);
});
});

describe('react-router: a table mapped inside <Route path> in the same file', () => {
let root: string;
let cg: CodeGraph;
Expand Down
Loading