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 @@ -265,6 +265,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript and JavaScript, a name ending in `$`, like `syncState$`, is no longer read as a declaration of `syncState`, so a call to an imported `syncState(…)` in the same function keeps its link, and a file's own `function $()` or `const $ = …` now counts as declaring `$`.
- In TypeScript, a type a file uses without importing it, like `Omit<T, K>`, `Record<K, V>`, the DOM's `Event`, or a generic's own `T`, no longer links to a type of that name another module of your project declares, like a local `export type Omit` or an enum member `T`: it links to the file's own type or a global one, declared in a script `.d.ts` or a `declare global` block. A type you import, write qualified (`vscode.TextDocument`), bring in with `import X = ns.X`, or name inside a `declare module` block links as before. Re-index TypeScript projects after upgrading.
- In TypeScript React files, a type annotation no longer links to a component of the same name. When a file declares a type next to a component or value of the same name, like `type ModalRegistration` beside a `ModalRegistration` component, its annotations now link to the type while its calls still link to the component, and a type the file declares on its own, like `type Option`, or a generic's type parameter, like `<Item>`, no longer links to an `Option` or `Item` component elsewhere in the project. Before, `codegraph callers` and impact listed those annotations as uses of the component. Re-index TypeScript React projects after upgrading.
- In React, a component declared through a wrapper with no render function of its own, like styled-components' `styled(ActionButton)` (with `.attrs(…)`, `.withConfig(…)` or type arguments too), emotion's and MUI's `styled(Box)(…)`, `memo(Button)` or `forwardRef(Render)`, now links to the component it wraps, so a render flow in `codegraph_explore`, a component's callers and its impact go on through the wrapper instead of stopping at it. The calls and values in a wrapper's styles, like `${(props) => theme.color(props)}` or a shared `css` block, now belong to the wrapper, as they do to any other constant, so a theme helper used only in styles has callers. A component that wraps its render function in `forwardRef(…)` or `memo(…)` now shows in the impact of the top-level constants it reads, as a function component does. A wrapper around a component that comes from a package, like react-router's `Link` or antd's `Typography.Text`, links to nothing, and the file that declares a wrapper no longer shows as referencing the component it wraps. Re-index React projects after upgrading.

## [1.6.2] - 2026-10-03

