diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58f4f3bf7..1d7845d54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 ` } />`, 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 ` `, 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 `` from antd, `` from `@mui/material/Typography`, ` ` from react-router-dom or `` 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 `` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `` and `` 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
diff --git a/__tests__/jsx-package-imports.test.ts b/__tests__/jsx-package-imports.test.ts
new file mode 100644
index 000000000..5c358af9a
--- /dev/null
+++ b/__tests__/jsx-package-imports.test.ts
@@ -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 ``, react-router's
+ * ` `, a Vue SFC's `` 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 ``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 {
+ 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
;\n}\n');
+ write('src/lib/charts/Tooltip.tsx', 'export function Tooltip() {\n return
;\n}\n');
+ write(
+ 'src/pages/Settings.tsx',
+ `import { Button, Tooltip } from 'antd';
+
+export function Settings() {
+ return (
+
+ Save
+
+ );
+}
+`
+ );
+ 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 ;\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 (
+
+ Sign up
+
+ );
+}
+`
+ );
+ 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
;\n}\n');
+ write('src/layouts/Header.tsx', 'export default function Header() {\n return ;\n}\n');
+ write(
+ 'src/container/Alerts.tsx',
+ `import Modal from 'components/Modal';
+
+export function Alerts() {
+ return ;
+}
+`
+ );
+ write(
+ 'src/container/Confirm.tsx',
+ `import { Modal } from 'antd';
+
+export function Confirm() {
+ return ;
+}
+`
+ );
+ // 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 ;
+}
+`
+ );
+ 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 ;\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 ;
+}
+`
+ );
+ 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',
+ '\n\n\n
\n \n'
+ );
+ write(
+ 'src/components/ui/button/Button.vue',
+ '\n\n\n \n \n'
+ );
+ write(
+ 'src/components/AppHeader.vue',
+ '\n\n\n \n \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',
+ `
+
+
+
+
+ Full screen
+
+
+`
+ );
+ await index();
+ expect(renders('Dashboard')).toEqual(['AppHeader src/components/AppHeader.vue:1']);
+ expect(targets('Dashboard', 'vue-handler')).toEqual([]);
+ });
+});
diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts
index 237c7007e..33cc99e1c 100644
--- a/src/resolution/callback-synthesizer.ts
+++ b/src/resolution/callback-synthesizer.ts
@@ -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.
*
@@ -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 `` 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(
@@ -1760,6 +1776,9 @@ function jsxChild(
importsOf: () => Map
): 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
@@ -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);
@@ -1988,17 +2005,22 @@ 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): 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, 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
@@ -2006,7 +2028,7 @@ async function vueTemplateEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
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))) {