Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,36 @@ See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings,
> [!NOTE]
> Known limitations: `with` and unshadowed `eval` are rejected when raising CJS to ESM because the rewrite would be unsound; bare specifiers are not rewritten—only relative specifiers participate in `rewriteSpecifier`.

### Diagnostics callback example

Pass a `diagnostics` callback to surface CJS→ESM edge cases (mixed `module.exports`/`exports`, top-level `return`, legacy `require.cache`/`require.extensions`, live-binding reassignments, string-literal export names):

```ts
import { transform } from '@knighted/module'

const diagnostics: any[] = []

await transform('./file.cjs', {
target: 'module',
diagnostics: diag => diagnostics.push(diag),
})

console.log(diagnostics)
// [
// {
// level: 'warning',
// code: 'cjs-mixed-exports',
// message: 'Both module.exports and exports are assigned in this module; CommonJS shadowing may not match synthesized ESM exports.',
// filePath: './file.cjs',
// loc: { start: 12, end: 48 }
// },
// ...
// ]
```

> [!WARNING]
> When raising CommonJS to ESM, synthesized named exports rely on literal keys and `const` literal aliases (e.g., `const key = 'foo'; exports[key] = value`). `var`/`let` bindings used as export keys are not tracked, so prefer direct property names or `const` literals when exporting.

## Pre-`tsc` transforms for TypeScript diagnostics

TypeScript reports asymmetric module-global errors (e.g., `import.meta` in CJS, `__dirname` in ESM) as tracked in [microsoft/TypeScript#58658](https://github.com/microsoft/TypeScript/issues/58658). You can mitigate this by running `@knighted/module` **before** `tsc` so the checker sees already-rewritten sources.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/module",
"version": "1.0.0-rc.3",
"version": "1.0.0-rc.4",
"description": "Transforms differences between ES modules and CommonJS.",
"type": "module",
"main": "dist/module.js",
Expand Down
90 changes: 89 additions & 1 deletion src/format.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ParseResult } from 'oxc-parser'
import type { FormatterOptions, ExportsMeta } from './types.js'
import type { FormatterOptions, ExportsMeta, Diagnostic } from './types.js'
import MagicString from 'magic-string'

import { identifier } from '#formatters/identifier.js'
Expand Down Expand Up @@ -496,6 +496,38 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
hasDefaultExportBeenReassigned: false,
hasDefaultExportBeenAssigned: false,
} satisfies ExportsMeta
const warned = new Set<string>()
const emitDiagnostic = (diag: Diagnostic) => {
if (opts.diagnostics) {
opts.diagnostics(diag)
return
}

if (diag.level === 'warning') {
// eslint-disable-next-line no-console -- used for opt-in diagnostics
console.warn(diag.message)
return
}

// eslint-disable-next-line no-console -- used for opt-in diagnostics
console.error(diag.message)
}
const warnOnce = (
codeId: string,
message: string,
loc?: { start: number; end: number },
) => {
const key = `${codeId}:${loc?.start ?? ''}`
if (warned.has(key)) return
warned.add(key)
emitDiagnostic({
level: 'warning',
code: codeId,
message,
filePath: opts.filePath,
loc,
})
}
const moduleIdentifiers = await collectModuleIdentifiers(ast.program)
const shadowedBindings = new Set(
[...moduleIdentifiers.entries()]
Expand All @@ -513,6 +545,29 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>

const exportTable =
opts.target === 'module' ? await collectCjsExports(ast.program) : null
if (opts.target === 'module' && exportTable) {
const hasExportsVia = [...exportTable.values()].some(entry =>
entry.via.has('exports'),
)
const hasModuleExportsVia = [...exportTable.values()].some(entry =>
entry.via.has('module.exports'),
)

if (hasExportsVia && hasModuleExportsVia) {
const firstExports = [...exportTable.values()].find(entry =>
entry.via.has('exports'),
)?.writes[0]
const firstModule = [...exportTable.values()].find(entry =>
entry.via.has('module.exports'),
)?.writes[0]

warnOnce(
'cjs-mixed-exports',
'Both module.exports and exports are assigned in this module; CommonJS shadowing may not match synthesized ESM exports.',
{ start: firstModule?.start ?? 0, end: firstExports?.end ?? 0 },
)
}
}
const shouldCheckTopLevelAwait = opts.target === 'commonjs' && opts.transformSyntax
const containsTopLevelAwait = shouldCheckTopLevelAwait
? hasTopLevelAwait(ast.program)
Expand Down Expand Up @@ -560,6 +615,18 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
async enter(node, ancestors) {
const parent = ancestors[ancestors.length - 2] ?? null

if (
shouldRaiseEsm &&
node.type === 'ReturnStatement' &&
parent?.type === 'Program'
) {
warnOnce(
'top-level-return',
'Top-level return is not allowed in ESM; the transformed module will fail to parse.',
{ start: node.start, end: node.end },
)
}

if (shouldRaiseEsm && node.type === 'BinaryExpression') {
const op = node.operator
const isEquality = op === '===' || op === '==' || op === '!==' || op === '!='
Expand Down Expand Up @@ -742,6 +809,9 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
if (shouldRaiseEsm) needsRequireResolveHelper = true
},
requireResolveName: '__requireResolve',
onDiagnostic: (codeId, message, loc) => {
if (shouldRaiseEsm) warnOnce(codeId, message, loc)
},
})
}

