diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e504c5ad..f6a57a9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, 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 Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. +- In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. - In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. diff --git a/__tests__/require-default.test.ts b/__tests__/require-default.test.ts new file mode 100644 index 000000000..a908e3596 --- /dev/null +++ b/__tests__/require-default.test.ts @@ -0,0 +1,243 @@ +/** + * `require('./x').default` reads the `default` PROPERTY of the module's + * exports, and so does `const { default: X } = require('./x')`. The import + * mapping records it as a named import of `default`, which is right for a + * CommonJS module that sets the property by name (`exports.default = fn`, or + * the dual `module.exports = X; module.exports.default = X`). A module + * written as an ES module and compiled to CommonJS sets it to its default + * export, `export default class Foo`, which is no named export: bitwarden's + * desktop app loads its macOS biometrics service with + * `require("./os-biometrics-mac.service").default` and `new`s it, and that + * `instantiates` reference stayed unresolved. The named property is still + * looked up first; the ES module's default export answers only when there is + * no such property, and only in the module asked for. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Edge } from '../src/types'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-require-default-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'desktop', devDependencies: { svelte: '^4' } }), + // bitwarden's shape: an ES module with an unexported helper above its + // default-exported class, loaded with `require(...).default` on one + // platform only. + 'src/biometrics/os-biometrics.service.ts': `export interface OsBiometricService { + enrollPersistent(userId: string): Promise; +} +`, + 'src/biometrics/os-biometrics-mac.service.ts': `import { OsBiometricService } from './os-biometrics.service'; + +function getLookupKeyForUser(userId: string): string { + return \`\${userId}_user_biometric\`; +} + +export default class OsBiometricsServiceMac implements OsBiometricService { + async enrollPersistent(userId: string): Promise { + return getLookupKeyForUser(userId); + } +} +`, + 'src/biometrics/main-biometrics.service.ts': `import { OsBiometricService } from './os-biometrics.service'; + +export class MainBiometricsService { + private osBiometricsService: OsBiometricService | undefined; + + constructor(platform: string) { + if (platform === 'darwin') { + // eslint-disable-next-line + const OsBiometricsServiceMac = require('./os-biometrics-mac.service').default; + this.osBiometricsService = new OsBiometricsServiceMac(); + } + } +} +`, + 'src/biometrics/os-biometrics-mac.service.spec.ts': `describe('OsBiometricsServiceMac', () => { + it('enrolls in an isolated module registry', async () => { + const { default: ServiceCtor } = require('./os-biometrics-mac.service'); + const isolated = new ServiceCtor(); + await isolated.enrollPersistent('user'); + }); +}); +`, + // A single-file component is its module's default export. + 'src/App.svelte': ` + +

Hello {name}!

+`, + 'src/main.js': `const App = require('./App.svelte').default; + +const app = new App({ target: document.body }); + +module.exports = app; +`, + // CommonJS modules that set the property by name. + 'lib/format.js': `'use strict'; + +exports.pad = function (value) { + return String(value).padStart(2, '0'); +}; + +function formatDate(date) { + return exports.pad(date.getDate()); +} + +exports.default = formatDate; +`, + 'lib/server.js': `'use strict'; + +function createServer(options) { + return { options }; +} + +module.exports = createServer; +module.exports.default = createServer; +module.exports.createServer = createServer; +`, + // A CommonJS module without the property: its exports are no default. + 'lib/plain.js': `'use strict'; + +exports.first = function () { + return 1; +}; + +exports.second = () => 2; +`, + // `export * from` forwards every export but the default. + 'src/widgets/Dial.ts': `export const DIAL_SIZE = 3; + +export default class Dial { + size = DIAL_SIZE; +} +`, + 'src/widgets/index.ts': `export * from './Dial'; +`, + 'src/dashboard.ts': `export function mountDashboard(): unknown { + const Widget = require('./widgets').default; + return new Widget(); +} +`, + 'lib/app.js': `'use strict'; + +const format = require('./format').default; +const build = require('./server').default; +const { default: fastify } = require('./server'); +const plain = require('./plain').default; + +function start() { + const server = build({ port: 3000 }); + fastify({ logger: true }); + plain(); + return format(new Date()) + server.options.port; +} + +module.exports = start; +`, + // Python has no default export: `default` is an attribute's name. + 'py/__init__.py': '', + 'py/mod.py': `def first(): + return 1 +`, + 'py/use.py': `from .mod import default + + +def run(): + return default() +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const node = (file: string, name: string) => { + const found = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'import'); + expect(found, `${file}: ${name}`).toBeDefined(); + return found!; +}; + +/** The edges into `target`, as `kind source-file resolvedBy`. */ +const into = (target: { id: string }, kinds: Edge['kind'][]) => + cg + .getIncomingEdges(target.id) + .filter((e) => kinds.includes(e.kind)) + .map((e) => `${e.kind} ${cg.getNode(e.source)!.filePath} ${(e.metadata as { resolvedBy?: string }).resolvedBy}`) + .sort(); + +/** The call and instantiation edges out of a file's nodes, as `kind target-file:target-name`. */ +const outOf = (file: string) => + cg + .getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id)) + .filter((e) => e.kind === 'calls' || e.kind === 'instantiates') + .map((e) => { + const target = cg.getNode(e.target)!; + return `${e.kind} ${target.filePath}:${target.name}`; + }) + .sort(); + +describe('`require(…).default` of an ES module', () => { + it('is the module’s default export, as `.default` and as `{ default: X }`', () => { + expect(into(node('src/biometrics/os-biometrics-mac.service.ts', 'OsBiometricsServiceMac'), ['instantiates'])).toEqual([ + 'instantiates src/biometrics/main-biometrics.service.ts import', + 'instantiates src/biometrics/os-biometrics-mac.service.spec.ts import', + ]); + }); + + it('is the component a single-file component file is', () => { + expect(into(node('src/App.svelte', 'App'), ['instantiates'])).toEqual(['instantiates src/main.js import']); + }); +}); + +describe('`require(…).default` of a CommonJS module', () => { + it('is what `exports.default` names, not the module’s first export', () => { + expect(outOf('lib/app.js')).toContain('calls lib/format.js:formatDate'); + expect(into(node('lib/format.js', 'pad'), ['calls'])).toEqual([]); + }); + + it('is X in the dual export `module.exports = X; module.exports.default = X`', () => { + expect(into(node('lib/server.js', 'createServer'), ['calls'])).toEqual([ + 'calls lib/app.js import', + 'calls lib/app.js import', + ]); + }); + + it('is nothing when the module sets no `default`: its exports are no default', () => { + expect(into(node('lib/plain.js', 'first'), ['calls'])).toEqual([]); + expect(into(node('lib/plain.js', 'second'), ['calls'])).toEqual([]); + }); +}); + +describe('what the fallback leaves alone', () => { + it('a default behind `export * from`, which forwards none', () => { + expect(into(node('src/widgets/Dial.ts', 'Dial'), ['instantiates', 'calls'])).toEqual([]); + expect(outOf('src/dashboard.ts')).toEqual([]); + }); + + it('every other require binding, which reaches only what it names', () => { + expect(outOf('lib/app.js').filter((e) => !e.includes(' lib/app.js:'))).toEqual([ + 'calls lib/format.js:formatDate', + 'calls lib/server.js:createServer', + 'calls lib/server.js:createServer', + ]); + }); + + it('Python’s `from .mod import default`, an attribute of that name', () => { + expect(into(node('py/mod.py', 'first'), ['calls'])).toEqual([]); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index e8c5e6a4e..8ac0695ca 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -111,6 +111,8 @@ interface FileExportIndex { 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; const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/; /** The identifier `export default NAME` names in a JS-family file, or null. */ @@ -179,6 +181,28 @@ function defaultExportBindingNode(filePath: string, idx: FileExportIndex, contex 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`). + */ +function esmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined { + return idx.defaultComponent ?? defaultExportBindingNode(filePath, idx, context) ?? idx.defaultFnClass; +} + +/** + * 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. + */ +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); +} + /** 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); @@ -2683,8 +2707,7 @@ function findExportedSymbolWalk( // 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; + const direct = esmDefaultExport(filePath, exportIndex, context); if (direct) return direct; // CommonJS: `module.exports = createApplication`, or `= require('./lib/express')`. const commonJs = commonJsDefaultExport(filePath, context); @@ -2703,6 +2726,22 @@ function findExportedSymbolWalk( } else { const direct = exportedByName(filePath, exportIndex, want.exportedName, context); 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 + // name (`exports.default = fn`, the dual `module.exports.default = X`), + // found just above. A module written as an ES module sets it to its + // default export, `export default class Foo`, which is no named export — + // the answer when the module has no such property. Only in the module + // asked for: `export * from` never forwards a default. + if ( + depth === 0 && + want.exportedName === 'default' && + ESM_IMPORT_LANGUAGES.has(language) && + hasEsmDefaultExport(filePath, exportIndex, context) + ) { + const esmDefault = esmDefaultExport(filePath, exportIndex, context); + if (esmDefault) return esmDefault; + } } // 2. Re-export hit: the file forwards the symbol to another module.