Skip to content

Commit 2c61213

Browse files
feat: hoisting and tdz.
1 parent 712d31f commit 2c61213

8 files changed

Lines changed: 158 additions & 2 deletions

File tree

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.

src/utils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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) {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// In strict mode, function declarations inside blocks are block-scoped and not hoisted
2+
// to the outer scope. Ensure we don't count reads before the declaration as hoisted.
3+
4+
{
5+
func()
6+
function func() {
7+
return 'block-func'
8+
}
9+
}
10+
11+
export {}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Imports are hoisted by the module system, but we do not treat them as part of
2+
// the module-scope identifier hoisting for collision/reads accounting.
3+
4+
import { x } from './importHoistDep.js'
5+
6+
const a = x
7+
const b = (() => x)()
8+
9+
export { a, b }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const x = 'import-hoist'
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// These TDZ reads would throw at runtime; we should not count them as safe hoists.
2+
// Access before declaration (let/const/class) remains in the TDZ.
3+
4+
// let
5+
const a = (() => {
6+
try {
7+
return foo
8+
} catch {
9+
return 'tdz'
10+
}
11+
})()
12+
let foo = 'foo'
13+
14+
// const
15+
const b = (() => {
16+
try {
17+
return bar
18+
} catch {
19+
return 'tdz'
20+
}
21+
})()
22+
const bar = 'bar'
23+
24+
// class
25+
const c = (() => {
26+
try {
27+
return Baz
28+
} catch {
29+
return 'tdz'
30+
}
31+
})()
32+
class Baz {}
33+
34+
// nested block TDZ
35+
{
36+
const d = (() => {
37+
try {
38+
return inner
39+
} catch {
40+
return 'tdz'
41+
}
42+
})()
43+
const inner = 'inner'
44+
}
45+
46+
export { a, b, c }

test/utils.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,56 @@ describe('collectModuleIdentifiers', () => {
138138
assert.deepEqual(innerBlockVarReads, [427])
139139
assert.deepEqual(catchVarReads, [536])
140140
})
141+
142+
it('does not treat TDZ reads (let/const/class) as hoists', async () => {
143+
const fixturePath = join(fixtures, 'identifiers', 'hoisting', 'tdz.js')
144+
const code = await readFile(fixturePath)
145+
const ast = parse('file.ts', code.toString())
146+
const idents = await collectModuleIdentifiers(ast.program, true)
147+
const { status } = spawnSync('node', [fixturePath], { stdio: 'inherit' })
148+
149+
// Test for valid syntax
150+
assert.equal(status, 0)
151+
152+
// TDZ reads should not be recorded as safe hoists; they should be absent or zero reads.
153+
assert.equal(idents.get('foo')?.read.length ?? 0, 0)
154+
assert.equal(idents.get('bar')?.read.length ?? 0, 0)
155+
assert.equal(idents.get('Baz')?.read.length ?? 0, 0)
156+
assert.equal(idents.get('inner')?.read.length ?? 0, 0)
157+
})
158+
159+
it('ignores import hoisting for identifier tracking', async () => {
160+
const fixturePath = join(fixtures, 'identifiers', 'hoisting', 'importHoist.js')
161+
const code = await readFile(fixturePath)
162+
const ast = parse('file.ts', code.toString())
163+
const idents = await collectModuleIdentifiers(ast.program, true)
164+
const { status } = spawnSync('node', [fixturePath], { stdio: 'inherit' })
165+
166+
// Test for valid syntax
167+
assert.equal(status, 0)
168+
169+
// Imported names should not be counted as module-scope hoists
170+
assert.equal(idents.has('x'), false)
171+
172+
// Local reads of imported value still exist via binding 'x' inside the module
173+
// but they should not appear in module hoist tracking because x is not declared locally.
174+
})
175+
176+
it('does not hoist function declarations inside blocks to module scope', async () => {
177+
const fixturePath = join(fixtures, 'identifiers', 'hoisting', 'functionInBlock.js')
178+
const code = await readFile(fixturePath)
179+
const ast = parse('file.ts', code.toString())
180+
const idents = await collectModuleIdentifiers(ast.program, true)
181+
const funcReads: number[] = []
182+
const { status } = spawnSync('node', [fixturePath], { stdio: 'inherit' })
183+
184+
// Test for valid syntax
185+
assert.equal(status, 0)
186+
187+
const funcMeta = idents.get('func')
188+
assert.equal(funcMeta, undefined)
189+
190+
// Ensure no reads got captured as module-scope hoists
191+
assert.deepEqual(funcReads, [])
192+
})
141193
})

0 commit comments

Comments
 (0)