jz 0.4.0 → 0.5.1

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.
package/src/prepare.js CHANGED
@@ -24,9 +24,9 @@
24
24
 
25
25
  import { parse } from 'subscript/feature/jessie'
26
26
  import { ctx, err, derive } from './ctx.js'
27
- import { T, STMT_OPS, VAL, valTypeOf, typedElemCtor, extractParams, collectParamNames, classifyParam, observeNodeFacts, staticPropertyKey } from './analyze.js'
27
+ import { T, STMT_OPS, VAL, extractParams, collectParamNames, classifyParam, observeNodeFacts, staticObjectProps, staticPropertyKey } from './analyze.js'
28
+ import { recordGlobalRep } from './infer.js'
28
29
  import { isFuncRef } from './ir.js'
29
- import { fuseSparseMapReads } from './fuse.js'
30
30
  import {
31
31
  CTORS, TIMER_NAMES,
32
32
  hasModule, includeModule,
@@ -38,6 +38,8 @@ import {
38
38
 
39
39
  let depth = 0 // arrow nesting depth (0=top-level, >0=inside function)
40
40
  let scopes = [] // block scope stack: [{names: Set, renames: Map}]
41
+ let staticConstScopes = [] // lexical const facts: [{strings: Map, arrays: Map}]
42
+ let assignedStaticGlobals = null
41
43
  // Per-arrow set of names already declared anywhere in the function body. Used
42
44
  // to force a rename when the same identifier is declared in two sibling blocks
43
45
  // (else-if arms, separate { ... } chunks): without renaming, both decls lower
@@ -89,10 +91,106 @@ const addHostImport = (mod, name, alias, spec) => {
89
91
 
90
92
  const isImportMeta = node => Array.isArray(node) && node[0] === '.' && node[1] === 'import' && node[2] === 'meta'
91
93
  const isImportMetaProp = (node, prop) => Array.isArray(node) && node[0] === '.' && isImportMeta(node[1]) && node[2] === prop
94
+ // In a pure boolean position (consumer reads only truthiness) `!!e` is exactly `e`.
95
+ // Drop redundant double-negation; recurse so `!!!!e → e`. NOT valid for `&&`/`||`
96
+ // operands — those are value-preserving (`!!a && b` returns `false`, not `a`).
97
+ const stripBoolNot = c => {
98
+ while (Array.isArray(c) && c[0] === '!' && Array.isArray(c[1]) && c[1][0] === '!') c = c[1][1]
99
+ return c
100
+ }
92
101
  const stringValue = node => Array.isArray(node) && node[0] == null && typeof node[1] === 'string' ? node[1] : null
93
102
  const flatArgs = args => args.length === 1 && Array.isArray(args[0]) && args[0][0] === ',' ? args[0].slice(1) : args
94
103
  const MUTATING_ARRAY_METHODS = new Set(['copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'])
95
104
 
105
+ function staticStringArrayValues(expr) {
106
+ if (!Array.isArray(expr) || expr[0] !== '[]' || expr.length !== 2) return null
107
+ const raw = Array.isArray(expr[1]) && expr[1][0] === ',' ? expr[1].slice(1) : [expr[1]]
108
+ const out = []
109
+ for (const item of raw) {
110
+ const s = staticStringExpr(item)
111
+ if (s == null) return null
112
+ out.push(s)
113
+ }
114
+ return out
115
+ }
116
+
117
+ function immediateStaticStringExpr(node) {
118
+ const lit = stringValue(node)
119
+ if (lit != null) return lit
120
+ if (Array.isArray(node) && node[0] === 'str' && typeof node[1] === 'string') return node[1]
121
+ if (!Array.isArray(node)) return null
122
+ const [op, ...args] = node
123
+ if (op === '+') {
124
+ const a = immediateStaticStringExpr(args[0])
125
+ const b = immediateStaticStringExpr(args[1])
126
+ return a != null && b != null ? a + b : null
127
+ }
128
+ if (op === '`') {
129
+ let out = ''
130
+ for (const part of args) {
131
+ const s = immediateStaticStringExpr(part)
132
+ if (s == null) return null
133
+ out += s
134
+ }
135
+ return out
136
+ }
137
+ return null
138
+ }
139
+
140
+ function immediateStaticStringArrayValues(expr) {
141
+ if (!Array.isArray(expr) || expr[0] !== '[]' || expr.length !== 2) return null
142
+ const raw = Array.isArray(expr[1]) && expr[1][0] === ',' ? expr[1].slice(1) : [expr[1]]
143
+ const out = []
144
+ for (const item of raw) {
145
+ const s = immediateStaticStringExpr(item)
146
+ if (s == null) return null
147
+ out.push(s)
148
+ }
149
+ return out
150
+ }
151
+
152
+ function eachTopLevelStatement(node, fn) {
153
+ if (Array.isArray(node) && node[0] === ';') {
154
+ for (let i = 1; i < node.length; i++) fn(node[i])
155
+ } else {
156
+ fn(node)
157
+ }
158
+ }
159
+
160
+ function collectAssignmentWrites(node, writes) {
161
+ if (!Array.isArray(node)) return
162
+ const [op, lhs] = node
163
+ if (op === '=' && typeof lhs === 'string') writes.set(lhs, (writes.get(lhs) || 0) + 1)
164
+ if ((op === '++' || op === '--') && typeof lhs === 'string') writes.set(lhs, (writes.get(lhs) || 0) + 1)
165
+ for (let i = 1; i < node.length; i++) collectAssignmentWrites(node[i], writes)
166
+ }
167
+
168
+ function collectTopLevelStaticAssignments(node, facts) {
169
+ if (!Array.isArray(node)) return
170
+ if (node[0] === ',') {
171
+ for (let i = 1; i < node.length; i++) collectTopLevelStaticAssignments(node[i], facts)
172
+ return
173
+ }
174
+ if (node[0] !== '=' || typeof node[1] !== 'string') return
175
+ const str = immediateStaticStringExpr(node[2])
176
+ const arr = immediateStaticStringArrayValues(node[2])
177
+ if (str != null || arr) facts.set(node[1], { str, arr })
178
+ }
179
+
180
+ function seedStaticGlobalAssignments(node) {
181
+ // jzify hoists function declarations ahead of `var` initializer assignments.
182
+ // Seed one-write static globals before preparing those function bodies so
183
+ // compile-time-only consumers (for example `new RegExp(`${PART}`)`) can still
184
+ // resolve the same constants they would see after module initialization.
185
+ const writes = new Map()
186
+ const facts = new Map()
187
+ collectAssignmentWrites(node, writes)
188
+ eachTopLevelStatement(node, stmt => collectTopLevelStaticAssignments(stmt, facts))
189
+ for (const [name, fact] of facts) {
190
+ if (writes.get(name) === 1) bindStaticGlobal(name, fact.str, fact.arr)
191
+ }
192
+ }
193
+
96
194
  function stringArrayValues(expr) {
97
195
  if (!Array.isArray(expr) || expr[0] !== '[' || expr.length === 1) return null
98
196
  const out = []
@@ -108,6 +206,63 @@ function staticString(value) {
108
206
  return ['str', value]
109
207
  }
110
208
 
209
+ function lookupStaticString(name) {
210
+ const resolved = scopes.length && isDeclared(name) ? resolveScope(name) : (ctx.scope.chain[name] || name)
211
+ for (let i = staticConstScopes.length - 1; i >= 0; i--) {
212
+ const v = staticConstScopes[i].strings.get(resolved)
213
+ if (v != null) return v
214
+ }
215
+ return ctx.scope.shapeStrs?.get(resolved) ?? ctx.scope.constStrs?.get(resolved) ?? null
216
+ }
217
+
218
+ function lookupStaticStringArray(name) {
219
+ const resolved = scopes.length && isDeclared(name) ? resolveScope(name) : (ctx.scope.chain[name] || name)
220
+ for (let i = staticConstScopes.length - 1; i >= 0; i--) {
221
+ const v = staticConstScopes[i].arrays.get(resolved)
222
+ if (v) return v
223
+ }
224
+ return ctx.scope.shapeStrArrays?.get(resolved) ?? null
225
+ }
226
+
227
+ function staticStringExpr(node) {
228
+ const lit = stringValue(node)
229
+ if (lit != null) return lit
230
+ if (Array.isArray(node) && node[0] === 'str' && typeof node[1] === 'string') return node[1]
231
+ if (typeof node === 'string') return lookupStaticString(node)
232
+ if (!Array.isArray(node)) return null
233
+ const [op, ...args] = node
234
+ if (op === '+') {
235
+ const a = staticStringExpr(args[0])
236
+ const b = staticStringExpr(args[1])
237
+ return a != null && b != null ? a + b : null
238
+ }
239
+ if (op === '`') {
240
+ let out = ''
241
+ for (const part of args) {
242
+ const s = staticStringExpr(part)
243
+ if (s == null) return null
244
+ out += s
245
+ }
246
+ return out
247
+ }
248
+ if (op === '``' && Array.isArray(args[0]) && args[0][0] === '.' && args[0][1] === 'String' && args[0][2] === 'raw') {
249
+ let out = ''
250
+ for (const part of args.slice(1)) {
251
+ const s = staticStringExpr(part)
252
+ if (s == null) return null
253
+ out += s
254
+ }
255
+ return out
256
+ }
257
+ if (op === '()' && Array.isArray(args[0]) && args[0][0] === '.' && args[0][2] === 'join' && typeof args[0][1] === 'string') {
258
+ const arr = lookupStaticStringArray(args[0][1])
259
+ if (!arr) return null
260
+ const sep = args.length > 1 && args[1] != null ? staticStringExpr(args[1]) : ','
261
+ return sep != null ? arr.join(sep) : null
262
+ }
263
+ return null
264
+ }
265
+
111
266
  function importMetaUrl() {
112
267
  if (!ctx.transform.importMetaUrl) err('`import.meta.url` requires compile option `importMetaUrl`')
113
268
  return ctx.transform.importMetaUrl
@@ -119,17 +274,6 @@ function resolveImportMeta(spec) {
119
274
  catch { err(`Cannot resolve import.meta specifier '${spec}' from '${base}'`) }
120
275
  }
121
276
 
122
- function recordGlobalValueFact(name, expr) {
123
- if (typeof name !== 'string') return
124
- const vt = valTypeOf(expr)
125
- if (vt) {
126
- ;(ctx.scope.globalValTypes ||= new Map()).set(name, vt)
127
- if (vt === VAL.REGEX && ctx.runtime.regex) ctx.runtime.regex.vars.set(name, expr)
128
- }
129
- const ctor = typedElemCtor(expr)
130
- if (ctor) (ctx.scope.globalTypedElem ||= new Map()).set(name, ctor)
131
- }
132
-
133
277
  function recordModuleInitFacts(root) {
134
278
  const facts = ctx.module.initFacts ||= {
135
279
  dynVars: new Set(), anyDyn: false, hasSchemaLiterals: false,
@@ -182,10 +326,13 @@ function recordModuleInitFacts(root) {
182
326
  export default function prepare(node) {
183
327
  depth = 0
184
328
  scopes = []
329
+ staticConstScopes = []
330
+ assignedStaticGlobals = new Set()
185
331
  funcLocalNames = [new Set()]
186
332
  includeModule('core')
187
333
  normalizeIdents(node)
188
- fuseSparseMapReads(node) // AST-level fusion; needs pre-resolution shape — see src/fuse.js
334
+ fuseSparseMapReads(node) // AST-level fusion; needs pre-resolution shape — defined at end of file
335
+ seedStaticGlobalAssignments(node)
189
336
  const ast = prep(node)
190
337
  // Top-level functions referenced as first-class values (e.g. `let o = { fn: g }`,
191
338
  // `arr.push(g)`, `return g`) need trampoline emission, which depends on the fn
@@ -250,7 +397,7 @@ export default function prepare(node) {
250
397
 
251
398
  // Invalidate shapeStrs for any module-level binding that's later assigned to.
252
399
  // shapeStrs is "effectively-const string literals at module scope" — used by
253
- // analyze.js's jsonConstString to enable shape inference on `let SRC = '{...}'`
400
+ // shape.js's jsonConstString to enable shape inference on `let SRC = '{...}'`
254
401
  // patterns (bench convention) without enabling the const-only static fold.
255
402
  // The scan must skip `=` nodes that are children of `let`/`const`/`export` —
256
403
  // those are decl-initializers, not reassignments.
@@ -281,7 +428,7 @@ export default function prepare(node) {
281
428
 
282
429
  // Named constants → numeric literals
283
430
  export const JZ_NULL = Symbol('null')
284
- const CONSTANTS = { 'true': 1, 'false': 0, 'null': JZ_NULL, 'undefined': JZ_NULL }
431
+ const CONSTANTS = { 'true': true, 'false': false, 'null': JZ_NULL, 'undefined': JZ_NULL }
285
432
  // NaN/Infinity stay as special f64 values in emit()
286
433
  const F64_CONSTANTS = { 'NaN': NaN, 'Infinity': Infinity }
287
434
 
@@ -297,6 +444,34 @@ function isDeclared(name) {
297
444
  return scopes.some(s => s.has(name))
298
445
  }
299
446
 
447
+ function pushScope(scope = new Map()) {
448
+ scopes.push(scope)
449
+ staticConstScopes.push({ strings: new Map(), arrays: new Map() })
450
+ }
451
+
452
+ function popScope() {
453
+ scopes.pop()
454
+ staticConstScopes.pop()
455
+ }
456
+
457
+ function bindStaticConst(name, str, arr) {
458
+ const frame = staticConstScopes.at(-1)
459
+ if (!frame || typeof name !== 'string') return
460
+ if (str != null) frame.strings.set(name, str)
461
+ if (arr) frame.arrays.set(name, arr)
462
+ }
463
+
464
+ function bindStaticGlobal(name, str, arr) {
465
+ if (typeof name !== 'string') return
466
+ if (str != null) (ctx.scope.shapeStrs ||= new Map()).set(name, str)
467
+ if (arr) (ctx.scope.shapeStrArrays ||= new Map()).set(name, arr)
468
+ }
469
+
470
+ function deleteStaticGlobal(name) {
471
+ ctx.scope.shapeStrs?.delete(name)
472
+ ctx.scope.shapeStrArrays?.delete(name)
473
+ }
474
+
300
475
  const hasFunc = name => ctx.func.names.has(name)
301
476
 
302
477
  const renameFunc = (func, nextName) => {
@@ -307,33 +482,81 @@ const renameFunc = (func, nextName) => {
307
482
 
308
483
  /** Map JS typeof strings to jz type checks. Codes < 0 trigger specialized emitTypeofCmp paths. */
309
484
  const TYPEOF_MAP = { 'number': -1, 'string': -2, 'undefined': -3, 'boolean': -4, 'object': -5, 'function': -6, 'bigint': -7 }
485
+ // Spec §13.5.3: `typeof undeclared_x` returns 'undefined' without throwing.
486
+ // True iff `name` is a bare identifier with no resolution path. Mirrors the
487
+ // resolution chain inside `prep()` so we don't speculate emit-time failures.
488
+ function isUnresolvableBareIdent(name) {
489
+ if (typeof name !== 'string') return false
490
+ if (name in CONSTANTS || name in F64_CONSTANTS) return false
491
+ if (name === 'Boolean' || name === 'Number') return false
492
+ if (PROHIBITED[name]) return false
493
+ if (scopes.length && isDeclared(name)) return false
494
+ if (ctx.scope.chain[name]) return false
495
+ if (GLOBALS[name]) return false
496
+ if (ctx.func.names.has(name)) return false
497
+ if (ctx.func?.locals?.has?.(name)) return false
498
+ // Top-level decls live in ctx.scope.globals / userGlobals (set by prepDecl at
499
+ // depth 0). Current arrow's local names are tracked in funcLocalNames.
500
+ if (ctx.scope.globals?.has?.(name)) return false
501
+ if (ctx.scope.userGlobals?.has?.(name)) return false
502
+ const fnNames = funcLocalNames[funcLocalNames.length - 1]
503
+ if (fnNames?.has(name)) return false
504
+ return true
505
+ }
310
506
  // Constant fold typeof for known builtin namespaces (e.g. Math.exp). prep(x) resolves Math.exp → 'math.exp'.
311
507
  function staticTypeofString(x) {
508
+ // Spec §13.5.3: unresolvable bare ref → 'undefined'.
509
+ if (isUnresolvableBareIdent(x)) return 'undefined'
312
510
  // Bare callable global: parseInt, parseFloat, isNaN, isFinite, Error, BigInt, etc.
313
511
  if (typeof x === 'string' && !ctx.func?.locals?.has(x) && GLOBALS[x] && ctx.core.emit?.[x]?.length > 0) return 'function'
314
512
  const px = prep(x)
315
513
  if (typeof px === 'string' && px.includes('.') && ctx.core.emit?.[px]?.length > 0) return 'function'
316
514
  return null
317
515
  }
516
+ // Builtin-namespace constructors expose `prototype`/`length`/`name` as own
517
+ // properties; plain namespaces (Math, JSON, Reflect, Atomics) do not.
518
+ const NS_CTORS = new Set(['Number', 'String', 'Boolean', 'BigInt', 'Object',
519
+ 'Array', 'Symbol', 'Error', 'Date', 'RegExp', 'Function', 'Map', 'Set',
520
+ 'Promise', 'ArrayBuffer', 'DataView', 'WeakMap', 'WeakSet'])
521
+ // `NS.hasOwnProperty("member")` is a compile-time question: jz models a
522
+ // builtin namespace as a set of emit keys, so a member is owned iff jz emits
523
+ // it — plus the universal constructor trio for constructor namespaces.
524
+ function namespaceHasOwn(mod, name, member) {
525
+ if (ctx.core.emit[`${mod}.${member}`] != null) return true
526
+ return NS_CTORS.has(name) && (member === 'prototype' || member === 'length' || member === 'name')
527
+ }
318
528
  function resolveTypeof(node) {
319
529
  const [op, a, b] = node
530
+ // `typeof` always yields a string, so `==`/`===` (and `!=`/`!==`) are
531
+ // equivalent here — both collapse to the same type check.
532
+ const eqLike = op === '==' || op === '==='
320
533
  // typeof x == 'string' → type check
321
534
  if (Array.isArray(a) && a[0] === 'typeof' && Array.isArray(b) && b[0] == null && typeof b[1] === 'string') {
322
535
  const known = staticTypeofString(a[1])
323
- if (known != null) return [, op === '==' ? known === b[1] : known !== b[1]]
536
+ if (known != null) return [, eqLike ? known === b[1] : known !== b[1]]
324
537
  const code = TYPEOF_MAP[b[1]]
325
538
  if (code != null) return [op, ['typeof', a[1]], [, code]]
326
539
  }
327
540
  // 'string' == typeof x
328
541
  if (Array.isArray(b) && b[0] === 'typeof' && Array.isArray(a) && a[0] == null && typeof a[1] === 'string') {
329
542
  const known = staticTypeofString(b[1])
330
- if (known != null) return [, op === '==' ? known === a[1] : known !== a[1]]
543
+ if (known != null) return [, eqLike ? known === a[1] : known !== a[1]]
331
544
  const code = TYPEOF_MAP[a[1]]
332
545
  if (code != null) return [op, ['typeof', b[1]], [, code]]
333
546
  }
334
547
  return node
335
548
  }
336
549
 
550
+ // Prepare a strict `===`/`!==`. resolveTypeof may fold `typeof x === 'type'` to a
551
+ // literal or rewrite it to a numeric-code compare; either way we prep the result's
552
+ // operands directly. The strict op stays intact (no collapse to loose `==`) so
553
+ // emit can apply the no-coercion type-mismatch fold.
554
+ function prepStrictEq(op, a, b) {
555
+ const r = resolveTypeof([op, a, b])
556
+ if (r[0] !== op) return prep(r) // folded to a literal — re-prep is safe
557
+ return [op, prep(r[1]), prep(r[2])] // keep strict op; prep operands only
558
+ }
559
+
337
560
  const cloneNode = (node) => {
338
561
  if (!Array.isArray(node)) return node
339
562
  const copy = node.map(cloneNode)
@@ -341,12 +564,40 @@ const cloneNode = (node) => {
341
564
  return copy
342
565
  }
343
566
 
567
+ /** True if `node` contains a `break`/`continue` that belongs to it — i.e. not
568
+ * one nested inside its own function. (Nested loops are intentionally counted:
569
+ * an over-detection only opts into the safe frame-carrying lowering below.) */
570
+ const hasLoopJump = (node) => {
571
+ if (!Array.isArray(node)) return false
572
+ const op = node[0]
573
+ if (op === 'break' || op === 'continue') return true
574
+ if (op === '=>' || op === 'function') return false
575
+ return node.some(hasLoopJump)
576
+ }
577
+
578
+ /** Retarget a for-in iteration's *own* unlabeled `break`/`continue` to explicit
579
+ * block labels — `break` to the construct-wide label, `continue` to this
580
+ * iteration's label. Nested loops/functions own their jumps and are skipped;
581
+ * labeled jumps already name their target and are left untouched. */
582
+ const retargetLoopJumps = (node, brkLabel, contLabel) => {
583
+ if (!Array.isArray(node)) return node
584
+ const op = node[0]
585
+ if (op === 'break' && node.length === 1) return ['break', brkLabel]
586
+ if (op === 'continue' && node.length === 1) return ['break', contLabel]
587
+ if (op === 'for' || op === 'for-in' || op === 'while' || op === 'do'
588
+ || op === '=>' || op === 'function') return node
589
+ return node.map(c => retargetLoopJumps(c, brkLabel, contLabel))
590
+ }
591
+
344
592
  function prep(node) {
345
593
  if (Array.isArray(node)) includeForOp(node[0])
346
594
  if (Array.isArray(node) && node.loc != null) ctx.error.loc = node.loc
347
595
  if (node == null) return [, 0] // null/undefined → 0 literal
348
- if (node === true) return [, 1]
349
- if (node === false) return [, 0]
596
+ // Keep boolean identity (was folded to 1/0). The working representation is
597
+ // still i32/f64 0/1 emit lowers the raw boolean — but valTypeOf now reads
598
+ // VAL.BOOL off the literal, so typeof/String/JSON/host boundary stay faithful.
599
+ if (node === true) return [, true]
600
+ if (node === false) return [, false]
350
601
  if (!Array.isArray(node)) {
351
602
  if (typeof node === 'string') {
352
603
  if (node in CONSTANTS) return [, CONSTANTS[node]]
@@ -379,7 +630,11 @@ function prep(node) {
379
630
  return handler ? handler(...args) : [op, ...args.map(prep)]
380
631
  }
381
632
 
382
- // FIXME: can we jzify some of these?
633
+ // Strict-jz prohibitions. `class` and `arguments` are *also* listed here but
634
+ // only reach this point when jzify is off — jzify lowers `class` to a factory
635
+ // arrow and rewrites `arguments` to a rest param before prepare runs. The
636
+ // remainder (`with`, `this`, `super`, `yield`, `eval`) have no safe lowering
637
+ // and stay errors in both modes.
383
638
  const PROHIBITED = { 'with': '`with` not supported', 'class': '`class` not supported', 'yield': '`yield` not supported',
384
639
  'this': '`this` not supported: use explicit parameter',
385
640
  'super': '`super` not supported: no class inheritance',
@@ -405,7 +660,21 @@ export const GLOBALS = {
405
660
  isFinite: 'number',
406
661
  parseInt: 'number',
407
662
  parseFloat: 'number',
663
+ encodeURIComponent: 'encodeURIComponent',
664
+ decodeURIComponent: 'decodeURIComponent',
408
665
  Error: 'Error',
666
+ // Error subclasses: distinct names in JS, but jz doesn't carry typed error
667
+ // info — `throw` accepts any value and stringification goes through the
668
+ // host. Treat them all as Error-shaped passthrough constructors so user
669
+ // code that throws specific subclasses (`throw new SyntaxError(msg)`) compiles
670
+ // identically. If we ever model `instanceof SyntaxError`, this is where to
671
+ // distinguish them; until then the surfaced message is what matters.
672
+ TypeError: 'Error',
673
+ SyntaxError: 'Error',
674
+ RangeError: 'Error',
675
+ ReferenceError: 'Error',
676
+ URIError: 'Error',
677
+ EvalError: 'Error',
409
678
  BigInt: 'BigInt',
410
679
  TextEncoder: 'TextEncoder',
411
680
  TextDecoder: 'TextDecoder',
@@ -442,6 +711,27 @@ function scalarArrayDestruct(pattern, rhs) {
442
711
  return prep([';', ['let', ...decls], ...assigns])
443
712
  }
444
713
 
714
+ function declareGlobal(name, user = true) {
715
+ if (depth !== 0 || typeof name !== 'string') return name
716
+ if (ctx.scope.globals.has(name)) err(`'${name}' conflicts with a compiler internal — choose a different name`)
717
+ ctx.scope.globals.set(name, `(global $${name} (mut f64) (f64.const 0))`)
718
+ if (user) ctx.scope.userGlobals.add(name)
719
+ return name
720
+ }
721
+
722
+ function bindingNames(pattern, out = new Set()) {
723
+ if (typeof pattern === 'string') out.add(pattern)
724
+ else if (Array.isArray(pattern)) {
725
+ if (pattern[0] === '...' && typeof pattern[1] === 'string') out.add(pattern[1])
726
+ else if (pattern[0] === '=') bindingNames(pattern[1], out)
727
+ else if (pattern[0] === ':') bindingNames(pattern[2], out)
728
+ else if (pattern[0] === '[]' || pattern[0] === '{}' || pattern[0] === ',') {
729
+ for (const item of pattern.slice(1)) bindingNames(item, out)
730
+ }
731
+ }
732
+ return out
733
+ }
734
+
445
735
  function pushPatternAssign(target, valueExpr, out, decls = null) {
446
736
  if (Array.isArray(target) && target[0] === '=') {
447
737
  pushPatternAssign(target[1], ['??', valueExpr, prep(target[2])], out, decls)
@@ -556,19 +846,51 @@ function prepDecl(op, ...inits) {
556
846
  if (ctx.scope.globals.has(declName)) err(`'${declName}' conflicts with a compiler internal — choose a different name`)
557
847
  ctx.scope.globals.set(declName, `(global $${declName} (mut f64) (f64.const 0))`)
558
848
  ctx.scope.userGlobals.add(declName)
849
+ } else if (typeof declName === 'string') {
850
+ // Bare hoisted decl inside a function (var X jzified to `let X` at top
851
+ // of arrow + a later `X = …` assignment). Without registering here, the
852
+ // name is invisible to scope predicates like `isUnresolvableBareIdent`
853
+ // until the assignment runs — which is after any reference to it.
854
+ const fnNames = funcLocalNames[funcLocalNames.length - 1]
855
+ if (fnNames) fnNames.add(declName)
856
+ if (scopes.length > 0) scopes[scopes.length - 1].set(declName, declName)
559
857
  }
560
858
  rest.push(declName)
561
859
  continue
562
860
  }
563
- const [, name, init] = i, normed = prep(init)
861
+ const [, name, init] = i
862
+ const staticStr = op === 'const' ? staticStringExpr(init) : null
863
+ const staticArr = op === 'const' ? staticStringArrayValues(init) : null
864
+ const normed = prep(init)
564
865
 
565
866
  if (isDestructPattern(name)) {
867
+ // Register each binding both as a module global (depth 0) and in the
868
+ // current arrow's local scope (depth ≠ 0). Without the local registration
869
+ // the name is invisible to `isUnresolvableBareIdent`, so a later
870
+ // `typeof x` would mis-fold to 'undefined' (spec §13.5.3) before emit ever
871
+ // sees the binding — see the bare-hoisted-decl branch above for the same fix.
872
+ const fnNames = funcLocalNames[funcLocalNames.length - 1]
873
+ for (const n of bindingNames(name)) {
874
+ declareGlobal(n)
875
+ if (depth !== 0 && typeof n === 'string') {
876
+ if (fnNames) fnNames.add(n)
877
+ if (scopes.length > 0) scopes[scopes.length - 1].set(n, n)
878
+ }
879
+ }
880
+ // A bare-identifier source needs no temp: reads are idempotent and
881
+ // side-effect-free, so we destructure straight off it. This keeps each
882
+ // element's static type tag (e.g. `let [, x] = strs` resolves `x` to the
883
+ // same STRING that `strs[1]` would) — a copy temp drops the array's
884
+ // element-type shape and `typeof x` would degrade to 'undefined'.
885
+ if (typeof normed === 'string') {
886
+ expandDestruct(name, normed, rest)
887
+ continue
888
+ }
566
889
  const tmp = `${T}d${ctx.func.uniq++}`
890
+ declareGlobal(tmp, false)
567
891
  rest.push(['=', tmp, normed])
568
892
  // Propagate schema to temp so rest destructuring can resolve it
569
- if (typeof normed === 'string' && ctx.schema.vars.has(normed))
570
- ctx.schema.vars.set(tmp, ctx.schema.vars.get(normed))
571
- else if (Array.isArray(normed) && normed[0] === '{}') {
893
+ if (Array.isArray(normed) && normed[0] === '{}') {
572
894
  const p = normed.slice(1).filter(p => Array.isArray(p) && p[0] === ':').map(p => p[1])
573
895
  if (p.length) ctx.schema.vars.set(tmp, ctx.schema.register(p))
574
896
  }
@@ -590,18 +912,19 @@ function prepDecl(op, ...inits) {
590
912
  scopes[scopes.length - 1].set(name, name)
591
913
  }
592
914
  if (typeof declName === 'string' && fnNames) fnNames.add(declName)
915
+ if (op === 'const') bindStaticConst(declName, staticStr, staticArr)
593
916
  // Track const for reassignment checks — only module-scope consts (depth 0)
594
917
  if (typeof declName === 'string' && depth === 0) {
595
918
  if (ctx.module.currentPrefix) {
596
919
  declName = `${ctx.module.currentPrefix}$${declName}`
597
920
  ctx.scope.chain[name] = declName
598
921
  }
922
+ if (op === 'const') bindStaticGlobal(declName, staticStr, staticArr)
599
923
  if (op === 'const') {
600
924
  if (!ctx.scope.consts) ctx.scope.consts = new Set()
601
925
  ctx.scope.consts.add(declName)
602
- if (Array.isArray(normed) && normed[0] === 'str' && typeof normed[1] === 'string')
603
- (ctx.scope.constStrs ||= new Map()).set(declName, normed[1])
604
- const strs = stringArrayValues(normed)
926
+ if (staticStr != null) (ctx.scope.constStrs ||= new Map()).set(declName, staticStr)
927
+ const strs = staticArr || stringArrayValues(normed)
605
928
  if (strs) (ctx.scope.shapeStrArrays ||= new Map()).set(declName, strs)
606
929
  } else if (op === 'let' && ctx.scope.consts?.has(declName)) {
607
930
  ctx.scope.consts.delete(declName)
@@ -614,7 +937,7 @@ function prepDecl(op, ...inits) {
614
937
  // entry whose name is later assigned to.
615
938
  if (Array.isArray(normed) && normed[0] === 'str' && typeof normed[1] === 'string')
616
939
  (ctx.scope.shapeStrs ||= new Map()).set(declName, normed[1])
617
- recordGlobalValueFact(declName, normed)
940
+ recordGlobalRep(declName, normed)
618
941
  }
619
942
  // Track object schemas (after prefix so schema is keyed to final name)
620
943
  if (typeof declName === 'string' && Array.isArray(normed) && normed[0] === '{}' && normed.length > 1) {
@@ -641,6 +964,119 @@ function prepDecl(op, ...inits) {
641
964
  return rest.length ? [op, ...rest] : null
642
965
  }
643
966
 
967
+ // --- `'()'` call-handler helpers --------------------------------------------
968
+ // The call handler is a thin dispatcher: it tries the compile-time folds
969
+ // below (each gated by callee shape, so at most one fires), then resolves the
970
+ // callee, then assembles the call. Each helper moves one concern out of line.
971
+
972
+ // `import.meta.resolve("spec")` → the resolved URL as a static string.
973
+ function foldImportMetaResolve(callee, args) {
974
+ if (!isImportMetaProp(callee, 'resolve')) return undefined
975
+ const callArgs = flatArgs(args).filter(a => a != null)
976
+ if (callArgs.length !== 1) err('`import.meta.resolve` requires one string literal argument')
977
+ const spec = stringValue(callArgs[0])
978
+ if (spec == null) err('`import.meta.resolve` supports only string literal arguments')
979
+ return staticString(resolveImportMeta(spec))
980
+ }
981
+
982
+ // String-callee constructor / named-builtin folds: `Array(n)` and the `CTORS`
983
+ // set redirect to the `new` handler; `BigInt64Array`/`BigUint64Array` build a
984
+ // direct module call. `includeForNamedCall` is probed for every string callee
985
+ // — that probe is also how a module-backed builtin gets its modules included.
986
+ // Returns the replacement IR, or `undefined` for an ordinary call.
987
+ function dispatchConstructorCall(callee, args) {
988
+ if (typeof callee !== 'string') return undefined
989
+ if (callee === 'Array') {
990
+ const callArgs = flatArgs(args).filter(a => a != null)
991
+ if (callArgs.length === 1) return handlers['new'](['()', callee, callArgs[0]])
992
+ }
993
+ if (CTORS.includes(callee)) return handlers['new'](['()', callee, ...args])
994
+ if (includeForNamedCall(callee) && (callee === 'BigInt64Array' || callee === 'BigUint64Array'))
995
+ return ['()', callee, ...args.filter(a => a != null).map(prep)]
996
+ return undefined
997
+ }
998
+
999
+ // Compile-time namespace introspection on a `obj.prop(...)` callee:
1000
+ // `Array.isArray(NS)` on a bare builtin global folds to `false` (a namespace
1001
+ // value is never an array); `NS.hasOwnProperty("member")` on a builtin
1002
+ // namespace folds to a literal — no runtime namespace object. Returns the
1003
+ // folded literal IR, or `undefined` when nothing folds.
1004
+ function foldNamespaceIntrospection(callee, args) {
1005
+ if (!Array.isArray(callee) || callee[0] !== '.') return undefined
1006
+ const [, obj, prop] = callee
1007
+ if (obj === 'Array' && prop === 'isArray') {
1008
+ const cargs = flatArgs(args).filter(a => a != null)
1009
+ const a0 = cargs.length === 1 ? cargs[0] : null
1010
+ if (typeof a0 === 'string' && GLOBALS[a0] && !(scopes.length && isDeclared(a0)) && !hasFunc(a0))
1011
+ return [, 0]
1012
+ }
1013
+ if (prop === 'hasOwnProperty' && typeof obj === 'string' && !(scopes.length && isDeclared(obj))) {
1014
+ const mod = ctx.scope.chain[obj]
1015
+ if (mod && !mod.includes('.') && hasModule(mod)) {
1016
+ const cargs = flatArgs(args).filter(a => a != null)
1017
+ const member = cargs.length === 1 ? stringValue(cargs[0]) : null
1018
+ // Include the module so its emit keys (the namespace's member set) are
1019
+ // registered; unreferenced emitters/data dead-strip in compile.
1020
+ if (member != null) { includeModule(mod); return [, namespaceHasOwn(mod, obj, member) ? 1 : 0] }
1021
+ }
1022
+ }
1023
+ return undefined
1024
+ }
1025
+
1026
+ // Resolve a callee to its lowered form, triggering module autoloads along the
1027
+ // way: a bare identifier through the scope chain, an `obj.prop` member call
1028
+ // through host imports / named-call / generic-method / namespace tables, and
1029
+ // any other expression through `prep` (a callable runtime value).
1030
+ function resolveCallee(callee, args) {
1031
+ if (typeof callee === 'string') {
1032
+ const local = scopes.length && isDeclared(callee)
1033
+ const resolved = local ? null : ctx.scope.chain[callee]
1034
+ if (local) return resolveScope(callee)
1035
+ if (resolved?.includes('.')) return resolved
1036
+ if (resolved && hasFunc(resolved)) return resolved
1037
+ if (resolved && !resolved.includes('.')) {
1038
+ if (hasModule(resolved) && !ctx.module.imports.some(i => i[3]?.[1] === `$${resolved}`)) includeModule(resolved)
1039
+ return callee
1040
+ }
1041
+ if (depth > 0 && !resolved && !ctx.func.exports[callee] && !ctx.module.imports.some(i => i[3]?.[1] === `$${callee}`))
1042
+ includeForCallableValue()
1043
+ return callee
1044
+ }
1045
+ if (Array.isArray(callee) && callee[0] === '.') {
1046
+ const [, obj, prop] = callee
1047
+ const key = typeof obj === 'string' && typeof prop === 'string' ? `${obj}.${prop}` : null
1048
+ if (key && ctx.module.hostImports?.[obj]?.[prop]) {
1049
+ const spec = ctx.module.hostImports[obj][prop]
1050
+ const alias = `${obj}$${prop}`
1051
+ addHostImport(obj, prop, alias, spec)
1052
+ return alias
1053
+ }
1054
+ if (key && includeForNamedCall(key)) return key
1055
+ if (includeForGenericMethod(prop)) return prep(callee)
1056
+ const mod = ctx.scope.chain[obj]
1057
+ if (typeof obj === 'string' && mod && !mod.includes('.') && hasModule(mod))
1058
+ return (includeModule(mod), mod + '.' + prop)
1059
+ return prep(callee)
1060
+ }
1061
+ includeForCallableValue()
1062
+ return prep(callee)
1063
+ }
1064
+
1065
+ // A lone parenthesized comma-expression argument — `f((a, b, c))` — is ONE
1066
+ // argument whose value is the last comma operand. The parser keeps it wrapped
1067
+ // (`['()', [',', …]]`); prep would strip the grouping, leaving a bare comma
1068
+ // that emit can no longer tell apart from an arg list and splats into N args.
1069
+ // With ≥2 args an outer arg-list comma already nests it — only the sole-arg
1070
+ // case loses the distinction. Re-nest it under a 1-element arg-list comma.
1071
+ function renestSoleCommaArg(args) {
1072
+ if (args.length === 1 && Array.isArray(args[0]) && args[0][0] === '()' && args[0].length === 2) {
1073
+ const ungroup = n => Array.isArray(n) && n[0] === '()' && n.length === 2 ? ungroup(n[1]) : n
1074
+ const core = ungroup(args[0])
1075
+ if (Array.isArray(core) && core[0] === ',') return [[',', args[0]]]
1076
+ }
1077
+ return args
1078
+ }
1079
+
644
1080
  const handlers = {
645
1081
  // Spread operator: [...expr] in arrays, f(...args) in calls, {...obj} in objects
646
1082
  '...'(expr) {
@@ -655,11 +1091,23 @@ const handlers = {
655
1091
  'class': () => err('class not supported: use object literals'),
656
1092
  'yield': () => err('generators not supported: use loops'),
657
1093
  'debugger': () => null,
658
- 'delete': () => err('delete not supported: object shape is fixed'),
1094
+ // Static-key delete (.x, ["x"], [literal]) would change the fixed schema reject.
1095
+ // Computed-key delete (obj[expr]) — including jessie's `delete ctx[k]` — lowers
1096
+ // to runtime __dyn_del against the per-object shadow property store.
1097
+ 'delete'(target) {
1098
+ const t = prep(target)
1099
+ if (Array.isArray(t) && t[0] === '[]' && t.length === 3) {
1100
+ const key = t[2]
1101
+ const isLiteralKey = Array.isArray(key) && key[0] == null && key.length === 2
1102
+ if (!isLiteralKey) return ['delete', t[1], key]
1103
+ }
1104
+ err('delete not supported: object shape is fixed')
1105
+ },
659
1106
  'in'(key, obj) { return ['in', prep(key), prep(obj)] },
660
1107
  'instanceof': () => err('instanceof not supported: use typeof'),
661
1108
  'with': () => err('`with` not supported: deprecated'),
662
1109
  ':': () => err('labeled statements not supported'),
1110
+ 'label'(name, body) { return ['label', name, prep(body)] },
663
1111
  'var': () => err('`var` not supported: use let/const'),
664
1112
  'function': () => err('`function` not supported: use arrow functions'),
665
1113
 
@@ -681,23 +1129,60 @@ const handlers = {
681
1129
  expandDestruct(lhs, tmp, stmts, decls)
682
1130
  return prep([';', ['let', ...decls], ...stmts])
683
1131
  }
684
- // Parser ambiguity: }[pattern] = rhs mis-parsed as subscript when it's stmt; [pattern] = rhs
685
- // Detect: ['[]', stmtExpr, commaExpr] with spread in comma → split into stmt + destructuring
686
- if (Array.isArray(lhs) && lhs[0] === '[]' && lhs.length === 3) {
687
- const hasSpr = n => Array.isArray(n) && (n[0] === '...' || n.some(hasSpr))
688
- if (hasSpr(lhs[2])) {
689
- const preStmt = lhs[1]
690
- const pattern = ['[]', lhs[2]]
691
- return prep([';', preStmt, ['=', pattern, rhs]])
1132
+ // Function property assignment: fn.prop = arrow extract as top-level function fn$prop.
1133
+ // A property can be reassigned esbuild/jessie wrapper-composition does
1134
+ // `p.s = ...; var old = p.s; p.s = () => old()...`. Each assignment extracts
1135
+ // its own top-level function; the property holds whichever was assigned last,
1136
+ // and an earlier snapshot keeps pointing at the prior one. Collide → fresh name.
1137
+ // The base resolves through the scope chain first so an *imported* function
1138
+ // (mangled to `_mod$fn`) is recognised the same as a local one — the
1139
+ // subscript parser's plugin model mutates `parse.step` etc. across modules,
1140
+ // and a reassignment in module B must mark module A's call sites mutable.
1141
+ if (depth === 0 && Array.isArray(lhs) && lhs[0] === '.' && typeof lhs[1] === 'string'
1142
+ && Array.isArray(rhs) && rhs[0] === '=>') {
1143
+ const fnBase = ctx.scope.chain[lhs[1]] || lhs[1]
1144
+ if (hasFunc(fnBase)) {
1145
+ let name = `${fnBase}$${lhs[2]}`
1146
+ // Reassignment → the property is mutable; record it so `fn.prop()` calls
1147
+ // emit a dynamic property read + indirect call instead of a direct call.
1148
+ if (ctx.func.names.has(name)) {
1149
+ ctx.func.multiProp.add(`${fnBase}.${lhs[2]}`)
1150
+ do name = `${fnBase}$${lhs[2]}$${ctx.func.uniq++}`; while (ctx.func.names.has(name))
1151
+ }
1152
+ // Build the target `.` node directly from the resolved base — re-`prep`ing
1153
+ // the lhs would resolve a multiProp `fn.prop` to an rvalue (closure
1154
+ // materialization block), which is not a valid assignment target.
1155
+ if (defFunc(name, prep(rhs))) return ['=', ['.', fnBase, lhs[2]], name]
692
1156
  }
693
1157
  }
694
- // Function property assignment: fn.prop = arrow → extract as top-level function fn$prop
695
- if (depth === 0 && Array.isArray(lhs) && lhs[0] === '.' && typeof lhs[1] === 'string'
696
- && hasFunc(lhs[1]) && Array.isArray(rhs) && rhs[0] === '=>') {
697
- const name = `${lhs[1]}$${lhs[2]}`
698
- if (defFunc(name, prep(rhs))) return ['=', prep(lhs), name]
1158
+ const staticStr = staticStringExpr(rhs)
1159
+ const staticArr = staticStringArrayValues(rhs)
1160
+ const plhs = prep(lhs)
1161
+ const prhs = prep(rhs)
1162
+ if (depth === 0 && typeof plhs === 'string' && ctx.scope.globals.has(plhs)) {
1163
+ // First assignment fixes the global's representation + object schema.
1164
+ if (!ctx.scope.globalReps?.has(plhs)) {
1165
+ recordGlobalRep(plhs, prhs)
1166
+ if (Array.isArray(prhs) && prhs[0] === '{}') {
1167
+ const props = staticObjectProps(prhs.slice(1))
1168
+ if (props) ctx.schema.vars.set(plhs, ctx.schema.register(props.names))
1169
+ }
1170
+ }
1171
+ // Static string/array facts hold only while every assignment is constant.
1172
+ if (!assignedStaticGlobals.has(plhs) && (staticStr != null || staticArr)) bindStaticGlobal(plhs, staticStr, staticArr)
1173
+ else deleteStaticGlobal(plhs)
1174
+ assignedStaticGlobals.add(plhs)
1175
+ }
1176
+ // Local object-literal assignment to a not-yet-shaped variable — e.g. a `var`
1177
+ // that jzify hoisted into `let x; x = {…}`. Recording the schema here lets the
1178
+ // binding behave like `let x = {…}`: fixed-slot field access and for-in unroll.
1179
+ // First assignment fixes the shape (mirrors the global rule above).
1180
+ else if (typeof plhs === 'string' && Array.isArray(prhs) && prhs[0] === '{}'
1181
+ && !ctx.schema.vars.has(plhs)) {
1182
+ const props = staticObjectProps(prhs.slice(1))
1183
+ if (props) ctx.schema.vars.set(plhs, ctx.schema.register(props.names))
699
1184
  }
700
- return ['=', prep(lhs), prep(rhs)]
1185
+ return ['=', plhs, prhs]
701
1186
  },
702
1187
 
703
1188
  // try/catch/throw
@@ -730,16 +1215,14 @@ const handlers = {
730
1215
  },
731
1216
 
732
1217
  // Tagged template: tag`a${x}b` → tag(['a','b'], x)
733
- // Parser drops empty string segments; reinsert them to satisfy the strings.length === exprs.length + 1 invariant.
734
1218
  '``'(tag, ...parts) {
1219
+ const raw = staticStringExpr(['``', tag, ...parts])
1220
+ if (raw != null) return staticString(raw)
735
1221
  const strs = [], exprs = []
736
- let prev = false
737
1222
  for (const p of parts) {
738
- const isStr = Array.isArray(p) && p[0] == null && typeof p[1] === 'string'
739
- if (isStr) { strs.push(p); prev = true }
740
- else { if (!prev) strs.push([null, '']); exprs.push(p); prev = false }
1223
+ if (Array.isArray(p) && p[0] == null && typeof p[1] === 'string') strs.push(p)
1224
+ else exprs.push(p)
741
1225
  }
742
- if (!prev) strs.push([null, ''])
743
1226
  const arr = strs.length === 1 ? ['[]', strs[0]] : ['[]', [',', ...strs]]
744
1227
  const callArgs = exprs.length === 0 ? arr : [',', arr, ...exprs]
745
1228
  return prep(['()', tag, callArgs])
@@ -747,6 +1230,9 @@ const handlers = {
747
1230
 
748
1231
  // Import
749
1232
  'import'(fromNode) {
1233
+ // Bare side-effect: `import './sub.js'` → AST is ['import', [null, 'path']]
1234
+ if (Array.isArray(fromNode) && fromNode[0] == null && typeof fromNode[1] === 'string')
1235
+ return handlers['from'](null, fromNode)
750
1236
  if (!Array.isArray(fromNode) || fromNode[0] !== 'from')
751
1237
  return err('Dynamic import() not supported')
752
1238
  return handlers['from'](fromNode[1], fromNode[2])
@@ -863,9 +1349,13 @@ const handlers = {
863
1349
  err(`Unknown module '${mod}'. Provide it via { modules: { '${mod}': source } } or { imports: { '${mod}': {...} } }`)
864
1350
  },
865
1351
 
866
- // === is == in jz (all comparisons are strict). Also handle typeof x === 'type' patterns.
867
- '==='(a, b) { return prep(resolveTypeof(['==', a, b])) },
868
- '!=='(a, b) { return prep(resolveTypeof(['!=', a, b])) },
1352
+ // `===`/`!==` keep strict semantics (no coercion); emit folds a statically-known
1353
+ // type mismatch to false and otherwise shares the loose `==`/`!=` same-type path.
1354
+ // resolveTypeof still collapses `typeof x === 'type'` to a compile-time check.
1355
+ // Prep operands directly (not via `prep` on the node) so the strict op survives
1356
+ // to emit instead of re-dispatching this handler forever.
1357
+ '==='(a, b) { return prepStrictEq('===', a, b) },
1358
+ '!=='(a, b) { return prepStrictEq('!==', a, b) },
869
1359
 
870
1360
  // Statements
871
1361
  ';': (...stmts) => [';', ...stmts.map(prep).filter(x => x != null)],
@@ -874,14 +1364,14 @@ const handlers = {
874
1364
 
875
1365
  // Block-scoped control flow: push scope for bodies so inner let/const shadows correctly
876
1366
  'if': (cond, then, els) => {
877
- const c = prep(cond)
878
- scopes.push(new Map()); const t = prep(then); scopes.pop()
879
- if (els != null) { scopes.push(new Map()); const e = prep(els); scopes.pop(); return ['if', c, t, e] }
1367
+ const c = prep(stripBoolNot(cond))
1368
+ pushScope(); const t = prep(then); popScope()
1369
+ if (els != null) { pushScope(); const e = prep(els); popScope(); return ['if', c, t, e] }
880
1370
  return ['if', c, t]
881
1371
  },
882
1372
  'while': (cond, body) => {
883
- const c = prep(cond)
884
- scopes.push(new Map()); const b = prep(body); scopes.pop()
1373
+ const c = prep(stripBoolNot(cond))
1374
+ pushScope(); const b = prep(body); popScope()
885
1375
  return ['while', c, b]
886
1376
  },
887
1377
 
@@ -964,7 +1454,7 @@ const handlers = {
964
1454
  for (const n of collectParamNames(raw)) fnScope.set(n, n)
965
1455
 
966
1456
  depth++
967
- scopes.push(fnScope)
1457
+ pushScope(fnScope)
968
1458
  funcLocalNames.push(new Set(collectParamNames(raw)))
969
1459
 
970
1460
  const nextParams = []
@@ -986,6 +1476,14 @@ const handlers = {
986
1476
  }
987
1477
  }
988
1478
  let preparedBody = prep(body)
1479
+ // An expression-bodied arrow returning an empty object literal — `() => ({})`
1480
+ // — preps to a bare `['{}']`, structurally identical to an empty block body.
1481
+ // The grouping `()` that marked it an expression is unwrapped by then, so
1482
+ // wrap it in an explicit `return` — otherwise downstream block/expression
1483
+ // classification (compile.js `isBlockBody`) misreads it as an empty block.
1484
+ if (!(Array.isArray(body) && body[0] === '{}')
1485
+ && Array.isArray(preparedBody) && preparedBody[0] === '{}' && preparedBody.length === 1)
1486
+ preparedBody = ['{}', [';', ['return', ['{}']]]]
989
1487
  if (bodyPrefix.length) {
990
1488
  const prefix = bodyPrefix.filter(x => x != null)
991
1489
  if (Array.isArray(preparedBody) && preparedBody[0] === '{}' && Array.isArray(preparedBody[1]) && preparedBody[1][0] === ';')
@@ -997,7 +1495,7 @@ const handlers = {
997
1495
  }
998
1496
  const inner = nextParams.length === 0 ? null : nextParams.length === 1 ? nextParams[0] : [',', ...nextParams]
999
1497
  const result = ['=>', Array.isArray(params) && params[0] === '()' ? ['()', inner] : inner, preparedBody]
1000
- scopes.pop()
1498
+ popScope()
1001
1499
  funcLocalNames.pop()
1002
1500
  depth--
1003
1501
  return result
@@ -1029,6 +1527,8 @@ const handlers = {
1029
1527
  return ['?.()', prep(callee), ...items.map(prep)]
1030
1528
  },
1031
1529
  // Boolean literals NaN-box as f64 — typeof at runtime returns 'number'. Fold here so the JS-spec value survives.
1530
+ // Unresolvable bare refs fold to 'undefined' via staticTypeofString (spec §13.5.3) —
1531
+ // the only place a stray identifier doesn't ReferenceError.
1032
1532
  'typeof'(a) {
1033
1533
  if (Array.isArray(a) && a[0] == null && typeof a[1] === 'boolean') { includeForStringOnly(); return ['str', 'boolean'] }
1034
1534
  const known = staticTypeofString(a)
@@ -1044,7 +1544,17 @@ const handlers = {
1044
1544
  includeForNumericCoercion()
1045
1545
  return ['u+', na]
1046
1546
  }
1047
- return ['+', prep(a), prep(b)]
1547
+ const pa = prep(a), pb = prep(b)
1548
+ // Compile-time fold of literal string concat. The combined bytes flow
1549
+ // through the `str` emitter as a single literal — SSO if ≤4 ASCII (zero
1550
+ // heap), otherwise one dataDedup entry (still cheaper than runtime
1551
+ // __str_concat_raw + heap alloc). Bottom-up, so `'a' + 'b' + 'c'` folds
1552
+ // left-associatively into one literal.
1553
+ if (Array.isArray(pa) && pa[0] === 'str' && typeof pa[1] === 'string' &&
1554
+ Array.isArray(pb) && pb[0] === 'str' && typeof pb[1] === 'string') {
1555
+ return ['str', pa[1] + pb[1]]
1556
+ }
1557
+ return ['+', pa, pb]
1048
1558
  },
1049
1559
  '-'(a, b) {
1050
1560
  if (b === undefined) { const na = prep(a); return isLit(na) && typeof na[1] === 'number' ? [, -na[1]] : ['u-', na] }
@@ -1052,7 +1562,7 @@ const handlers = {
1052
1562
  },
1053
1563
 
1054
1564
  // Ternary: parser emits '?' not '?:'
1055
- '?'(cond, then, els) { return ['?:', prep(cond), prep(then), prep(els)] },
1565
+ '?'(cond, then, els) { return ['?:', prep(stripBoolNot(cond)), prep(then), prep(els)] },
1056
1566
 
1057
1567
  // ++/-- prefix vs postfix: parser sends trailing null for postfix
1058
1568
  // Postfix i++ = (++i) - 1: increment happens, arithmetic recovers old value
@@ -1079,73 +1589,18 @@ const handlers = {
1079
1589
  '()'(callee, ...args) {
1080
1590
  // Grouping: (expr) → ['()', expr] with no args. Call: f() → ['()', 'f', null] with null arg.
1081
1591
  if (args.length === 0) return prep(callee)
1592
+ if (typeof callee === 'string' && PROHIBITED[callee]) err(PROHIBITED[callee])
1082
1593
 
1083
- if (isImportMetaProp(callee, 'resolve')) {
1084
- const callArgs = flatArgs(args).filter(a => a != null)
1085
- if (callArgs.length !== 1) err('`import.meta.resolve` requires one string literal argument')
1086
- const spec = stringValue(callArgs[0])
1087
- if (spec == null) err('`import.meta.resolve` supports only string literal arguments')
1088
- return staticString(resolveImportMeta(spec))
1089
- }
1090
-
1091
- const hasRealArgs = args.some(a => a != null)
1594
+ // Compile-time folds: the callee names something resolvable now. Each fold
1595
+ // is gated by callee shape, so at most one of the three fires.
1596
+ const folded = foldImportMetaResolve(callee, args)
1597
+ ?? dispatchConstructorCall(callee, args)
1598
+ ?? foldNamespaceIntrospection(callee, args)
1599
+ if (folded !== undefined) return folded
1092
1600
 
1093
- if (typeof callee === 'string') {
1094
- if (PROHIBITED[callee]) err(PROHIBITED[callee])
1095
- if (CTORS.includes(callee)) return handlers['new'](['()', callee, ...args])
1601
+ callee = resolveCallee(callee, args)
1602
+ args = renestSoleCommaArg(args)
1096
1603
 
1097
- if (includeForNamedCall(callee)) {
1098
- if (callee === 'BigInt64Array' || callee === 'BigUint64Array') {
1099
- return ['()', callee, ...args.filter(a => a != null).map(prep)]
1100
- }
1101
- }
1102
-
1103
- const local = scopes.length && isDeclared(callee)
1104
- const resolved = local ? null : ctx.scope.chain[callee]
1105
- if (local) callee = resolveScope(callee)
1106
- else if (resolved?.includes('.')) callee = resolved
1107
- else if (resolved && hasFunc(resolved)) callee = resolved
1108
- else if (resolved && !resolved.includes('.')) {
1109
- if (hasModule(resolved) && !ctx.module.imports.some(i => i[3]?.[1] === `$${resolved}`)) includeModule(resolved)
1110
- }
1111
- else if (depth > 0 && !resolved && !ctx.func.exports[callee] && !ctx.module.imports.some(i => i[3]?.[1] === `$${callee}`)) {
1112
- includeForCallableValue()
1113
- }
1114
- } else if (Array.isArray(callee) && callee[0] === '.') {
1115
- const [, obj, prop] = callee
1116
- const key = typeof obj === 'string' && typeof prop === 'string' ? `${obj}.${prop}` : null
1117
- if (key && ctx.module.hostImports?.[obj]?.[prop]) {
1118
- const spec = ctx.module.hostImports[obj][prop]
1119
- const alias = `${obj}$${prop}`
1120
- addHostImport(obj, prop, alias, spec)
1121
- callee = alias
1122
- } else if (key && includeForNamedCall(key)) {
1123
- callee = key
1124
- } else if (includeForGenericMethod(prop)) {
1125
- callee = prep(callee)
1126
- } else {
1127
- const mod = ctx.scope.chain[obj]
1128
- if (typeof obj === 'string' && mod && !mod.includes('.') && hasModule(mod)) {
1129
- callee = (includeModule(mod), mod + '.' + prop)
1130
- } else {
1131
- callee = prep(callee)
1132
- }
1133
- }
1134
- } else {
1135
- includeForCallableValue()
1136
- callee = prep(callee)
1137
- }
1138
-
1139
- // Drop trailing-comma sentinel inside a comma group: `f(a, b,)` parses as
1140
- // ['()', 'f', [',', a, b, null]] — without trimming, the trailing null
1141
- // becomes a [, 0] literal and inflates arguments.length.
1142
- if (args.length === 1 && Array.isArray(args[0]) && args[0][0] === ',') {
1143
- let end = args[0].length
1144
- while (end > 1 && args[0][end - 1] == null) end--
1145
- if (end < args[0].length) {
1146
- args[0] = end === 2 ? args[0][1] : args[0].slice(0, end)
1147
- }
1148
- }
1149
1604
  const preppedArgs = args.filter(a => a != null).map(prep)
1150
1605
  for (const a of preppedArgs) {
1151
1606
  if (typeof a === 'string' && hasFunc(a)) {
@@ -1165,7 +1620,9 @@ const handlers = {
1165
1620
  const inner = args[0]
1166
1621
  includeForArrayLiteral()
1167
1622
  if (inner == null) return ['[']
1168
- if (Array.isArray(inner) && inner[0] === ',') { const items = inner.slice(1); if (items.length && items[items.length - 1] === null) items.pop(); return ['[', ...items.map(item => item == null ? [, JZ_NULL] : prep(item))] }
1623
+ // jessie consumes the trailing comma itself; every remaining `null` in the
1624
+ // element list is a genuine elision (`[,]` → length 1, `[1,,]` → length 2).
1625
+ if (Array.isArray(inner) && inner[0] === ',') { const items = inner.slice(1); return ['[', ...items.map(item => item == null ? [, undefined] : prep(item))] }
1169
1626
  return ['[', prep(inner)]
1170
1627
  }
1171
1628
  if (typeof args[0] === 'string' && ctx.module.namespaces?.[args[0]]) {
@@ -1185,9 +1642,9 @@ const handlers = {
1185
1642
 
1186
1643
  // Bare block statement: push scope for let/const shadowing
1187
1644
  '{'(inner) {
1188
- scopes.push(new Map())
1645
+ pushScope()
1189
1646
  const result = ['{', prep(inner)]
1190
- scopes.pop()
1647
+ popScope()
1191
1648
  return result
1192
1649
  },
1193
1650
 
@@ -1196,14 +1653,44 @@ const handlers = {
1196
1653
  // Detect block body vs object literal
1197
1654
  if (Array.isArray(inner) && STMT_OPS.has(inner[0])) {
1198
1655
  // Block body: push block scope for let/const shadowing
1199
- scopes.push(new Map())
1656
+ pushScope()
1200
1657
  const result = ['{}', prep(inner)]
1201
- scopes.pop()
1658
+ popScope()
1202
1659
  return result
1203
1660
  }
1204
1661
 
1205
1662
  includeForObjectLiteral()
1206
1663
  if (inner == null) return ['{}']
1664
+ const items = Array.isArray(inner) && inner[0] === ','
1665
+ ? inner.slice(1)
1666
+ : [inner]
1667
+
1668
+ // Computed keys: `{[k]: v}` where `k` isn't compile-time foldable. jz's
1669
+ // object layout is slot-based (fixed schema at the literal site), so a
1670
+ // truly-dynamic key can't slot in. Lower to the existing dict path:
1671
+ // {a:1, [k]:v, b:2} → ((__t) => (__t[k]=v, __t))({a:1, b:2})
1672
+ // Static-but-non-string keys still fold via `staticPropertyKey` below.
1673
+ const isComputed = p => Array.isArray(p) && p[0] === ':'
1674
+ && typeof p[1] !== 'string' && staticPropertyKey(p[1]) == null
1675
+ if (items.some(isComputed)) {
1676
+ const staticItems = items.filter(p => !isComputed(p))
1677
+ const computedItems = items.filter(isComputed)
1678
+ const tmp = `${T}o${ctx.func.uniq++}`
1679
+ // Body: comma sequence of dict-sets, terminated with the tmp itself.
1680
+ // Computed key shape from parser is `[':', ['[]', keyExpr], valExpr]` —
1681
+ // unwrap the `['[]', keyExpr]` to grab keyExpr directly.
1682
+ const assigns = computedItems.map(p => {
1683
+ const keyExpr = Array.isArray(p[1]) && p[1][0] === '[]' ? p[1][1] : p[1]
1684
+ return ['=', ['[]', tmp, keyExpr], p[2]]
1685
+ })
1686
+ const body = [',', ...assigns, tmp]
1687
+ const arrow = ['=>', ['()', tmp], body]
1688
+ const arg = staticItems.length === 1 ? ['{}', staticItems[0]]
1689
+ : staticItems.length ? ['{}', [',', ...staticItems]]
1690
+ : ['{}']
1691
+ return prep(['()', arrow, arg])
1692
+ }
1693
+
1207
1694
  // Process properties: shorthand 'x' → [':', 'x', 'x'], or [':', key, val] → prep val only
1208
1695
  const prop = p => {
1209
1696
  if (typeof p === 'string') return [':', p, prep(p)]
@@ -1214,13 +1701,6 @@ const handlers = {
1214
1701
  }
1215
1702
  return prep(p)
1216
1703
  }
1217
- // Drop trailing-comma artifacts: subscript represents `{a:1, b,}` as
1218
- // `[",", [":","a",1], "b", null]` — the trailing `null` would prep to a
1219
- // literal-0 entry, leaving the literal carrying a phantom slot and
1220
- // shifting any subsequent slot-position resolution.
1221
- const items = Array.isArray(inner) && inner[0] === ','
1222
- ? inner.slice(1).filter(p => p != null)
1223
- : [inner]
1224
1704
  let prepped = items.map(prop)
1225
1705
  // ES spec: duplicate keys allowed; key takes first-seen position, last-seen value.
1226
1706
  const lastValue = new Map()
@@ -1244,10 +1724,11 @@ const handlers = {
1244
1724
 
1245
1725
  // For loop
1246
1726
  'for'(head, body) {
1247
- scopes.push(new Map())
1727
+ pushScope()
1248
1728
  let r
1249
1729
  if (Array.isArray(head) && head[0] === ';') {
1250
1730
  let [, init, cond, step] = head
1731
+ cond = stripBoolNot(cond)
1251
1732
  // Hoist .length / .size / .byteLength from for-condition:
1252
1733
  // `i < arr.length` → `let __len = arr.length | 0; ... i < __len`
1253
1734
  // The `| 0` forces i32 even for unknown-typed receivers (where __length
@@ -1297,30 +1778,54 @@ const handlers = {
1297
1778
  if (sid != null) {
1298
1779
  // Known schema → compile-time unrolling with string keys
1299
1780
  const keys = ctx.schema.list[sid]
1300
- if (!keys || !keys.length) { scopes.pop(); return null }
1781
+ if (!keys || !keys.length) { popScope(); return null }
1301
1782
  includeForKnownKeyIteration()
1302
- const stmts = []
1303
- for (let i = 0; i < keys.length; i++) {
1304
- stmts.push(i === 0
1305
- ? ['let', ['=', varName, [, keys[i]]]]
1306
- : ['=', varName, [, keys[i]]])
1307
- stmts.push(cloneNode(body))
1783
+ if (!hasLoopJump(body)) {
1784
+ // No break/continue flat unroll, no loop frame needed.
1785
+ const stmts = []
1786
+ for (let i = 0; i < keys.length; i++) {
1787
+ stmts.push(i === 0
1788
+ ? ['let', ['=', varName, [, keys[i]]]]
1789
+ : ['=', varName, [, keys[i]]])
1790
+ stmts.push(cloneNode(body))
1791
+ }
1792
+ r = prep([';', ...stmts])
1793
+ } else {
1794
+ // break/continue present → an unrolled loop still needs its frames.
1795
+ // Wrap each iteration in a labeled block (continue target) and the
1796
+ // whole run in an outer labeled block (break target): `break` exits
1797
+ // the construct, `continue` falls through to the next iteration.
1798
+ const brkL = `${T}fibrk${ctx.func.uniq++}`
1799
+ const decl = prep(['let', ['=', varName, [, keys[0]]]])
1800
+ const parts = [decl]
1801
+ for (let i = 0; i < keys.length; i++) {
1802
+ const contL = `${T}ficont${ctx.func.uniq++}`
1803
+ const iter = prep(i === 0
1804
+ ? cloneNode(body)
1805
+ : [';', ['=', varName, [, keys[i]]], cloneNode(body)])
1806
+ parts.push(['label', contL, retargetLoopJumps(iter, brkL, contL)])
1807
+ }
1808
+ r = ['label', brkL, [';', ...parts]]
1308
1809
  }
1309
- r = prep([';', ...stmts])
1310
1810
  } else {
1311
1811
  // Dynamic object → HASH runtime iteration
1312
1812
  includeForRuntimeKeyIteration()
1313
1813
  r = ['for-in', varName, prep(src), prep(body)]
1314
1814
  }
1315
1815
  } else {
1316
- r = ['for', prep(head), prep(body)]
1816
+ // Some parser/jzify shapes for `for (;;)` and `for (; cond; )` arrive
1817
+ // as a null or bare-condition head instead of the canonical
1818
+ // `[';', init, cond, step]` tuple. Normalize them before emit so they
1819
+ // remain ordinary for-loops, not malformed two-slot nodes.
1820
+ r = ['for', null, head == null ? null : prep(head), null, prep(body)]
1317
1821
  }
1318
- scopes.pop()
1822
+ popScope()
1319
1823
  return r
1320
1824
  },
1321
1825
 
1322
1826
  // Property access - resolve namespaces or object/array properties
1323
1827
  '.'(obj, prop) {
1828
+ prop = typeof prop === 'string' ? prop : staticPropertyKey(prop)
1324
1829
  if (prop === 'caller' || prop === 'callee') err('`.caller` and `.callee` are prohibited: deprecated stack introspection')
1325
1830
  if (prop === 'url' && isImportMeta(obj)) return staticString(importMetaUrl())
1326
1831
  const mod = ctx.scope.chain[obj]
@@ -1358,6 +1863,20 @@ const handlers = {
1358
1863
  }
1359
1864
  }
1360
1865
 
1866
+ // `new RegExp("pattern", "flags?")` with string-literal pattern → compile
1867
+ // like a regex literal `/pattern/flags`. Dynamic pattern is not supported
1868
+ // (would require a runtime regex interpreter). Reported as build blocker #6.
1869
+ if (name === 'RegExp') {
1870
+ const literalArgs = ctorArgs.filter(a => a != null)
1871
+ const pattern = staticStringExpr(literalArgs[0])
1872
+ if (pattern == null)
1873
+ err('new RegExp() requires a string-literal pattern; dynamic regex construction is not supported')
1874
+ const flags = literalArgs.length > 1 ? staticStringExpr(literalArgs[1]) : ''
1875
+ if (flags == null)
1876
+ err('new RegExp() flags must be a string literal')
1877
+ return prep(['//', pattern, flags || undefined])
1878
+ }
1879
+
1361
1880
  // Wrap multi-arg ctor arg lists back into a single comma-group — the '()' op
1362
1881
  // expects callArgs as a single element (possibly comma-grouped).
1363
1882
  const wrapArgs = (args) => args.length === 0 ? [null]
@@ -1525,7 +2044,7 @@ function prepareModule(specifier, source) {
1525
2044
  const mangled = `${prefix}$${localName}`
1526
2045
  moduleExports.set(exportName, mangled)
1527
2046
  const func = ctx.func.list.find(f => f.name === localName)
1528
- if (func) renameFunc(func, mangled)
2047
+ if (func) { renameFunc(func, mangled); func._modulePrefix = prefix }
1529
2048
  if (ctx.scope.globals.has(localName)) {
1530
2049
  const wat = ctx.scope.globals.get(localName).replace(`$${localName}`, `$${mangled}`)
1531
2050
  ctx.scope.globals.delete(localName)
@@ -1587,15 +2106,17 @@ function prepareModule(specifier, source) {
1587
2106
  }
1588
2107
 
1589
2108
  // Rename ALL non-exported functions created during this module's prep
1590
- // (fn property assignments like f32.parse, internal helpers like cleanInt)
2109
+ // (fn property assignments like f32.parse, internal helpers like cleanInt).
2110
+ // Funcs added by nested prepareModule calls are tagged with `_modulePrefix`
2111
+ // by their own pass; skip those so prefixes don't stack (`a$b$name`).
1591
2112
  for (let i = savedFuncCount; i < ctx.func.list.length; i++) {
1592
2113
  const func = ctx.func.list[i]
1593
2114
  if (func.raw || func.name.startsWith(prefix + '$')) continue
1594
- // Skip functions from sub-imports (already prefixed with another module's prefix)
1595
- if (func.name.includes('__') && func.name.includes('$')) continue
2115
+ if (func._modulePrefix && func._modulePrefix !== prefix) continue
1596
2116
  const mangled = `${prefix}$${func.name}`
1597
2117
  moduleExports.set(func.name, mangled)
1598
2118
  renameFunc(func, mangled)
2119
+ func._modulePrefix = prefix
1599
2120
  }
1600
2121
 
1601
2122
  // Add mangled non-exported globals to moduleExports for walk renaming
@@ -1627,6 +2148,8 @@ function prepareModule(specifier, source) {
1627
2148
  for (let i = savedFuncCount; i < ctx.func.list.length; i++) {
1628
2149
  const func = ctx.func.list[i]
1629
2150
  if (!func.body) continue
2151
+ // Sub-module funcs already had their own walk; parent's rename map doesn't apply.
2152
+ if (func._modulePrefix && func._modulePrefix !== prefix) continue
1630
2153
  const funcParams = new Set(func.sig?.params?.map(p => p.name) || [])
1631
2154
  walk(func.body, funcParams)
1632
2155
  if (func.defaults) for (const [k, v] of Object.entries(func.defaults)) func.defaults[k] = walk(v, funcParams)
@@ -1652,3 +2175,160 @@ function prepareModule(specifier, source) {
1652
2175
  ctx.module.resolvedModules.set(specifier, result)
1653
2176
  return result
1654
2177
  }
2178
+
2179
+ // =============================================================================
2180
+ // AST-level fusion passes (pre-resolution)
2181
+ // =============================================================================
2182
+ // Unlike src/optimize.js (a pure WAT IR→IR rewrite, post-emission), these
2183
+ // rewrites need the *raw, pre-resolution* AST shape — bindings still named,
2184
+ // arrow bodies still inline — so they run inside prepare(), before scope
2185
+ // resolution and emit. They mutate the AST in place; shape guards are strict
2186
+ // enough that misfires are impossible.
2187
+
2188
+ /** Sparse-read .map fusion: rewrite `const b = a.map(arrow); for(...; j<b.length; ...) USE(b[j])`
2189
+ * into a fused for-loop that inlines `arrow(a[j])` at the read site, eliminating the materialized
2190
+ * intermediate array. Only fires on shapes where every use of `b` is a numeric `b[idx]` read or a
2191
+ * `b.length` read, the arrow is pure with a single named param, and `b` is not referenced after the
2192
+ * consumer for-loop. Preserves observable behavior because the arrow's pure-expression body has no
2193
+ * order-dependent effects. */
2194
+ function fuseSparseMapReads(root) {
2195
+ walkSparse(root)
2196
+ }
2197
+ function walkSparse(node) {
2198
+ if (!Array.isArray(node)) return
2199
+ for (let i = 1; i < node.length; i++) walkSparse(node[i])
2200
+ if (node[0] === ';') tryFuseInBlock(node)
2201
+ }
2202
+ function tryFuseInBlock(seq) {
2203
+ for (let i = 1; i < seq.length - 1; i++) {
2204
+ const fused = tryFusePair(seq[i], seq[i + 1], seq, i)
2205
+ if (fused) {
2206
+ seq.splice(i, 2, ...fused)
2207
+ i-- // re-examine same position (chained fusions)
2208
+ }
2209
+ }
2210
+ }
2211
+ function tryFusePair(decl, forNode, seq, declIdx) {
2212
+ if (!Array.isArray(decl) || (decl[0] !== 'const' && decl[0] !== 'let')) return null
2213
+ if (decl.length !== 2) return null // single binding only
2214
+ const bind = decl[1]
2215
+ if (!Array.isArray(bind) || bind[0] !== '=' || typeof bind[1] !== 'string') return null
2216
+ const NAME = bind[1], rhs = bind[2]
2217
+ if (!Array.isArray(rhs) || rhs[0] !== '()') return null
2218
+ const callee = rhs[1]
2219
+ if (!Array.isArray(callee) || callee[0] !== '.' || callee[2] !== 'map') return null
2220
+ const RECV = callee[1]
2221
+ if (typeof RECV !== 'string' || RECV === NAME) return null
2222
+ const arrow = rhs[2]
2223
+ if (!Array.isArray(arrow) || arrow[0] !== '=>') return null
2224
+ // Single-name param only: `x => …` or `(x) => …`
2225
+ const ap = arrow[1]
2226
+ const PARAM = typeof ap === 'string' ? ap :
2227
+ (Array.isArray(ap) && ap[0] === '()' && typeof ap[1] === 'string' ? ap[1] : null)
2228
+ if (!PARAM || PARAM === NAME || PARAM === RECV) return null
2229
+ // Body: single-expression arrow only (block bodies skipped — could extend later).
2230
+ const aBody = arrow[2]
2231
+ if (Array.isArray(aBody) && aBody[0] === '{}') return null
2232
+ if (!isPureSparseArrowBody(aBody, PARAM)) return null
2233
+ // For-loop: ['for', [';', initStmt, cond, inc], body]
2234
+ if (!Array.isArray(forNode) || forNode[0] !== 'for' || forNode.length !== 3) return null
2235
+ const head = forNode[1]
2236
+ if (!Array.isArray(head) || head[0] !== ';' || head.length !== 4) return null
2237
+ const cond = head[2], forBody = forNode[2]
2238
+ // Verify `NAME` is used only as `NAME[idx]` or `NAME.length` inside cond+forBody.
2239
+ if (!hasOnlySparseUses(cond, NAME)) return null
2240
+ if (!hasOnlySparseUses(forBody, NAME)) return null
2241
+ if (!hasAnyIndexedRead(forBody, NAME) && !hasAnyIndexedRead(cond, NAME)) return null
2242
+ // `NAME` must not be read after the for-loop in the same block.
2243
+ for (let k = declIdx + 2; k < seq.length; k++) {
2244
+ if (refsName(seq[k], NAME)) return null
2245
+ }
2246
+ // RECV must not be reassigned inside the for-loop (would invalidate substitution).
2247
+ if (assignsName(forNode, RECV) || assignsName(forNode, NAME)) return null
2248
+ // PARAM must not collide with any binding inside forBody (otherwise substitution shadows wrongly).
2249
+ if (bindsName(forNode, PARAM)) return null
2250
+ // Apply substitution: NAME.length → RECV.length; NAME[idx] → arrowBody[PARAM ← RECV[idx]].
2251
+ const newCond = substSparse(cond, NAME, RECV, PARAM, aBody)
2252
+ const newBody = substSparse(forBody, NAME, RECV, PARAM, aBody)
2253
+ const newHead = [';', head[1], newCond, head[3]]
2254
+ return [['for', newHead, newBody]]
2255
+ }
2256
+ function isPureSparseArrowBody(n, PARAM) {
2257
+ if (typeof n === 'string') return true
2258
+ if (!Array.isArray(n)) return true
2259
+ const op = n[0]
2260
+ // Calls / new / assignments / increments are unsafe for repeated-substitution semantics.
2261
+ if (op === '()' || op === '?.()' || op === 'new' || op === '++' || op === '--') return false
2262
+ if (op === '=>') return false // nested closure is opaque
2263
+ if (typeof op === 'string' && op !== '=>' && op !== '===' && op !== '!==' && op !== '==' && op !== '!=' && op !== '<=' && op !== '>=' && op.endsWith('=') && op !== '=') return false
2264
+ if (op === '=') return false
2265
+ for (let i = 1; i < n.length; i++) if (!isPureSparseArrowBody(n[i], PARAM)) return false
2266
+ return true
2267
+ }
2268
+ function hasOnlySparseUses(n, NAME) {
2269
+ if (typeof n === 'string') return n !== NAME
2270
+ if (!Array.isArray(n)) return true
2271
+ const op = n[0]
2272
+ if (op === '[]' && n.length === 3 && n[1] === NAME) return hasOnlySparseUses(n[2], NAME) // NAME[idx] — idx must not reference NAME
2273
+ if (op === '.' && n[1] === NAME) {
2274
+ if (n[2] === 'length') return true
2275
+ return false // any other property access on NAME is opaque
2276
+ }
2277
+ for (let i = 1; i < n.length; i++) if (!hasOnlySparseUses(n[i], NAME)) return false
2278
+ return true
2279
+ }
2280
+ function hasAnyIndexedRead(n, NAME) {
2281
+ if (!Array.isArray(n)) return false
2282
+ if (n[0] === '[]' && n.length === 3 && n[1] === NAME) return true
2283
+ for (let i = 1; i < n.length; i++) if (hasAnyIndexedRead(n[i], NAME)) return true
2284
+ return false
2285
+ }
2286
+ function refsName(n, NAME) {
2287
+ if (typeof n === 'string') return n === NAME
2288
+ if (!Array.isArray(n)) return false
2289
+ for (let i = 1; i < n.length; i++) if (refsName(n[i], NAME)) return true
2290
+ return false
2291
+ }
2292
+ function assignsName(n, NAME) {
2293
+ if (!Array.isArray(n)) return false
2294
+ const op = n[0]
2295
+ if ((op === '=' || op === '++' || op === '--' ||
2296
+ (typeof op === 'string' && op.endsWith('=') && op !== '==' && op !== '===' && op !== '!=' && op !== '!==' && op !== '<=' && op !== '>='))
2297
+ && n[1] === NAME) return true
2298
+ for (let i = 1; i < n.length; i++) if (assignsName(n[i], NAME)) return true
2299
+ return false
2300
+ }
2301
+ function bindsName(n, NAME) {
2302
+ if (!Array.isArray(n)) return false
2303
+ const op = n[0]
2304
+ if ((op === 'let' || op === 'const')) {
2305
+ for (let i = 1; i < n.length; i++) {
2306
+ const bind = n[i]
2307
+ if (Array.isArray(bind) && bind[0] === '=' && bind[1] === NAME) return true
2308
+ }
2309
+ }
2310
+ if (op === '=>') {
2311
+ const p = n[1]
2312
+ if (p === NAME) return true
2313
+ if (Array.isArray(p)) {
2314
+ if (p[0] === '()' && p[1] === NAME) return true
2315
+ // skip deeper destructuring forms — conservative
2316
+ }
2317
+ }
2318
+ for (let i = 1; i < n.length; i++) if (bindsName(n[i], NAME)) return true
2319
+ return false
2320
+ }
2321
+ function substSparse(n, NAME, RECV, PARAM, arrowBody) {
2322
+ if (typeof n !== 'object' || n === null || !Array.isArray(n)) return n
2323
+ if (n[0] === '.' && n[1] === NAME && n[2] === 'length') return ['.', RECV, 'length']
2324
+ if (n[0] === '[]' && n.length === 3 && n[1] === NAME) {
2325
+ const idx = substSparse(n[2], NAME, RECV, PARAM, arrowBody)
2326
+ return cloneAndBind(arrowBody, PARAM, ['[]', RECV, idx])
2327
+ }
2328
+ return n.map((c, i) => i === 0 ? c : substSparse(c, NAME, RECV, PARAM, arrowBody))
2329
+ }
2330
+ function cloneAndBind(node, PARAM, replacement) {
2331
+ if (node === PARAM) return replacement
2332
+ if (!Array.isArray(node)) return node
2333
+ return node.map((c, i) => i === 0 ? c : cloneAndBind(c, PARAM, replacement))
2334
+ }