Expand Down Expand Up @@ -823,6 +893,17 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
return `__export_${safe}`
}

for (const [key, entry] of exportTable) {
if (entry.reassignments.length) {
const loc = entry.reassignments[0]
warnOnce(
`cjs-export-reassignment:${key}`,
`Export '${key}' is reassigned after export; ESM live bindings may change consumer behavior.`,
{ start: loc.start, end: loc.end },
)
}
}

const lines: string[] = []

const defaultEntry = exportTable.get('default')
Expand All @@ -834,6 +915,13 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
for (const [key, entry] of exportTable) {
if (key === 'default') continue

if (!isValidExportName(key)) {
warnOnce(
`cjs-string-export:${key}`,
`Synthesized string-literal export '${key}'. Some tooling may require bracket access to use it.`,
)
}

if (entry.fromIdentifier) {
lines.push(`export { ${entry.fromIdentifier} as ${asExportName(key)} };`)
} else {
Expand Down
31 changes: 31 additions & 0 deletions src/formatters/memberExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { exportsRename } from '#utils/exports.js'
type MemberExpressionExtras = {
onRequireResolve?: () => void
requireResolveName?: string
onDiagnostic?: (
code: string,
message: string,
loc?: { start: number; end: number },
) => void
}

export const memberExpression = (
Expand Down Expand Up @@ -59,8 +64,20 @@ export const memberExpression = (
* Can of worms here. ¯\_(ツ)_/¯
* @see https://github.com/nodejs/help/issues/2806
*/
extras?.onDiagnostic?.(
'legacy-require-cache',
'Access to require.cache is not supported when raising to ESM; behavior may differ.',
{ start, end },
)
src.update(start, end, '{}')
break
case 'extensions':
extras?.onDiagnostic?.(
'legacy-require-extensions',
'Access to require.extensions is not supported when raising to ESM; use loaders instead.',
{ start, end },
)
break
}
}

Expand All @@ -73,6 +90,20 @@ export const memberExpression = (
if (!shadowed?.has('module')) {
src.update(node.start, node.end, 'require')
}
return
}

if (
node.object.type === 'Identifier' &&
node.property.type === 'Identifier' &&
node.object.name === 'module' &&
(node.property.name === 'parent' || node.property.name === 'children')
) {
extras?.onDiagnostic?.(
`legacy-module-${node.property.name}`,
`Access to module.${node.property.name} may not behave the same in ESM; consider loaders or explicit wiring instead.`,
{ start: node.start, end: node.end },
)
}
}
}
2 changes: 1 addition & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ const defaultOptions = {
inPlace: false,
} satisfies ModuleOptions
const transform = async (filename: string, options: ModuleOptions = defaultOptions) => {
const opts = { ...defaultOptions, ...options }
const opts = { ...defaultOptions, ...options, filePath: filename }
const appendMode: AppendJsExtensionMode =
options?.appendJsExtension ?? (opts.target === 'module' ? 'relative-only' : 'off')
const dirIndex =
Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,24 @@ export type ModuleOptions = {
cjsDefault?: 'module-exports' | 'auto' | 'none'
/** Handling for top-level await constructs. */
topLevelAwait?: 'error' | 'wrap' | 'preserve'
/** Optional diagnostics sink for warnings/errors emitted during transform. */
diagnostics?: (diag: Diagnostic) => void
/** Optional source file path used for diagnostics context. */
filePath?: string
/** Output directory or file path when writing. */
out?: string
/** Overwrite input files instead of writing to out. */
inPlace?: boolean
}

export type Diagnostic = {
level: 'warning' | 'error'
code: string
message: string
filePath?: string
loc?: { start: number; end: number }
}

export type SpannedNode = Node & Span

export type ExportsMeta = {
Expand Down
11 changes: 11 additions & 0 deletions test/fixtures/diagnostics.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = { a: 1 }
exports.b = 2

let foo = 1
exports.foo = foo
foo = 2

exports['weird-name'] = 3

require.cache
return
18 changes: 18 additions & 0 deletions test/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,24 @@ describe('@knighted/module', () => {
assert.equal((mod as any).value, 42)
})

it('emits diagnostics for CJS to ESM edge cases', async () => {
const fixturePath = join(fixtures, 'diagnostics.cjs')
const diagnostics: Array<{ code: string }> = []

await transform(fixturePath, {
target: 'module',
diagnostics: diag => diagnostics.push(diag),
})

const codes = diagnostics.map(d => d.code).sort()

assert.ok(codes.includes('cjs-mixed-exports'))
assert.ok(codes.some(code => code.startsWith('cjs-export-reassignment:foo')))
assert.ok(codes.includes('cjs-string-export:weird-name'))
assert.ok(codes.includes('top-level-return'))
assert.ok(codes.includes('legacy-require-cache'))
})

it('normalizes builtin specifiers to the node: protocol', async t => {
const specifierRoot = join(fixtures, 'specifier')
const fixturePath = join(specifierRoot, 'builtin.cjs')
Expand Down