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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374)
- 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.
- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way.
- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <Widget />)`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading.

## [1.6.2] - 2026-10-03

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

/**
* The pass reads tag names off a parent's source with a pattern, and two
* kinds of name it reads that way are no other file's component.
*
* - A type argument: `<PaginatedList<Document> …>` or `useState<User>()`
* writes a type's name right after another name. outline's document list
* rendered its `Document` model class, its settings tables the `User` and
* `Group` classes.
* - A name the parent binds itself, as a `const` or a parameter, is that local
* wherever its tags sit. outline's menus pick a Radix part into `const
* Content = variant === 'dropdown' ? DropdownMenu.SubContent :
* ContextMenu.SubContent` and rendered the command bar's styled `Content`
* instead. A local is no node, so it renders nothing, unless the parent
* declares a component of that name inside itself.
*/
describe('JSX child: a name the parent writes only as a type, or binds itself', () => {
let dir: string;
let cg: any;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-local-'));
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();
}

/** `name file:line` of each node a jsx-render edge out of `parent` points at. */
const renders = (parent: string): string[] =>
cg.db.db
.prepare(
`SELECT t.name || ' ' || t.file_path || ':' || t.start_line 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);

it('renders nothing for a name written only as a type argument or parameter', async () => {
write('app/models/Document.ts', 'export default class Document {\n title = "";\n}\n');
write('app/models/User.ts', 'export default class User {\n name = "";\n}\n');
write(
'app/components/PaginatedList.tsx',
'export function PaginatedList<T>({ items }: { items: T[] }) {\n return <ul>{items.length}</ul>;\n}\n'
);
write(
'app/components/Badge.tsx',
'import * as React from "react";\nexport class Badge extends React.Component {\n render() {\n return <b />;\n }\n}\n'
);
write(
'app/components/DocumentList.tsx',
`import * as React from 'react';
import Document from '../models/Document';
import User from '../models/User';
import { Badge } from './Badge';
import { PaginatedList } from './PaginatedList';

export function DocumentList({ items }: { items: Document[] }) {
const [owner] = React.useState<User | null>(null);
const badge = React.useRef<Badge>(null);
return (
<div>
<PaginatedList<Document> items={items} />
<Badge ref={badge} />
</div>
);
}
`
);
// bulletproof-react's table: a generic arrow function's type parameter.
write('app/profile/entry.tsx', 'export function Entry() {\n return <div />;\n}\n');
write(
'app/components/Table.tsx',
`type BaseEntity = { id: string };

export const Table = <Entry extends BaseEntity>({ data }: { data: Entry[] }) => {
return <table>{data.length}</table>;
};
`
);
await index();
// `Badge` is written as a type argument too, but a tag of it is enough.
// A generic tag's own name is not read at all: no tag ends after
// `<PaginatedList` (its type arguments follow).
expect(renders('DocumentList')).toEqual(['Badge app/components/Badge.tsx:2']);
expect(renders('Table')).toEqual([]);
});

it('renders nothing for a name the parent binds itself as a const or a parameter', async () => {
write(
'app/components/CommandBarItem.tsx',
'export function Content() {\n return <div />;\n}\n\nexport function Icon() {\n return <svg />;\n}\n'
);
write('shared/editor/Widget.tsx', 'export function Widget() {\n return <div />;\n}\n');
write('app/components/Portal.tsx', 'export function Portal({ children }) {\n return <div>{children}</div>;\n}\n');
write(
'app/components/Menu.tsx',
`import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import * as ContextMenu from '@radix-ui/react-context-menu';

const Portal = DropdownMenu.Portal;

export function SubMenuContent({ variant, children }) {
const Portal =
variant === 'dropdown' ? DropdownMenu.Portal : ContextMenu.Portal;
const Content =
variant === 'dropdown'
? DropdownMenu.SubContent
: ContextMenu.SubContent;
return (
<Portal>
<Content collisionPadding={6}>{children}</Content>
</Portal>
);
}

export function SettingsLinks({ config }) {
return (
<ul>
{config.map((item) => {
const Icon = item.icon;
return <li key={item.path}><Icon /></li>;
})}
</ul>
);
}

export function Widgets({ widgets }) {
return <div>{Object.values(widgets).map((Widget, index) => <Widget key={index} />)}</div>;
}
`
);
await index();
// The file's own `Portal` is shadowed too: the tag means the local.
expect(renders('SubMenuContent')).toEqual([]);
expect(renders('SettingsLinks')).toEqual([]);
expect(renders('Widgets')).toEqual([]);
});

