diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f912d16..467e62476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``. 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` followed by its CSS, or `styled(Base)`, 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 `` 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 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 ; } +` + ); + 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('reads the module’s own `export default` past ones a template writes as text', async () => { + // react.dev's SandpackWithHTMLOutput.tsx: sandbox files' sources sit in + // templates above the real statement, and one names a function the file + // also declares, which only the statement that declares it may bind. + write( + 'components/sandpack.tsx', + `import { memo } from 'react'; +export function formatHTML(markup: string) { return markup.trim(); } +const files = { + '/App.js': \` +export default function ShowRenderedHTML({children}) { + return children; +} +\`, + '/formatHTML.js': \` +export default function formatHTML(markup) { + return markup; +} +\`, +}; +export default memo(function SandpackWithHTMLOutput() { return
    {Object.keys(files).join()}
    ; }); +` + ); + write('mdx.tsx', `import Sandpack from './components/sandpack';\nexport function MDX() { return ; }\n`); + const db = await index(); + expect(rendersFrom(db, 'MDX')).toEqual(['components/sandpack.tsx:SandpackWithHTMLOutput']); + }); + + 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('does not reach the argument of a factory a module exports by default', async () => { + fs.writeFileSync( + path.join(dir, 'factory.ts'), + `export function createStore(r: (s: unknown) => unknown) { return { dispatch(a: unknown) { return r(a); } }; }\n` + ); + fs.writeFileSync( + path.join(dir, 'store.ts'), + `import { createStore } from './factory'; +function reducer(state: unknown) { return state; } +export default createStore(reducer); +` + ); + fs.writeFileSync( + path.join(dir, 'main.ts'), + `import store from './store'; +export function start() { return store.dispatch({ type: 'go' }); } +` + ); + 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).not.toContain('reducer'); + }); + + 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 72704a076..a686535af 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, jsCodeBindsName } from './name-matcher'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; @@ -1723,6 +1723,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 = 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, + }); + 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. * @@ -1737,17 +1781,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 @@ -1763,8 +1820,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); @@ -1848,9 +1903,16 @@ 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); + }; // A function renders the tags its own lines hold, so functions that span // the same lines share them: every function of a minified bundle spans // its one line, and reading that line again per function took 9 to 25 s @@ -1887,7 +1949,7 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): for (const [name, line] of names.tags) { if (added >= MAX_JSX_CHILDREN) break; if (!line) continue; - let child = jsxChild(ctx, name, file, importsOf); + let child = childOf(name); // A name the parent binds itself by its first tag is that local. if (child && names.bare.has(name) && jsCodeBindsName(name, parent, file, line, ctx)) { child = declaredInside(ctx, name, parent, child); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 651825f41..5daf9f617 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 { moduleTail } from '../db/reference-tail'; import { resolveMethodOnType, @@ -90,39 +91,357 @@ 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` statements say its default export is + * (see {@link defaultStatements}). `undefined` until first read; empty when + * the module has no such statement or is not a JS-family file. */ - defaultBinding?: Node | null; + defaultStatements?: DefaultStatement[]; /** * 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; -/** Any `export default …` statement: the module is an ES module with a default export. */ -const ESM_DEFAULT_EXPORT_RE = /^[ \t]*export\s+default\b/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; + /** For a function or class the expression declares itself: where its name is written in the code read. */ + nameAt?: number; + /** For such a declaration in an `export default` statement: the lines its node starts on, the statement's to its name's. */ + declaredLines?: { from: number; to: number }; +} + +/** 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)\`…\``) when it is a + * component's name or ends a curried chain (see {@link handsOn}) — 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')`), a + * factory's argument (`createStore(reducer)`). `code` is comment-free. + */ +function namedBindingAt(code: string, at: number, wrapped = false, depth = 0, curried = false): 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 && handsOn(name, wrapped, curried) ? { name, wrapped, nameAt: at + declared[0].length - name.length } : 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 handsOn(callee, wrapped, curried) ? { name: callee, wrapped } : null; + } + 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; + let calls = 0; + for (;;) { + if (code[i] === '(') { + args = i; + calls++; + 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, calls > 1); +} + +/** + * Whether a wrapper call hands on the name it is given: a component's name + * (`memo(Card)`, `observer(function Settings() {…})`), or whatever ends a + * curried chain (`connect(mapState)(view)`, `traceFunction({…})(provision)`). + * `createStore(reducer)` hands on a store, not `reducer`. A name the + * expression holds on its own is always handed on. + */ +function handsOn(name: string, wrapped: boolean, curried: boolean): boolean { + return !wrapped || curried || /^[A-Z]/.test(name); +} + +/** + * What a JS-family module's default export is, read once from the statements + * that make it: the binding one 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. Every line-initial `export default` outside a block + * comment is read, in order: a template's text can hold one too — react.dev's + * `SandpackWithHTMLOutput.tsx` writes two sandbox files' `export default + * function` lines above its own `export default memo(function …)` — and the + * first that names a node is the module's (see the default branch of + * {@link findExportedSymbolWalk}). Empty when the module has none. + */ +function defaultStatements(filePath: string, idx: FileExportIndex, context: ResolutionContext): DefaultStatement[] { + if (idx.defaultStatements !== undefined) return idx.defaultStatements; + const found: DefaultStatement[] = []; + const source = JS_FAMILY_FILE.test(filePath) ? context.readFile(filePath) : null; + if (source && source.includes('default')) { + let lineAt = 0; + let line = 1; + for (const at of exportDefaultStarts(source)) { + for (let i = source.indexOf('\n', lineAt); i >= 0 && i < at; i = source.indexOf('\n', i + 1)) line++; + lineAt = at; + // 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 binding = namedBindingAt(code, 0); + if (binding?.nameAt !== undefined) { + // A declaration's node starts on the statement's line, or on its + // name's when the statement breaks before it. + let to = line; + for (let i = code.indexOf('\n'); i >= 0 && i < binding.nameAt; i = code.indexOf('\n', i + 1)) to++; + binding.declaredLines = { from: line, to }; + } + found.push(binding ?? { line, column: at - source.lastIndexOf('\n', at - 1) - 1 }); + } + if (found.length === 0) { + const clause = extractLocalExportAliases(source).find((a) => a.exportedName === 'default'); + if (clause) found.push({ name: clause.localName, wrapped: false }); + } + } + idx.defaultStatements = 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 each `export default` statement outside a block comment starts, in order. */ +function exportDefaultStarts(source: string): number[] { + const starts: number[] = []; + const inBlockComment = blockCommentReader(source); + EXPORT_DEFAULT_RE.lastIndex = 0; + for (let m = EXPORT_DEFAULT_RE.exec(source); m; m = EXPORT_DEFAULT_RE.exec(source)) { + if (!inBlockComment(m.index)) starts.push(m.index + m[0].length); + } + return starts; +} + +/** + * Whether each of a rising run of offsets in a JS source sits inside a block + * comment, read in one pass past strings, template literals and line comments + * the way `stripCommentsForRegex` reads them — without copying the file. + */ +function blockCommentReader(source: string): (offset: number) => boolean { + let i = 0; + return (offset) => { + while (i < offset) { + const ch = source[i]; + if (ch === '/' && source[i + 1] === '*') { + const end = source.indexOf('*/', i + 2); + if (end < 0 || end >= offset) return true; + i = end + 2; + } else if (ch === '/' && source[i + 1] === '/') { + const end = source.indexOf('\n', i + 2); + i = end < 0 ? source.length : end; + } else if (ch === '"' || ch === "'" || ch === '`') { + // A quote ends at its match; one that isn't a template's, at its line's end. + let j = i + 1; + for (; j < source.length && source[j] !== ch; j++) { + if (source[j] === '\\') j++; + else if (ch !== '`' && source[j] === '\n') break; + } + if (j >= offset) return false; + i = j + 1; + } else i++; + } + return false; + }; +} + +/** + * The node a default export statement makes the export. A function or class + * the statement declares is the node that starts there — never a namesake + * elsewhere in the file, which a template's text would otherwise reach. 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); + } + const lines = statement.declaredLines; + if (lines) { + return nodesInFileNamed(filePath, statement.name, context) + .find((n) => WRAPPED_BINDING_KINDS.has(n.kind) && n.startLine >= lines.from && n.startLine <= lines.to); + } + 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 statements = defaultStatements(filePath, getFileExportIndex(filePath, context), context); + return statements.length > 0 && statements.every((s) => + !('name' in s) && defaultStatementNode(filePath, s, '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?.nameAt !== undefined) { + // `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 (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 && 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>(); /** @@ -168,40 +487,40 @@ 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; -} - /** - * The declaration a module default-exports: the component a single-file - * component file is, else what an `export default NAME` statement names, else - * the first exported function or class (`export default class Foo`). + * The node a module default-exports. A JS module's `export default` + * statements say it (the first that names a node; none when they all name + * nothing — see {@link defaultStatements}); a single-file component is its + * own default export; a module that says nothing falls back to its first + * exported function or class. */ -function esmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined { - return idx.defaultComponent ?? defaultExportBindingNode(filePath, idx, context) ?? idx.defaultFnClass; +function esmDefaultExport( + filePath: string, + idx: FileExportIndex, + language: Language, + context: ResolutionContext, + visited: Set, + depth: number +): Node | undefined { + const statements = defaultStatements(filePath, idx, context); + if (statements.length === 0) return idx.defaultComponent ?? idx.defaultFnClass; + for (const statement of statements) { + const node = defaultStatementNode(filePath, statement, language, context, visited, depth); + if (node) return node; + } + return undefined; } /** - * Whether the module has an ESM default export at all: it is a single-file - * component, or a JS-family file with an `export default` statement. A - * CommonJS module has none, though its `exports.x = function` declarations are - * exported and would feed the first-exported-function guess. + * Whether the module has an ESM default export at all: a JS-family file with + * an `export default` statement or an `export { X as default }` clause, or a + * single-file component. A CommonJS module has none, though its `exports.x = + * function` declarations are exported and would feed the + * first-exported-function guess. */ function hasEsmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): boolean { - if (idx.defaultComponent) return true; - if (!JS_FAMILY_FILE.test(filePath)) return false; - const source = context.readFile(filePath); - return !!source && source.includes('default') && ESM_DEFAULT_EXPORT_RE.test(source); + if (!JS_FAMILY_FILE.test(filePath)) return idx.defaultComponent !== undefined; + return defaultStatements(filePath, idx, context).length > 0; } /** What this file exports as `name`: an exported declaration, else a local export clause's binding. */ @@ -220,17 +539,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); @@ -2709,15 +3050,22 @@ 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. Of several + // statements, the first that names a node is the module's own; one a + // template only writes as text names none. + if (defaultStatements(filePath, exportIndex, context).length > 0) { + return esmDefaultExport(filePath, exportIndex, 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 = esmDefaultExport(filePath, exportIndex, context); + // 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); @@ -2731,10 +3079,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; // `require('./x').default` and `const { default: X } = require('./x')` read // the `default` property of module.exports. A CommonJS module sets it by @@ -2749,7 +3097,7 @@ function findExportedSymbolWalk( ESM_IMPORT_LANGUAGES.has(language) && hasEsmDefaultExport(filePath, exportIndex, context) ) { - const esmDefault = esmDefaultExport(filePath, exportIndex, context); + const esmDefault = esmDefaultExport(filePath, exportIndex, language, context, visited, depth); if (esmDefault) return esmDefault; } }