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 @@ -113,6 +113,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph sync` and the file watcher now move a React Router route to its page when the router file loads that page lazily into a value of its own, like `const Docs = lazy(() => import('./pages/Docs'))` rendered by `<Route path="/docs" element={<Docs />} />`, and the page's file is added after the router was indexed, comes back after being deleted, or gets or changes its default export in a later edit. Before, the route kept pointing at the `Docs` declaration, or at the page's old component, until the router file itself changed or the project was re-indexed, unless the page's component happened to share the declaration's name. The same now holds for a `lazy: () => import('./pages/Docs')` route whose page switches its default export to another component. Re-index React projects after upgrading to correct routes an earlier version left behind.
- `codegraph sync` and the file watcher now link a component to the JSX that renders it, like `<Team />`, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused.
- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. Re-index JavaScript and TypeScript projects after upgrading.
- 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.

## [1.6.2] - 2026-10-03

Expand Down
231 changes: 231 additions & 0 deletions __tests__/jsx-package-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
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 tag the file imports from a package — antd's `<Button>`, react-router's
* `<Link>`, a Vue SFC's `<Card>` from ant-design-vue — renders that package's
* component, which the index does not hold. Any project symbol that happens
* to share the name is a stranger. The JSX pass took that namesake, and when
* the name was unique it never looked at the file's imports at all. On SigNoz
* that bound about a thousand tags: 431 antd and @signozhq/ui `<Button>`s
* rendered `export const Button = styled(Link)` from a 404 page's styles.
*
* "A package" is what the name matcher already means by it for calls and
* values: a specifier no project file answers, which a package.json on the
* file's way to the root declares. An alias that reads like a package
* (`components/Modal`), an undeclared bare specifier and a workspace package
* of the repository all stay the project's.
*/
describe('JSX and Vue template tags imported from a package', { timeout: 60_000 }, () => {
let dir: string;
let cg: any;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-pkg-'));
});

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 an edge of `synthesizedBy` out of `parent` points at. */
const targets = (parent: string, synthesizedBy: 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') = ?
ORDER BY r`
)
.all(parent, synthesizedBy)
.map((row: any) => row.r);
const renders = (parent: string) => targets(parent, 'jsx-render');

it('renders nothing for a tag imported from a declared package, whether one or several project symbols share its name', async () => {
write('package.json', JSON.stringify({ dependencies: { react: '^18.0.0', antd: '^5.0.0' } }));
write(
'src/components/NotFound/styles.ts',
`import styled from 'styled-components';
import { Link } from 'react-router-dom';

export const Button = styled(Link)\`
color: inherit;
\`;
`
);
write('src/lib/uPlot/Tooltip.tsx', 'export function Tooltip() {\n return <div />;\n}\n');
write('src/lib/charts/Tooltip.tsx', 'export function Tooltip() {\n return <div />;\n}\n');
write(
'src/pages/Settings.tsx',
`import { Button, Tooltip } from 'antd';

export function Settings() {
return (
<Tooltip title="Save">
<Button type="primary">Save</Button>
</Tooltip>
);
}
`
);
await index();
expect(renders('Settings')).toEqual([]);
});

it('renders nothing for a default import from a package subpath, or a package import under another name', async () => {
// mantis/berry: `import Typography from '@mui/material/Typography'` beside
// the theme's own `Typography` override; a router link renamed on import.
write(
'package.json',
JSON.stringify({ dependencies: { react: '^18.0.0', '@mui/material': '^5.0.0', 'react-router-dom': '^6.0.0' } })
);
write(
'src/themes/overrides/Typography.js',
'export default function Typography(theme) {\n return { MuiTypography: { styleOverrides: {} } };\n}\n'
);
write('src/components/RouterLink.tsx', 'export function RouterLink() {\n return <a />;\n}\n');
write(
'src/pages/Login.tsx',
`import Typography from '@mui/material/Typography';
import { Link as RouterLink } from 'react-router-dom';

export function Login() {
return (
<Typography variant="h3">
<RouterLink to="/register">Sign up</RouterLink>
</Typography>
);
}
`
);
await index();
expect(renders('Login')).toEqual([]);
});

