Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named.
- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of `<anonymous>`. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading.
- In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div<WrapperProps>` followed by its CSS, or `styled(Base)<Props>`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `<Text>` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading.
- In React, a component written as a JSX tag now links to the component the file imports under that name, followed through barrel files, renamed imports and default imports, instead of to whichever component shares the name. Before, the name decided and the import only broke ties: in a monorepo whose apps each keep their own `Button` behind an `@/components/ui/button` barrel, a page rendered another app's copy; a default import named like an unrelated component, or one whose module exports an unnamed `export default () => …`, rendered that unrelated component; and a function component imported under another name, like `import Settings from './settings'` for `export default function SettingsRoute()`, rendered nothing. A tag that names a type or a styled value now renders nothing rather than a namesake elsewhere, and one imported from a package keeps its old behavior. Re-index React projects after upgrading.
- In JavaScript and TypeScript, a default import now links to what the module's `export default` actually exports: `export default function Vans()`, `export default Page`, `export { Card as default }`, the function a wrapper hands on, like `export default observer(Card)` or `export default traceFunction({ … })(accountProvisioner)`, an instance's class for `export default new Storage()`, and through an `index` file that re-exports another module's default. Before, the module's first exported function or component stood in for its default export, so with `export function loader()` above `export default function Vans()`, every `import Vans from './Vans'` was the loader, and a call, a decorator, a route or `Storage.get()` through a default import could land on the wrong function or on a same-named class in another part of the app. A default export with no name of its own, like `export default () => …`, now links to nothing rather than to a guess. Re-index JavaScript and TypeScript projects after upgrading.

## [1.6.2] - 2026-10-03

Expand Down
274 changes: 274 additions & 0 deletions __tests__/default-export-declaration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
/**
* A default import is the declaration the module's `export default` statement
* names, wherever it sits in the file — not the first exported function.
*
* A React Router 6.4+ data-router page exports its loader or action above the
* page component (`export function loader() {…}` then `export default function
* Vans() {…}`), and `import Vans, { loader as vansLoader } from './Vans'` bound
* `Vans` to `loader`: the route `/vans` rendered the loader, and every call
* through the default import went there too. A component file that exports a
* styled or memo component lost its default function or binding to that
* component, and an anonymous default (`export default function () {…}`,
* `export default () => …`) took whatever exported function came first — even
* one nested inside the default itself.
*/
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';

let root = '';
let cg: CodeGraph;

const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'vanlife', private: true, dependencies: { react: '^18', 'react-router-dom': '^6.4' } }),
'src/index.jsx': `import { RouterProvider, createBrowserRouter, createRoutesFromElements, Route } from "react-router-dom"
import Vans, { loader as vansLoader } from "./pages/Vans/Vans"
import HostVans, { loader as hostVansLoader } from "./pages/Host/HostVans"
import Login, { action as loginAction } from "./pages/Login"

const router = createBrowserRouter(createRoutesFromElements(
<Route path="/">
<Route path="login" element={<Login />} action={loginAction} />
<Route path="vans" element={<Vans />} loader={vansLoader} />
<Route path="host/vans" element={<HostVans />} loader={hostVansLoader} />
</Route>
))

export default function App() {
return <RouterProvider router={router} />
}
`,
'src/api.js': `export function getVans() {
return fetch("/api/vans")
}
`,
'src/pages/Vans/Vans.jsx': `import { useLoaderData, defer } from "react-router-dom"
import { getVans } from "../../api"

export function loader() {
return defer({ vans: getVans() })
}

export default function Vans() {
const dataPromise = useLoaderData()
function renderVanElements(vans) {
return vans.map((van) => <h3 key={van.id}>{van.name}</h3>)
}
return <div>{renderVanElements(dataPromise.vans)}</div>
}
`,
'src/pages/Host/HostVans.jsx': `import { useLoaderData } from "react-router-dom"
import { getVans } from "../../api"

export const loader = async () => getVans()

export default function HostVans() {
return <ul>{useLoaderData().length}</ul>
}
`,
'src/pages/Login.jsx': `import { useActionData } from "react-router-dom"

export async function action({ request }) {
return request.formData()
}

export default function Login() {
const data = useActionData()
return <form>{data}</form>
}
`,
// A Next.js API route: a helper exported above the default handler.
'src/api/vans.ts': `export function listVans() {
return []
}

export default async function handler(req: unknown, res: { json(v: unknown): void }) {
res.json(listVans())
}
`,
'src/store.ts': `export function createStore() {
return {}
}