it('renders a component the parent declares inside itself, and the outer one for a tag above the local', async () => {
write('a-decoy/row.tsx', 'export function Row() {\n return <tr />;\n}\n');
write('app/icon.tsx', 'export function Icon() {\n return <svg />;\n}\n');
write(
'app/table.tsx',
`import { Icon } from './icon';

function Row() {
return <tr className="outer" />;
}

export function Table({ rows }) {
const Row = ({ row }) => <tr>{row.name}</tr>;
return <table>{rows.map((row) => <Row key={row.id} row={row} />)}</table>;
}

export function Toolbar({ actions }) {
return (
<div>
<Icon />
{actions.map((action) => {
const Icon = action.icon;
return <Icon key={action.id} />;
})}
</div>
);
}
`
);
await index();
expect(renders('Table')).toEqual(['Row app/table.tsx:8']);
expect(renders('Toolbar')).toEqual(['Icon app/icon.tsx:1']);
});

it('keeps a destructured name on the same-named component, as a destructured call does', async () => {
// excalidraw's example app: `const { Sidebar, Footer, … } = excalidrawLib`.
// Destructuring is not the parent's own binding here, as it is not for a
// bare call (`const { t } = useI18n()`), so the name links by name.
write(
'packages/excalidraw/components/WelcomeScreen.tsx',
'export default function WelcomeScreen() {\n return <div />;\n}\n'
);
write(
'examples/app/ExampleApp.tsx',
`export default function ExampleApp({ excalidrawLib }) {
const { WelcomeScreen, exportToSvg } = excalidrawLib;
return <div><WelcomeScreen /></div>;
}
`
);
await index();
expect(renders('ExampleApp')).toEqual(['WelcomeScreen packages/excalidraw/components/WelcomeScreen.tsx:1']);
});
});
81 changes: 72 additions & 9 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { createYielder, type MaybeYield } from './cooperative-yield';
import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequestEdges } from './tier-synthesizer';
import { enclosingFn, makeLineAt } from './synth-utils';
import { resolveImportPath } from './import-resolver';
import { crossesCodeBoundary } from './name-matcher';
import { crossesCodeBoundary, jsCodeBindsName } from './name-matcher';

const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
Expand Down Expand Up @@ -1584,14 +1584,62 @@ function jsxChild(
return candidates.find((n) => JSX_CHILD_LANGUAGES.includes(n.language)) ?? candidates[0];
}

/**
* Whether the `<Name` at `at` opens a JSX tag. A type argument list follows
* its type's name directly — `useState<User>()`, `Array<Item>`, the
* `<Document>` of `<PaginatedList<Document>` — and a tag never does, short of
* a `return` written up against it. A generic arrow function's type
* parameters read like a tag but constrain theirs: `<Entry extends
* BaseEntity>({ data }) =>`.
*/
function opensTag(src: string, at: number, name: string): boolean {
const end = at + 1 + name.length;
if (/^\s+extends\s/.test(src.slice(end, end + 40))) return false;
return !/[\w$]/.test(src[at - 1] ?? '') || /\breturn$/.test(src.slice(Math.max(0, at - 7), at));
}

/**
* Whether `src` writes `name` other than in a tag (`<Name`, `</Name`): a
* parent binds a name only where it writes it bare (`const Content =`,
* `(Widget) =>`), so the check for that runs only on these.
*/
function writtenBare(src: string, name: string): boolean {
for (let at = src.indexOf(name); at !== -1; at = src.indexOf(name, at + name.length)) {
if (/[\w$]/.test(src[at - 1] ?? '') || /[\w$]/.test(src[at + name.length] ?? '')) continue;
if (src[at - 1] === '<' || (src[at - 1] === '/' && src[at - 2] === '<')) continue;
return true;
}
return false;
}

