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 @@ -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

Expand Down
253 changes: 253 additions & 0 deletions __tests__/import-default-as.test.ts
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
4 changes: 3 additions & 1 deletion src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down