Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- React Router routes written as JSX now read `<Route index>` the way route objects do: inside `<Route path="/" element={<Layout />}>`, `<Route index element={<Home />} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `<Route element={<RequireAuth />}>`. Before, the index route was never read, so `/` led to the layout itself and a `<Route path="/dashboard">` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `<Suspense fallback={<Loader />}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading.

- A React Router route whose `lazy` loader picks its page from the module it loads now links to that page: `lazy: async () => { const { TestTab } = await import('./tabs'); return { Component: TestTab }; }`, `const { default: Component } = await import('./pages/Login')`, `import('./reports').then((m) => ({ Component: m.ReportList }))`, `({ Component: (await import('./reports')).ReportPage })`, React Router 7's `lazy: { Component: async () => (await import('./show')).Show }`, and a page the loader shows inside a guard, like `element: <AgeGate><FireworksPage /></AgeGate>`. Before, such a route linked to the module's default export, else its `Component` export, which is a different page or none at all, as with luci-go's tabs loaded from a folder that forwards them. A loader written as a method, `async lazy() { … }`, is now read too, so its route is no longer missing, and a loader that hands React Router only a `loader` no longer makes the module's default export the route's page or the layout of the routes inside it. Re-index React projects after upgrading.
- A React Router route, or a page loaded with `lazy(() => import(…))`, now links to its page when the folder it loads passes the page on through an import of its own, like `import { UserLayout } from './UserLayout'; export { UserLayout }` or `import Login from './Login'; export default Login`. Before, the folder's `index.ts` was searched for a page declared in it, which it doesn't have, so the route linked nothing. A default export that wraps the page in a call, like `export default withAuth(Page)`, still links neither the wrapper nor a guess. Re-index React projects after upgrading.
- `codegraph sync` and the file watcher now link a lazily loaded React Router route to its page when the page appears, or gains the export the route reads, in a folder below the one the route loads, like `src/pages/Home/index.tsx` behind `import('@/pages')`. Before, such a route kept linking nothing until the folder's own `index.ts` changed or the project was re-indexed, although a fresh index linked it.
- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way.


Expand Down Expand Up @@ -684,6 +686,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In `codegraph_explore`, an override that hands off to the method it overrides no longer leads the Flow when the question names only the bare method (`pre_sql_setup`), so the Flow starts at the base method instead.
- In React and Vue, a tag for a component the file imports from a package, like `<Button>` from antd, `<Typography>` from `@mui/material/Typography`, `<Link>` from react-router-dom or `<Card>` from ant-design-vue, no longer links to a component, function or class in your repository that only shares its name. Before, the tag linked to whatever the repository had by that name, so `codegraph_explore`, callers and impact followed renders that never happen: in SigNoz, antd's `<Button>` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `<Typography>` and `<Button>` tags showed up as callers of the theme's style overrides. A package here is one that a `package.json` above the file lists and that no file in the repository answers to. A tag imported through a path alias like `components/Modal`, from a workspace package of the repository, or under a name no `package.json` lists still links as before. Re-index React and Vue projects after upgrading.
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. 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')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias 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 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.

## About this fork

This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`5da4a41d`](https://github.com/colbymchenry/codegraph/commit/5da4a41d) (after v1.6.2) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests.
This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`d0bb9be5`](https://github.com/colbymchenry/codegraph/commit/d0bb9be5) (after v1.6.2) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests.

The fork publishes no releases. The install scripts, npm package, badges and `codegraph upgrade` further down this page install **upstream's** releases. To run the fork, build it from source (below).

Expand Down
303 changes: 303 additions & 0 deletions __tests__/jsx-child-disambiguation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,306 @@ export function PostList({ posts }: { posts: IPost[] }) {
expect(renders('PostList')).toEqual([]);
});
});

/**
* 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']);
});

// `before` is the text in front of the tag in the shell's JSX.
const declarationWhenOutOfReach = async (before: string) => {
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>${before}<LoginPage /></main>;
}
`
);
await index();
expect(renders('AuthShell')).toEqual(['constant LoginPage vite/src/layout/AuthShell.jsx']);
};

it('renders the declaration itself when the module it loads is out of reach', () => declarationWhenOutOfReach('Sign in: '));

// Known limit of the fork: `opensTag` skips the whitespace before `<` and reads a word there
// as a type or comparison (`count < Max`), so upstream's `Sign in <LoginPage />` is no tag
// here. `it.fails` keeps this red-by-design case visible and turns the suite red once fixed.
it.fails('renders the declaration itself when a word precedes the tag (upstream fixture)', () => declarationWhenOutOfReach('Sign in '));

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 the branch of a conditional that loads lazily, and a wrapper typed past its call or given options', async () => {
// SigNoz loads its devtools in development only and memoizes a table body
// with a comparator; mastodon types its `forwardRef` with an object type
// that keeps the generics.
write('a-decoy/AuthZDevModal.tsx', 'export function AuthZDevModal() {\n return <div />;\n}\n');
write('a-decoy/Combobox.tsx', 'export function Combobox() {\n return <div />;\n}\n');
write('a-decoy/MemoizedRows.tsx', 'export function MemoizedRows() {\n return <div />;\n}\n');
write('src/lib/devtools/AuthZDevModal.tsx', 'export function AuthZDevModal() {\n return <dialog />;\n}\n');
write(
'src/palette.tsx',
`import React, { forwardRef } from 'react';

const IS_DEV = process.env.NODE_ENV === 'development';

const AuthZDevModal = IS_DEV
? React.lazy(() =>
import('./lib/devtools/AuthZDevModal').then((m) => ({
default: m.AuthZDevModal,
})),
)
: null;

const ComboboxWithRef = <Item extends { id: string }>(
props: { items: Item[] },
ref: React.ForwardedRef<HTMLInputElement>,
) => {
return <input ref={ref} />;
};

export const Combobox = forwardRef(ComboboxWithRef) as {
<Item extends { id: string }>(props: { items: Item[] }): ReturnType<typeof ComboboxWithRef>;
displayName: string;
};

function Rows({ rows }: { rows: string[] }) {
return <ul>{rows.map((row) => <li key={row}>{row}</li>)}</ul>;
}

const MemoizedRows = React.memo(
Rows,
(prev, next) => prev.rows === next.rows,
) as typeof Rows;

export function Palette() {
return <div>{AuthZDevModal && <AuthZDevModal />}<Combobox items={[]} /><MemoizedRows rows={[]} /></div>;
}
`
);
await index();
expect(renders('Palette')).toEqual([
'function AuthZDevModal src/lib/devtools/AuthZDevModal.tsx',
'function ComboboxWithRef src/palette.tsx',
'function Rows src/palette.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']);
});
});
Loading
Loading