it('keeps a tag imported through an alias that reads like a package, or through a bare specifier no package.json declares', async () => {
write('package.json', JSON.stringify({ dependencies: { react: '^18.0.0', antd: '^5.0.0' } }));
write(
'tsconfig.json',
JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { 'components/*': ['src/components/*'] } } })
);
write('src/components/Modal/index.tsx', 'export default function Modal() {\n return <div />;\n}\n');
write('src/layouts/Header.tsx', 'export default function Header() {\n return <header />;\n}\n');
write(
'src/container/Alerts.tsx',
`import Modal from 'components/Modal';

export function Alerts() {
return <Modal />;
}
`
);
write(
'src/container/Confirm.tsx',
`import { Modal } from 'antd';

export function Confirm() {
return <Modal />;
}
`
);
// No alias maps `layouts/*` and no package.json declares `layouts`: the
// resolver can't follow it, which does not make it a package.
write(
'src/App.tsx',
`import Header from 'layouts/Header';

export function App() {
return <Header />;
}
`
);
await index();
expect(renders('Alerts')).toEqual(['Modal src/components/Modal/index.tsx:1']);
expect(renders('Confirm')).toEqual([]);
expect(renders('App')).toEqual(['Header src/layouts/Header.tsx:1']);
});

it('keeps a tag imported from a workspace package of the repository', async () => {
write('package.json', JSON.stringify({ private: true, workspaces: ['packages/*', 'apps/*'] }));
write('packages/ui/package.json', JSON.stringify({ name: '@acme/ui', version: '1.0.0' }));
write('packages/ui/index.tsx', 'export function Button() {\n return <button />;\n}\n');
// The app depends on it by version, as a published package would be.
write(
'apps/web/package.json',
JSON.stringify({ name: 'web', dependencies: { '@acme/ui': '^1.0.0', react: '^18.0.0' } })
);
write(
'apps/web/src/Page.tsx',
`import { Button } from '@acme/ui';

export function Page() {
return <Button />;
}
`
);
await index();
expect(renders('Page')).toEqual(['Button packages/ui/index.tsx:1']);
});

it('binds nothing in a Vue template for a tag or a composable imported from a package', async () => {
write(
'package.json',
JSON.stringify({ dependencies: { vue: '^3.4.0', 'ant-design-vue': '^4.0.0', '@vueuse/core': '^10.0.0' } })
);
write(
'src/components/ui/card/Card.vue',
'<script setup lang="ts">\ndefineProps<{ title?: string }>();\n</script>\n\n<template>\n <div class="card"><slot /></div>\n</template>\n'
);
write(
'src/components/ui/button/Button.vue',
'<script setup lang="ts">\ndefineProps<{ variant?: string }>();\n</script>\n\n<template>\n <button><slot /></button>\n</template>\n'
);
write(
'src/components/AppHeader.vue',
'<script setup lang="ts">\nconst title = "App";\n</script>\n\n<template>\n <header>{{ title }}</header>\n</template>\n'
);
write(
'src/composables/useFullscreen.ts',
'export function useFullscreen() {\n function toggle() {\n return document.fullscreenElement;\n }\n return { toggle };\n}\n'
);
// The handler is what the package's composable returns, not the project's
// composable of the same name.
write(
'src/views/Dashboard.vue',
`<script setup lang="ts">
import { Button, Card } from 'ant-design-vue';
import { useFullscreen } from '@vueuse/core';
import AppHeader from '../components/AppHeader.vue';

const { toggle: toggleFullscreen } = useFullscreen();
</script>

<template>
<AppHeader />
<Card title="Usage">
<Button @click="toggleFullscreen">Full screen</Button>
</Card>
</template>
`
);
await index();
expect(renders('Dashboard')).toEqual(['AppHeader src/components/AppHeader.vue:1']);
expect(targets('Dashboard', 'vue-handler')).toEqual([]);
});
});
40 changes: 31 additions & 9 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1735,6 +1735,19 @@ function importedFrom(ctx: ResolutionContext, file: string, language: Language):
return out;
}

/**
* Does `file` import `name` from a package outside the repository (`import {
* Button } from 'antd'`)? A package means one the file's package.json, or an
* enclosing one, declares, under a specifier no project file answers. That is
* the test the name matcher applies to calls and values. What such a name
* renders is the package's, never a project symbol that shares the name.
*/
function importedFromPackage(ctx: ResolutionContext, name: string, file: string): boolean {
const language = languageForJsxFile(file);
const binding = ctx.getImportMappings(file, language).find((m) => m.localName === name);
return !!binding && ctx.isOutOfRepoImport?.(binding.source, file, language) === true;
}

