mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
4731149469
The jsx-render pass reads tag names off a parent's source with a pattern,
and two kinds of name it read that way linked a same-named class or
component elsewhere:
- A name written only in a type argument or type parameter list:
outline's `<PaginatedList<Document> …>` and `useState<User>()`, or
bulletproof-react's generic `<Entry extends BaseEntity>(…) =>`. A `<Name`
right after an identifier opens a type argument list (the rule #2420
applies to the values a file declares, here for every name), and a
`<Name extends …` opens a type parameter list.
- A name the parent binds itself by its first tag, a `const`/`let`/`var` or
a parameter (name-matcher's `jsCodeBindsName`, now exported): outline's
`const Content = variant === "dropdown" ? DropdownMenu.SubContent :
ContextMenu.SubContent`, `(Widget, index) => <Widget />`. Such a tag
renders only a component the parent declares inside itself, else
nothing. The check runs only for names the parent also writes outside a
tag. Destructured names still link by name, as for a destructured call.
Fresh before/after indexes on 13 repos (main ed199e60): 66 jsx-render
edges removed and none added; no other edge, node, ref or file changes.
Every removal checks out against the TypeScript compiler (38 names with
no JSX tag in the parent, 28 tags bound to a local); 64 of the 66 targets
were wrong, 2 right by luck. mantis, proshop_mern, next-saas-starter and
create-t3-turbo are byte-identical, and Screens is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import * as os from 'node:os';
|
||
import { CodeGraph } from '../src';
|
||
|
||
/**
|
||
* A JSX tag names ONE component, and the file it is written in says which:
|
||
* the one that file declares, or the one it imports. The synthesizer used to
|
||
* take the first node of that name in the whole graph, which is a coin flip as
|
||
* soon as a name repeats — and repeated component names are the norm, not the
|
||
* exception (`Section`, `Picker`, `FrameCard`, one per feature folder).
|
||
*
|
||
* Getting it wrong costs twice: the parent gains an edge to a component it
|
||
* never renders, and the component it DOES render is left with no caller, so
|
||
* every walk back from that subtree — Screens' navigation attribution,
|
||
* `getCallers`, an impact radius — dead-ends there. On an Expo app that showed
|
||
* up as a navigation standing alone on the Screens tab with no screen behind
|
||
* it, while the edge pointed at an unrelated card in another sheet.
|
||
*
|
||
* Each decoy here is deliberately named to sort BEFORE the right answer, so a
|
||
* first-match resolver picks it and the test fails.
|
||
*/
|
||
describe('JSX child disambiguation among same-named components', () => {
|
||
let dir: string;
|
||
let cg: any;
|
||
|
||
beforeEach(() => {
|
||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-child-'));
|
||
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() {
|
||
cg = await CodeGraph.init(dir, { silent: true });
|
||
await cg.indexAll();
|
||
return (cg as any).db.db;
|
||
}
|
||
|
||
/** The files a jsx-render edge out of `parent` points into. */
|
||
const rendersFrom = (db: any, parent: string): string[] =>
|
||
db
|
||
.prepare(
|
||
`SELECT 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 = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
|
||
ORDER BY f`
|
||
)
|
||
.all(parent)
|
||
.map((r: any) => r.f);
|
||
|
||
it('follows the import when the same name is declared in another file', async () => {
|
||
write('a-decoy/card.tsx', `export function Card() { return <div>decoy</div>; }\n`);
|
||
write('real/card.tsx', `export function Card() { return <div>real</div>; }\n`);
|
||
write(
|
||
'grid.tsx',
|
||
`import { Card } from './real/card';
|
||
export function Grid() { return <div><Card /></div>; }
|
||
`
|
||
);
|
||
const db = await index();
|
||
expect(rendersFrom(db, 'Grid')).toEqual(['real/card.tsx']);
|
||
});
|
||
|
||
it('follows a tsconfig path alias the same way a relative import is followed', async () => {
|
||
fs.writeFileSync(
|
||
path.join(dir, 'tsconfig.json'),
|
||
JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } } })
|
||
);
|
||
write('src/a-decoy/row.tsx', `export function Row() { return <li>decoy</li>; }\n`);
|
||
write('src/real/row.tsx', `export function Row() { return <li>real</li>; }\n`);
|
||
write(
|
||
'src/list.tsx',
|
||
`import { Row } from '@/real/row';
|
||
export function List() { return <ul><Row /></ul>; }
|
||
`
|
||
);
|
||
const db = await index();
|
||
expect(rendersFrom(db, 'List')).toEqual(['src/real/row.tsx']);
|
||
});
|
||
|
||
it('prefers a component declared in the same file over a same-named import elsewhere', async () => {
|
||
write('a-decoy/pill.tsx', `export function Pill() { return <span>decoy</span>; }\n`);
|
||
write(
|
||
'toolbar.tsx',
|
||
`function Pill() { return <span>local</span>; }
|
||
export function Toolbar() { return <div><Pill /></div>; }
|
||
`
|
||
);
|
||
const db = await index();
|
||
expect(rendersFrom(db, 'Toolbar')).toEqual(['toolbar.tsx']);
|
||
});
|
||
|
||
it('prefers a JS component over a same-named class in the app’s native half', async () => {
|
||
// A React Native app: `<CaptureSettings/>` is the TS component the screen
|
||
// imports, never the Swift type that happens to share its name.
|
||
write('a-ios/CaptureSettings.swift', `class CaptureSettings {\n func sync() {}\n}\n`);
|
||
write('ui/capture-settings.tsx', `export function CaptureSettings() { return <div />; }\n`);
|
||
write(
|
||
'ui/overlay.tsx',
|
||
`import { CaptureSettings } from './capture-settings';
|
||
export function Overlay() { return <div><CaptureSettings /></div>; }
|
||
`
|
||
);
|
||
const db = await index();
|
||
expect(rendersFrom(db, 'Overlay')).toEqual(['ui/capture-settings.tsx']);
|
||
});
|
||
|
||
it('still links a name that appears exactly once', async () => {
|
||
write('only/badge.tsx', `export function Badge() { return <b>1</b>; }\n`);
|
||
write(
|
||
'header.tsx',
|
||
`import { Badge } from './only/badge';
|
||
export function Header() { return <h1><Badge /></h1>; }
|
||
`
|
||
);
|
||
const db = await index();
|
||
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']);
|
||
});
|
||
});
|