diff --git a/packages/metro-transform-plugins/src/import-export-plugin.js b/packages/metro-transform-plugins/src/import-export-plugin.js index e9d1809c46..4153b85628 100644 --- a/packages/metro-transform-plugins/src/import-export-plugin.js +++ b/packages/metro-transform-plugins/src/import-export-plugin.js @@ -36,7 +36,7 @@ export type Options = Readonly<{ importDefault: string, importAll: string, resolve: boolean, - out?: {isESModule: boolean, ...}, + out?: {isESModule?: boolean, ...}, }>; type State = { @@ -570,11 +570,11 @@ export default function importExportPlugin({ state.exportNamed.length ) { body.unshift(esModuleExportTemplate()); + // Only ever set a positive signal: a definite ES module by + // presence of export syntax. if (state.opts.out) { state.opts.out.isESModule = true; } - } else if (state.opts.out) { - state.opts.out.isESModule = false; } }, }, diff --git a/packages/metro-transform-worker/API.md b/packages/metro-transform-worker/API.md index df6d43c32e..6a83af1c3f 100644 --- a/packages/metro-transform-worker/API.md +++ b/packages/metro-transform-worker/API.md @@ -18,6 +18,7 @@ export type JsOutput = Readonly<{ lineCount: number; map: VlqMap; functionMap: null | undefined | FBSourceFunctionMap; + isESModule?: boolean; }>; type: JSFileType; }>; diff --git a/packages/metro-transform-worker/src/__tests__/index-test.js b/packages/metro-transform-worker/src/__tests__/index-test.js index 2387b786ba..5445de1a24 100644 --- a/packages/metro-transform-worker/src/__tests__/index-test.js +++ b/packages/metro-transform-worker/src/__tests__/index-test.js @@ -265,6 +265,159 @@ test('transforms import/export syntax when experimental flag is on', async () => ]); }); +describe('isESModule', () => { + test('is true for an ES module (positive hint from import-export-plugin)', async () => { + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from('export default 42;', 'utf8'), + {...baseTransformOptions, experimentalImportSupport: true}, + ); + + expect(result.output[0].data.isESModule).toBe(true); + }); + + test('is true for ESM already lowered to CJS by Babel (AST fallback)', async () => { + const contents = [ + 'Object.defineProperty(exports, "__esModule", { value: true });', + 'exports.default = 42;', + ].join('\n'); + + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from(contents, 'utf8'), + baseTransformOptions, + ); + + expect(result.output[0].data.isESModule).toBe(true); + }); + + test('is true for the `exports.__esModule = true` assignment form', async () => { + const contents = [ + 'exports.__esModule = true;', + 'exports.default = 42;', + ].join('\n'); + + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from(contents, 'utf8'), + baseTransformOptions, + ); + + expect(result.output[0].data.isESModule).toBe(true); + }); + + test('is true for the sequence-expression assignment form (`@babel/runtime` helpers)', async () => { + // Shape emitted by every helper under `@babel/runtime/helpers/`: + // module.exports = fn, module.exports.__esModule = true, + // module.exports["default"] = module.exports; + const contents = [ + 'function _interopRequireDefault(e) { return e; }', + 'module.exports = _interopRequireDefault,', + ' module.exports.__esModule = true,', + ' module.exports["default"] = module.exports;', + ].join('\n'); + + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from(contents, 'utf8'), + baseTransformOptions, + ); + + expect(result.output[0].data.isESModule).toBe(true); + }); + + test('is unset (never false) for a module with no ESM marker but WITH dependencies', async () => { + // A module with any require call could resolve to an ES module at + // runtime (e.g. `module.exports = require('./esm-thing')`), so we can't + // rule out ESM interop without whole-graph analysis. The provably-not-ESM + // rule requires zero dependencies. + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from("var x = require('./other'); module.exports = x;", 'utf8'), + baseTransformOptions, + ); + + expect(result.output[0].data.isESModule).toBeUndefined(); + }); + + test('is false for a module with no ESM marker and no dependencies', async () => { + // Not ESM: no marker AND no dependencies means the module would have to + // be deliberately obfuscating emission of `__esModule` at runtime, this is + // sufficient proof of non-ESM for our purposes. + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from('module.exports = 42;', 'utf8'), + baseTransformOptions, + ); + + expect(result.output[0].data.isESModule).toBe(false); + }); + + test('is unset (never false) for a CommonJS re-export of an ES module', async () => { + // At runtime this module IS an ES module: it re-exports `./esm`, whose + // `exports.__esModule` is truthy. In isolation, though, the marker isn't + // statically visible here, so we must leave the hint unset rather than + // asserting a misleading `false` (a false negative). + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from("module.exports = require('./esm');", 'utf8'), + baseTransformOptions, + ); + + expect(result.output[0].data.isESModule).toBeUndefined(); + }); + + test('is false for a JSON module (trivially never an ES module)', async () => { + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.json', + Buffer.from('{"foo": 1}', 'utf8'), + baseTransformOptions, + ); + + expect(result.output[0].data.isESModule).toBe(false); + }); + + test('is unset (never false) for a module that only imports (no exports)', async () => { + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from('import "./c";', 'utf8'), + {...baseTransformOptions, experimentalImportSupport: true}, + ); + + expect(result.output[0].data.isESModule).toBeUndefined(); + }); + + test('is unset (never false) for a script', async () => { + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from('doStuff();', 'utf8'), + {...baseTransformOptions, type: 'script'}, + ); + + expect(result.output[0].data.isESModule).toBeUndefined(); + }); +}); + test('does not add "use strict" on non-modules', async () => { const result = await Transformer.transform( baseConfig, diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index c48f6f8025..70f53ab91a 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -54,6 +54,10 @@ import { } from 'metro-source-map'; import metroTransformPlugins from 'metro-transform-plugins'; import collectDependencies from 'metro/private/ModuleGraph/worker/collectDependencies'; +import { + canDefineESModuleInterop, + definesESModuleInterop, +} from 'metro/private/ModuleGraph/worker/esmClassification'; import generateImportNames from 'metro/private/ModuleGraph/worker/generateImportNames'; import { importLocationsPlugin, @@ -173,6 +177,26 @@ export type JsOutput = Readonly<{ lineCount: number, map: VlqMap, functionMap: ?FBSourceFunctionMap, + // ESM-interop signal. + // + // `true` - definitely an ES module (a truthy top-level + // `exports.__esModule`, as emitted by Metro's own ESM + // transform or by ESM precompiled to CJS by Babel/tsc, i.e. + // a module with a real `.default`). + // `false` - provably NOT an ES module: either trivially (JSON) or + // because the module has no ESM interop marker AND no + // dependencies at all, so it cannot expose ESM interop at + // runtime (nothing to re-export via `module.exports = + // require('./esm')`). Common at FBiOS scale via generated + // Relay fragments and similar build-generated data modules. + // unset - undetermined. A module with `require(...)` calls but no + // ESM marker could still expose ESM interop at runtime, so + // the classifier stays silent. Consumers must fall back to + // the runtime interop helper. + // + // Serialiser-level rewrites use this tri-state to bypass the runtime + // interop helper for definitively-classified reads. + isESModule?: boolean, }>, type: JSFileType, }>; @@ -299,12 +323,18 @@ async function transformJS( // fold requires and perform constant folding (if in dev). const plugins: Array = []; + // Positive-only ESM hint from the import-export-plugin (set to `true` for a + // definite ES module, left unset otherwise). Forwarded to collectDependencies, + // which falls back to AST detection when it is unset. + const importExportOut: {isESModule?: boolean} = {}; + if (options.experimentalImportSupport === true) { plugins.push([ metroTransformPlugins.importExportPlugin, { importAll, importDefault, + out: importExportOut, resolve: false, } as ImportExportPluginOptions, ]); @@ -376,6 +406,19 @@ async function transformJS( let dependencyMapName = ''; let dependencies; + let isESModule = false; + // No ESM marker, no dependencies, and no expression anywhere in the module + // that could define `exports.__esModule` out of view of the top-level scan + // (see `canDefineESModuleInterop`). The dependency check is retained + // separately because a module with dependencies could re-export an ES module + // wholesale - `module.exports = require('./esm.js')` - which is a runtime + // property of the graph rather than of this module's syntax. + // + // Note this establishes the absence of ESM interop, not the presence of + // CommonJS - a script or an empty module qualifies too. Sufficient to cover + // the common FBiOS case: generated Relay fragments and other build-generated + // data modules that literal-export constants and never require anything else. + let hasNoESModuleInterop = false; let wrappedAst; // If the module to transform is a script (meaning that is not part of the @@ -410,6 +453,14 @@ async function transformJS( : null, }; ({ast, dependencies, dependencyMapName} = collectDependencies(ast, opts)); + // Positive-only hint from the import-export-plugin (a definite ES module), + // otherwise infer from the AST (catches ESM already lowered to CJS by + // Babel/tsc, where the plugin saw no ESM syntax). + isESModule = importExportOut.isESModule ?? definesESModuleInterop(ast); + hasNoESModuleInterop = + !isESModule && + dependencies.length === 0 && + !canDefineESModuleInterop(ast); } catch (error) { if (error instanceof InternalInvalidRequireCallError) { throw new InvalidRequireCallError(error, file.filename); @@ -513,6 +564,18 @@ async function transformJS( functionMap: file.functionMap, lineCount, map, + // A tri-state signal (see JsOutput.data.isESModule): + // `true` - definitely an ES module (positive ESM check). + // `false` - definitely no ESM interop: no marker, no dependencies, + // and no expression that could define the marker out of + // view. Not a claim that the module is CommonJS. + // unset - undetermined; consumers must fall back to helper + // behaviour. + ...(isESModule + ? {isESModule: true} + : hasNoESModuleInterop + ? {isESModule: false} + : null), }, type: file.type, }, @@ -638,7 +701,16 @@ async function transformJSON( const outputMap = vlqMapFromTuples(map); const output: Array = [ { - data: {code, functionMap: null, lineCount, map: outputMap}, + data: { + code, + functionMap: null, + lineCount, + map: outputMap, + // JSON is trivially never an ES module, so we can assert a definite + // `false` here (unlike the JS path, where an undetected runtime ESM + // means we must leave the hint unset rather than emit a false negative). + isESModule: false, + }, type: jsType, }, ]; diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js b/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js index 8513ee4e7b..60151e3c34 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js @@ -34,8 +34,12 @@ export default function getSourceMapInfo( readonly lineCount: number, readonly isIgnored: boolean, } { + const data = getJsOutput(module).data; return { - ...getJsOutput(module).data, + code: data.code, + functionMap: data.functionMap, + lineCount: data.lineCount, + map: data.map, isIgnored: options.shouldAddToIgnoreList(module), path: options?.getSourceUrl?.(module) ?? module.path, source: options.excludeSource ? '' : getModuleSource(module), diff --git a/packages/metro/src/ModuleGraph/worker/__tests__/esmClassification-test.js b/packages/metro/src/ModuleGraph/worker/__tests__/esmClassification-test.js new file mode 100644 index 0000000000..1e6920d606 --- /dev/null +++ b/packages/metro/src/ModuleGraph/worker/__tests__/esmClassification-test.js @@ -0,0 +1,181 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import { + canDefineESModuleInterop, + definesESModuleInterop, +} from '../esmClassification'; +import {parse} from '@babel/parser'; + +const ast = (code: string) => parse(code, {sourceType: 'script'}); + +const defines = (code: string) => definesESModuleInterop(ast(code)); +const canDefine = (code: string) => canDefineESModuleInterop(ast(code)); + +describe('definesESModuleInterop', () => { + test.each([ + ["Object.defineProperty(exports, '__esModule', {value: true});", 'babel'], + ['Object.defineProperty(exports, "__esModule", {value: !0});', 'minified'], + [ + "Object.defineProperty(module.exports, '__esModule', {value: 1});", + 'handwritten wrapper', + ], + ['exports.__esModule = true;', 'loose'], + [ + 'module.exports = f, module.exports.__esModule = true, module.exports["default"] = module.exports;', + '@babel/runtime helper', + ], + ])('detects the marker: %s (%s)', code => { + expect(defines(code)).toBe(true); + }); + + test('does not fire on an unrelated export', () => { + expect(defines('exports.foo = 1;')).toBe(false); + }); + + test('does not fire on a marker nested inside a function', () => { + // Not top-level, so out of scope for this check - which is precisely the + // gap `canDefineESModuleInterop` exists to close. + expect( + defines('function r(e) {Object.defineProperty(e, "__esModule", {});}'), + ).toBe(false); + }); +}); + +describe('canDefineESModuleInterop', () => { + describe('rules a module out', () => { + test('the generated Relay artifact shape', () => { + // Shape emitted by relay-compiler for a fragment, after the Flow types + // (which are comments) are stripped: a single module-scope binding + // initialised from an IIFE returning an object literal, one static + // property write, and a whole-object export. + expect( + canDefine(` + 'use strict'; + var node = (function(){ + var v0 = {"kind": "Literal", "name": "id", "value": 42}; + return { + "argumentDefinitions": [v0], + "kind": "Fragment", + "metadata": null, + "name": "SomeFragment", + "selections": [v0], + "type": "SomeType", + "abstractKey": "__isSomeType" + }; + })(); + if (__DEV__) { + node.hash = "4e3995aa3aa0eb9886c4cfa56381b521"; + } + module.exports = node; + `), + ).toBe(false); + }); + + test('a module with only static named exports', () => { + expect(canDefine('exports.a = 1; exports.b = "two";')).toBe(false); + }); + + test('a module exporting an object literal directly', () => { + expect(canDefine('module.exports = {a: 1, b: 2};')).toBe(false); + }); + + test('an empty module', () => { + expect(canDefine("'use strict';")).toBe(false); + }); + + test('static writes via module.exports.', () => { + expect(canDefine('module.exports.a = 1;')).toBe(false); + }); + }); + + describe('bails out', () => { + test('when the marker is present at the top level', () => { + expect(canDefine('exports.__esModule = true;')).toBe(true); + }); + + test('when the token appears anywhere at all, however nested', () => { + expect( + canDefine( + 'function r(e) {Object.defineProperty(e, "__esModule", {value: 1});}', + ), + ).toBe(true); + }); + + test('when the token appears only as an object key', () => { + expect( + canDefine('module.exports = {__esModule: true, default: 1};'), + ).toBe(true); + }); + + test('on the webpack UMD bundle shape', () => { + // The marker is installed by a helper on a dynamically passed object and + // the exported value is opaque - invisible to a top-level scan, and + // reachable with zero dependencies. This is the real-world case that + // motivates the check (e.g. vendored `*.min.js` bundles). + expect( + canDefine(` + !function(e, t) { + "object" == typeof exports && "object" == typeof module + ? module.exports = t() + : e.math = t(); + }(this, function() { + function i(e) { var t = {exports: {}}; return t.exports; } + i.r = function(e) { + Object.defineProperty(e, "__esModule", {value: !0}); + }; + return i(0); + }); + `), + ).toBe(true); + }); + + test('when exports is passed to a function', () => { + expect(canDefine('makeItESM(exports);')).toBe(true); + }); + + test('when exports is aliased to a local', () => { + expect(canDefine('var e = exports; e.foo = 1;')).toBe(true); + }); + + test('when a property is written under a computed key', () => { + expect(canDefine("exports['__' + 'esModule'] = true;")).toBe(true); + }); + + test('when an object literal uses a computed key', () => { + expect(canDefine("module.exports = {['__' + 'esModule']: true};")).toBe( + true, + ); + }); + + test('when the exported object spreads another value', () => { + expect(canDefine('module.exports = {...someOtherModule};')).toBe(true); + }); + + test('on Object.assign into exports', () => { + expect(canDefine('Object.assign(exports, someOtherModule);')).toBe(true); + }); + + test('on Object.defineProperties', () => { + expect(canDefine('Object.defineProperties(exports, descriptors);')).toBe( + true, + ); + }); + + test('when module is read for something other than exports', () => { + expect(canDefine('module.hot.accept();')).toBe(true); + }); + + test('when the exports object is returned from the module scope', () => { + expect(canDefine('someRegistry.register(module.exports);')).toBe(true); + }); + }); +}); diff --git a/packages/metro/src/ModuleGraph/worker/esmClassification.js b/packages/metro/src/ModuleGraph/worker/esmClassification.js new file mode 100644 index 0000000000..28ade28f49 --- /dev/null +++ b/packages/metro/src/ModuleGraph/worker/esmClassification.js @@ -0,0 +1,289 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type { + CallExpression as BabelNodeCallExpression, + File as BabelNodeFile, + Identifier as BabelNodeIdentifier, + Node as BabelNode, +} from '@babel/types'; + +import * as types from '@babel/types'; + +/** + * Classifies a module's relationship to ESM/CJS interop from two directions: + * + * `definesESModuleInterop` - does this module set `exports.__esModule`? + * `canDefineESModuleInterop` - could it, anywhere we cannot see? + * + * The two are not complements. The first only inspects top-level statements, + * so a false result means "no marker here", not "no marker" - the marker can + * be installed from anywhere the exports object is reachable. The second + * closes that gap, so it takes both to assert that a module has no ESM + * interop at all. + */ + +function isExportsObject(node: BabelNode): boolean { + // `exports` + if (types.isIdentifier(node, {name: 'exports'})) { + return true; + } + // `module.exports` + return ( + types.isMemberExpression(node, {computed: false}) && + types.isIdentifier(node.object, {name: 'module'}) && + types.isIdentifier(node.property, {name: 'exports'}) + ); +} + +function isTruthyConstant(node: BabelNode): boolean { + if (types.isBooleanLiteral(node)) { + return node.value === true; + } + if (types.isNumericLiteral(node)) { + return node.value !== 0; + } + // `!0` (minified `true`) + if (types.isUnaryExpression(node, {operator: '!', prefix: true})) { + return types.isNumericLiteral(node.argument, {value: 0}); + } + return false; +} + +// `Object.defineProperty(exports, "__esModule", { value: })` +function isDefinePropertyESModule(call: BabelNodeCallExpression): boolean { + const callee = call.callee; + if ( + !types.isMemberExpression(callee, {computed: false}) || + !types.isIdentifier(callee.object, {name: 'Object'}) || + !types.isIdentifier(callee.property, {name: 'defineProperty'}) + ) { + return false; + } + const args = call.arguments; + if ( + args.length < 3 || + !isExportsObject(args[0]) || + !types.isStringLiteral(args[1], {value: '__esModule'}) || + !types.isObjectExpression(args[2]) + ) { + return false; + } + return args[2].properties.some( + prop => + types.isObjectProperty(prop, {computed: false}) && + (types.isIdentifier(prop.key, {name: 'value'}) || + types.isStringLiteral(prop.key, {value: 'value'})) && + isTruthyConstant(prop.value), + ); +} + +function expressionSetsESModule(expr: BabelNode): boolean { + if (types.isSequenceExpression(expr)) { + // `a, b, c` at the top level - each subexpression is independently + // observable. Recognise the marker in any position, so patterns like + // `module.exports = fn, module.exports.__esModule = true, ...` from + // `@babel/runtime/helpers/*` are detected. + return expr.expressions.some(expressionSetsESModule); + } + if (types.isCallExpression(expr)) { + return isDefinePropertyESModule(expr); + } + if (!types.isAssignmentExpression(expr) || expr.operator !== '=') { + return false; + } + const left = expr.left; + return ( + types.isMemberExpression(left) && + left.computed !== true && + types.isIdentifier(left.property, {name: '__esModule'}) && + isExportsObject(left.object) && + isTruthyConstant(expr.right) + ); +} + +/** + * Returns whether the given (post-transform) module AST declares ESM/CJS + * interop by setting `exports.__esModule` truthy at the top level. Recognises + * four shapes seen in the wild: + * + * Object.defineProperty(exports, '__esModule', {value: true}) + * - Metro's own ESM transform (`import-export-plugin`) + * - `@babel/plugin-transform-modules-commonjs` (default output) + * - typescript compiler `--module commonjs` + * - rollup with `esModule: true` (default) + * Object.defineProperty(module.exports, '__esModule', {value: true}) + * - handwritten interop wrappers + * exports.__esModule = true // also value 1 or !0 + * - `@babel/plugin-transform-modules-commonjs` with `loose: true` + * - some older tsc output + * - rollup with `esModule: 'if-default-prop'` + * module.exports = fn, module.exports.__esModule = true, ... + * - every helper under `@babel/runtime/helpers/` (sequence expression) + * + * AST-based (not a scan of generated code) so the check is robust to + * whitespace, quoting, and attribute ordering. Intentionally independent of + * whether the import-export-plugin ran: a module already lowered to CJS with + * the marker must still be recognised as an ES module, so this cannot be + * replaced by the plugin's `out.isESModule`. + */ +export function definesESModuleInterop(ast: BabelNodeFile): boolean { + for (const stmt of ast.program.body) { + if ( + types.isExpressionStatement(stmt) && + expressionSetsESModule(stmt.expression) + ) { + return true; + } + } + return false; +} + +// Collects the `exports`/`module` identifier nodes that belong to an export +// write we can fully account for: `module.exports = `, +// `exports. = ` and `module.exports. = `. Any +// occurrence left uncollected is treated as an escape by the caller. +function collectAccountedExportsRefs( + ast: BabelNodeFile, +): Set { + const accounted = new Set(); + + const accountForExportsObject = (node: BabelNode): boolean => { + // `exports` + if (types.isIdentifier(node, {name: 'exports'})) { + accounted.add(node); + return true; + } + // `module.exports` - both identifiers are occurrences of the names we + // track, so both have to be accounted for. Bound to locals so the + // refinements survive the calls that establish them. + if (types.isMemberExpression(node, {computed: false})) { + const object = node.object; + const property = node.property; + if ( + types.isIdentifier(object, {name: 'module'}) && + types.isIdentifier(property, {name: 'exports'}) + ) { + accounted.add(object); + accounted.add(property); + return true; + } + } + return false; + }; + + for (const stmt of ast.program.body) { + if (!types.isExpressionStatement(stmt)) { + continue; + } + const expr = stmt.expression; + if (!types.isAssignmentExpression(expr) || expr.operator !== '=') { + continue; + } + const left = expr.left; + // `module.exports = ` + if (accountForExportsObject(left)) { + continue; + } + // `exports. = ` / `module.exports. = `. A + // computed key is rejected by `hasDynamicPropertyDefinition`. + if (types.isMemberExpression(left, {computed: false})) { + accountForExportsObject(left.object); + } + } + + return accounted; +} + +// Any construct that can define a property whose key is not visible in the +// source text. With the `__esModule` token absent, these are the only +// remaining ways to produce the key (e.g. `exports['__' + 'esModule']`). +function hasDynamicPropertyDefinition(ast: BabelNodeFile): boolean { + let found = false; + types.traverseFast(ast, node => { + if (found) { + return; + } + if ( + // `x[k] = v` + (types.isAssignmentExpression(node) && + types.isMemberExpression(node.left, {computed: true})) || + // `{[k]: v}` + (types.isObjectProperty(node) && node.computed === true) || + // `{...x}` - `x` may carry the key + types.isSpreadElement(node) || + // `Object.assign(target, ...)`, `Object.defineProperties(...)` + (types.isCallExpression(node) && + types.isMemberExpression(node.callee, {computed: false}) && + types.isIdentifier(node.callee.object, {name: 'Object'}) && + (types.isIdentifier(node.callee.property, {name: 'assign'}) || + types.isIdentifier(node.callee.property, { + name: 'defineProperties', + }))) + ) { + found = true; + } + }); + return found; +} + +/** + * Returns whether the module *might* define `exports.__esModule`, i.e. whether + * `definesESModuleInterop` returning false could be a false negative. + * + * `definesESModuleInterop` only inspects top-level statements, so on its own it + * cannot distinguish "no marker" from "marker installed somewhere it can't + * see". A self-contained bundle, for instance, may hand its exports object to a + * helper that sets the key (webpack's `__webpack_require__.r`), which is + * invisible to a statement scan and needs no dependencies to do it. + * + * Returning false is an assertion that no expression in the module can produce + * the key, established by checking that all three hold: + * + * 1. `__esModule` does not occur anywhere, as an identifier or string. + * 2. `exports`/`module` are only ever read as the target of an export write + * we can enumerate - never aliased, passed to a function, or accessed + * with a computed key, any of which would let the key be set out of view. + * 3. No construct can define a property under a key that is not literally + * present in the source (see `hasDynamicPropertyDefinition`), which is + * what closes the gap left by (1). + * + * Deliberately conservative: anything unrecognised returns true. This is not a + * claim that the module is CommonJS - a script or an empty module also + * qualifies - only that it does not opt into ESM interop. + */ +export function canDefineESModuleInterop(ast: BabelNodeFile): boolean { + if (hasDynamicPropertyDefinition(ast)) { + return true; + } + const accounted = collectAccountedExportsRefs(ast); + let unsafe = false; + types.traverseFast(ast, node => { + if (unsafe) { + return; + } + if ( + types.isIdentifier(node, {name: '__esModule'}) || + types.isStringLiteral(node, {value: '__esModule'}) + ) { + unsafe = true; + return; + } + if ( + (types.isIdentifier(node, {name: 'exports'}) || + types.isIdentifier(node, {name: 'module'})) && + !accounted.has(node) + ) { + unsafe = true; + } + }); + return unsafe; +}