webfuscator 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/analysis/constant.js +3 -4
  2. package/dist/analysis/constant.js.map +1 -1
  3. package/dist/analysis/inlineability.js +1 -1
  4. package/dist/analysis/inlineability.js.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/obfuscator.d.ts +2 -1
  8. package/dist/obfuscator.d.ts.map +1 -1
  9. package/dist/obfuscator.js +47 -12
  10. package/dist/obfuscator.js.map +1 -1
  11. package/dist/options.d.ts +17 -4
  12. package/dist/options.d.ts.map +1 -1
  13. package/dist/preparation/expand-destructuring.d.ts +3 -3
  14. package/dist/preparation/expand-destructuring.js +6 -6
  15. package/dist/preparation/expand-destructuring.js.map +1 -1
  16. package/dist/transforms/arrow-to-function.js +3 -5
  17. package/dist/transforms/arrow-to-function.js.map +1 -1
  18. package/dist/transforms/conceal-strings.d.ts +57 -0
  19. package/dist/transforms/conceal-strings.d.ts.map +1 -0
  20. package/dist/transforms/conceal-strings.js +361 -0
  21. package/dist/transforms/conceal-strings.js.map +1 -0
  22. package/dist/transforms/const-let-to-var.d.ts +3 -3
  23. package/dist/transforms/const-let-to-var.d.ts.map +1 -1
  24. package/dist/transforms/const-let-to-var.js +36 -9
  25. package/dist/transforms/const-let-to-var.js.map +1 -1
  26. package/dist/transforms/const-to-let.d.ts +2 -3
  27. package/dist/transforms/const-to-let.d.ts.map +1 -1
  28. package/dist/transforms/const-to-let.js +3 -5
  29. package/dist/transforms/const-to-let.js.map +1 -1
  30. package/dist/transforms/fold-builtin-methods.d.ts.map +1 -1
  31. package/dist/transforms/fold-builtin-methods.js +48 -10
  32. package/dist/transforms/fold-builtin-methods.js.map +1 -1
  33. package/dist/transforms/fold-constants.js +3 -0
  34. package/dist/transforms/fold-constants.js.map +1 -1
  35. package/dist/transforms/hoist-variables.d.ts +30 -0
  36. package/dist/transforms/hoist-variables.d.ts.map +1 -0
  37. package/dist/transforms/hoist-variables.js +224 -0
  38. package/dist/transforms/hoist-variables.js.map +1 -0
  39. package/dist/transforms/inline-functions.js +3 -4
  40. package/dist/transforms/inline-functions.js.map +1 -1
  41. package/dist/transforms/mangle-properties.js +7 -4
  42. package/dist/transforms/mangle-properties.js.map +1 -1
  43. package/dist/transforms/merge-variable-declarations.d.ts +17 -0
  44. package/dist/transforms/merge-variable-declarations.d.ts.map +1 -0
  45. package/dist/transforms/merge-variable-declarations.js +68 -0
  46. package/dist/transforms/merge-variable-declarations.js.map +1 -0
  47. package/dist/transforms/numbers-to-strings.d.ts.map +1 -1
  48. package/dist/transforms/numbers-to-strings.js +1 -8
  49. package/dist/transforms/numbers-to-strings.js.map +1 -1
  50. package/dist/transforms/pack-declarations-into-parameters.js +1 -5
  51. package/dist/transforms/pack-declarations-into-parameters.js.map +1 -1
  52. package/dist/transforms/pack.d.ts +5 -7
  53. package/dist/transforms/pack.d.ts.map +1 -1
  54. package/dist/transforms/pack.js +10 -17
  55. package/dist/transforms/pack.js.map +1 -1
  56. package/dist/transforms/remove-unused-code.d.ts.map +1 -1
  57. package/dist/transforms/remove-unused-code.js +7 -39
  58. package/dist/transforms/remove-unused-code.js.map +1 -1
  59. package/dist/transforms/rename-identifiers.d.ts.map +1 -1
  60. package/dist/transforms/rename-identifiers.js +29 -17
  61. package/dist/transforms/rename-identifiers.js.map +1 -1
  62. package/dist/transforms/reorder-function-declarations.d.ts +25 -0
  63. package/dist/transforms/reorder-function-declarations.d.ts.map +1 -0
  64. package/dist/transforms/reorder-function-declarations.js +64 -0
  65. package/dist/transforms/reorder-function-declarations.js.map +1 -0
  66. package/dist/transforms/specials-to-strings.js +4 -3
  67. package/dist/transforms/specials-to-strings.js.map +1 -1
  68. package/dist/transforms/update-to-assignment.js +1 -16
  69. package/dist/transforms/update-to-assignment.js.map +1 -1
  70. package/dist/utils/ast.d.ts +1 -2
  71. package/dist/utils/ast.d.ts.map +1 -1
  72. package/dist/utils/ast.js +12 -42
  73. package/dist/utils/ast.js.map +1 -1
  74. package/dist/utils/literal.d.ts.map +1 -1
  75. package/dist/utils/literal.js +74 -2
  76. package/dist/utils/literal.js.map +1 -1
  77. package/dist/utils/statement-lists.d.ts +9 -0
  78. package/dist/utils/statement-lists.d.ts.map +1 -0
  79. package/dist/utils/statement-lists.js +27 -0
  80. package/dist/utils/statement-lists.js.map +1 -0
  81. package/package.json +10 -12
  82. package/src/analysis/constant.ts +3 -6
  83. package/src/analysis/inlineability.ts +1 -1
  84. package/src/index.ts +1 -0
  85. package/src/obfuscator.ts +51 -14
  86. package/src/options.ts +21 -3
  87. package/src/preparation/expand-destructuring.ts +6 -6
  88. package/src/transforms/arrow-to-function.ts +3 -5
  89. package/src/transforms/conceal-strings.ts +530 -0
  90. package/src/transforms/const-let-to-var.ts +37 -9
  91. package/src/transforms/const-to-let.ts +3 -5
  92. package/src/transforms/fold-builtin-methods.ts +67 -12
  93. package/src/transforms/fold-constants.ts +3 -0
  94. package/src/transforms/hoist-variables.ts +272 -0
  95. package/src/transforms/inline-functions.ts +3 -4
  96. package/src/transforms/mangle-properties.ts +7 -7
  97. package/src/transforms/merge-variable-declarations.ts +91 -0
  98. package/src/transforms/numbers-to-strings.ts +2 -12
  99. package/src/transforms/pack-declarations-into-parameters.ts +1 -5
  100. package/src/transforms/pack.ts +10 -18
  101. package/src/transforms/remove-unused-code.ts +9 -64
  102. package/src/transforms/rename-identifiers.ts +42 -17
  103. package/src/transforms/reorder-function-declarations.ts +80 -0
  104. package/src/transforms/specials-to-strings.ts +4 -3
  105. package/src/transforms/update-to-assignment.ts +1 -17
  106. package/src/utils/ast.ts +15 -51
  107. package/src/utils/literal.ts +91 -2
  108. package/src/utils/statement-lists.ts +45 -0
  109. package/DISCLOSURE +0 -9
  110. package/README.md +0 -67