/**
* The component a JSX tag names, among every node that shares the name.
*
Expand All @@ -1749,8 +1762,11 @@ function importedFrom(ctx: ResolutionContext, file: string, language: Language):
* another sheet.
*
* Same file first — a small component declared beside its use is the commonest
* shape, and the one an import can never disambiguate. Then the file the name
* is imported from. Then the language, which only decides a tie: a `.tsx` tag
* shape, and the one an import can never disambiguate. Then a name the file
* imports from a package: that package's component, which is no node here, so
* nothing. On SigNoz, antd's `<Button>` rendered the one project `Button` there
* was, a styled link in a 404 page's styles. Then the file the name is
* imported from. Then the language, which only decides a tie: a `.tsx` tag
* naming both a TS component and a same-named Swift class means the TS one.
*/
function jsxChild(
Expand All @@ -1760,6 +1776,9 @@ function jsxChild(
importsOf: () => Map<string, string>
): Node | undefined {
const candidates = ctx.getNodesByName(name).filter((n) => JSX_CHILD_KINDS.has(n.kind));
const local = candidates.find((n) => n.filePath === file);
if (local) return local;
if (importedFromPackage(ctx, name, file)) return undefined;
if (candidates.length === 0) {
// A name nothing declares is the file's DEFAULT import of a module's one
// component under another name: segmented-control renders
Expand All @@ -1775,8 +1794,6 @@ function jsxChild(
return components.length === 1 ? components[0] : undefined;
}
if (candidates.length === 1) return candidates[0];
const local = candidates.find((n) => n.filePath === file);
if (local) return local;
const from = importsOf().get(name);
if (from) {
const imported = candidates.find((n) => n.filePath === from);
Expand Down Expand Up @@ -1988,25 +2005,30 @@ async function vueTemplateEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
added++;
};
// Prefer a target in THIS SFC (handlers live in the same file's script) —
// avoids cross-file mis-match when a name repeats across a monorepo.
const resolve = (name: string, kinds: Set<string>): Node | undefined => {
// avoids cross-file mis-match when a name repeats across a monorepo. A name
// the SFC imports from a package (`import { NButton } from 'naive-ui'`) is
// the package's: no other project symbol, nor a Nuxt auto-imported
// component (`fallback`), is what it renders or calls.
const resolve = (name: string, kinds: Set<string>, fallback?: Node): Node | undefined => {
const matches = ctx.getNodesByName(name).filter((n) => kinds.has(n.kind));
return matches.find((n) => n.filePath === file) ?? matches[0];
const local = matches.find((n) => n.filePath === file);
if (local || importedFromPackage(ctx, name, file)) return local;
return matches[0] ?? fallback;
};

let m: RegExpExecArray | null;
VUE_KEBAB_RE.lastIndex = 0;
while ((m = VUE_KEBAB_RE.exec(tpl))) {
const tag = kebabToPascal(m[1]!);
addEdge(resolve(tag, COMPONENT_KINDS) ?? nuxtComponents.get(tag), { synthesizedBy: 'jsx-render', via: m[1] });
addEdge(resolve(tag, COMPONENT_KINDS, nuxtComponents.get(tag)), { synthesizedBy: 'jsx-render', via: m[1] });
}
// PascalCase component tags. Try a direct name match first (flat components
// and explicit registrations), then the Nuxt dir-prefixed auto-import name
// (`<MediaCard>` → components/media/Card.vue). Built-ins match neither → no edge.
VUE_PASCAL_RE.lastIndex = 0;
while ((m = VUE_PASCAL_RE.exec(tpl))) {
const tag = m[1]!;
addEdge(resolve(tag, COMPONENT_KINDS) ?? nuxtComponents.get(tag), { synthesizedBy: 'jsx-render', via: tag });
addEdge(resolve(tag, COMPONENT_KINDS, nuxtComponents.get(tag)), { synthesizedBy: 'jsx-render', via: tag });
}
VUE_HANDLER_RE.lastIndex = 0;
while ((m = VUE_HANDLER_RE.exec(tpl))) {
Expand Down