Skip to content

Commit 39719a9

Browse files
feat: hoisting and tdz. (#20)
1 parent 712d31f commit 39719a9

21 files changed

Lines changed: 312 additions & 13 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Behavior notes (defaults in parentheses)
133133
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
134134
- `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.
135135
- `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only.
136+
- CommonJS → ESM lowering will throw on `with` statements and unshadowed `eval` calls to avoid unsound rewrites.
136137
137138
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).
138139
@@ -141,3 +142,4 @@ See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings,
141142
- Remove `@knighted/specifier` and avoid double parsing.
142143
- Emit source maps and clearer diagnostics for transform choices.
143144
- Broaden fixtures covering live-binding and top-level await edge cases across Node versions.
145+
- Benchmark scope analysis choices: compare `periscopic`, `scope-analyzer`, and `eslint-scope` on fixtures and pick the final adapter.

docs/cjs-to-esm.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Rewrites CommonJS modules to ESM when `target: 'module'` with `transformSyntax` enabled.
66
- Assumes Node 22.21+ runtime with native ESM.
77
- Skips lowering when `module` or `exports` are shadowed at module scope to avoid mis-compilation.
8+
- Throws when encountering `with` statements or unshadowed `eval` to avoid unsound rewrites.
89
- Deprecated CJS features (`require.extensions`, `module.parent`, legacy folder-as-module resolution) are left as-is.
910

1011
## Imports

docs/esm-to-cjs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## Scope
44

55
- Translates ESM syntax to CommonJS when `target: 'commonjs'` with `transformSyntax` enabled.
6-
- Assumes Node 20.11+ runtime with `__filename`/`__dirname` shims supplied by the host.
6+
- Assumes Node 22.21+ runtime with `__filename`/`__dirname` shims supplied by the host (matches package engines).
77
- Keeps optional live-binding behavior via `liveBindings` (strict/loose/off).
88
- Top-level await (TLA) handling is controlled by `topLevelAwait: 'error' | 'wrap' | 'preserve'` when lowering to CommonJS.
99
- Optional import.meta.main gating via `importMetaMain: 'shim' | 'warn' | 'error'`.

docs/hoisting-tdz.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Hoisting and TDZ in identifier tracking
2+
3+
## Purpose
4+
5+
This library tracks module-scope identifiers so CJS↔ESM lowering can avoid collisions and emit correct exports/imports. We model only hoisting behaviors that affect module-scope resolution and skip cases that either error at runtime or are handled by the module loader.
6+
7+
## What we treat as hoisted
8+
9+
- `function` declarations at module scope: reads before the declaration are counted.
10+
- `var` declarations at module scope: reads before the declaration are counted (value is `undefined`).
11+
12+
## What we do not hoist
13+
14+
- `let` / `const` / `class`: reads in the temporal dead zone are ignored for hoist accounting (they are runtime errors). See fixtures/tests under `test/fixtures/identifiers/hoisting/tdz.js`.
15+
- `import` bindings: import hoisting is handled by the module system; we exclude imports from module-scope hoist tracking. See `test/fixtures/identifiers/hoisting/importHoist.js`.
16+
- Function declarations inside blocks (strict mode): they stay block-scoped and are not treated as module-scope hoists. See `test/fixtures/identifiers/hoisting/functionInBlock.js`.
17+
18+
## Why this scope
19+
20+
- Our goal is collision-free lowering, not simulating all JS runtime hoisting/TDZ behavior.
21+
- Excluding TDZ and import hoists keeps the identifier table focused on cases that meaningfully affect CJS↔ESM transforms.

oxlint.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
"no-unused-expressions": "off",
1818
"no-dupe-class-members": "off",
1919
"no-useless-rename": "off",
20-
"unicorn/no-empty-file": "off"
20+
"unicorn/no-empty-file": "off",
21+
"no-with": "off",
22+
"no-console": "off",
23+
"no-eval": "off"
2124
}
2225
},
2326
{

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/module",
3-
"version": "1.0.0-beta.4",
3+
"version": "1.0.0-beta.5",
44
"description": "Transforms differences between ES modules and CommonJS.",
55
"type": "module",
66
"main": "dist/module.js",

src/format.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,20 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
515515
}
516516
}
517517

