mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
fix(js,ts): import { default as X } is the module's default import (#2412)
bulletproof-react's router imports its app shell as
`import { default as AppRoot, ErrorBoundary as AppRootErrorBoundary }
from './routes/app/root'`. The import mapping recorded AppRoot as a
named import of an export called `default`, which no module declares,
so resolveViaImport found nothing for it and every reference fell
through to name matching. The route /app bound to AppRoot by
exact-match, and in a project where another file declares an AppRoot
(or a default export is imported under a name another file uses) the
call, route or JSX attribute bound to that file's symbol instead.
The mapping now records `{ default as X }` as the default import, the
same as `import X from`, so every reader of isDefault agrees with it:
the import resolver finds the module's default export, the binding's
own `imports` reference links the module file as a default import's
does, and the JSX-child, Expo native-module, HTTP-client and styled
Link lookups read it as the default import. Re-exports
(`export { default as X } from`) were already chased as the default.
`require('./x').default` is unchanged: `.default` there is a property
of module.exports, which a CommonJS module can set by name.
alan2207/bulletproof-react: 4 edges re-resolved at the same sites (the
route /app reaches AppRoot by import; three binding imports link their
module file). bitwarden/clients: 2 failed Storybook imports now link
their module. bradtraversy/proshop_mern, leerob/next-saas-starter and
t3-oss/create-t3-turbo: byte-identical. Dead-code reports unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into `<Route {...rest}>`, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `<Link to="/counter">` links led nowhere. Each route links to the page its `element` renders, past a guard like `<ProtectedRoute>` or `<AdminWrapper>`, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading.
|
||||
- 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.
|
||||
|
||||
## [1.6.2] - 2026-10-03
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* `import { default as X } from './x'` is the module's default import, spelled
|
||||
* as a named one. The import mapping recorded it as a named import of an
|
||||
* export called `default`, which no module declares, so a call, a route or a
|
||||
* JSX attribute naming `X` never resolved through the import. It fell through
|
||||
* to matching the name alone, which can bind another file's `X`, and binds
|
||||
* nothing (or a stranger) when the default export has a name of its own.
|
||||
* bulletproof-react's router imports its app shell this way:
|
||||
* `import { default as AppRoot, ErrorBoundary as AppRootErrorBoundary } from
|
||||
* './routes/app/root'`.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { extractImportMappings } from '../src/resolution/import-resolver';
|
||||
import type { Edge } from '../src/types';
|
||||
|
||||
const mappings = (source: string, language: Parameters<typeof extractImportMappings>[2] = 'tsx') =>
|
||||
extractImportMappings('src/a.tsx', source, language).map(
|
||||
(m) => `${m.localName}<${m.exportedName}${m.isDefault ? ' default' : ''}${m.isNamespace ? ' ns' : ''}`
|
||||
);
|
||||
|
||||
describe('`{ default as X }` maps to the default import', () => {
|
||||
it('beside a named import, across lines, with a trailing comma', () => {
|
||||
expect(mappings(`import {\n default as AppRoot,\n ErrorBoundary as AppRootErrorBoundary,\n} from './routes/app/root';\n`)).toEqual([
|
||||
'AppRoot<default default',
|
||||
'AppRootErrorBoundary<ErrorBoundary',
|
||||
]);
|
||||
});
|
||||
|
||||
it('alone, type-only, and next to a default binding', () => {
|
||||
expect(mappings(`import { default as Settings } from './settings';\n`)).toEqual(['Settings<default default']);
|
||||
expect(mappings(`import type { default as Config } from './config';\n`)).toEqual(['Config<default default']);
|
||||
expect(mappings(`import { type default as Config } from './config';\n`)).toEqual(['Config<default default']);
|
||||
expect(mappings(`import Main, { default as Again, helper } from './main';\n`)).toEqual([
|
||||
'Main<default default',
|
||||
'Again<default default',
|
||||
'helper<helper',
|
||||
]);
|
||||
});
|
||||
|
||||
it('only for the export named exactly `default`', () => {
|
||||
expect(mappings(`import { defaults as d, defaultTheme as theme } from './theme';\n`)).toEqual([
|
||||
'd<defaults',
|
||||
'theme<defaultTheme',
|
||||
]);
|
||||
});
|
||||
|
||||
it('in a Svelte or Vue script block too', () => {
|
||||
const sfc = `<script>\n import { default as Card } from './Card.svelte';\n</script>\n\n<Card />\n`;
|
||||
expect(mappings(sfc, 'svelte')).toEqual(['Card<default default']);
|
||||
expect(mappings(sfc, 'vue')).toEqual(['Card<default default']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a symbol imported as `{ default as X }`', () => {
|
||||
let root = '';
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-default-as-'));
|
||||
const files: Record<string, string> = {
|
||||
'package.json': JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router': '^7' } }),
|
||||
// bulletproof-react's shape: the default export is a const declared
|
||||
// above it, beside a named ErrorBoundary.
|
||||
'src/app/routes/app/root.tsx': `import { Outlet } from 'react-router';
|
||||
|
||||
export const ErrorBoundary = () => {
|
||||
return <div>Something went wrong!</div>;
|
||||
};
|
||||
|
||||
const AppRoot = () => {
|
||||
return (
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRoot;
|
||||
`,
|
||||
// A default export imported under a name that is not its own.
|
||||
'src/app/routes/app/settings.tsx': `export default function SettingsRoute() {
|
||||
return <section>settings</section>;
|
||||
}
|
||||
`,
|
||||
'src/components/card.tsx': `import { memo } from 'react';
|
||||
|
||||
const Card = memo(() => <div className="card" />);
|
||||
|
||||
export default Card;
|
||||
`,
|
||||
'src/components/layout.tsx': `export function Layout({ sidebar, children }: { sidebar: unknown; children: unknown }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
`,
|
||||
// Other symbols with the importing file's local names. Matching by name
|
||||
// alone can only choose between these and the real targets.
|
||||
'src/app/legacy.tsx': `export function AppRoot() {
|
||||
return <div>legacy shell</div>;
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
return <div>legacy settings</div>;
|
||||
}
|
||||
`,
|
||||
'src/app/router.tsx': `import { createBrowserRouter } from 'react-router';
|
||||
|
||||
import {
|
||||
default as AppRoot,
|
||||
ErrorBoundary as AppRootErrorBoundary,
|
||||
} from './routes/app/root';
|
||||
import { default as Settings } from './routes/app/settings';
|
||||
|
||||
export const createAppRouter = () =>
|
||||
createBrowserRouter([
|
||||
{
|
||||
path: '/app',
|
||||
element: <AppRoot />,
|
||||
ErrorBoundary: AppRootErrorBoundary,
|
||||
children: [{ path: 'settings', Component: Settings }],
|
||||
},
|
||||
]);
|
||||
|
||||
export function renderShell() {
|
||||
return AppRoot();
|
||||
}
|
||||
`,
|
||||
'src/app/AppRoutes.tsx': `import { Routes, Route } from 'react-router';
|
||||
|
||||
import { Layout } from '../components/layout';
|
||||
import { default as Settings } from './routes/app/settings';
|
||||
import { default as Tile } from '../components/card';
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/preferences" element={<Settings />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export function Gallery() {
|
||||
return (
|
||||
<Layout sidebar={Settings}>
|
||||
<Tile />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
`,
|
||||
};
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const node = (file: string, name: string) => {
|
||||
const found = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'import');
|
||||
expect(found, `${file}: ${name}`).toBeDefined();
|
||||
return found!;
|
||||
};
|
||||
/** The edges into `target`, as `kind source-name resolvedBy`. */
|
||||
const into = (target: { id: string }, kinds: Edge['kind'][]) =>
|
||||
cg
|
||||
.getIncomingEdges(target.id)
|
||||
.filter((e) => kinds.includes(e.kind))
|
||||
.map((e) => {
|
||||
const meta = (e.metadata ?? {}) as { resolvedBy?: string; synthesizedBy?: string };
|
||||
return `${e.kind} ${cg.getNode(e.source)!.name} ${meta.resolvedBy ?? meta.synthesizedBy}`;
|
||||
})
|
||||
.sort();
|
||||
const route = (name: string) => {
|
||||
const found = cg.getNodesByKind('route').find((r) => r.name === name);
|
||||
expect(found, `route ${name}`).toBeDefined();
|
||||
return found!;
|
||||
};
|
||||
const renders = (name: string) =>
|
||||
cg
|
||||
.getOutgoingEdges(route(name).id)
|
||||
.filter((e) => e.kind === 'references')
|
||||
.map((e) => {
|
||||
const target = cg.getNode(e.target)!;
|
||||
return `${target.filePath}:${target.name} ${(e.metadata as { resolvedBy?: string }).resolvedBy}`;
|
||||
})
|
||||
.sort();
|
||||
|
||||
it('a call reaches the module’s default export through the import', () => {
|
||||
expect(into(node('src/app/routes/app/root.tsx', 'AppRoot'), ['calls'])).toEqual([
|
||||
'calls createAppRouter jsx-render',
|
||||
'calls renderShell import',
|
||||
]);
|
||||
expect(into(node('src/app/legacy.tsx', 'AppRoot'), ['calls', 'references'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('a data router’s `element` and `Component` render the default exports', () => {
|
||||
expect(renders('/app')).toEqual(['src/app/routes/app/root.tsx:AppRoot import']);
|
||||
// A child route renders inside its parent's element, its layout.
|
||||
expect(renders('/app/settings')).toEqual([
|
||||
'src/app/routes/app/root.tsx:AppRoot framework',
|
||||
'src/app/routes/app/settings.tsx:SettingsRoute import',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a JSX route and a JSX attribute name the default export', () => {
|
||||
expect(renders('/preferences')).toEqual(['src/app/routes/app/settings.tsx:SettingsRoute import']);
|
||||
// The router's `Component: Settings` is a function reference as well.
|
||||
expect(into(node('src/app/routes/app/settings.tsx', 'SettingsRoute'), ['references'])).toEqual([
|
||||
'references /app/settings import',
|
||||
'references /preferences import',
|
||||
'references Gallery import',
|
||||
'references createAppRouter import',
|
||||
]);
|
||||
expect(into(node('src/app/legacy.tsx', 'Settings'), ['references'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('a JSX tag renders the default-exported component it was imported as', () => {
|
||||
expect(into(node('src/components/card.tsx', 'Card'), ['calls'])).toEqual(['calls Gallery jsx-render']);
|
||||
});
|
||||
|
||||
it('the named import beside it is unchanged', () => {
|
||||
expect(into(node('src/app/routes/app/root.tsx', 'ErrorBoundary'), ['references'])).toEqual([
|
||||
'references createAppRouter import',
|
||||
]);
|
||||
});
|
||||
|
||||
it('the import binding links the module, as a default import’s does', () => {
|
||||
const file = cg.getNodesInFile('src/app/router.tsx').find((n) => n.kind === 'file')!;
|
||||
const bindings = cg
|
||||
.getOutgoingEdges(file.id)
|
||||
.filter((e) => e.kind === 'imports')
|
||||
.map((e) => {
|
||||
const meta = e.metadata as { refName?: string; resolvedBy?: string };
|
||||
const target = cg.getNode(e.target)!;
|
||||
return `${meta.refName} → ${target.kind} ${target.filePath} ${meta.resolvedBy}`;
|
||||
})
|
||||
.filter((s) => !s.startsWith('.'))
|
||||
.sort();
|
||||
expect(bindings).toEqual([
|
||||
'AppRoot → file src/app/routes/app/root.tsx import',
|
||||
'AppRootErrorBoundary → function src/app/routes/app/root.tsx import',
|
||||
'Settings → file src/app/routes/app/settings.tsx import',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1019,7 +1019,9 @@ function extractJSImports(content: string): ImportMapping[] {
|
||||
localName: aliasMatch[2]!,
|
||||
exportedName: aliasMatch[1]!,
|
||||
source: source!,
|
||||
isDefault: false,
|
||||
// `{ default as X }` is the default import spelled as a named one;
|
||||
// no module declares an export named `default` to find.
|
||||
isDefault: aliasMatch[1] === 'default',
|
||||
isNamespace: false,
|
||||
});
|
||||
} else if (name) {
|
||||
|
||||
Reference in New Issue
Block a user