mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
statedDefaultExport reads a JS/TS file's own export default statement
by scanning comment-stripped source for line-initial `export default`
heads. Strings are deliberately not masked there (a masker that loses
step on nested templates blanked real statements), and a head counted
when it named a node of the file, the first such head winning. A code
generator's template text breaks that: in
export function App() {} export function Main() {}
export const template = `
export default App
`;
export default Main;
the template's line names the real App, so `import X from './gen'`
bound to App instead of Main (issue #2968). A template holding
`export default () => …` also made a module whose real default is an
expression read as anonymous, which keeps every guess away.
templateLiteralTextAt (strip-comments.ts) scans the source once for
template-literal text: it follows `${…}` nesting (a template inside an
interpolation, a brace inside a regex there), and skips comments,
quoted strings and regex literals, which may hold a backtick; a `/`
after a value divides, anywhere else it opens a regex. A head inside
template text is now skipped. When some template never closes the scan
vouches for nothing and every head counts, as before. Nothing else
changes: a head outside template text is read exactly as it was.
Validation: default-export-declaration.test.ts gains the issue's file,
a template returned by a function behind a nested template, one passed
at module scope and one in a class field, a template's anonymous
default above an expression default, and controls (a regex and a string
holding a backtick, a division after a parenthesis, and a file whose
scan can't close a template); strip-comments.test.ts tests the scan
directly. Red with main's resolver: 2 of 10 failed; green: all pass.
Checked against tree-sitter on every line-initial head of 4,191
JS/TS files in 16 corpora (outline, excalidraw, vben, bitwarden apps,
mastodon, shadcn docs, zod, bulletproof-react, express, astro, react,
trezor-suite, this repo's src, tests and ui): of 4,149 real export
default statements the scan puts none inside template text, and all
322 heads it does place there are template text. A/B on excalidraw
d3a9c2a1, vben 61c07936, astro e3bec3d2 (packages/astro/src and
packages/integrations), with gin-gonic/gin 0f09c3a9 and psf/requests
3c7baaa0 as controls, both arms on main 4b0f72b7 through the native
kernel (and again on 66449255 through wasm): all six byte-identical,
since no default import there is decided by template text; index time
unchanged.
Not in this change: hasEsmDefaultExport, which only gates
require('./x').default, still reads the raw text.
Reimplements PR #3042 by @johnhldiver-beep.
Fixes #2968
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
/**
|
|
* 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. `require('./x').default` of an ES
|
|
* module reads the same default, through the same lookup.
|
|
*/
|
|
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()
|
|
`,
|
|
// An abstract class the statement declares, below an exported helper.
|
|
'src/repository.ts': `export function connect(): string {
|
|
return "db"
|
|
}
|
|
|
|
export default abstract class Repository {
|
|
abstract find(id: string): unknown
|
|
|
|
describe(): string {
|
|
return connect()
|
|
}
|
|
}
|
|
`,
|
|
// React Native's AnimatedColor shape, loaded with `require(…).default`
|
|
// (read the way a default import reads it) instead of imported.
|
|
'src/animated/AnimatedColor.ts': `export function getRgbaValueAndNativeColor(value: string): string {
|
|
return value
|
|
}
|
|
|
|
export default class AnimatedColor {
|
|
constructor(value: string) {
|
|
getRgbaValueAndNativeColor(value)
|
|
}
|
|
}
|
|
`,
|
|
'src/animated/load.ts': `export function makeColors(): unknown[] {
|
|
const AnimatedColor = require("./AnimatedColor").default
|
|
const { default: Color } = require("./AnimatedColor")
|
|
return [new AnimatedColor("red"), new Color("blue")]
|
|
}
|
|
`,
|
|
// An `export default` line written in a template literal is text, not the
|
|
// module's statement (#2968). A code generator's template names the file's
|
|
// own `App`, above the real default — the issue's own example first.
|
|
'src/gen/issue.ts': `export function App() { return 1; }
|
|
export function Main() { return 2; }
|
|
export const template = \`
|
|
export default App
|
|
\`;
|
|
export default Main;
|
|
`,
|
|
// Returned by a function, behind a template nested in an interpolation.
|
|
'src/gen/render.ts': `export function App() { return 1 }
|
|
export function Main() { return 2 }
|
|
export function render() {
|
|
return \`intro \${\`nested\`}
|
|
export default App
|
|
\`
|
|
}
|
|
export default Main
|
|
`,
|
|
// Passed at module scope, where no declaration encloses it, and a class field.
|
|
'src/gen/write.ts': `import { writeFileSync } from "fs"
|
|
export function App() { return 1 }
|
|
export function Main() { return 2 }
|
|
writeFileSync("out.ts", \`
|
|
export default App
|
|
\`)
|
|
export class Emitter {
|
|
sample = \`
|
|
export default App
|
|
\`
|
|
}
|
|
export default Main
|
|
`,
|
|
// A template's anonymous default made the expression default below it
|
|
// anonymous too, which keeps every guess away.
|
|
'src/gen/model.ts': `export class Model {
|
|
save() {
|
|
return 1
|
|
}
|
|
}
|
|
export const usage = \`
|
|
export default class extends Model {}
|
|
\`
|
|
export default observable(Model)
|
|
function observable<T>(target: T): T {
|
|
return target
|
|
}
|
|
`,
|
|
// No template text: a regex and a string that hold a backtick, and a
|
|
// division after a parenthesis.
|
|
'src/gen/plain.ts': `export function App() { return 1 }
|
|
export function Main() { return 2 }
|
|
const tick = /[\`]/g
|
|
const half = (App() + Main()) / 2 + "\`"
|
|
export default Main
|
|
`,
|
|
// A regex right after `if (…)` reads as a division, so its backtick opens a
|
|
// literal the scan never closes: then the scan vouches for nothing, and the
|
|
// statement still counts.
|
|
'src/gen/unbalanced.ts': `export function App() { return 1 }
|
|
export function Main() { return 2 }
|
|
export function check(s: string, ok: boolean) {
|
|
if (ok) /\`/.test(s)
|
|
return s
|
|
}
|
|
export default Main
|
|
`,
|
|
'src/gen/use.ts': `import Issue from "./issue"
|
|
import Render from "./render"
|
|
import Write from "./write"
|
|
import Persisted from "./model"
|
|
import Plain from "./plain"
|
|
import Unbalanced from "./unbalanced"
|
|
|
|
export function generate() {
|
|
Issue()
|
|
Render()
|
|
Write()
|
|
new Persisted()
|
|
Plain()
|
|
Unbalanced()
|
|
}
|
|
`,
|
|
'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"
|
|
import Repository from "./repository"
|
|
|
|
export function consume() {
|
|
handler({}, { json() {} })
|
|
new Store().read()
|
|
headingToSlug("Vans")
|
|
stream()
|
|
Title()
|
|
Banner()
|
|
runAnonymous()
|
|
Arrow()
|
|
service.run()
|
|
}
|
|
|
|
export class Users extends Repository {
|
|
find(id: string) {
|
|
return id
|
|
}
|
|
}
|
|
`,
|
|
};
|
|
|
|
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('extends the abstract class the statement declares', () => {
|
|
const users = cg.getNodesInFile('src/use.tsx').find((n) => n.name === 'Users' && n.kind === 'class')!;
|
|
const bases = cg
|
|
.getOutgoingEdges(users.id)
|
|
.filter((e) => e.kind === 'extends')
|
|
.map((e) => `${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`);
|
|
expect(bases).toEqual(['src/repository.ts:Repository']);
|
|
});
|
|
|
|
it('reads `require(…).default` and `{ default: X } = require(…)` the same way', () => {
|
|
const make = cg.getNodesInFile('src/animated/load.ts').find((n) => n.name === 'makeColors')!;
|
|
const created = cg
|
|
.getOutgoingEdges(make.id)
|
|
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
|
|
.map((e) => `${e.kind} ${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`)
|
|
.sort();
|
|
expect(created).toEqual([
|
|
'instantiates src/animated/AnimatedColor.ts:AnimatedColor',
|
|
'instantiates src/animated/AnimatedColor.ts:AnimatedColor',
|
|
]);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('an `export default` line inside a template literal', () => {
|
|
/** `file:name` of every node `generate` calls or instantiates. */
|
|
function generated(): string[] {
|
|
const generate = cg.getNodesInFile('src/gen/use.ts').find((n) => n.name === 'generate')!;
|
|
return cg
|
|
.getOutgoingEdges(generate.id)
|
|
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
|
|
.map((e) => cg.getNode(e.target)!)
|
|
.map((n) => `${n.filePath}:${n.name}`)
|
|
.sort();
|
|
}
|
|
|
|
it('is text, so the real statement below it names the default', () => {
|
|
const targets = generated();
|
|
for (const file of ['issue', 'render', 'write']) {
|
|
expect(targets).toContain(`src/gen/${file}.ts:Main`);
|
|
expect(targets).not.toContain(`src/gen/${file}.ts:App`);
|
|
}
|
|
});
|
|
|
|
it('does not make an expression default anonymous', () => {
|
|
expect(generated()).toContain('src/gen/model.ts:Model');
|
|
});
|
|
|
|
it('leaves regexes, strings and division alone, and a scan it cannot finish', () => {
|
|
const targets = generated();
|
|
expect(targets).toContain('src/gen/plain.ts:Main');
|
|
expect(targets).toContain('src/gen/unbalanced.ts:Main');
|
|
expect(targets).not.toContain('src/gen/plain.ts:App');
|
|
expect(targets).not.toContain('src/gen/unbalanced.ts:App');
|
|
});
|
|
});
|