From 43d5c1855df95ea1ae90cbfdfb77d788c32ebad9 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 6 Oct 2026 22:15:11 -0500 Subject: [PATCH] fix(js,ts): `import { default as X }` is the module's default import 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 --- CHANGELOG.md | 1 + __tests__/import-default-as.test.ts | 253 ++++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 4 +- 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 __tests__/import-default-as.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1144fe4..a4db6ea1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - 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 ``, 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 `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, 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 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 diff --git a/__tests__/import-default-as.test.ts b/__tests__/import-default-as.test.ts new file mode 100644 index 000000000..a842d0e19 --- /dev/null +++ b/__tests__/import-default-as.test.ts @@ -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[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 { + expect(mappings(`import { default as Settings } from './settings';\n`)).toEqual(['Settings { + expect(mappings(`import { defaults as d, defaultTheme as theme } from './theme';\n`)).toEqual([ + 'd { + const sfc = `\n\n\n`; + expect(mappings(sfc, 'svelte')).toEqual(['Card { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-default-as-')); + const files: Record = { + '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
Something went wrong!
; +}; + +const AppRoot = () => { + return ( +
+ +
+ ); +}; + +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
settings
; +} +`, + 'src/components/card.tsx': `import { memo } from 'react'; + +const Card = memo(() =>
); + +export default Card; +`, + 'src/components/layout.tsx': `export function Layout({ sidebar, children }: { sidebar: unknown; children: unknown }) { + return
{children}
; +} +`, + // 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
legacy shell
; +} + +export function Settings() { + return
legacy settings
; +} +`, + '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: , + 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 ( + + } /> + + ); +} + +export function Gallery() { + return ( + + + + ); +} +`, + }; + 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', + ]); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index bf5dab41b..9ba5ea4ac 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -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) {