From 504248ae73db1f9a0371f3f72f85270c364f9c0d Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 09:07:09 -0500 Subject: [PATCH 1/4] fix(react): a JSX tag renders what the file imports under that name A JSX tag was matched by name, with the file's import consulted only to break a tie, so a monorepo's apps rendered each other's same-named components (bulletproof-react: 179 of 559 jsx-render edges crossed apps through `@/components/ui/*` barrels), a default import named like an unrelated symbol bound that symbol, and a function component imported under another name rendered nothing. The tag now resolves through the import first (same-file declarations still win, and a name the file does not import from the project keeps the old name-based rules). A value the import names hands on what it wraps or aliases, a type renders nothing, and a component reached under another name renders only where the name is written as a tag, not as a type argument. Following the import needed a precise default export: findExportedSymbol guessed the module's first exported component or function, so `export function loader` above `export default function Vans` made every `import Vans` the loader. The default branch now reads the module's own `export default` statement for JS-family files: a declaration, a name, `export { X as default }`, what a wrapper call wraps (`observer(Card)`, `connect(m)(Bar)`, `traceFunction(...)(fn)`), an instance's class (`new Storage()`), a binding the module imports, or nothing for an anonymous expression. A named lookup also follows an export clause that forwards an import. Includes #2412's `{ default as X }` mapping (same hunk). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + __tests__/jsx-child-imports.test.ts | 376 +++++++++++++++++++++++++ src/resolution/callback-synthesizer.ts | 92 +++++- src/resolution/import-resolver.ts | 371 +++++++++++++++++++++--- 4 files changed, 789 insertions(+), 52 deletions(-) create mode 100644 __tests__/jsx-child-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1144fe4..d1bdf7d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,8 @@ 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 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 diff --git a/__tests__/jsx-child-imports.test.ts b/__tests__/jsx-child-imports.test.ts new file mode 100644 index 000000000..0aa082288 --- /dev/null +++ b/__tests__/jsx-child-imports.test.ts @@ -0,0 +1,376 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +/** + * A JSX tag the file imports renders what that import resolves to — through + * barrels, renames and the module's default export — not whichever component + * happens to share the tag's name. The synthesizer used to pick by name and + * consult the import only to break a tie, so a monorepo whose apps each import + * `Button` from their own `@/components/ui/button` barrel rendered another + * app's copy (the barrel declares no `Button` to break the tie with), a + * default import named like an unrelated symbol bound that symbol, and a + * function component default-imported under another name rendered nothing. + * + * Following the import is only as good as the default-export lookup behind + * it, which guessed — the first exported function or component of the module + * stood in for its default export — so these also pin that it reads the + * module's `export default` statement instead. + */ +describe('JSX tags follow the file’s import of them', () => { + let dir: string; + let cg: any; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-import-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0","mobx-react":"^9.0.0"}}'); + }); + + afterEach(() => { + cg?.close?.(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + async function index() { + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + return (cg as any).db.db; + } + + /** `file:name` of every node a jsx-render edge out of `parent` points at. */ + const rendersFrom = (db: any, parent: string): string[] => + db + .prepare( + `SELECT t.file_path || ':' || t.name AS r FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render' + ORDER BY r` + ) + .all(parent) + .map((r: any) => r.r); + + it('renders its own app’s component through a barrel when every app has one of that name', async () => { + // Each app's `@/` is its own `src/`; the barrel only re-exports, so it + // declares no `Button` for a name lookup to find. The decoy app sorts first. + for (const app of ['a-admin', 'b-shop']) { + write(`apps/${app}/tsconfig.json`, JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } })); + write(`apps/${app}/src/components/ui/button/index.ts`, `export * from './button';\n`); + write( + `apps/${app}/src/components/ui/button/button.tsx`, + `import * as React from 'react'; +const Button = React.forwardRef((props, ref) => ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'Checkout')).toEqual(['apps/b-shop/src/components/ui/button/button.tsx:Button']); + }); + + it('renders a module’s default export under any local name, never a same-named symbol elsewhere', async () => { + write('a-decoy/settings.tsx', `export function Settings() { return
decoy
; }\n`); + write('routes/app/settings.tsx', `export default function SettingsRoute() { return
settings
; }\n`); + write('routes/app/profile.tsx', `export function ProfileRoute() { return
profile
; }\n`); + write( + 'router.tsx', + `import { default as Settings } from './routes/app/settings'; +export function AliasedRouter() { return ; } +` + ); + write( + 'router-default.tsx', + `import Settings from './routes/app/settings'; +export function DefaultRouter() { return ; } +` + ); + write( + 'router-renamed.tsx', + `import { ProfileRoute as Settings } from './routes/app/profile'; +export function RenamedRouter() { return ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'AliasedRouter')).toEqual(['routes/app/settings.tsx:SettingsRoute']); + expect(rendersFrom(db, 'DefaultRouter')).toEqual(['routes/app/settings.tsx:SettingsRoute']); + expect(rendersFrom(db, 'RenamedRouter')).toEqual(['routes/app/profile.tsx:ProfileRoute']); + }); + + it('renders a function component default-imported under another name', async () => { + write('pages/settings.tsx', `export default function SettingsPage() { return
; }\n`); + write( + 'app.tsx', + `import Settings from './pages/settings'; +export function App() { return ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'App')).toEqual(['pages/settings.tsx:SettingsPage']); + }); + + it('reads the default export from its statement, past the functions exported above it', async () => { + // A data router's route module: `loader` and `action` come first, and the + // first exported function used to stand in for the default export. + write( + 'pages/vans.tsx', + `export async function loader() { return []; } +export function action() { return null; } +export default function Vans() { return
    ; } +` + ); + write( + 'pages/card.tsx', + `import { observer } from 'mobx-react'; +export function formatPrice(n: number) { return '$' + n; } +function Card() { return
    ; } +export default observer(Card); +` + ); + write( + 'app.tsx', + `import VansPage from './pages/vans'; +import PriceCard from './pages/card'; +export function App() { return
    ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'App')).toEqual(['pages/card.tsx:Card', 'pages/vans.tsx:Vans']); + }); + + it('renders nothing for a default export with no declaration of its own, not a same-named symbol elsewhere', async () => { + write('app/header.tsx', `export function Header() { return ; }\n`); + write('emails/components/header.tsx', `export default () =>
    email header
    ;\n`); + write( + 'emails/welcome.tsx', + `import Header from './components/header'; +export function Welcome() { return
    ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'Welcome')).toEqual([]); + }); + + it('renders the component an anonymous default wraps when its own module declares it', async () => { + // A React Router v6 shim around a class-era page: the default export renders `Search`. + write('a-decoy/search.tsx', `export function Search() { return
    decoy
    ; }\n`); + write( + 'pages/search.tsx', + `export function Search(props: any) { return
    search
    ; } +export default (props: any) => ; +` + ); + write( + 'routes.tsx', + `import Search from './pages/search'; +export function Routes() { return ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'Routes')).toEqual(['pages/search.tsx:Search']); + }); + + it('renders the value a module declares under the name of its props type', async () => { + write( + 'components/collapse-button.tsx', + `export interface CollapseButton { label: string } +export const CollapseButton = ({ label }: CollapseButton) => ; +` + ); + write( + 'sidebar.tsx', + `import { CollapseButton } from './components/collapse-button'; +export function Sidebar() { return ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'Sidebar')).toEqual(['components/collapse-button.tsx:CollapseButton']); + }); + + it('renders a native component a spec module exports, directly or through a value', async () => { + fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0","react-native":"^0.73"}}'); + write( + 'spec/MyViewNativeComponent.ts', + `import { codegenNativeComponent } from 'react-native'; +export interface NativeProps { color?: string } +export default codegenNativeComponent('MyView'); +` + ); + write( + 'spec/OtherViewNativeComponent.ts', + `import { codegenNativeComponent } from 'react-native'; +export interface NativeProps { size?: number } +const OtherViewNativeComponent = codegenNativeComponent('OtherView'); +export default OtherViewNativeComponent; +` + ); + write( + 'src/App.tsx', + `import NativeView from '../spec/MyViewNativeComponent'; +import OtherView from '../spec/OtherViewNativeComponent'; +export function App() { return ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'App')).toEqual([ + 'spec/MyViewNativeComponent.ts:MyView', + 'spec/OtherViewNativeComponent.ts:OtherView', + ]); + }); + + it('renders a class imported under another name only where the name is written as a tag', async () => { + write( + 'editor/index.tsx', + `import * as React from 'react'; +export default class Editor extends React.Component { render() { return
    ; } } +` + ); + write('models/import.ts', `export default class Import { id = ''; }\n`); + write( + 'components/editor-host.tsx', + `import * as React from 'react'; +import SharedEditor from '../editor'; +import ImportModel from '../models/import'; +export function EditorHost() { + const ref = React.useRef(null); + const [current] = React.useState(null); + return
    {current ? 'busy' : null}
    ; +} +export function EditorFrame() { return ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'EditorHost')).toEqual([]); + expect(rendersFrom(db, 'EditorFrame')).toEqual(['editor/index.tsx:Editor']); + }); + + it('follows an index that forwards a default export, and a value that aliases a component', async () => { + write('a-decoy/avatar.tsx', `export function Avatar() { return decoy; }\n`); + write('a-decoy/marker.tsx', `export function Marker() { return decoy; }\n`); + write('components/Avatar/Avatar.tsx', `export default function Avatar() { return real; }\n`); + write('components/Avatar/index.ts', `import Avatar from './Avatar';\nexport default Avatar;\n`); + write('components/Map/marker.tsx', `export default function Marker() { return ; }\n`); + write('components/Map/index.ts', `import Marker from './marker';\nexport { Marker };\n`); + write('components/Badge/BadgeWithTooltip.tsx', `export default function BadgeWithTooltip() { return ; }\n`); + write( + 'components/Badge/index.ts', + `import BadgeWithTooltip from './BadgeWithTooltip'; +const Badge = BadgeWithTooltip; +export { Badge }; +` + ); + write( + 'profile.tsx', + `import Avatar from './components/Avatar'; +import { Badge } from './components/Badge'; +import { Marker } from './components/Map'; +export function Profile() { return
    ; } +` + ); + const db = await index(); + expect(rendersFrom(db, 'Profile')).toEqual([ + 'components/Avatar/Avatar.tsx:Avatar', + 'components/Badge/BadgeWithTooltip.tsx:BadgeWithTooltip', + 'components/Map/marker.tsx:Marker', + ]); + }); +}); + +describe('a call through a default import', () => { + let dir: string; + let cg: any; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'default-call-')); + }); + + afterEach(() => { + cg?.close?.(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('reaches the function the module exports as its default, not the first one it exports', async () => { + fs.writeFileSync( + path.join(dir, 'app.ts'), + `export function helper() { return 1; } +export default function createApp() { return helper() + 1; } +` + ); + fs.writeFileSync( + path.join(dir, 'main.ts'), + `import makeApp from './app'; +export function start() { return makeApp(); } +` + ); + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const callees = (cg as any).db.db + .prepare( + `SELECT t.name AS n FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = 'start' AND e.kind = 'calls'` + ) + .all() + .map((r: any) => r.n); + expect(callees).toEqual(['createApp']); + }); + + it('reaches the function a wrapper call hands on, and the methods of an exported instance', async () => { + fs.writeFileSync( + path.join(dir, 'tracing.ts'), + `export function traceFunction(opts: object) { return (fn: T) => fn; }\n` + ); + fs.writeFileSync( + path.join(dir, 'provisioner.ts'), + `import { traceFunction } from './tracing'; +export type Result = { ok: boolean }; +async function accountProvisioner() { return { ok: true }; } +export default traceFunction({ spanName: 'accountProvisioner' })(accountProvisioner); +` + ); + fs.writeFileSync( + path.join(dir, 'storage.ts'), + `export function storageKey(k: string) { return 'app:' + k; } +export class Storage { + get(key: string) { return localStorage.getItem(storageKey(key)); } +} +export default new Storage(); +` + ); + fs.writeFileSync( + path.join(dir, 'main.ts'), + `import provision from './provisioner'; +import Storage from './storage'; +export async function signIn() { await provision(); return Storage.get('user'); } +` + ); + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const callees = (cg as any).db.db + .prepare( + `SELECT t.kind || ' ' || t.name AS n FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = 'signIn' AND e.kind = 'calls' + ORDER BY n` + ) + .all() + .map((r: any) => r.n); + expect(callees).toEqual(['function accountProvisioner', 'method get']); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index ebecb41a8..d13fdf18d 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -38,7 +38,7 @@ import { svelteKitLinkEdges, svelteKitPageComponentEdges } from './sveltekit-syn import { createYielder, type MaybeYield } from './cooperative-yield'; import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequestEdges } from './tier-synthesizer'; import { enclosingFn, makeLineAt } from './synth-utils'; -import { resolveImportPath } from './import-resolver'; +import { hasAnonymousDefaultExport, resolveImportPath, valueBinding } from './import-resolver'; import { crossesCodeBoundary } from './name-matcher'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; @@ -1352,6 +1352,50 @@ function importedFrom(ctx: ResolutionContext, file: string, language: Language): return out; } +function nodesInFileNamed(ctx: ResolutionContext, file: string, name: string): Node[] { + return ctx.getNodesInFileNamed?.(file, name) ?? ctx.getNodesInFile(file).filter((n) => n.name === name); +} + +/** + * What a tag the file imports from one of the project's own modules renders, + * found the way the import resolver finds any imported name: through barrels, + * renames and the module's default export. `null` when the import names + * nothing that renders — a type, a value that wraps no function, a default + * export with no declaration of its own — and `undefined` when the file + * doesn't import the name from the project or the import can't be followed, + * which leaves the tag to its name. + */ +function importedJsxChild(ctx: ResolutionContext, name: string, file: string, language: Language): Node | null | undefined { + if (!ctx.resolveImport || !ctx.getNodeById) return undefined; + const mapping = ctx.getImportMappings(file, language).find((m) => m.localName === name); + if (!mapping || mapping.isNamespace) return undefined; + const module = resolveImportPath(mapping.source, file, language, ctx); + if (!module) return undefined; + const resolved = ctx.resolveImport({ + fromNodeId: '', referenceName: name, referenceKind: 'references', line: 0, column: 0, filePath: file, language, + }); + const node = resolved ? ctx.getNodeById(resolved.targetNodeId) : null; + if (!node) { + const isDefault = mapping.isDefault || mapping.exportedName === 'default'; + if (!isDefault || !hasAnonymousDefaultExport(module, ctx)) return undefined; + // `export default (props) => ` + // renders what the module declares under the tag's name, if anything — + // never a same-named component of another module. + return nodesInFileNamed(ctx, module, name).find((n) => JSX_CHILD_KINDS.has(n.kind)) ?? null; + } + if (JSX_CHILD_KINDS.has(node.kind)) return node; + // The value a module declares under its type's name (`export interface Button` + // above `export const Button = (…) => …`), or the function a value holds + // (`export const Card = observer(function Card() {…})`): the same name, there. + const twin = nodesInFileNamed(ctx, node.filePath, node.name) + .filter((n) => JSX_CHILD_KINDS.has(n.kind) && + (n.qualifiedName === node.name || (n.startLine >= node.startLine && n.endLine <= node.endLine))) + .sort((a, b) => Number(b.qualifiedName === node.name) - Number(a.qualifiedName === node.name))[0]; + if (twin) return twin; + // `const Avatar = AvatarWithHoverCard`: what the value hands on. + return valueBinding(node, ctx) ?? null; +} + /** * The component a JSX tag names, among every node that shares the name. * @@ -1366,17 +1410,30 @@ function importedFrom(ctx: ResolutionContext, file: string, language: Language): * another sheet. * * Same file first — a small component declared beside its use is the commonest - * shape, and the one an import can never disambiguate. Then the file the name - * is imported from. Then the language, which only decides a tie: a `.tsx` tag - * naming both a TS component and a same-named Swift class means the TS one. + * shape, and the one an import can never disambiguate. Then what the file's + * import of the name resolves to, which no other same-named component can be: + * bulletproof-react's three apps each import `Button` from their own + * `@/components/ui/button` barrel, and the name alone bound a third of their + * renders to another app's copy; a default import renders the module's default + * export under any name (`import Settings from './settings'` for `export + * default function SettingsRoute`). Then, for a name the file neither declares + * nor imports from the project, or whose import can't be followed, the name: + * the file it is imported from, then the language, which only decides a tie: + * a `.tsx` tag naming both a TS component and a same-named Swift class means + * the TS one. */ function jsxChild( ctx: ResolutionContext, name: string, file: string, + language: Language, importsOf: () => Map ): Node | undefined { const candidates = ctx.getNodesByName(name).filter((n) => JSX_CHILD_KINDS.has(n.kind)); + const local = candidates.find((n) => n.filePath === file); + if (local) return local; + const imported = importedJsxChild(ctx, name, file, language); + if (imported !== undefined) return imported ?? undefined; if (candidates.length === 0) { // A name nothing declares is the file's DEFAULT import of a module's one // component under another name: segmented-control renders @@ -1392,8 +1449,6 @@ function jsxChild( return components.length === 1 ? components[0] : undefined; } if (candidates.length === 1) return candidates[0]; - const local = candidates.find((n) => n.filePath === file); - if (local) return local; const from = importsOf().get(name); if (from) { const imported = candidates.find((n) => n.filePath === from); @@ -1429,21 +1484,36 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): ); if (parents.length === 0) continue; // Read once per file, and only when a name actually turns out ambiguous. + const language = parents[0]!.language; let imports: Map | null = null; const importsOf = () => - (imports ??= importedFrom(ctx, file, parents[0]!.language)); + (imports ??= importedFrom(ctx, file, language)); + // A tag names the same component wherever the file writes it. + const children = new Map(); + const childOf = (name: string) => { + if (!children.has(name)) children.set(name, jsxChild(ctx, name, file, language, importsOf)); + return children.get(name); + }; for (const parent of parents) { const src = sliceLines(content, parent.startLine, parent.endLine); if (!src || (!src.includes(''))) continue; - const names = new Set(); + // Each name, and whether a ``). + const names = new Map(); JSX_TAG_RE.lastIndex = 0; let m: RegExpExecArray | null; - while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!); + while ((m = JSX_TAG_RE.exec(src))) { + const asTag = !/[\w$]/.test(src[m.index - 1] ?? '') || /\breturn$/.test(src.slice(Math.max(0, m.index - 7), m.index)); + names.set(m[1]!, (names.get(m[1]!) ?? false) || asTag); + } let added = 0; - for (const name of names) { + for (const [name, asTag] of names) { if (added >= MAX_JSX_CHILDREN) break; - const child = jsxChild(ctx, name, file, importsOf); + const child = childOf(name); if (!child || child.id === parent.id || crossesCodeBoundary(parent.language, child.language)) continue; + // What the file imports under another name (`import SharedEditor from + // '~/editor'`) renders only where that name is written as a tag. + if (!asTag && child.name !== name) continue; const key = `${parent.id}>${child.id}`; if (seen.has(key)) continue; seen.add(key); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index bf5dab41b..ac0d8280f 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -13,6 +13,7 @@ import { extractLocalExportAliases } from './alias-binding'; import { resolveWorkspaceImport } from './workspace-packages'; import { stripCommentsForRegex } from './strip-comments'; import { dartDirectiveFile } from './dart-libraries'; +import { matchBracket, skipString } from './frameworks/object-literal'; import { resolveMethodOnType, resolveObjectLiteralMember, @@ -89,37 +90,312 @@ const exportedSymbolMemos = new WeakMap; + /** First exported component: an SFC is its own default export; elsewhere a guess. */ defaultComponent: Node | undefined; + /** First exported function or class: the guess for a module that never says. */ defaultFnClass: Node | undefined; /** - * The node an `export default NAME` statement names, exported at its - * declaration or not — the precise answer where `defaultFnClass` is a - * guess. `const Home = () => …; export default Home` and the namespace - * object `const UploadApi = { uploadARCapture }; export default UploadApi` - * are both invisible to the `isExported` index above: neither declaration - * has an `export_statement` ancestor. `undefined` until first read through - * {@link defaultExportBindingNode}; `null` when there is none. + * What the module's `export default` statement says its default export is + * (see {@link defaultStatement}). `undefined` until first read; `null` when + * the module has no such statement or is not a JS-family file. */ - defaultBinding?: Node | null; + defaultStatement?: DefaultStatement | null; /** * Names a local export clause (`export { impl as alias }`) binds to a * declaration the extractor never flagged isExported, for names not in * `byName`. `undefined` until first read through {@link exportedByName}. */ clauseAliases?: Map; + /** Names a local export clause forwards from the file's imports: exported name → local name. Filled with `clauseAliases`. */ + clauseImports?: Map; } const DEFAULT_BINDING_KINDS = new Set(['function', 'class', 'component', 'constant', 'variable']); -const DEFAULT_EXPORT_BINDING_RE = /^[ \t]*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?[ \t]*$/m; +/** What a wrapper call (`memo(Foo)`, `connect(mapState)(Bar)`) can hand on as the export: a thing that runs. */ +const WRAPPED_BINDING_KINDS = new Set(['function', 'class', 'component']); const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/; +const EXPORT_DEFAULT_RE = /^[ \t]*export\s+default\s+/gm; +/** Words that open an expression but never name a binding: `export default await load()`. */ +const NOT_A_BINDING = new Set(['new', 'await', 'typeof', 'void', 'delete', 'yield', 'async', 'this', 'super', 'null', 'undefined', 'true', 'false', 'import']); -/** The identifier `export default NAME` names in a JS-family file, or null. */ -function defaultExportBinding(filePath: string, context: ResolutionContext): string | null { - if (!JS_FAMILY_FILE.test(filePath)) return null; - const source = context.readFile(filePath); - if (!source || !source.includes('export default')) return null; - return source.match(DEFAULT_EXPORT_BINDING_RE)?.[1] ?? null; +/** A name a JS expression hands on; `wrapped` when a call stands around it (`memo(Foo)`). */ +interface NamedBinding { + name: string; + wrapped: boolean; +} + +/** A default export written as an expression with no name of its own, and where it starts. */ +interface AnonymousDefault { + line: number; + column: number; +} + +type DefaultStatement = NamedBinding | AnonymousDefault; + +function skipSpace(code: string, at: number): number { + let i = at; + while (i < code.length && /\s/.test(code[i]!)) i++; + return i; +} + +/** Past the `<…>` type arguments opening at `at` (`memo(Foo)`), or -1. */ +function skipTypeArguments(code: string, at: number): number { + let depth = 0; + for (let i = at; i < code.length && i < at + 400; i++) { + const ch = code[i]!; + if (ch === '"' || ch === "'" || ch === '`') { + const end = skipString(code, i); + if (end < 0) return -1; + i = end; + } else if (ch === '<') depth++; + else if (ch === '>' && code[i - 1] !== '=' && --depth === 0) return i + 1; + } + return -1; +} + +/** + * The name the JS expression at `at` hands on: a name (`Page`), a named + * function or class (`function Vans() {…}`, `class Baz …`), or what a wrapper + * call wraps — the first argument of its last call, read the same way + * (`memo(Foo)`, `connect(mapState)(Bar)`, `withRouter(connect(m)(Bar))`, + * `observer(function Settings() {…})`, `styled(Button)\`…\``), or the class of an + * instance (`new Storage()`, whose members are the class's). Null for one that + * names nothing: an arrow function, an object literal, `Primitive.Root`, a + * call on a literal (`requireNativeComponent('X')`). `code` is comment-free. + */ +function namedBindingAt(code: string, at: number, wrapped = false, depth = 0): NamedBinding | null { + const head = code.slice(at, at + 200); + const declared = /^(?:async\s+)?function\b\s*\*?\s*([A-Za-z_$][\w$]*)?|^(?:abstract\s+)?class\b(?:\s+(?!extends\b|implements\b)([A-Za-z_$][\w$]*))?/.exec(head); + if (declared) { + const name = declared[1] ?? declared[2]; + return name ? { name, wrapped } : null; + } + const constructed = /^new\s+([A-Za-z_$][\w$]*)\b(?!\s*\.)/.exec(head); + if (constructed) return { name: constructed[1]!, wrapped: true }; + const callee = /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*/.exec(head)?.[0]; + if (!callee || NOT_A_BINDING.has(callee)) return null; + let i = at + callee.length; + while (code[i] === ' ' || code[i] === '\t') i++; + // A name on its own, up to the end of its statement or argument. + if (!callee.includes('.') && /^(?:[;,)\]}]|\r?\n|$|(?:as|satisfies)\b)/.test(code.slice(i, i + 12))) { + return { name: callee, wrapped }; + } + i = skipSpace(code, i); + if (code[i] === '<') { + i = skipTypeArguments(code, i); + if (i < 0) return null; + i = skipSpace(code, i); + } + // The call chain: `(…)(…)` and tagged templates; the last argument list is the one that wraps. + // One whose end is out of reach (an apostrophe in JSX text reads as a quote) is the last one seen. + let args = -1; + for (;;) { + if (code[i] === '(') { + args = i; + const close = matchBracket(code, i); + if (close < 0) break; + i = skipSpace(code, close + 1); + } else if (code[i] === '`') { + const end = skipString(code, i); + if (end < 0) break; + i = skipSpace(code, end + 1); + } else break; + } + // `createSlice(…).reducer` hands on a member of what the call returns. + if (args < 0 || code[i] === '.' || depth >= 4) return null; + return namedBindingAt(code, skipSpace(code, args + 1), true, depth + 1); +} + +/** + * What a JS-family module's default export is, read once from the statement + * that makes it: the binding it names — `export default Page`, `export + * default function Vans()`, `export default class Baz`, `export { Qux as + * default }`, or what a wrapper call hands on (`export default memo(Foo)`) — + * or, for an expression that names nothing (`export default () => …`), where + * that expression starts. Null when the module has no such statement. + */ +function defaultStatement(filePath: string, idx: FileExportIndex, context: ResolutionContext): DefaultStatement | null { + if (idx.defaultStatement !== undefined) return idx.defaultStatement; + let found: DefaultStatement | null = null; + const source = JS_FAMILY_FILE.test(filePath) ? context.readFile(filePath) : null; + if (source && source.includes('default')) { + const at = exportDefaultAt(source); + if (at >= 0) { + // The statement's own text is all it takes: what a wrapper call wraps + // is its first argument, near the call's start. + const code = stripCommentsForRegex(source.slice(at, at + DEFAULT_STATEMENT_WINDOW), 'typescript'); + const before = source.slice(0, at); + found = namedBindingAt(code, 0) ?? { + line: before.split('\n').length, + column: at - before.lastIndexOf('\n') - 1, + }; + } else { + const clause = extractLocalExportAliases(source).find((a) => a.exportedName === 'default'); + if (clause) found = { name: clause.localName, wrapped: false }; + } + } + idx.defaultStatement = found; + return found; +} + +/** How much of an `export default` statement is read: enough for any wrapper call's head. */ +const DEFAULT_STATEMENT_WINDOW = 4000; + +/** Where the expression of the first `export default` statement outside a block comment starts, or -1. */ +function exportDefaultAt(source: string): number { + EXPORT_DEFAULT_RE.lastIndex = 0; + for (let m = EXPORT_DEFAULT_RE.exec(source); m; m = EXPORT_DEFAULT_RE.exec(source)) { + if (!insideBlockComment(source, m.index)) return m.index + m[0].length; + } + return -1; +} + +/** + * Whether `offset` sits inside a block comment of a JS source, read from the + * start past strings, template literals and line comments the way + * `stripCommentsForRegex` reads them — without copying the file. + */ +function insideBlockComment(source: string, offset: number): boolean { + for (let i = 0; i < offset; i++) { + const ch = source[i]; + if (ch === '/' && source[i + 1] === '*') { + const end = source.indexOf('*/', i + 2); + if (end < 0 || end >= offset) return true; + i = end + 1; + } else if (ch === '/' && source[i + 1] === '/') { + const end = source.indexOf('\n', i + 2); + if (end < 0) return false; + i = end; + } else if (ch === '"' || ch === "'" || ch === '`') { + // A quote ends at its match; one that isn't a template's, at its line's end. + for (i++; i < offset && source[i] !== ch; i++) { + if (source[i] === '\\') i++; + else if (ch !== '`' && source[i] === '\n') break; + } + } + } + return false; +} + +/** + * The node a default export statement makes the export. A named binding is + * the file's own declaration of it, else what the file imports under that + * name (`import Login from './Login'; export default Login` in an `index`); + * one a wrapper call hands on must run — `export default createRouter(routes)` + * exports a router, not `routes`. An anonymous one is a node only when one + * stands at the expression itself: React Native's + * `export default codegenNativeComponent('MyView')`. + */ +function defaultStatementNode( + filePath: string, + statement: DefaultStatement, + language: Language, + context: ResolutionContext, + visited: Set, + depth: number +): Node | undefined { + if (!('name' in statement)) { + return context.getNodesInFile(filePath).find((n) => + WRAPPED_BINDING_KINDS.has(n.kind) && n.startLine === statement.line && n.startColumn === statement.column); + } + return bindingNode(filePath, statement, language, context, visited, depth); +} + +/** What `binding` names in `filePath`: its declaration there, else the export the file imports under that name. */ +function bindingNode( + filePath: string, + binding: NamedBinding, + language: Language, + context: ResolutionContext, + visited: Set, + depth: number +): Node | undefined { + const kinds = binding.wrapped ? WRAPPED_BINDING_KINDS : DEFAULT_BINDING_KINDS; + // A module-level declaration before one nested in a function body. + const topLevel = (n: Node) => (n.qualifiedName === binding.name ? 0 : 1); + const own = nodesInFileNamed(filePath, binding.name, context) + .filter((n) => kinds.has(n.kind)) + .sort((a, b) => topLevel(a) - topLevel(b) || a.startLine - b.startLine || a.startColumn - b.startColumn)[0]; + if (own) return own; + const imported = context.getImportMappings(filePath, language).find((m) => m.localName === binding.name && !m.isNamespace); + const next = imported ? resolveImportPath(imported.source, filePath, language, context) : null; + if (!imported || !next) return undefined; + const found = findExportedSymbol( + next, + { isDefault: imported.isDefault, isNamespace: false, exportedName: imported.isDefault ? 'default' : imported.exportedName, memberName: null }, + language, + context, + visited, + depth + 1 + ); + return found && kinds.has(found.kind) ? found : undefined; +} + +/** + * Whether a JS-family module's default export is an expression with no name + * and no node of its own — `export default () => …`, `export default + * memo((props) => …)`, an object literal — so nothing in the graph is it, and + * a same-named symbol elsewhere is not it either. + */ +export function hasAnonymousDefaultExport(filePath: string, context: ResolutionContext): boolean { + const idx = getFileExportIndex(filePath, context); + const statement = defaultStatement(filePath, idx, context); + return statement !== null && !('name' in statement) && + defaultStatementNode(filePath, statement, 'typescript', context, new Set(), 0) === undefined; } + +const valueBindingMemos = new WeakMap>(); + +/** + * The function, class or component a JS value declaration hands on, which is + * what renders or runs when the value does: what `const X = …` names when it + * is a name or wraps one — `const Avatar = AvatarWithHoverCard`, `const + * Suggestions = observer(function Suggestions() {…})`, `const List = + * memo(ListInner)` — declared in the value's file or imported into it. + * Undefined for any other value: a styled template, `Primitive.Root`, an + * object literal. + */ +export function valueBinding(value: Node, context: ResolutionContext): Node | undefined { + if ((value.kind !== 'constant' && value.kind !== 'variable') || !JS_FAMILY_FILE.test(value.filePath)) return undefined; + let memo = valueBindingMemos.get(context); + if (!memo) { + memo = new Map(); + valueBindingMemos.set(context, memo); + } + const hit = memo.get(value.id); + if (hit !== undefined) return hit ?? undefined; + let found: Node | undefined; + // The declarator's own text, `Avatar = AvatarWithHoverCard`; its head is enough. + const last = Math.min(value.endLine, value.startLine + 60); + const lines = (context.getFileLines?.(value.filePath) ?? context.readFile(value.filePath)?.split('\n') ?? []) + .slice(value.startLine - 1, last); + if (lines.length > 0) { + if (last === value.endLine && value.endColumn > 0) lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, value.endColumn); + lines[0] = lines[0]!.slice(value.startColumn); + const code = stripCommentsForRegex(lines.join('\n').slice(0, DEFAULT_STATEMENT_WINDOW), 'typescript'); + const init = /^[A-Za-z_$][\w$]*\s*(?::(?:[^=]|=>)*?)?=(?![=>])\s*/.exec(code); + const at = init ? init[0].length : -1; + const binding = init ? namedBindingAt(code, at) : null; + if (binding && binding.name !== value.name) { + found = bindingNode(value.filePath, { name: binding.name, wrapped: true }, value.language, context, new Set([value.filePath]), 0); + } else if (binding) { + // `const Suggestions = observer(function Suggestions() {…})`: the function the value holds. + found = nodesInFileNamed(value.filePath, binding.name, context) + .find((n) => WRAPPED_BINDING_KINDS.has(n.kind) && n.startLine >= value.startLine && n.endLine <= value.endLine); + } else if (init) { + // `const MyView = codegenNativeComponent('MyView')`: a node standing at the initializer itself. + const before = code.slice(0, at); + const newline = before.lastIndexOf('\n'); + const line = value.startLine + before.split('\n').length - 1; + const column = newline < 0 ? value.startColumn + at : at - newline - 1; + found = context.getNodesInFile(value.filePath) + .find((n) => WRAPPED_BINDING_KINDS.has(n.kind) && n.startLine === line && n.startColumn === column); + } + } + memo.set(value.id, found ?? null); + return found; +} + const fileExportIndexes = new WeakMap>(); /** @@ -165,20 +441,6 @@ function nodesInFileNamed(filePath: string, name: string, context: ResolutionCon return context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name); } -/** The declaration `export default NAME` names in this file (see FileExportIndex.defaultBinding). */ -function defaultExportBindingNode(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined { - if (idx.defaultBinding === undefined) { - const bound = defaultExportBinding(filePath, context); - idx.defaultBinding = - bound === null - ? null - : (nodesInFileNamed(filePath, bound, context) - .filter((n) => DEFAULT_BINDING_KINDS.has(n.kind)) - .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0] ?? null); - } - return idx.defaultBinding ?? undefined; -} - /** What this file exports as `name`: an exported declaration, else a local export clause's binding. */ function exportedByName(filePath: string, idx: FileExportIndex, name: string, context: ResolutionContext): Node | undefined { const direct = idx.byName.get(name); @@ -195,17 +457,39 @@ function exportedByName(filePath: string, idx: FileExportIndex, name: string, co if (idx.byName.has(exportedName) || idx.clauseAliases.has(exportedName)) continue; const decl = nodesInFileNamed(filePath, localName, context)[0]; if (decl) idx.clauseAliases.set(exportedName, decl); + else (idx.clauseImports ??= new Map()).set(exportedName, localName); } } } return idx.clauseAliases.get(name); } +/** + * What this file exports as `name`, through an export clause that forwards a + * name it imports rather than declares — the `index` that does `import + * AdvancedMarker from './advanced-marker'; export { AdvancedMarker }` — too. + */ +function exportedOrForwarded( + filePath: string, + idx: FileExportIndex, + name: string, + language: Language, + context: ResolutionContext, + visited: Set, + depth: number +): Node | undefined { + const direct = exportedByName(filePath, idx, name, context); + if (direct) return direct; + const local = idx.clauseImports?.get(name); + return local ? bindingNode(filePath, { name: local, wrapped: false }, language, context, visited, depth) : undefined; +} + /** Drop the per-context memo tables (see ReferenceResolver.clearCaches). */ export function clearImportResolverMemos(context: ResolutionContext): void { importPathMemos.delete(context); exportedSymbolMemos.delete(context); fileExportIndexes.delete(context); + valueBindingMemos.delete(context); luaFileBasenameIndexes.delete(context); cobolCopybookIndexes.delete(context); pythonModuleFileMemos.delete(context); @@ -1019,7 +1303,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) { @@ -2638,16 +2924,19 @@ function findExportedSymbolWalk( // 1. Direct hit: the symbol is declared in this file. if (want.isDefault) { + // A JS module says what its default export is — the binding its `export + // default` names, or an expression with no node of its own — so it is + // never guessed: the first exported function or component stood in for + // it, and `export function loader` above `export default function Vans` + // made every `import Vans from './Vans'` the loader. + const statement = defaultStatement(filePath, exportIndex, context); + if (statement) return defaultStatementNode(filePath, statement, language, context, visited, depth); // Svelte/Vue single-file components ARE the module's default export, - // but are extracted as kind 'component' (not function/class). Prefer - // the component node; fall back to an exported function/class for the - // `.ts`/`.tsx` `export default fn`/`class` case. Without the component - // branch, an `export { default as X } from './X.svelte'` barrel never - // resolves and the component shows a false 0 callers (#629). - // A component file IS its default export; otherwise the statement that - // names the binding beats the first-exported-function guess. - const direct = - exportIndex.defaultComponent ?? defaultExportBindingNode(filePath, exportIndex, context) ?? exportIndex.defaultFnClass; + // but are extracted as kind 'component' (not function/class). Without + // this, an `export { default as X } from './X.svelte'` barrel never + // resolves and the component shows a false 0 callers (#629). A module + // that never says falls back to its first exported function or class. + const direct = exportIndex.defaultComponent ?? exportIndex.defaultFnClass; if (direct) return direct; // CommonJS: `module.exports = createApplication`, or `= require('./lib/express')`. const commonJs = commonJsDefaultExport(filePath, context); @@ -2661,10 +2950,10 @@ function findExportedSymbolWalk( if (bound) return bound; } } else if (want.isNamespace && want.memberName) { - const direct = exportedByName(filePath, exportIndex, want.memberName, context); + const direct = exportedOrForwarded(filePath, exportIndex, want.memberName, language, context, visited, depth); if (direct) return direct; } else { - const direct = exportedByName(filePath, exportIndex, want.exportedName, context); + const direct = exportedOrForwarded(filePath, exportIndex, want.exportedName, language, context, visited, depth); if (direct) return direct; } From dce9ba4ae56799b1e56c680c05fcab45b5145a0e Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 09:15:58 -0500 Subject: [PATCH 2/4] refactor(react): a JSX tag's module reads the import mapping's resolved path first Co-Authored-By: Claude Opus 5.5 --- src/resolution/callback-synthesizer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 69ce9b836..d78f1065b 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1551,7 +1551,7 @@ function importedJsxChild(ctx: ResolutionContext, name: string, file: string, la if (!ctx.resolveImport || !ctx.getNodeById) return undefined; const mapping = ctx.getImportMappings(file, language).find((m) => m.localName === name); if (!mapping || mapping.isNamespace) return undefined; - const module = resolveImportPath(mapping.source, file, language, ctx); + const module = mapping.resolvedPath ?? resolveImportPath(mapping.source, file, language, ctx); if (!module) return undefined; const resolved = ctx.resolveImport({ fromNodeId: '', referenceName: name, referenceKind: 'references', line: 0, column: 0, filePath: file, language, From d1553d63efd3a2ed0667dfae12661b2e1d7b9217 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 09:34:50 -0500 Subject: [PATCH 3/4] fix(js,ts): of several `export default` lines, the one that names a node is the module's A template can hold statement-shaped text: react.dev's SandpackWithHTMLOutput.tsx writes two sandbox files' `export default function` lines above its own `export default memo(function ...)`, and reading only the first line left the module's default import with nothing. Every line-initial statement outside a block comment is now read, the first that names a node wins, and a function or class a statement declares binds only the node that starts there, so a template's `export default function formatHTML` can't reach the file's real formatHTML. The block-comment check reads the file once for all of its statements. Carries #2432's default-export tests unchanged (all pass), and adapts its multi-statement and declaration-position rules. Co-Authored-By: Claude Opus 5.5 --- __tests__/default-export-declaration.test.ts | 274 +++++++++++++++++++ __tests__/jsx-child-imports.test.ts | 28 ++ src/resolution/import-resolver.ts | 168 +++++++----- 3 files changed, 405 insertions(+), 65 deletions(-) create mode 100644 __tests__/default-export-declaration.test.ts diff --git a/__tests__/default-export-declaration.test.ts b/__tests__/default-export-declaration.test.ts new file mode 100644 index 000000000..ab39db1c2 --- /dev/null +++ b/__tests__/default-export-declaration.test.ts @@ -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 = { + '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( + + } action={loginAction} /> + } loader={vansLoader} /> + } loader={hostVansLoader} /> + +)) + +export default function App() { + return +} +`, + '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) =>

    {van.name}

    ) + } + return
    {renderVanElements(dataPromise.vans)}
    +} +`, + '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
      {useLoaderData().length}
    +} +`, + '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
    {data}
    +} +`, + // 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 Vans +} +`, + 'src/components/Banner.tsx': `import styled from "styled-components" + +export const Strip = styled.div\` + display: flex; +\` + +const Banner = () => + +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