Expand Down
6 changes: 4 additions & 2 deletions __tests__/expo-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,8 +741,10 @@ export default function ReviewScreen() {

// One transition into /capture/review, and it comes from /capture.
expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']);
// Written right there: no chain, and no Swift guard smuggled in.
expect(links[0]!.via).toEqual([]);
// Written in this screen's own file, reached through the memo wrapper it
// renders, and no Swift guard smuggled in.
expect(links[0]!.via.map((v) => v.name)).toEqual(['MemoizedCaptureComponent', 'CaptureComponent']);
expect(links[0]!.via.every((v) => v.language !== 'swift')).toBe(true);
expect(links[0]!.when).toBe('!isRetakeBatchActive');
expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx');
// …and /capture/review is not left feeding only itself.
Expand Down
10 changes: 10 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,13 @@ async function exprReceivers(x) {
this.a.b.run();
new Runner().go();
}
// --- component wrappers walked under the component ------------------------------
const PANEL_GAP = 8;
const Panel = styled(Widget)`
gap: ${PANEL_GAP}px;
color: ${(props) => colorOf(props.theme)};
`;
const Row = styled.div.attrs((props) => ({ role: roleOf(props) }))`
${Panel} { margin: 0; }
`;
const MemoPanel = React.memo(Panel, legacyHelper);
25 changes: 25 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,31 @@ export const Styled = styled.button`
`;
const memoCache = memo(computeThing); // lowercase — stays a constant

// Wrappers with no render function: the initializer is walked under the
// component — interpolations, `.attrs()` / `.withConfig()` / options
// arguments, a style function, a comparator — but not the wrapper's own chain,
// and the wrapped component is no fn-ref (a typed tag's type is not walked).
const BASE_MIXIN = css`color: red;`;
export const StyledBase = styled(Button)`
color: ${(p) => themeColor(p, 'accent')};
${BASE_MIXIN}
${breakpoint('tablet')`padding: 0;`}
`;
export const AttrsWrapped = styled(ImportedComponent).attrs((props: ButtonProps) => ({ title: labelFor(props) }))<ButtonProps>`
width: ${(props) => sizeOf(props)}px;
`;
export const Configured = styled(MemoRow)
.withConfig({ shouldForwardProp: (prop) => !isInternalProp(prop) })
.attrs<RowProps>((props) => ({ align: alignOf(props) }))<RowProps>``;
export const TypedTag = styled.section<ButtonProps | RowProps>`margin: ${spacing(2)};`;
export const MuiStyled = styled(ImportedComponent, { shouldForwardProp: helperFn })(({ theme }) => ({
padding: theme.spacing(1),
color: pickColor(theme, CONFIG_TABLE),
}));
export const MemoCompared = memo(ImportedComponent, helperFn);
export const MemoForward = memo(forwardRef(RenderInput));
export const RefForwarded = React.forwardRef(RenderInput);

export function App() {
const nodes: GraphNode[] = [];
return (
Expand Down
246 changes: 246 additions & 0 deletions __tests__/react-wrapper-initializers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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';
import { ToolHandler } from '../src/mcp/tools';
import { resetKernelForTests } from '../src/extraction/kernel';

/**
* A React component declared through a wrapper with no render function of its
* own — `styled.div\`…\``, outline's `styled(ActionButton).attrs(…)<Props>\`…\``,
* MUI's `styled(Box)(({ theme }) => …)`, `memo(Button)` — is a `component` node
* (#841, #2429) that nothing walked: the calls in its template's
* interpolations went nowhere, where a plain `const X = …` attributes them to X
* (#693), and nothing linked it to the component it wraps, so a render flow
* Page → NudeButton → ActionButton stopped at NudeButton.
*
* Now the initializer is walked under the component, minus the wrapper's own
* chain (`styled`, `styled.div`, `.attrs`, `memo`: the library, which no project
* code answers), and the wrapped component is what the wrapper renders: a
* `jsx-render` hop bound as a tag of that name in the file would be, so a
* package's `styled(Link)` renders no project `Link`. Both engines.
*/

const KERNEL_PATH = path.join(
__dirname,
'..',
'codegraph-kernel',
'prebuilds',
`${process.platform}-${process.arch}`,
'codegraph-kernel.node'
);
const kernelBuilt = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1';
const ENGINES = [
{ engine: 'wasm', kernel: '0' as string | undefined },
...(kernelBuilt ? [{ engine: 'kernel', kernel: undefined as string | undefined }] : []),
];

const FILES: Record<string, string> = {
'package.json': JSON.stringify({
dependencies: { react: '^18.0.0', 'styled-components': '^6.0.0', 'react-router-dom': '^6.0.0', antd: '^5.0.0' },
}),
'theme.ts': `export function s(key: string) { return key; }
export function breakpoint(size: string) { return (strings: TemplateStringsArray) => size + strings.join(''); }
export function labelFor(props: unknown) { return String(props); }
export function areEqual(a: unknown, b: unknown) { return a === b; }
export function pickColor(theme: unknown) { return String(theme); }
`,
'ActionButton.tsx': `export default function ActionButton(props: { title?: string }) {
return <button>{props.title}</button>;
}
`,
// A project namesake of react-router's Link: what styled(Link) must not render.
'Link.tsx': `export function Link(props: { to: string }) {
return <a href={props.to} />;
}
`,
// Namesakes a guess would take for names the wrappers below don't bind to them.
'other.tsx': `import styled from 'styled-components';
export const Icon = styled.i\`\`;
export const Text = styled.span\`\`;
`,
// A base imported through a path nothing resolves (a Meteor app's
// `/imports/…`), and one the file destructures from a library.
'unbound.tsx': `import styled from 'styled-components';
import Icon from '/imports/ui/components/icon/component';
import { Typography } from 'antd';
const { Text } = Typography;
export const BigIcon = styled(Icon)\`\`;
export const StyledText = styled(Text)\`\`;
`,
'ui.tsx': `import styled, { css } from 'styled-components';
import { Link } from 'react-router-dom';
import { memo } from 'react';
import ActionButton from './ActionButton';
import { s, breakpoint, labelFor, areEqual, pickColor } from './theme';

type Props = { active: boolean };
const BASE_CSS = css\`color: red;\`;

export const Plain = styled.div\`
color: \${(p) => s('accent')};
\${BASE_CSS}
\${breakpoint('tablet')\`padding: 0;\`}
\`;
export const NudeButton = styled(ActionButton).attrs((props: Props) => ({ title: labelFor(props) }))<Props>\`
width: \${(props) => s('width')};
\`;
export const Root = styled(ActionButton)(({ theme }) => ({ color: pickColor(theme) }));
export const Memoized = memo(ActionButton, areEqual);
const Box = styled.div\`\`;
export const Card = styled(Box)\`\`;
export const StyledLink = styled(Link)\`\`;
`,
// A styles file: TypeScript, no markup of its own.
'styles.ts': `import styled from 'styled-components';
import ActionButton from './ActionButton';
export const Panel = styled(ActionButton)\`margin: 0;\`;
`,
// A wrapper of an element only, in a file no synthesis pass reads yet.
'bare.ts': `import styled from 'styled-components';
import ActionButton from './ActionButton';
export const Bare = styled.div\`margin: 0;\`;
`,
'Page.tsx': `import { NudeButton } from './ui';
export function Page() {
return <NudeButton active />;
}
`,
};

describe.each(ENGINES)('a wrapper component walks its initializer and renders what it wraps ($engine)', ({ kernel }) => {
let dir: string;
let cg: any;
let saved: string | undefined;

beforeEach(() => {
saved = process.env.CODEGRAPH_KERNEL;
if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL;
else process.env.CODEGRAPH_KERNEL = kernel;
resetKernelForTests();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-wrap-'));
for (const [file, text] of Object.entries(FILES)) fs.writeFileSync(path.join(dir, file), text);
});

afterEach(() => {
cg?.close?.();
cg = undefined;
if (saved === undefined) delete process.env.CODEGRAPH_KERNEL;
else process.env.CODEGRAPH_KERNEL = saved;
resetKernelForTests();
fs.rmSync(dir, { recursive: true, force: true });
});

async function index() {
cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
return (cg as any).db.db;
}

/** `kind` edges from a component to a node, as `Source>target`. */
const edgesFrom = (db: any, kind: string, where = '1=1'): string[] =>
db
.prepare(
`SELECT s.name src, t.name dst FROM edges e
JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind = ? AND s.kind = 'component' AND ${where}
ORDER BY s.name, t.name`
)
.all(kind)
.map((r: any) => `${r.src}>${r.dst}`);

it('attributes the calls and values in its template, attrs and style function to the component', async () => {
const db = await index();
const calls = new Set(edgesFrom(db, 'calls', "json_extract(e.metadata, '$.synthesizedBy') IS NULL"));
for (const edge of ['Plain>s', 'Plain>breakpoint', 'NudeButton>labelFor', 'NudeButton>s', 'Root>pickColor']) {
expect(calls, edge).toContain(edge);
}
// The style function MUI hands the wrapper is the component's code, not a
// function of its own named for the declarator.
expect(db.prepare("SELECT kind FROM nodes WHERE name = 'Root'").all().map((r: any) => r.kind)).toEqual(['component']);
// A file-scope value the template reads, and a memo comparator passed by name.
expect(edgesFrom(db, 'references', "json_extract(e.metadata, '$.valueRef') = 1")).toContain('Plain>BASE_CSS');
expect(edgesFrom(db, 'references', "json_extract(e.metadata, '$.fnRef') = 1")).toEqual(['Memoized>areEqual']);
});

it('records none of the wrapper machinery, and no function-as-value reference to the wrapped component', async () => {
const db = await index();
const fromComponents = db
.prepare(
`SELECT r.reference_name name FROM unresolved_refs r JOIN nodes n ON n.id = r.from_node_id
WHERE n.kind = 'component'`
)
.all()
.map((r: any) => r.name as string);
for (const machinery of ['styled', 'styled.div', 'styled().attrs', 'memo', 'React.memo']) {
expect(fromComponents, machinery).not.toContain(machinery);
}
expect(fromComponents.filter((n) => n.startsWith('styled('))).toEqual([]);
// The wrapped component is rendered, not passed as a value: no fn-ref from the
// component or from the file, resolved or not.
const fnRefs = db
.prepare(
`SELECT r.reference_name name FROM unresolved_refs r WHERE r.reference_kind = 'function_ref'
UNION ALL
SELECT t.name FROM edges e JOIN nodes t ON t.id = e.target WHERE json_extract(e.metadata, '$.fnRef') = 1`
)
.all()
.map((r: any) => r.name as string);
for (const wrapped of ['ActionButton', 'Box', 'Link']) expect(fnRefs, wrapped).not.toContain(wrapped);
});

it('renders the component it wraps, bound as a tag of that name in its file', async () => {
const db = await index();
const rows = db
.prepare(
`SELECT s.name src, t.name dst, t.file_path file, e.metadata meta FROM edges e
JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata, '$.wrapped') = 1 ORDER BY s.name, t.name`
)
.all();
expect(rows.map((r: any) => `${r.src}>${r.dst}@${r.file}`)).toEqual([
'Card>Box@ui.tsx',
'Memoized>ActionButton@ActionButton.tsx',
'NudeButton>ActionButton@ActionButton.tsx',
'Panel>ActionButton@ActionButton.tsx',
'Root>ActionButton@ActionButton.tsx',
]);
for (const r of rows) {
expect(JSON.parse(r.meta)).toMatchObject({ synthesizedBy: 'jsx-render', via: r.dst, wrapped: true });
}
// react-router's Link renders no project namesake, nor does a name the file
// imports from a module the index can't follow, or destructures itself.
for (const wrapper of ['StyledLink', 'BigIcon', 'StyledText']) {
expect(edgesFrom(db, 'calls').filter((e) => e.startsWith(`${wrapper}>`)), wrapper).toEqual([]);
}
});

it('lets explore follow a render flow through the wrapper to the component it wraps', async () => {
await index();
const res = await new ToolHandler(cg).execute('codegraph_explore', { query: 'Page NudeButton ActionButton' });
const text: string = res.content?.[0]?.text ?? '';
const flow = text.slice(text.indexOf('**Flow'));
expect(text).toContain('**Flow');
const steps = [...flow.matchAll(/^(\d+)\. (\w+) \(/gm)].map((m) => `${m[1]}. ${m[2]}`);
expect(steps.slice(0, 3)).toEqual(['1. Page', '2. NudeButton', '3. ActionButton']);
});

it('draws the render link when a sync turns a wrapper in a file with no markup onto a component', async () => {
const db = await index();
const wrapped = () =>
db
.prepare(
`SELECT s.name src, t.name dst FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata, '$.wrapped') = 1 AND s.name = 'Bare'`
)
.all()
.map((r: any) => `${r.src}>${r.dst}`);
expect(wrapped()).toEqual([]);
// No markup, no synthesized edge and the same component names before and
// after: only the wrapper itself says the JSX edges need redrawing.
fs.writeFileSync(path.join(dir, 'bare.ts'), FILES['bare.ts']!.replace('styled.div`', 'styled(ActionButton)`'));
await cg.sync();
expect(wrapped()).toEqual(['Bare>ActionButton']);
});
});
Loading