export default class Store {
read() {
return 1
}
}
`,
// A regex with a backtick inside a template literal's interpolation
// (outline's headingToSlug): a string masker loses step there and blanked
// the statement below it.
'src/slug.ts': `export function escapeHtml(text: string) {
return text
}

function safeSlugify(text: string) {
return \`h-\${escapeHtml(text).replace(/[\`]/g, "")}\`
}

export default function headingToSlug(text: string, index = 0) {
return index === 0 ? safeSlugify(text) : \`\${safeSlugify(text)}-\${index}\`
}
`,
// A line break between the statement and the declaration it writes.
'src/stream.js': `export function emit() {
return 1
}

export default
function* stream() {
yield emit()
}
`,
// An exported styled component above the default function, or the binding.
'src/components/Title.tsx': `import styled from "styled-components"

export const Heading = styled.h1\`
font-size: 2rem;
\`

export default function Title() {
return <Heading>Vans</Heading>
}
`,
'src/components/Banner.tsx': `import styled from "styled-components"

export const Strip = styled.div\`
display: flex;
\`

const Banner = () => <Strip />

export default Banner
`,
// Anonymous defaults name no declaration of their own.
'src/anonymous.js': `export function helper() {
return 1
}

export default function () {
function inner() {
return helper()
}
return inner()
}
`,
'src/arrow.jsx': `export function useThing() {
return 1
}

export default () => {
const handle = () => useThing()
return <button onClick={handle} />
}
`,
// An expression default keeps the class it instantiates.
'src/service.ts': `export class Service {
run() {
return 1
}
}

export default new Service()
`,
'src/use.tsx': `import handler from "./api/vans"
import Store from "./store"
import headingToSlug from "./slug"
import stream from "./stream"
import Title from "./components/Title"
import Banner from "./components/Banner"
import runAnonymous from "./anonymous"
import Arrow from "./arrow"
import service from "./service"

export function consume() {
handler({}, { json() {} })
new Store().read()
headingToSlug("Vans")
stream()
Title()
Banner()
runAnonymous()
Arrow()
service.run()
}
`,
};

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-default-export-'));
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 });
});

/** `file:name` of every node `consume` calls or instantiates. */
function consumed(): string[] {
const consume = cg.getNodesInFile('src/use.tsx').find((n) => n.name === 'consume')!;
return cg
.getOutgoingEdges(consume.id)
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
.map((e) => cg.getNode(e.target)!)
.map((n) => `${n.filePath}:${n.name}`)
.sort();
}

describe('a default import of a module that exports something above its default', () => {
it('binds each data-router route to the page component, not its loader or action', () => {
const bindings = cg
.getNodesByKind('route')
.flatMap((r) =>
cg
.getOutgoingEdges(r.id)
.filter((e) => e.kind === 'references')
.map((e) => `${r.name} -> ${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`)
)
.sort();
expect(bindings).toEqual([
'/host/vans -> src/pages/Host/HostVans.jsx:HostVans',
'/login -> src/pages/Login.jsx:Login',
'/vans -> src/pages/Vans/Vans.jsx:Vans',
]);
});

it('calls the default-exported function or class the statement declares', () => {
const targets = consumed();
expect(targets).toContain('src/api/vans.ts:handler');
expect(targets).toContain('src/store.ts:Store');
expect(targets).toContain('src/slug.ts:headingToSlug');
expect(targets).toContain('src/stream.js:stream');
expect(targets).not.toContain('src/api/vans.ts:listVans');
expect(targets).not.toContain('src/store.ts:createStore');
expect(targets).not.toContain('src/slug.ts:escapeHtml');
expect(targets).not.toContain('src/stream.js:emit');
});

it('prefers what the statement exports to an exported component above it', () => {
const targets = consumed();
expect(targets).toContain('src/components/Title.tsx:Title');
expect(targets).toContain('src/components/Banner.tsx:Banner');
expect(targets).not.toContain('src/components/Title.tsx:Heading');
expect(targets).not.toContain('src/components/Banner.tsx:Strip');
});

it('binds an anonymous default to nothing, not to an exported function beside or inside it', () => {
const targets = consumed();
for (const wrong of ['src/anonymous.js:helper', 'src/anonymous.js:inner', 'src/arrow.jsx:useThing', 'src/arrow.jsx:handle']) {
expect(targets).not.toContain(wrong);
}
});

it('still finds the class behind an expression default', () => {
expect(consumed()).toContain('src/service.ts:run');
});
});
Loading