/**
* What a tag renders when its parent binds the name itself: a component the
* parent declares in its own lines (`const Row = ({ row }) => <tr />` in a
* table's body), else nothing. Any other local is no node, and a same-named
* component elsewhere is only a guess at what it holds.
*/
function declaredInside(ctx: ResolutionContext, name: string, parent: Node, child: Node): Node | undefined {
const inside = (n: Node) => n.id !== parent.id && n.filePath === parent.filePath &&
n.startLine >= parent.startLine && n.startLine <= parent.endLine;
if (inside(child)) return child;
return ctx.getNodesByName(name).find((n) => JSX_CHILD_KINDS.has(n.kind) && inside(n));
}

/**
* Phase 5: React JSX child rendering. A component that returns `<Child .../>`
* mounts Child — React calls it — but JSX instantiation isn't a static call edge,
* so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the
* JSX hop. Link parent → each capitalized JSX child it renders. File-oriented
* (read each JSX file once). Precision gate: the child name must resolve to a
* component/function/class node — TS generics like `Array<Foo>` resolve to a type
* (or nothing) and are dropped.
* (read each JSX file once). Precision gates: the parent must write the name as
* a tag, not only in a type argument or parameter list (`useState<User>()`,
* `<Entry extends BaseEntity>(…) =>`; outline's `<PaginatedList<Document>`
* read its `Document` model class as a child), and the name must resolve to a
* component/function/class node. A name the parent binds itself by its first
* tag — a parameter, or a `const` like outline's `const Content = variant ===
* "dropdown" ? DropdownMenu.SubContent : ContextMenu.SubContent` — is that
* local, and renders only a component declared inside the parent
* (`declaredInside`). A destructured name still links by name, as a
* destructured function call does.
*/
async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
let scannedFiles = 0;
Expand Down Expand Up @@ -1620,26 +1668,41 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
// over go-ethereum's graphiql.min.js (2,234 functions on 980 KB). The
// file is split once, not once per function.
let lines: string[] | null = null;
const tagsBySpan = new Map<string, Set<string>>();
// Each name a span holds, and the line of its first tag: 0 while only type
// argument or parameter lists name it (`opensTag`). `bare` holds the
// names it also writes outside a tag (`writtenBare`).
const tagsBySpan = new Map<string, { tags: Map<string, number>; bare: Set<string> }>();
for (const parent of parents) {
if (!parent.startLine || !parent.endLine) continue;
const span = `${parent.startLine}:${parent.endLine}`;
let names = tagsBySpan.get(span);
if (!names) {
names = new Set<string>();
names = { tags: new Map<string, number>(), bare: new Set<string>() };
lines ??= content.split('\n');
const src = lines.slice(parent.startLine - 1, parent.endLine).join('\n');
if (src.includes('</') || src.includes('/>')) {
let line = parent.startLine;
let counted = 0;
JSX_TAG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!);
while ((m = JSX_TAG_RE.exec(src))) {
for (let nl = src.indexOf('\n', counted); nl !== -1 && nl < m.index; nl = src.indexOf('\n', nl + 1)) line++;
counted = m.index;
if (!names.tags.get(m[1]!)) names.tags.set(m[1]!, opensTag(src, m.index, m[1]!) ? line : 0);
}
for (const [name, first] of names.tags) if (first && writtenBare(src, name)) names.bare.add(name);
}
tagsBySpan.set(span, names);
}
let added = 0;
for (const name of names) {
for (const [name, line] of names.tags) {
if (added >= MAX_JSX_CHILDREN) break;
const child = jsxChild(ctx, name, file, importsOf);
if (!line) continue;
let child = jsxChild(ctx, name, file, importsOf);
// A name the parent binds itself by its first tag is that local.
if (child && names.bare.has(name) && jsCodeBindsName(name, parent, file, line, ctx)) {
child = declaredInside(ctx, name, parent, child);
}
if (!child || child.id === parent.id || crossesCodeBoundary(parent.language, child.language)) continue;
const key = `${parent.id}>${child.id}`;
if (seen.has(key)) continue;
Expand Down
Loading