@@ -2,7 +2,7 @@ import type { NodePath, Visitor } from '@babel/traverse'
2
2
  import * as t from '@babel/types'
3
3
  import type { File } from '@babel/types'
4
4
 
5
- import { enclosingScopeHasDirectEval, isInsideWith } from '../utils/ast'
5
+ import { isInsideWith } from '../utils/ast'
6
6
  import { traverseForChanges } from '../utils/change-tracking'
7
7
  import type { ChangeState } from '../utils/change-tracking'
8
8
 
@@ -65,10 +65,8 @@ const visitor: Visitor<ChangeState> = {
65
65
  arrowReferencesSuper(path) ||
66
66
  inUnsafeCaptureContext(path) ||
67
67
  usesReassignedArguments(path) ||
68
- // `with` can shadow capture aliases, and direct eval can inspect the changed
69
- // runtime meaning of `this` or `arguments`.
70
- isInsideWith(path) ||
71
- enclosingScopeHasDirectEval(path)
68
+ // `with` can capture Babel's `this` or `arguments` alias.
69
+ isInsideWith(path)
72
70
  ) {
73
71
  return
74
72
  }
@@ -0,0 +1,530 @@
1
+ import traverse from '@babel/traverse'
2
+ import type { NodePath, Visitor } from '@babel/traverse'
3
+ import * as t from '@babel/types'
4
+ import type { File } from '@babel/types'
5
+
6
+ import type { ConcealStringsOptions, TransformContext } from '../options'
7
+ import { isNonComputedKey } from '../utils/ast'
8
+ import type { ChangeState } from '../utils/change-tracking'
9
+ import { mulberry32, shuffleInPlace } from '../utils/random'
10
+
11
+ const RECORD_WIDTH = 4
12
+
13
+ /**
14
+ * Replaces runtime string literals with calls to a generated decoder. Each vault
15
+ * stores masked positions in a shuffled UTF-16 pool. The decoder handles lone
16
+ * surrogates without relying on mutable built-ins. The default `regional` pool
17
+ * covers U+0000-00FF and every 256-unit page used by the program. `full` covers
18
+ * all 65,536 code units.
19
+ *
20
+ * `scope: 'program'` creates one vault. `scope: 'function'` creates a vault per
21
+ * function but repeats the program-wide pool in each one. Parameter defaults and
22
+ * computed keys use the enclosing vault because they run before the function
23
+ * body. Code inside `with` gets a lexical vault so the object cannot capture
24
+ * decoder references.
25
+ *
26
+ * Directives, module specifiers, import attributes, and non-computed keys are not
27
+ * runtime expressions, so they stay unchanged.
28
+ *
29
+ * This is obfuscation, not encryption. The output contains the decoder and all
30
+ * data needed to recover each string. `cover: 'used'` also leaves one-unit
31
+ * strings in the pool and may reproduce longer text by chance.
32
+ *
33
+ * The example uses `{ cover: 'used' }` to keep the pool short.
34
+ *
35
+ * @example
36
+ * // ◀️ before
37
+ * function greet(name) {
38
+ * return "Hello, " + name;
39
+ * }
40
+ *
41
+ * // ▶️ after
42
+ * const _stringPool = " lo,He",
43
+ * _stringPayload = [0, 4, 7, 2, 2, 6, 2],
44
+ * _stringMetadata = [0, 7, 4, 5],
45
+ * _stringCache = [null],
46
+ * _stringDecoder = function (_entry) {
47
+ * let _cached = _stringCache[_entry];
48
+ * if (_cached !== null) return _cached;
49
+ * let _result = "",
50
+ * _record = _entry * 4,
51
+ * _start = _stringMetadata[_record],
52
+ * _length = _stringMetadata[_record + 1],
53
+ * _stringKey = _stringMetadata[_record + 2],
54
+ * _stringStep = _stringMetadata[_record + 3];
55
+ * for (let _position = 0; _position < _length; _position++) {
56
+ * let _stringMask = _stringKey + _position * _stringStep & 7;
57
+ * _result += _stringPool[_stringPayload[_start + _position] ^ _stringMask];
58
+ * }
59
+ * _stringCache[_entry] = _result;
60
+ * return _result;
61
+ * };
62
+ * function greet(name) {
63
+ * return _stringDecoder(0) + name;
64
+ * }
65
+ */
66
+ export function concealStrings(ast: File, ctx: TransformContext): boolean {
67
+ const options = resolveOptions(ctx.concealStrings)
68
+ const rng = mulberry32(ctx.seed)
69
+ const state: State = { changed: false, groups: new Map(), options }
70
+
71
+ if (options.scope === 'function') {
72
+ normalizeConciseArrows(ast, options)
73
+ }
74
+
75
+ traverse(ast, collectVisitor, undefined, state)
76
+ if (state.groups.size === 0) {
77
+ return false
78
+ }
79
+ const pool = buildSharedPool(state.groups.values(), options.cover, rng)
80
+ for (const group of state.groups.values()) {
81
+ concealGroup(group, options, pool, rng)
82
+ state.changed = true
83
+ }
84
+ return state.changed
85
+ }
86
+
87
+ interface ResolvedOptions {
88
+ cache: boolean
89
+ cover: 'full' | 'regional' | 'used'
90
+ deduplicate: boolean
91
+ scope: 'function' | 'program'
92
+ }
93
+
94
+ type VaultOwner = t.Program | t.Function | t.BlockStatement
95
+ type VaultOwnerPath = NodePath<VaultOwner>
96
+
97
+ interface Occurrence {
98
+ path: NodePath<t.StringLiteral>
99
+ value: string
100
+ }
101
+
102
+ interface VaultGroup {
103
+ occurrences: Occurrence[]
104
+ owner: VaultOwnerPath
105
+ }
106
+
107
+ interface State extends ChangeState {
108
+ groups: Map<VaultOwner, VaultGroup>
109
+ options: ResolvedOptions
110
+ }
111
+
112
+ interface Entry {
113
+ occurrences: Occurrence[]
114
+ value: string
115
+ }
116
+
117
+ interface VaultData {
118
+ indexMask: number
119
+ metadata: number[]
120
+ payload: number[]
121
+ pool: string
122
+ }
123
+
124
+ interface SharedPool {
125
+ indexMask: number
126
+ positions: ReadonlyMap<string, number>
127
+ value: string
128
+ }
129
+
130
+ interface VaultIdentifiers {
131
+ cache: t.Identifier | null
132
+ decoder: t.Identifier
133
+ metadata: t.Identifier
134
+ payload: t.Identifier
135
+ pool: t.Identifier
136
+ }
137
+
138
+ const collectVisitor: Visitor<State> = {
139
+ StringLiteral(path, state) {
140
+ if (!isRuntimeString(path)) {
141
+ return
142
+ }
143
+ const owner = findVaultOwner(path, state.options.scope)
144
+ if (!owner) {
145
+ return
146
+ }
147
+ let group = state.groups.get(owner.node)
148
+ if (!group) {
149
+ group = { occurrences: [], owner }
150
+ state.groups.set(owner.node, group)
151
+ }
152
+ group.occurrences.push({ path, value: path.node.value })
153
+ },
154
+ }
155
+
156
+ function resolveOptions(options: ConcealStringsOptions | undefined): ResolvedOptions {
157
+ const cache = options?.cache ?? true
158
+ const cover = options?.cover ?? 'regional'
159
+ const deduplicate = options?.deduplicate ?? true
160
+ const scope = options?.scope ?? 'program'
161
+ if (typeof cache !== 'boolean') {
162
+ throw new TypeError('concealStrings cache must be a boolean')
163
+ }
164
+ if (cover !== 'full' && cover !== 'regional' && cover !== 'used') {
165
+ throw new TypeError("concealStrings cover must be 'full', 'regional', or 'used'")
166
+ }
167
+ if (typeof deduplicate !== 'boolean') {
168
+ throw new TypeError('concealStrings deduplicate must be a boolean')
169
+ }
170
+ if (scope !== 'function' && scope !== 'program') {
171
+ throw new TypeError("concealStrings scope must be 'function' or 'program'")
172
+ }
173
+ return { cache, cover, deduplicate, scope }
174
+ }
175
+
176
+ function normalizeConciseArrows(ast: File, options: ResolvedOptions): void {
177
+ const marked = new WeakSet<t.ArrowFunctionExpression>()
178
+ traverse(ast, {
179
+ StringLiteral(path) {
180
+ if (!isRuntimeString(path)) {
181
+ return
182
+ }
183
+ const owner = findVaultOwner(path, options.scope)
184
+ if (owner?.isArrowFunctionExpression() && !t.isBlockStatement(owner.node.body)) {
185
+ marked.add(owner.node)
186
+ }
187
+ },
188
+ ArrowFunctionExpression: {
189
+ exit(path) {
190
+ if (!marked.has(path.node) || t.isBlockStatement(path.node.body)) {
191
+ return
192
+ }
193
+ const expression = path.node.body
194
+ path.get('body').replaceWith(t.blockStatement([t.returnStatement(expression)]))
195
+ path.node.expression = false
196
+ },
197
+ },
198
+ })
199
+ }
200
+
201
+ function findVaultOwner(
202
+ path: NodePath<t.StringLiteral>,
203
+ scope: ResolvedOptions['scope'],
204
+ ): VaultOwnerPath | null {
205
+ let child: NodePath = path
206
+ let current: NodePath | null = path.parentPath
207
+ while (current) {
208
+ if (current.isFunction() && child.key === 'body' && scope === 'function') {
209
+ return current as VaultOwnerPath
210
+ }
211
+ if (current.isWithStatement() && child.key === 'body') {
212
+ const body = current.get('body')
213
+ return body.isBlockStatement() ? (body as VaultOwnerPath) : null
214
+ }
215
+ if (current.isProgram()) {
216
+ return current
217
+ }
218
+ child = current
219
+ current = current.parentPath
220
+ }
221
+ return null
222
+ }
223
+
224
+ function isRuntimeString(path: NodePath<t.StringLiteral>): boolean {
225
+ const parent = path.parent
226
+ const node = path.node
227
+ if (
228
+ (t.isImportDeclaration(parent) && parent.source === node) ||
229
+ (t.isExportNamedDeclaration(parent) && parent.source === node) ||
230
+ (t.isExportAllDeclaration(parent) && parent.source === node) ||
231
+ t.isImportAttribute(parent) ||
232
+ (t.isImportSpecifier(parent) && parent.imported === node) ||
233
+ (t.isExportSpecifier(parent) && parent.exported === node) ||
234
+ (t.isExportNamespaceSpecifier(parent) && parent.exported === node)
235
+ ) {
236
+ return false
237
+ }
238
+ return !isNonComputedKey(path)
239
+ }
240
+
241
+ function concealGroup(
242
+ group: VaultGroup,
243
+ options: ResolvedOptions,
244
+ pool: SharedPool,
245
+ rng: () => number,
246
+ ): void {
247
+ const entries = makeEntries(group.occurrences, options.deduplicate)
248
+ shuffleInPlace(entries, rng)
249
+ const data = encodeEntries(entries, pool, rng)
250
+ const identifiers = insertVault(statementBody(group.owner), entries.length, data, options.cache)
251
+
252
+ for (let index = 0; index < entries.length; index++) {
253
+ for (const occurrence of entries[index]!.occurrences) {
254
+ occurrence.path.replaceWith(
255
+ t.callExpression(t.identifier(identifiers.decoder.name), [t.numericLiteral(index)]),
256
+ )
257
+ occurrence.path.skip()
258
+ }
259
+ }
260
+ }
261
+
262
+ function makeEntries(occurrences: Occurrence[], deduplicate: boolean): Entry[] {
263
+ if (!deduplicate) {
264
+ return occurrences.map((occurrence) => ({ occurrences: [occurrence], value: occurrence.value }))
265
+ }
266
+ const byValue = new Map<string, Entry>()
267
+ for (const occurrence of occurrences) {
268
+ let entry = byValue.get(occurrence.value)
269
+ if (!entry) {
270
+ entry = { occurrences: [], value: occurrence.value }
271
+ byValue.set(occurrence.value, entry)
272
+ }
273
+ entry.occurrences.push(occurrence)
274
+ }
275
+ return [...byValue.values()]
276
+ }
277
+
278
+ function buildSharedPool(
279
+ groups: Iterable<VaultGroup>,
280
+ cover: ResolvedOptions['cover'],
281
+ rng: () => number,
282
+ ): SharedPool {
283
+ const usedUnits = new Set<string>()
284
+ for (const group of groups) {
285
+ for (const occurrence of group.occurrences) {
286
+ let index = 0
287
+ while (index < occurrence.value.length) {
288
+ usedUnits.add(occurrence.value[index]!)
289
+ index++
290
+ }
291
+ }
292
+ }
293
+
294
+ const poolUnits = makePoolUnits(usedUnits, cover)
295
+ shuffleInPlace(poolUnits, rng)
296
+ return {
297
+ indexMask: coveringBitMask(poolUnits.length),
298
+ positions: indexPoolPositions(poolUnits),
299
+ value: poolUnits.join(''),
300
+ }
301
+ }
302
+
303
+ function encodeEntries(entries: Entry[], pool: SharedPool, rng: () => number): VaultData {
304
+ const metadata: number[] = []
305
+ const payload: number[] = []
306
+
307
+ for (const entry of entries) {
308
+ const key = randomInteger(pool.indexMask + 1, rng)
309
+ const step = randomOddInteger(pool.indexMask, rng)
310
+ metadata.push(payload.length, entry.value.length, key, step)
311
+ for (let position = 0; position < entry.value.length; position++) {
312
+ const truePosition = pool.positions.get(entry.value[position]!)!
313
+ payload.push(truePosition ^ maskPosition(position, key, step, pool.indexMask))
314
+ }
315
+ }
316
+ return { indexMask: pool.indexMask, metadata, payload, pool: pool.value }
317
+ }
318
+
319
+ function makePoolUnits(usedUnits: ReadonlySet<string>, cover: ResolvedOptions['cover']): string[] {
320
+ return cover === 'used' ? [...usedUnits] : coveredPageUnits(usedUnits, cover)
321
+ }
322
+
323
+ function coveredPageUnits(
324
+ usedUnits: ReadonlySet<string>,
325
+ cover: Exclude<ResolvedOptions['cover'], 'used'>,
326
+ ): string[] {
327
+ const pages = new Set<number>()
328
+ if (cover === 'full') {
329
+ for (let page = 0; page < 0x01_00; page++) {
330
+ pages.add(page)
331
+ }
332
+ } else {
333
+ pages.add(0)
334
+ for (const unit of usedUnits) {
335
+ pages.add(unit.codePointAt(0)! >>> 8)
336
+ }
337
+ }
338
+
339
+ const units: string[] = []
340
+ for (const page of pages) {
341
+ const start = page << 8
342
+ for (let offset = 0; offset < 0x01_00; offset++) {
343
+ units.push(String.fromCodePoint(start + offset))
344
+ }
345
+ }
346
+ return units
347
+ }
348
+
349
+ function indexPoolPositions(pool: readonly string[]): Map<string, number> {
350
+ const positions = new Map<string, number>()
351
+ for (let index = 0; index < pool.length; index++) {
352
+ positions.set(pool[index]!, index)
353
+ }
354
+ return positions
355
+ }
356
+
357
+ function coveringBitMask(length: number): number {
358
+ let mask = 0
359
+ while (mask < length - 1) {
360
+ mask = (mask << 1) | 1
361
+ }
362
+ return mask
363
+ }
364
+
365
+ function randomInteger(limit: number, rng: () => number): number {
366
+ return limit === 0 ? 0 : Math.floor(rng() * limit)
367
+ }
368
+
369
+ function randomOddInteger(mask: number, rng: () => number): number {
370
+ return mask === 0 ? 0 : randomInteger((mask + 1) / 2, rng) * 2 + 1
371
+ }
372
+
373
+ function maskPosition(position: number, key: number, step: number, indexMask: number): number {
374
+ return (key + position * step) & indexMask
375
+ }
376
+
377
+ function statementBody(owner: VaultOwnerPath): NodePath<t.Program | t.BlockStatement> {
378
+ if (owner.isProgram() || owner.isBlockStatement()) {
379
+ return owner
380
+ }
381
+ const body = owner.get('body') as NodePath
382
+ if (!body.isBlockStatement()) {
383
+ throw new Error('concealStrings expected a normalized function body')
384
+ }
385
+ return body
386
+ }
387
+
388
+ function insertVault(
389
+ body: NodePath<t.Program | t.BlockStatement>,
390
+ entryCount: number,
391
+ data: VaultData,
392
+ cache: boolean,
393
+ ): VaultIdentifiers {
394
+ const fresh = (purpose: string): t.Identifier => t.identifier(body.scope.generateUid(purpose))
395
+ const identifiers: VaultIdentifiers = {
396
+ cache: cache ? fresh('stringCache') : null,
397
+ decoder: fresh('stringDecoder'),
398
+ metadata: fresh('stringMetadata'),
399
+ payload: fresh('stringPayload'),
400
+ pool: fresh('stringPool'),
401
+ }
402
+ const decoder = t.functionExpression(null, [], t.blockStatement([]))
403
+ const declarations = [
404
+ t.variableDeclarator(identifiers.pool, t.stringLiteral(data.pool)),
405
+ t.variableDeclarator(identifiers.payload, numericArray(data.payload)),
406
+ t.variableDeclarator(identifiers.metadata, numericArray(data.metadata)),
407
+ ]
408
+ if (identifiers.cache) {
409
+ declarations.push(
410
+ t.variableDeclarator(
411
+ identifiers.cache,
412
+ t.arrayExpression(Array.from({ length: entryCount }, () => t.nullLiteral())),
413
+ ),
414
+ )
415
+ }
416
+ declarations.push(t.variableDeclarator(identifiers.decoder, decoder))
417
+
418
+ const declaration = t.variableDeclaration('const', declarations)
419
+ const [declarationPath] = body.unshiftContainer('body', declaration)
420
+ const declaratorPaths = declarationPath!.get('declarations') as NodePath<t.VariableDeclarator>[]
421
+ const decoderPath = declaratorPaths
422
+ .find((path) => path.node.init === decoder)!
423
+ .get('init') as NodePath<t.FunctionExpression>
424
+ populateDecoder(decoderPath, identifiers, data.indexMask)
425
+ return identifiers
426
+ }
427
+
428
+ function populateDecoder(
429
+ decoderPath: NodePath<t.FunctionExpression>,
430
+ vault: VaultIdentifiers,
431
+ indexMask: number,
432
+ ): void {
433
+ const fresh = (purpose: string): t.Identifier =>
434
+ t.identifier(decoderPath.scope.generateUid(purpose))
435
+ const entry = fresh('entry')
436
+ const result = fresh('result')
437
+ const record = fresh('record')
438
+ const start = fresh('start')
439
+ const length = fresh('length')
440
+ const key = fresh('stringKey')
441
+ const step = fresh('stringStep')
442
+ const position = fresh('position')
443
+ const mask = fresh('stringMask')
444
+ const statements: t.Statement[] = []
445
+
446
+ if (vault.cache) {
447
+ const cached = fresh('cached')
448
+ statements.push(
449
+ t.variableDeclaration('let', [t.variableDeclarator(cached, indexed(vault.cache, entry))]),
450
+ t.ifStatement(t.binaryExpression('!==', cached, t.nullLiteral()), t.returnStatement(cached)),
451
+ )
452
+ }
453
+
454
+ statements.push(
455
+ t.variableDeclaration('let', [
456
+ t.variableDeclarator(result, t.stringLiteral('')),
457
+ t.variableDeclarator(record, t.binaryExpression('*', entry, t.numericLiteral(RECORD_WIDTH))),
458
+ t.variableDeclarator(start, indexed(vault.metadata, record)),
459
+ t.variableDeclarator(
460
+ length,
461
+ indexed(vault.metadata, t.binaryExpression('+', record, t.numericLiteral(1))),
462
+ ),
463
+ t.variableDeclarator(
464
+ key,
465
+ indexed(vault.metadata, t.binaryExpression('+', record, t.numericLiteral(2))),
466
+ ),
467
+ t.variableDeclarator(
468
+ step,
469
+ indexed(vault.metadata, t.binaryExpression('+', record, t.numericLiteral(3))),
470
+ ),
471
+ ]),
472
+ t.forStatement(
473
+ t.variableDeclaration('let', [t.variableDeclarator(position, t.numericLiteral(0))]),
474
+ t.binaryExpression('<', position, length),
475
+ t.updateExpression('++', position),
476
+ t.blockStatement([
477
+ t.variableDeclaration('let', [
478
+ t.variableDeclarator(mask, maskExpression(position, key, step, indexMask)),
479
+ ]),
480
+ t.expressionStatement(
481
+ t.assignmentExpression(
482
+ '+=',
483
+ result,
484
+ indexed(
485
+ vault.pool,
486
+ t.binaryExpression(
487
+ '^',
488
+ indexed(vault.payload, t.binaryExpression('+', start, position)),
489
+ mask,
490
+ ),
491
+ ),
492
+ ),
493
+ ),
494
+ ]),
495
+ ),
496
+ )
497
+ if (vault.cache) {
498
+ statements.push(
499
+ t.expressionStatement(t.assignmentExpression('=', indexed(vault.cache, entry), result)),
500
+ )
501
+ }
502
+ statements.push(t.returnStatement(result))
503
+ decoderPath.node.params = [entry]
504
+ decoderPath.node.body.body = statements
505
+ }
506
+
507
+ function maskExpression(
508
+ position: t.Identifier,
509
+ key: t.Identifier,
510
+ step: t.Identifier,
511
+ indexMask: number,
512
+ ): t.Expression {
513
+ return t.binaryExpression(
514
+ '&',
515
+ t.binaryExpression(
516
+ '+',
517
+ t.cloneNode(key),
518
+ t.binaryExpression('*', t.cloneNode(position), t.cloneNode(step)),
519
+ ),
520
+ t.numericLiteral(indexMask),
521
+ )
522
+ }
523
+
524
+ function indexed(object: t.Identifier, property: t.Expression): t.MemberExpression {
525
+ return t.memberExpression(t.cloneNode(object), t.cloneNode(property), true)
526
+ }
527
+
528
+ function numericArray(values: readonly number[]): t.ArrayExpression {
529
+ return t.arrayExpression(values.map((value) => t.numericLiteral(value)))
530
+ }
@@ -4,13 +4,13 @@ import * as t from '@babel/types'
4
4
  import type { File } from '@babel/types'