518+
if (shouldRaiseEsm && node.type === 'WithStatement') {
519+
throw new Error('Cannot transform to ESM: with statements are not supported.')
520+
}
521+
522+
if (
523+
shouldRaiseEsm &&
524+
node.type === 'CallExpression' &&
525+
node.callee.type === 'Identifier' &&
526+
node.callee.name === 'eval' &&
527+
!shadowedBindings.has('eval')
528+
) {
529+
throw new Error('Cannot transform to ESM: eval is not supported.')
530+
}
531+
518532
if (
519533
shouldRaiseEsm &&
520534
node.type === 'CallExpression' &&

src/utils.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import { scopeNodes } from './utils/scopeNodes.js'
1010

1111
type UpdateSrcLang = Parameters<Specifier['updateSrc']>[1]
1212
const getLangFromExt = (filename: string): UpdateSrcLang => {
13-
const ext = extname(filename)
13+
const ext = extname(filename).toLowerCase()
1414

15-
if (ext.endsWith('.js')) {
15+
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
1616
return 'js'
1717
}
1818

19-
if (ext.endsWith('.ts')) {
19+
if (ext === '.ts' || ext === '.mts' || ext === '.cts') {
2020
return 'ts'
2121
}
2222

@@ -276,6 +276,14 @@ const collectModuleIdentifiers = async (ast: Node, hoisting: boolean = true) =>
276276
const isVarDeclarationInGlobalScope =
277277
identifier.isVarDeclarationInGlobalScope(ancestors)
278278

279+
const parent = ancestors[ancestors.length - 2]
280+
const grandParent = ancestors[ancestors.length - 3]
281+
const hoistSafe =
282+
parent.type === 'FunctionDeclaration' ||
283+
(parent.type === 'VariableDeclarator' &&
284+
grandParent?.type === 'VariableDeclaration' &&
285+
grandParent.kind === 'var')
286+
279287
if (
280288
isModuleScope ||
281289
isClassOrFuncDeclaration ||
@@ -284,7 +292,7 @@ const collectModuleIdentifiers = async (ast: Node, hoisting: boolean = true) =>
284292
meta.declare.push(node)
285293

286294
// Check for hoisted reads
287-
if (hoisting && globalReads.has(name)) {
295+
if (hoisting && hoistSafe && globalReads.has(name)) {
288296
const reads = globalReads.get(name)
289297

290298
if (reads) {

src/utils/identifiers.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,14 @@ const collectModuleIdentifiers = async (ast: Node, hoisting: boolean = true) =>
155155
const isVarDeclarationInGlobalScope =
156156
identifier.isVarDeclarationInGlobalScope(ancestors)
157157

158+
const parent = ancestors[ancestors.length - 2]
159+
const grandParent = ancestors[ancestors.length - 3]
160+
const hoistSafe =
161+
parent.type === 'FunctionDeclaration' ||
162+
(parent.type === 'VariableDeclarator' &&
163+
grandParent?.type === 'VariableDeclaration' &&
164+
grandParent.kind === 'var')
165+
158166
if (
159167
isModuleScope ||
160168
isClassOrFuncDeclaration ||
@@ -163,7 +171,7 @@ const collectModuleIdentifiers = async (ast: Node, hoisting: boolean = true) =>
163171
meta.declare.push(node)
164172

165173
// Check for hoisted reads
166-
if (hoisting && globalReads.has(name)) {
174+
if (hoisting && hoistSafe && globalReads.has(name)) {
167175
const reads = globalReads.get(name)
168176

169177
if (reads) {

0 commit comments

Comments
 (0)