diff --git a/README.md b/README.md index 2787fb9..27eb93f 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Behavior notes (defaults in parentheses) - `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`. - `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only. -See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings, interop helpers, top-level await behavior, and `import.meta.main` handling. +See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings, interop helpers, top-level await behavior, and `import.meta.main` handling. For CommonJS to ESM lowering details, read [docs/cjs-to-esm.md](docs/cjs-to-esm.md). ## Roadmap diff --git a/docs/cjs-exports.md b/docs/cjs-exports.md index 41f2ef9..953f408 100644 --- a/docs/cjs-exports.md +++ b/docs/cjs-exports.md @@ -24,4 +24,5 @@ ## Testing strategy - Fixtures cover each supported pattern: computed literals, alias chains, destructuring, Object.assign fan-out, overwrite-then-augment sequences, and defineProperty/defineProperties (value + getter). Dynamic non-literal keys have a fixture to verify they remain un-exported. +- Alias-chain fixture covers `module.exports` aliases plus subsequent `exports` writes to ensure both surfaces stay connected. - Each transformed fixture is executed with Node before assertions to catch runtime/syntax issues, then its exports are asserted via ESM import. diff --git a/docs/cjs-to-esm.md b/docs/cjs-to-esm.md new file mode 100644 index 0000000..b2a63d7 --- /dev/null +++ b/docs/cjs-to-esm.md @@ -0,0 +1,38 @@ +# CommonJS to ESM lowering + +## Scope + +- Rewrites CommonJS modules to ESM when `target: 'module'` with `transformSyntax` enabled. +- Assumes Node 20.11+ runtime with native ESM. +- Skips lowering when `module` or `exports` are shadowed at module scope to avoid mis-compilation. +- Deprecated CJS features (`require.extensions`, `module.parent`, legacy folder-as-module resolution) are left as-is. + +## Imports + +- Top-level static `require()` (and `module.require()`) calls become hoisted ESM imports: + - `const foo = require('./x')` → `import * as foo from './x'` + - `const foo = module.require('./x')` → `import * as foo from './x'` + - Multi-declarator: `const a = require('./x'), { foo } = require('./y')` → hoisted namespace imports plus destructuring from those namespaces. + - `require('./x')` → `import './x'` +- Dynamic/templated/non-literal `require()` calls (or any require that isn't hoistable) stay as-is and trigger a `createRequire` shim: + - `import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);` + - Nested or block-scoped `require()` calls also stay under `createRequire` and execute at runtime. +- `require.main` maps to `import.meta.main`; comparisons against `module` are simplified to `import.meta.main` / `!import.meta.main`. +- `require.resolve` is rewritten to `import.meta.resolve` via the member-expression formatter. +- `require.cache` becomes an empty object placeholder; `require.extensions` is not rewritten (deprecated). + +## Exports + +- `exports`/`module.exports` assignments are rewritten to an internal `__exports` object. +- Export metadata is collected to emit real ESM exports at the end of the file: + - Default export synthesized when `module.exports = ...` is found. + - Named exports emitted for `exports.foo = ...`, `Object.assign(exports, {...})`, `Object.defineProperty`, etc. +- Shadowed `module`/`exports` bindings cause the transform to throw to avoid emitting invalid exports. +- Exports inside simple control flow are collected and re-exported; hoisting preserves the original runtime writes on `__exports`. + +## Runtime notes + +- `__filename`/`__dirname` become `import.meta.url`/`import.meta.dirname` in ESM output. +- `import.meta.filename` seeding ensures `import.meta` is present even if unused in source. +- Live-binding semantics for exports are preserved to the extent they exist in the source; assignments remain on `__exports` and are re-exported by reference. +- Conditional or runtime-dependent `require()` calls are not hoisted; they continue to execute at runtime under `createRequire`. diff --git a/package-lock.json b/package-lock.json index d760f5e..37437a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/module", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/module", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "license": "MIT", "dependencies": { "@knighted/specifier": "^2.0.9", diff --git a/package.json b/package.json index f28a993..8b0e221 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/module", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "description": "Transforms differences between ES modules and CommonJS.", "type": "module", "main": "dist/module.js", diff --git a/src/format.ts b/src/format.ts index b08843c..e866fce 100644 --- a/src/format.ts +++ b/src/format.ts @@ -30,6 +30,119 @@ const exportAssignment = ( const defaultInteropName = '__interopDefault' const interopHelper = `const ${defaultInteropName} = mod => (mod && mod.__esModule ? mod.default : mod);\n` +const isRequireCallee = (callee: any, shadowed: Set) => { + if ( + callee.type === 'Identifier' && + callee.name === 'require' && + !shadowed.has('require') + ) { + return true + } + + if ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'module' && + !shadowed.has('module') && + callee.property.type === 'Identifier' && + callee.property.name === 'require' + ) { + return true + } + + return false +} + +const isStaticRequire = (node: any, shadowed: Set) => + node.type === 'CallExpression' && + isRequireCallee(node.callee, shadowed) && + node.arguments.length === 1 && + node.arguments[0].type === 'Literal' && + typeof node.arguments[0].value === 'string' + +const isRequireCall = (node: any, shadowed: Set) => + node.type === 'CallExpression' && isRequireCallee(node.callee, shadowed) + +type RequireTransform = { + start: number + end: number + code: string +} + +const lowerCjsRequireToImports = ( + program: any, + code: MagicString, + shadowed: Set, +) => { + const transforms: RequireTransform[] = [] + const imports: string[] = [] + let nsIndex = 0 + let needsCreateRequire = false + + for (const stmt of program.body as any[]) { + if (stmt.type === 'VariableDeclaration') { + const decls = stmt.declarations + const allStatic = + decls.length > 0 && + decls.every((decl: any) => decl.init && isStaticRequire(decl.init, shadowed)) + + if (allStatic) { + for (const decl of decls) { + const init = decl.init! + const source = code.slice(init.arguments[0].start, init.arguments[0].end) + + if (decl.id.type === 'Identifier') { + imports.push(`import * as ${decl.id.name} from ${source};\n`) + } else if (decl.id.type === 'ObjectPattern') { + const ns = `__cjsImport${nsIndex++}` + const pattern = code.slice(decl.id.start, decl.id.end) + imports.push(`import * as ${ns} from ${source};\n`) + imports.push(`const ${pattern} = ${ns};\n`) + } else { + needsCreateRequire = true + } + } + + transforms.push({ start: stmt.start, end: stmt.end, code: ';\n' }) + continue + } + + for (const decl of decls) { + const init = decl.init + if (init && isRequireCall(init, shadowed)) { + needsCreateRequire = true + } + } + } + + if (stmt.type === 'ExpressionStatement') { + const expr = stmt.expression + + if (expr && isStaticRequire(expr, shadowed)) { + const source = code.slice(expr.arguments[0].start, expr.arguments[0].end) + imports.push(`import ${source};\n`) + transforms.push({ start: stmt.start, end: stmt.end, code: ';\n' }) + continue + } + + if (expr && isRequireCall(expr, shadowed)) { + needsCreateRequire = true + } + } + } + + return { transforms, imports, needsCreateRequire } +} + +const isRequireMainMember = (node: any, shadowed: Set) => + node && + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'require' && + !shadowed.has('require') && + node.property.type === 'Identifier' && + node.property.name === 'main' + const hasTopLevelAwait = (program: any) => { let found = false @@ -320,15 +433,33 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) => hasDefaultExportBeenReassigned: false, hasDefaultExportBeenAssigned: false, } satisfies ExportsMeta + const moduleIdentifiers = await collectModuleIdentifiers(ast.program) + const shadowedBindings = new Set( + [...moduleIdentifiers.entries()] + .filter(([, meta]) => meta.declare.length > 0) + .map(([name]) => name), + ) + + if (opts.target === 'module' && opts.transformSyntax) { + if (shadowedBindings.has('module') || shadowedBindings.has('exports')) { + throw new Error( + 'Cannot transform to ESM: module or exports is shadowed in module scope.', + ) + } + } + const exportTable = opts.target === 'module' ? await collectCjsExports(ast.program) : null - await collectModuleIdentifiers(ast.program) const shouldCheckTopLevelAwait = opts.target === 'commonjs' && opts.transformSyntax const containsTopLevelAwait = shouldCheckTopLevelAwait ? hasTopLevelAwait(ast.program) : false const shouldLowerCjs = opts.target === 'commonjs' && opts.transformSyntax + const shouldRaiseEsm = opts.target === 'module' && opts.transformSyntax + let hoistedImports: string[] = [] + let pendingRequireTransforms: RequireTransform[] = [] + let needsCreateRequire = false let pendingCjsTransforms: { transforms: Array needsInterop: boolean @@ -340,21 +471,74 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) => ) } - if (opts.target === 'module' && opts.transformSyntax) { - /** - * Prepare ESM output by renaming `exports` to `__exports` and seeding an - * `import.meta.filename` touch so import.meta is present even when the - * original source never referenced it. - */ - code.prepend(`let ${exportsRename} = {}; -void import.meta.filename; -`) + if (shouldRaiseEsm) { + const { + transforms, + imports, + needsCreateRequire: reqCreate, + } = lowerCjsRequireToImports(ast.program, code, shadowedBindings) + + pendingRequireTransforms = transforms + hoistedImports = imports + needsCreateRequire = reqCreate } await ancestorWalk(ast.program, { async enter(node, ancestors) { const parent = ancestors[ancestors.length - 2] ?? null + if (shouldRaiseEsm && node.type === 'BinaryExpression') { + const op = node.operator + const isEquality = op === '===' || op === '==' || op === '!==' || op === '!=' + + if (isEquality) { + const leftMain = isRequireMainMember(node.left, shadowedBindings) + const rightMain = isRequireMainMember(node.right, shadowedBindings) + const leftModule = + node.left.type === 'Identifier' && + node.left.name === 'module' && + !shadowedBindings.has('module') + const rightModule = + node.right.type === 'Identifier' && + node.right.name === 'module' && + !shadowedBindings.has('module') + + if ((leftMain && rightModule) || (rightMain && leftModule)) { + const negate = op === '!==' || op === '!=' + code.update( + node.start, + node.end, + negate ? '!import.meta.main' : 'import.meta.main', + ) + return + } + } + } + + if ( + shouldRaiseEsm && + node.type === 'CallExpression' && + isRequireCall(node, shadowedBindings) + ) { + const isStatic = isStaticRequire(node, shadowedBindings) + const parent = ancestors[ancestors.length - 2] ?? null + const grandparent = ancestors[ancestors.length - 3] ?? null + const greatGrandparent = ancestors[ancestors.length - 4] ?? null + + // Hoistable cases are handled separately and don't need createRequire. + const topLevelExprStmt = + parent?.type === 'ExpressionStatement' && grandparent?.type === 'Program' + const topLevelVarDecl = + parent?.type === 'VariableDeclarator' && + grandparent?.type === 'VariableDeclaration' && + greatGrandparent?.type === 'Program' + const hoistableTopLevel = isStatic && (topLevelExprStmt || topLevelVarDecl) + + if (!isStatic || !hoistableTopLevel) { + needsCreateRequire = true + } + } + if ( node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || @@ -445,7 +629,7 @@ void import.meta.filename; } if (node.type === 'MemberExpression') { - memberExpression(node, parent, code, opts) + memberExpression(node, parent, code, opts, shadowedBindings) } if (isIdentifierName(node)) { @@ -455,11 +639,18 @@ void import.meta.filename; code, opts, meta: exportsMeta, + shadowed: shadowedBindings, }) } }, }) + if (pendingRequireTransforms.length) { + for (const t of pendingRequireTransforms) { + code.overwrite(t.start, t.end, t.code) + } + } + if (shouldLowerCjs) { const { importTransforms, exportTransforms, needsInterop } = lowerEsmToCjs( ast.program, @@ -525,6 +716,30 @@ void import.meta.filename; } } + if (shouldRaiseEsm && opts.transformSyntax) { + const importPrelude: string[] = [] + + if (needsCreateRequire) { + importPrelude.push('import { createRequire } from "node:module";\n') + } + + if (hoistedImports.length) { + importPrelude.push(...hoistedImports) + } + + const requireInit = needsCreateRequire + ? 'const require = createRequire(import.meta.url);\n' + : '' + + const prelude = `${importPrelude.join('')}${ + importPrelude.length ? '\n' : '' + }${requireInit}let ${exportsRename} = {}; +void import.meta.filename; +` + + code.prepend(prelude) + } + if (opts.target === 'commonjs' && opts.transformSyntax && containsTopLevelAwait) { const body = code.toString() diff --git a/src/formatters/identifier.ts b/src/formatters/identifier.ts index 0b79479..b570a77 100644 --- a/src/formatters/identifier.ts +++ b/src/formatters/identifier.ts @@ -11,12 +11,24 @@ type IdentifierArg = { code: MagicString opts: FormatterOptions meta: ExportsMeta + shadowed?: Set } -export const identifier = ({ node, ancestors, code, opts, meta }: IdentifierArg) => { +export const identifier = ({ + node, + ancestors, + code, + opts, + meta, + shadowed, +}: IdentifierArg) => { if (opts.target === 'module') { const { start, end, name } = node + if (shadowed?.has(name)) { + return + } + switch (name) { case '__filename': code.update(start, end, 'import.meta.url') diff --git a/src/formatters/memberExpression.ts b/src/formatters/memberExpression.ts index d14c86b..73267c5 100644 --- a/src/formatters/memberExpression.ts +++ b/src/formatters/memberExpression.ts @@ -9,8 +9,15 @@ export const memberExpression = ( parent: Node | null, src: MagicString, options: FormatterOptions, + shadowed?: Set, ) => { if (options.target === 'module') { + if ( + (node.object.type === 'Identifier' && shadowed?.has(node.object.name)) || + (node.property.type === 'Identifier' && shadowed?.has(node.property.name)) + ) { + return + } if ( node.object.type === 'Identifier' && node.property.type === 'Identifier' && @@ -32,19 +39,10 @@ export const memberExpression = ( // CommonJS properties of `require` switch (name) { case 'main': - /** - * Node.js team still quibbling over import.meta.main ¯\_(ツ)_/¯ - * @see https://github.com/nodejs/node/pull/32223 - */ - if (parent?.type === 'ExpressionStatement') { - // This is a standalone expression so remove it to not cause run-time errors. - src.remove(start, end) - } - /** - * Transform require.main === module. - */ if (parent?.type === 'BinaryExpression') { + return } + src.update(start, end, 'import.meta.main') break case 'resolve': src.update(start, end, 'import.meta.resolve') @@ -58,5 +56,16 @@ export const memberExpression = ( break } } + + if ( + node.object.type === 'Identifier' && + node.property.type === 'Identifier' && + node.object.name === 'module' && + node.property.name === 'require' + ) { + if (!shadowed?.has('module')) { + src.update(node.start, node.end, 'require') + } + } } } diff --git a/test/fixtures/aliasModuleExports.cjs b/test/fixtures/aliasModuleExports.cjs new file mode 100644 index 0000000..bd9a420 --- /dev/null +++ b/test/fixtures/aliasModuleExports.cjs @@ -0,0 +1,7 @@ +module.exports = {} +const m = module.exports +const n = m + +m.foo = 1 +n.bar = 2 +exports.baz = 3 diff --git a/test/fixtures/blockRequire.cjs b/test/fixtures/blockRequire.cjs new file mode 100644 index 0000000..5eac63e --- /dev/null +++ b/test/fixtures/blockRequire.cjs @@ -0,0 +1,10 @@ +let fooVal +let commonjsVal + +if (Date.now() > 0) { + const mod = require('./values.cjs') + fooVal = mod.foo + commonjsVal = mod.commonjs +} + +module.exports = { foo: fooVal, commonjs: commonjsVal } diff --git a/test/fixtures/exportsControlFlow.cjs b/test/fixtures/exportsControlFlow.cjs new file mode 100644 index 0000000..104c232 --- /dev/null +++ b/test/fixtures/exportsControlFlow.cjs @@ -0,0 +1,7 @@ +const cond = Date.now() > 0 + +if (cond) { + exports.flag = 'ok' +} else { + exports.flag = 'ok' +} diff --git a/test/fixtures/moduleRequire.cjs b/test/fixtures/moduleRequire.cjs new file mode 100644 index 0000000..aae5c0a --- /dev/null +++ b/test/fixtures/moduleRequire.cjs @@ -0,0 +1,2 @@ +const mod = module.require('./values.cjs') +module.exports = { foo: mod.foo, commonjs: mod.commonjs } diff --git a/test/fixtures/nestedRequire.cjs b/test/fixtures/nestedRequire.cjs new file mode 100644 index 0000000..d86af7a --- /dev/null +++ b/test/fixtures/nestedRequire.cjs @@ -0,0 +1,6 @@ +function build() { + const mod = require('./values.cjs') + return { foo: mod.foo, commonjs: mod.commonjs } +} + +module.exports = build() diff --git a/test/fixtures/noop.cjs b/test/fixtures/noop.cjs new file mode 100644 index 0000000..ada54b5 --- /dev/null +++ b/test/fixtures/noop.cjs @@ -0,0 +1 @@ +module.exports = 'noop' diff --git a/test/fixtures/requireDynamic.cjs b/test/fixtures/requireDynamic.cjs new file mode 100644 index 0000000..113fd87 --- /dev/null +++ b/test/fixtures/requireDynamic.cjs @@ -0,0 +1,9 @@ +const { join } = require('node:path') +const target = './values.cjs' +const mod = require(target) + +module.exports = { + foo: mod.foo, + commonjs: mod.commonjs, + resolved: join('fixtures', 'values'), +} diff --git a/test/fixtures/requireMain.cjs b/test/fixtures/requireMain.cjs new file mode 100644 index 0000000..157fa41 --- /dev/null +++ b/test/fixtures/requireMain.cjs @@ -0,0 +1,5 @@ +if (require.main === module) { + module.exports = { main: true } +} else { + module.exports = { main: false } +} diff --git a/test/fixtures/requireMulti.cjs b/test/fixtures/requireMulti.cjs new file mode 100644 index 0000000..ca80b6f --- /dev/null +++ b/test/fixtures/requireMulti.cjs @@ -0,0 +1,6 @@ +const a = require('./values.cjs'), + { foo, commonjs } = require('./values.cjs') + +exports.a = a +exports.foo = foo +exports.commonjs = commonjs diff --git a/test/fixtures/requireStatic.cjs b/test/fixtures/requireStatic.cjs new file mode 100644 index 0000000..4922b2e --- /dev/null +++ b/test/fixtures/requireStatic.cjs @@ -0,0 +1,9 @@ +const mod = require('./values.cjs') +const { foo, commonjs: renamed } = require('./values.cjs') +require('./noop.cjs') + +module.exports = { + foo: mod.foo, + renamed, + cjs: mod.cjs, +} diff --git a/test/fixtures/shadowedExports.cjs b/test/fixtures/shadowedExports.cjs new file mode 100644 index 0000000..da784e1 --- /dev/null +++ b/test/fixtures/shadowedExports.cjs @@ -0,0 +1,3 @@ +const exports = { local: true } +exports.foo = 'bar' +module.exports = exports diff --git a/test/module.ts b/test/module.ts index 4075a6c..508256c 100644 --- a/test/module.ts +++ b/test/module.ts @@ -207,6 +207,205 @@ describe('@knighted/module', () => { }) }) + it('rewrites multi-declarator static require to imports when lowering to esm', async t => { + const fixturePath = join(fixtures, 'requireMulti.cjs') + const outFile = join(fixtures, 'requireMulti.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + assert.ok(result.indexOf("import * as a from './values.cjs'") > -1) + assert.ok(result.indexOf('const { foo, commonjs } = __cjsImport0;') > -1) + assert.equal(/require\(['"]\.\/values\.cjs['"]\)/.test(result), false) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).foo, 'bar') + assert.equal((mod as any).commonjs, true) + assert.equal((mod as any).a.cjs, 'commonjs') + }) + + it('supports module.require while lowering to esm', async t => { + const fixturePath = join(fixtures, 'moduleRequire.cjs') + const outFile = join(fixtures, 'moduleRequire.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + assert.ok(result.indexOf("import * as mod from './values.cjs'") > -1) + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).default.foo, 'bar') + assert.equal((mod as any).default.commonjs, true) + }) + + it('keeps nested requires via createRequire when lowering to esm', async t => { + const fixturePath = join(fixtures, 'nestedRequire.cjs') + const outFile = join(fixtures, 'nestedRequire.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + assert.ok(result.indexOf('createRequire') > -1) + assert.ok(result.indexOf('const require = createRequire(import.meta.url);') > -1) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).default.foo, 'bar') + assert.equal((mod as any).default.commonjs, true) + }) + + it('keeps block-scoped require via createRequire when lowering to esm', async t => { + const fixturePath = join(fixtures, 'blockRequire.cjs') + const outFile = join(fixtures, 'blockRequire.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + assert.ok(result.indexOf('createRequire') > -1) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).default.foo, 'bar') + assert.equal((mod as any).default.commonjs, true) + }) + + it('handles module.exports alias chains when lowering to esm', async t => { + const fixturePath = join(fixtures, 'aliasModuleExports.cjs') + const outFile = join(fixtures, 'aliasModuleExports.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).foo, 1) + assert.equal((mod as any).bar, 2) + assert.equal((mod as any).baz, 3) + }) + + it('rewrites require.main to import.meta.main', async t => { + const fixturePath = join(fixtures, 'requireMain.cjs') + const outFile = join(fixtures, 'requireMain.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + assert.ok(result.indexOf('import.meta.main') > -1) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).default.main, false) + }) + + it('lifts exports inside control flow when lowering to esm', async t => { + const fixturePath = join(fixtures, 'exportsControlFlow.cjs') + const outFile = join(fixtures, 'exportsControlFlow.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).flag, 'ok') + }) + + it('rewrites static require to imports when lowering to esm', async t => { + const fixturePath = join(fixtures, 'requireStatic.cjs') + const outFile = join(fixtures, 'requireStatic.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + assert.ok(result.indexOf("import * as mod from './values.cjs'") > -1) + assert.equal(/require\(['"]\.\/values\.cjs['"]\)/.test(result), false) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).default.foo, 'bar') + assert.equal((mod as any).default.renamed, true) + assert.equal((mod as any).default.cjs, 'commonjs') + }) + + it('keeps dynamic require via createRequire when lowering to esm', async t => { + const fixturePath = join(fixtures, 'requireDynamic.cjs') + const outFile = join(fixtures, 'requireDynamic.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + assert.ok(result.indexOf('createRequire') > -1) + assert.ok(result.indexOf('const require = createRequire(import.meta.url);') > -1) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).default.foo, 'bar') + assert.equal((mod as any).default.commonjs, true) + }) + + it('throws when module or exports is shadowed in cjs to esm lowering', async () => { + const fixturePath = join(fixtures, 'shadowedExports.cjs') + + await assert.rejects( + () => transform(fixturePath, { target: 'module' }), + /shadowed in module scope/i, + ) + }) + const transformEsmToCjs = async (t: any, file: string) => { const fixturePath = join(fixtures, file) const result = await transform(fixturePath, { target: 'commonjs' })