5
5
 
6
6
  import { initializerReaches } from '../analysis/document-order'
7
- import { enclosingScopeHasDirectEval, isInsideWith } from '../utils/ast'
7
+ import { isInsideWith } from '../utils/ast'
8
8
  import { hasConstantViolation } from '../utils/paths'
9
9
 
10
10
  /**
11
- * Lifts safe lexical declarations to function-scoped `var`, renaming collisions
12
- * caused by hoisting. TDZ reads, reassigned constants, captured loop bindings,
13
- * uninitialized loop heads, program scope, `with`, and direct eval are refused.
11
+ * Converts safe `let` and `const` declarations to `var` and renames bindings
12
+ * that would conflict after hoisting. It skips TDZ reads, reassigned constants,
13
+ * captured loop bindings, uninitialized loop heads, script globals, and `with`.
14
14
  *
15
15
  * @example
16
16
  * // ◀️ before
@@ -33,8 +33,8 @@ export function constLetToVar(ast: File): boolean {
33
33
  if (kind !== 'let' && kind !== 'const') {
34
34
  return
35
35
  }
36
- // Babel cannot see names supplied by `with` or direct eval.
37
- if (isInsideWith(path) || enclosingScopeHasDirectEval(path)) {
36
+ // Babel's scope table cannot account for properties supplied by `with`.
37
+ if (isInsideWith(path)) {
38
38
  return
39
39
  }
40
40
  // A `var` write would lose the `const` TypeError.
@@ -49,11 +49,19 @@ export function constLetToVar(ast: File): boolean {
49
49
  if (forHeadMissingInitializer(path)) {
50
50
  return
51
51
  }
52
- // Top-level `var` creates a global property. Lexical declarations do not.
53
52
  const blockScope = path.scope
54
53
  const varScope = blockScope.getFunctionParent() ?? blockScope.getProgramParent()
55
54
  if (varScope.path.isProgram()) {
56
- return
55
+ // Script `var` creates a global-object property; module `var` does not.
56
+ if (varScope.path.node.sourceType !== 'module') {
57
+ return
58
+ }
59
+ // A circular importer can read this binding before initialization. A
60
+ // lexical binding throws in TDZ then, while `var` reads `undefined`. That
61
+ // external read does not appear in this file's references.
62
+ if (bindsExportedName(path)) {
63
+ return
64
+ }
57
65
  }
58
66
  // One `var` cannot preserve per-iteration bindings captured by closures.
59
67
  if (capturedInEnclosingLoop(path)) {
@@ -68,7 +76,7 @@ export function constLetToVar(ast: File): boolean {
68
76
  if (!id) {
69
77
  return
70
78
  }
71
- if (isInsideWith(path) || enclosingScopeHasDirectEval(path)) {
79
+ if (isInsideWith(path)) {
72
80
  return
73
81
  }
74
82
 
@@ -135,6 +143,26 @@ function forHeadMissingInitializer(path: NodePath<t.VariableDeclaration>): boole
135
143
  return path.node.declarations.some((decl) => decl.init == null)
136
144
  }
137
145
 
146
+ // A source clause re-exports another module's binding, not this local binding.
147
+ function bindsExportedName(path: NodePath<t.VariableDeclaration>): boolean {
148
+ if (path.parentPath.isExportNamedDeclaration()) {
149
+ return true
150
+ }
151
+ const names = new Set(Object.keys(path.getBindingIdentifiers()))
152
+ const program = path.scope.getProgramParent().path.node as t.Program
153
+ for (const statement of program.body) {
154
+ if (!t.isExportNamedDeclaration(statement) || statement.source) {
155
+ continue
156
+ }
157
+ for (const specifier of statement.specifiers) {
158
+ if (t.isExportSpecifier(specifier) && names.has(specifier.local.name)) {
159
+ return true
160
+ }
161
+ }
162
+ }
163
+ return false
164
+ }
165
+
138
166
  // A nested function or class can observe the loop's per-iteration binding.
139
167
  function capturedInEnclosingLoop(path: NodePath<t.VariableDeclaration>): boolean {
140
168
  const loop = enclosingLoop(path)
@@ -1,15 +1,13 @@
1
1
  import type { Visitor } from '@babel/traverse'
2
2
  import type { File } from '@babel/types'
3
3
 
4
- import { enclosingScopeHasDirectEval } from '../utils/ast'
5
4
  import { traverseForChanges } from '../utils/change-tracking'
6
5
  import type { ChangeState } from '../utils/change-tracking'
7
6
  import { hasConstantViolation } from '../utils/paths'
8
7
 
9
8
  /**
10
- * Changes `const` to `let` when no write can observe the lost TypeError. Direct
11
- * eval and recorded constant violations keep the declaration unchanged. Scope
12
- * and TDZ behavior are otherwise identical.
9
+ * Changes `const` to `let` when Babel finds no writes. Read-only bindings keep
10
+ * the same scope and TDZ behavior; the declarations differ only on writes.
13
11
  *
14
12
  * @example
15
13
  * // ◀️ before
@@ -29,7 +27,7 @@ const visitor: Visitor<ChangeState> = {
29
27
  if (path.node.kind !== 'const') {
30
28
  return
31
29
  }
32
- if (enclosingScopeHasDirectEval(path) || hasConstantViolation(path)) {
30
+ if (hasConstantViolation(path)) {
33
31
  return
34
32
  }
35
33
  path.node.kind = 'let'