fix(js,ts): require('./x').default reaches an ES module's default export (#2433)

`const X = require('./x').default` and `const { default: X } = require('./x')`
map to a named import of `default`. That is right for a CommonJS module that
sets the property itself (`exports.default = fn`, or the dual
`module.exports = X; module.exports.default = X`), but a module written as an
ES module and compiled to CommonJS sets it to its default export, which is no
named export. References through the binding stayed unresolved (bitwarden's
`new OsBiometricsServiceMac(...)`) or fell to name matching: the local
variable holding the module, or a namesake method in another package.

findExportedSymbolWalk now falls back to the module's ESM default export when
the named lookup finds no `default`. Only for JS-family refs, only in the
module the require names (`export * from` forwards no default), and only when
that module has an ESM default export at all: a single-file component, or an
`export default` statement. A CommonJS module's `exports.x = function`
declarations are flagged exported, so without that gate the
first-exported-function guess would invent a default for it.

bitwarden +2 edges (the two failed `instantiates` refs). react-native +872
-617: all 617 re-resolved at the same site (541 from the local require
binding to the real function or class, 47 from a namesake method in another
package to the right module, 28 metadata only, 1 from a type signature to the
implementation), 870 of 872 added edges on the declared default or what it
holds. express, eslint, mocha, koa, body-parser, fastify, topcoder,
proshop_mern, next-saas-starter and create-t3-turbo byte-identical. Dead-code
claims unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-07 14:49:21 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cdb994b417
commit 9a1d4bd91d
3 changed files with 285 additions and 2 deletions
+1
View File
@@ -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 `<Route {...rest}>`, 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 `<Link to="/counter">` links led nowhere. Each route links to the page its `element` renders, past a guard like `<ProtectedRoute>` or `<AdminWrapper>`, 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 `<Route index>` the way route objects do: inside `<Route path="/" element={<Layout />}>`, `<Route index element={<Home />} />` 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 `<Route element={<RequireAuth />}>`. Before, the index route was never read, so `/` led to the layout itself and a `<Route path="/dashboard">` 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 `<Suspense fallback={<Loader />}>`, 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 `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <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.
+243
View File
@@ -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<string, string> = {
'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<string>;
}
`,
'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<string> {
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': `<script>
export let name = 'world';
</script>
<h1>Hello {name}!</h1>
`,
'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([]);
});
});
+41 -2
View File
@@ -111,6 +111,8 @@ interface FileExportIndex {
const DEFAULT_BINDING_KINDS = new Set<string>(['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.