jz 0.5.1 → 0.7.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 (86) hide show
  1. package/README.md +315 -318
  2. package/bench/README.md +369 -0
  3. package/bench/bench.svg +102 -0
  4. package/cli.js +104 -30
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +362 -84
  9. package/interop.js +159 -186
  10. package/jz.svg +5 -0
  11. package/jzify/arguments.js +97 -0
  12. package/jzify/bundler.js +382 -0
  13. package/jzify/classes.js +328 -0
  14. package/jzify/hoist-vars.js +177 -0
  15. package/jzify/index.js +51 -0
  16. package/jzify/names.js +37 -0
  17. package/jzify/switch.js +106 -0
  18. package/jzify/transform.js +349 -0
  19. package/layout.js +184 -0
  20. package/module/array.js +377 -176
  21. package/module/collection.js +639 -145
  22. package/module/console.js +55 -43
  23. package/module/core.js +264 -153
  24. package/module/date.js +15 -3
  25. package/module/function.js +73 -6
  26. package/module/index.js +2 -1
  27. package/module/json.js +226 -61
  28. package/module/math.js +551 -187
  29. package/module/number.js +327 -60
  30. package/module/object.js +474 -184
  31. package/module/regex.js +255 -25
  32. package/module/schema.js +24 -6
  33. package/module/simd.js +117 -0
  34. package/module/string.js +621 -226
  35. package/module/symbol.js +1 -1
  36. package/module/timer.js +9 -14
  37. package/module/typedarray.js +459 -73
  38. package/package.json +58 -14
  39. package/src/abi/index.js +39 -23
  40. package/src/abi/string.js +38 -41
  41. package/src/ast.js +460 -0
  42. package/src/autoload.js +29 -24
  43. package/src/bridge.js +111 -0
  44. package/src/compile/analyze-scans.js +824 -0
  45. package/src/compile/analyze.js +1600 -0
  46. package/src/compile/emit-assign.js +411 -0
  47. package/src/compile/emit.js +3512 -0
  48. package/src/compile/flow-types.js +103 -0
  49. package/src/{compile.js → compile/index.js} +778 -128
  50. package/src/{infer.js → compile/infer.js} +62 -98
  51. package/src/compile/loop-divmod.js +155 -0
  52. package/src/{narrow.js → compile/narrow.js} +409 -106
  53. package/src/compile/peel-stencil.js +261 -0
  54. package/src/compile/plan/advise.js +370 -0
  55. package/src/compile/plan/common.js +150 -0
  56. package/src/compile/plan/index.js +122 -0
  57. package/src/compile/plan/inline.js +682 -0
  58. package/src/compile/plan/literals.js +1199 -0
  59. package/src/compile/plan/loops.js +472 -0
  60. package/src/compile/plan/scope.js +649 -0
  61. package/src/compile/program-facts.js +423 -0
  62. package/src/ctx.js +220 -64
  63. package/src/ir.js +589 -172
  64. package/src/kind-traits.js +132 -0
  65. package/src/kind.js +524 -0
  66. package/src/op-policy.js +57 -0
  67. package/src/{optimize.js → optimize/index.js} +1432 -473
  68. package/src/optimize/vectorize.js +4660 -0
  69. package/src/param-reps.js +65 -0
  70. package/src/parse.js +44 -0
  71. package/src/{prepare.js → prepare/index.js} +649 -205
  72. package/src/prepare/lift-iife.js +149 -0
  73. package/src/reps.js +116 -0
  74. package/src/resolve.js +12 -3
  75. package/src/static.js +208 -0
  76. package/src/type.js +651 -0
  77. package/src/{assemble.js → wat/assemble.js} +275 -55
  78. package/src/wat/optimize.js +3938 -0
  79. package/transform.js +21 -0
  80. package/wasi.js +47 -5
  81. package/src/analyze.js +0 -3818
  82. package/src/emit.js +0 -3040
  83. package/src/jzify.js +0 -1580
  84. package/src/plan.js +0 -2132
  85. package/src/vectorize.js +0 -1088
  86. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -0,0 +1,3512 @@
1
+ /**
2
+ * AST → WASM IR emission.
3
+ *
4
+ * # Stage contract
5
+ * IN: prepared AST node + ctx state (func.locals, func.localReps, types.typedElem, etc.)
6
+ * OUT: IR node (array) with `.type` ('i32' | 'f64' | 'void'). For statements, a flat
7
+ * list of WASM instructions (no type tag).
8
+ * NO-MUTATE: emit does not rewrite the AST. Side effects go to ctx.runtime.*,
9
+ * ctx.core.includes (via inc()), ctx.func.uniq (local naming), and ctx.features.*.
10
+ *
11
+ * # Dispatch
12
+ * `emit(node, expect?)` handles literals inline and routes arrays to ctx.core.emit[op].
13
+ * `emitVoid(node)` emits + drops any value (statement context; routes block bodies to emitBlockBody).
14
+ * `emitBlockBody(node)` unwraps a `{}` block and concatenates flat statement IR.
15
+ *
16
+ * The emitter table (`emitter` export) is copied into ctx.core.emit by reset();
17
+ * language modules add/override entries to extend dispatch.
18
+ *
19
+ * Low-level IR construction helpers live in `ir.js` and are imported below.
20
+ *
21
+ * @module emit
22
+ */
23
+
24
+ import {
25
+ commaList, T, isBlockBody, isReassigned, mutatesArrayLength, isConstLiteral, constLiteralHoistable,
26
+ hasOwnContinue, hasLabeledContinueTo, hasOwnBreakOrContinue, extractParams, classifyParam, JZ_UNDEF, TYPEOF,
27
+ } from '../ast.js'
28
+ import { ctx, err, inc, warnDeopt, PTR } from '../ctx.js'
29
+ import { nonNegIntLiteral, intLiteralValue, staticPropertyKey } from '../static.js'
30
+ import { findFreeVars } from './analyze.js'
31
+ import {
32
+ containsNestedClosure, containsNestedLoop, nestedSmallLoopBudget,
33
+ containsDeclOf, cloneWithSubst, containsKnownTypedArrayIndex,
34
+ smallConstForTripCount, isTerminator, scanBoundedLoops, inBoundsCharCodeAt,
35
+ exprType, MAX_SMALL_FOR_UNROLL, MAX_NESTED_FOR_UNROLL,
36
+ } from '../type.js'
37
+ import { valTypeOf, shapeOf } from '../kind.js'
38
+ import { VAL, lookupValType, repOf, updateRep, repOfGlobal } from '../reps.js'
39
+ import {
40
+ typed, asF64, asI32, asI64, asPtrOffset, asParamType, toI32, fromI64,
41
+ NULL_IR, nullExpr, undefExpr, MAX_CLOSURE_ARITY,
42
+ WASM_OPS, SPREAD_MUTATORS, BOXED_MUTATORS,
43
+ mkPtrIR, ptrOffsetIR, ptrTypeIR, ptrTypeEq, dispatchByPtrType, sidecarOverride, valKindToPtr,
44
+ isLit, litVal, isNullishLit, isPureIR, emitNum, f64rem, toNumF64, toStrI64, maskBound,
45
+ truthyIR, toBoolFromEmitted, isPostfix,
46
+ isGlobal, isConst, usesDynProps, needsDynShadow,
47
+ temp, tempI32, tempI64, allocPtr,
48
+ block64, withTemp,
49
+ boxedAddr, readVar, writeVar, isNullish, isNull, isUndef, isBoolAtom,
50
+ boolBoxIR,
51
+ isLiteralStr, resolveValType, isFuncRef,
52
+ multiCount, loopTop, flat,
53
+ reconstructArgsWithSpreads, tcoTailRewrite,
54
+ } from '../ir.js'
55
+ import { isBoundName } from '../ir.js'
56
+ import { extractRefinements, withRefinements } from './flow-types.js'
57
+ import { emitElementAssign, emitPropertyAssign, persistBindingPtr } from './emit-assign.js'
58
+
59
+ const stringOps = (node) => {
60
+ const rep = typeof node === 'string' ? repOf(node) : null
61
+ return ctx.abi.resolve('string', rep)?.ops ?? ctx.abi.string.ops
62
+ }
63
+
64
+
65
+ // === Emitter state & operand classification ===
66
+
67
+ // Current emission "expect" mode ('void' or null); set by emit(), read by
68
+ // compound-assignment emitters (here and in emit-assign.js — shared via ctx so
69
+ // the module graph stays acyclic) to decide between value-returning and
70
+ // side-effect-only forms. Transient: meaningful only within one dispatch.
71
+
72
+ // A genuine i32 *number* — safe for the i32 fast path in arithmetic/bitwise
73
+ // operators. An unboxed pointer (object/array/string/closure local kept as a
74
+ // raw i32 handle) is *also* i32-typed but carries `.ptrKind`; treating it as a
75
+ // number would compute on raw pointer bits. A ptrKind-carrying operand must
76
+ // instead route through ToNumber (`toNumF64`), which performs ToPrimitive.
77
+ const isI32Num = (v) => v.type === 'i32' && v.ptrKind == null
78
+
79
+ // f64 arithmetic that can MINT a sign-nondeterministic NaN (0/0, ∞−∞, 0·∞, x%0): on x86
80
+ // these are 0xFFF8…, on arm 0x7FF8…. sqrt/min/max/neg are NOT here — they canon at their
81
+ // own emit (math.js / unary `-`), so they reach canonNum already canonical.
82
+ const NAN_MINTING = new Set(['f64.div', 'f64.add', 'f64.sub', 'f64.mul'])
83
+
84
+ const canonNum = (node) => {
85
+ // Fold a possibly-non-canonical NaN to the canonical number-NaN before it reaches a
86
+ // bit-comparing consumer (__is_truthy / untyped === / typeof), which match the canonical
87
+ // NaN by bits and so misread x86's 0xFFF8 as truthy. ONLY an un-canon'd NaN-minting
88
+ // arithmetic op can carry such a value — literals, i32-conversions, opaque locals/calls
89
+ // (canonical by the canon-at-source invariant) and already-canon'd shapes don't — so
90
+ // skipping everything else keeps the size win. (The broken middle ground was
91
+ // `02873d0`'s `isNumericIR` skip, which dropped canon for f64.div too → x86 miscompile.)
92
+ const arith = Array.isArray(node) &&
93
+ (NAN_MINTING.has(node[0]) || (node[0] === 'call' && node[1] === '$__rem'))
94
+ if (!arith) return node
95
+ const t = temp('cn')
96
+ return typed(['block', ['result', 'f64'],
97
+ ['local.set', `$${t}`, node],
98
+ ['select',
99
+ ['f64.const', 'nan'],
100
+ ['local.get', `$${t}`],
101
+ ['f64.ne', ['local.get', `$${t}`], ['local.get', `$${t}`]]]], 'f64')
102
+ }
103
+
104
+ // Is an emitted arm `v` (AST `node`) a plain NUMBER? The predicate the two-arm merges
105
+ // (?:, ??) share to decide canon: an i32 number, NUMBER-tagged IR, or a NUMBER
106
+ // value-type qualifies; a pointer/opaque arm does not. `vt` is the node's resolved
107
+ // value-type — pass it when already computed to avoid the re-resolve.
108
+ const isNumArm = (v, node, vt = resolveValType(node, valTypeOf, lookupValType)) =>
109
+ isI32Num(v) || v.valKind === VAL.NUMBER || vt === VAL.NUMBER
110
+
111
+ // One arm of a two-arm f64 merge (?:, ??, ||, &&) whose result may be bit-tested while
112
+ // untyped. Canon (canonNum, a no-op unless the arm is NaN-minting arithmetic) ONLY a
113
+ // LONE numeric arm: when both arms are numeric the merge is value-typed NUMBER and read
114
+ // NaN-by-value (no canon); when the other arm is opaque the result is untyped, so a
115
+ // non-canonical NaN here would be misread by __is_truthy — fold it. A pointer arm
116
+ // (isNum=false) is never touched (canon would destroy its NaN-box).
117
+ const canonArm = (f, isNum, otherNum) => isNum && !otherNum ? canonNum(f) : f
118
+
119
+ // Host globals auto-imported as `(import "env" "name" (global … i64))` when
120
+ // referenced as a value. Drained from ctx.core.hostGlobals at assembly.
121
+ const HOST_GLOBALS = new Set(['WebAssembly', 'globalThis', 'self', 'window', 'global', 'process'])
122
+
123
+ // An operand whose uint32 value can be *observed as a JS number* — a `>>>`
124
+ // result, an `unsignedResult` call, or an unsigned i32.const. Its magnitude can
125
+ // exceed signed-i32 range, so wrapping i32 arithmetic would corrupt it; widen to
126
+ // f64 instead. A `.wrapSafe` operand is also unsigned but is a `narrowUint32`
127
+ // accumulator read proven to be re-truncated by a `>>>` (ToUint32) sink at every
128
+ // use — wrapping is exactly its intended semantics, so it stays on the i32 path.
129
+ const widensUnsigned = (v) => v.unsigned && !v.wrapSafe
130
+
131
+ // Strip a redundant NaN-canon wrapper (math.js `canon`) from an operand that
132
+ // feeds a NaN-propagating f64 op. `f64.sqrt`/`min`/`max` mint a sign-nondeterministic
133
+ // NaN that math.js canon-izes so it can't be bit-confused with a NaN-boxed pointer in
134
+ // `===`/`typeof`. But when the result flows straight into `f64.add`/`sub`/`mul`/`div`,
135
+ // the consumer propagates that NaN identically and is itself canon-ized if IT escapes —
136
+ // so the inner per-op canon (local.set + select + f64.ne, ~3 ops) is dead on the
137
+ // critical path. This is THE gap that put sqrt-heavy kernels ~23% behind V8
138
+ // (julia/raymarcher/boids); stripping it makes them match native JS.
139
+ const stripCanon = (v) => (v && v.canonOf != null) ? typed(v.canonOf, 'f64') : v
140
+
141
+ const FIRST_CLASS_UNARY_MATH = {
142
+ 'math.abs': 'f64.abs',
143
+ 'math.sqrt': 'f64.sqrt',
144
+ 'math.ceil': 'f64.ceil',
145
+ 'math.floor': 'f64.floor',
146
+ 'math.trunc': 'f64.trunc',
147
+ }
148
+
149
+ function builtinFunctionValue(name) {
150
+ const op = FIRST_CLASS_UNARY_MATH[name]
151
+ if (!op) err(`Builtin function '${name}' cannot be used as a first-class value`)
152
+ if (!ctx.closure.table) err(`Builtin function '${name}' used as value requires closure support`)
153
+ const fn = `${T}builtin_${name.replace(/\W/g, '_')}`
154
+ if (!ctx.core.stdlib[fn]) {
155
+ const width = ctx.closure.width ?? MAX_CLOSURE_ARITY
156
+ const params = ['(param $__env f64)', '(param $__argc i32)']
157
+ for (let i = 0; i < width; i++) params.push(`(param $__a${i} f64)`)
158
+ ctx.core.stdlib[fn] = `(func $${fn} ${params.join(' ')} (result f64) (${op} (local.get $__a0)))`
159
+ inc(fn)
160
+ }
161
+ let idx = ctx.closure.table.indexOf(fn)
162
+ if (idx < 0) { idx = ctx.closure.table.length; ctx.closure.table.push(fn) }
163
+ const ir = mkPtrIR(PTR.CLOSURE, idx, 0)
164
+ ir.closureFuncIdx = idx
165
+ return ir
166
+ }
167
+
168
+ /** Emit unary negation: constant-fold, or i32 sub from 0 / f64.neg. */
169
+ const emitNeg = (a) => {
170
+ if (valTypeOf(a) === VAL.BIGINT) return fromI64(['i64.sub', ['i64.const', 0], asI64(emit(a))])
171
+ const v = emit(a)
172
+ if (isLit(v)) return emitNum(-litVal(v))
173
+ if (isI32Num(v)) return typed(['i32.sub', typed(['i32.const', 0], 'i32'), v], 'i32')
174
+ // f64.neg flips the sign bit, so negating a NaN yields 0xFFF8.. — a non-canonical
175
+ // number-NaN that overlaps the NaN-boxed value space (jz reserves 0x7FF8.. as THE
176
+ // number-NaN). `__is_truthy`/`__eq` compare against that exact pattern, so a sign-
177
+ // flipped NaN reads as a tagged value (truthy / not-NaN). Fold any NaN result back
178
+ // to canonical — the same invariant math.sqrt/min/max keep via `canon` (module/math.js).
179
+ const t = temp('ng')
180
+ return typed(['block', ['result', 'f64'],
181
+ ['local.set', `$${t}`, ['f64.neg', toNumF64(a, v)]],
182
+ ['select', ['f64.const', 'nan'], ['local.get', `$${t}`],
183
+ ['f64.ne', ['local.get', `$${t}`], ['local.get', `$${t}`]]]], 'f64')
184
+ }
185
+
186
+ /** Try constant-folding binary arith: returns emitNum(result) or null. */
187
+ // `.unsigned` literals carry a uint32 value whose i32 `litVal` is its *signed* bit
188
+ // pattern (e.g. `-1` for 4294967295), so folding them through `fn` numerically would
189
+ // be wrong. Bail to the runtime path — the arithmetic handlers widen unsigned operands
190
+ // to f64 (convert_i32_u), reproducing the JS-spec result.
191
+ const foldConst = (va, vb, fn, guard) =>
192
+ isLit(va) && isLit(vb) && !va.unsigned && !vb.unsigned && (!guard || guard(litVal(vb)))
193
+ ? emitNum(fn(litVal(va), litVal(vb))) : null
194
+
195
+ // JS `*` is an f64 multiply; `i32.mul` yields only the exact product mod 2^32.
196
+ // Those agree under a ToInt32/ToUint32 sink (and as plain numbers) while the
197
+ // exact product stays f64-exact, i.e. |product| <= 2^53. Two i32 operands can
198
+ // reach 2^62, so `i32.mul` is sound only when one side is bounded small enough
199
+ // that, against the full i32 range (2^31) of the other, the product holds within
200
+ // 2^53 — i.e. its magnitude <= 2^22. A literal qualifies directly; so does a
201
+ // masked operand (`x & 63`, `x >>> k`) whose value is provably bounded. Keeps
202
+ // index arithmetic (`i*4`) and bitwise-masked scales (bytebeat's `t*(m&63)`) on
203
+ // `i32.mul` while routing hash-mix-scale products to `f64.mul`.
204
+ const FITS_I32_MAX = 0x400000 // 2^22 — see derivation above
205
+ const mulFitsI32 = (va, vb) =>
206
+ (isLit(va) && Math.abs(litVal(va)) <= FITS_I32_MAX) ||
207
+ (isLit(vb) && Math.abs(litVal(vb)) <= FITS_I32_MAX) ||
208
+ (!isLit(va) && maskBound(va) <= FITS_I32_MAX) ||
209
+ (!isLit(vb) && maskBound(vb) <= FITS_I32_MAX)
210
+
211
+ /** Emit typeof comparison: typeof x == typeCode → type-aware check. */
212
+ export function emitTypeofCmp(a, b, cmpOp) {
213
+ let typeofExpr, code
214
+ if (Array.isArray(a) && a[0] === 'typeof' && typeof b === 'number') { typeofExpr = a[1]; code = b }
215
+ else if (Array.isArray(a) && a[0] === 'typeof' && Array.isArray(b) && b[0] == null) { typeofExpr = a[1]; code = b[1] }
216
+ else return null
217
+ if (typeof code !== 'number') return null
218
+
219
+ const t = temp()
220
+ const va = asF64(emit(typeofExpr))
221
+ const eq = cmpOp === 'eq'
222
+ // Trailing eqz-wrapper for atomic checks: `check` if eq, `!check` if ne.
223
+ const wrap = check => typed(eq ? check : ['i32.eqz', check], 'i32')
224
+ // De-Morgan'd `(X && Y)` vs `(!X || !Y)` — kept explicit so WAT output is
225
+ // byte-identical to the previous inlined form (watopt may shape it differently).
226
+ const both = (X, Y) => typed(eq ? ['i32.and', X, Y] : ['i32.or', ['i32.eqz', X], ['i32.eqz', Y]], 'i32')
227
+ // "isPtr AND ptr_type == kind" — shared by typeof "string" / "function" /
228
+ // user-supplied positive PTR codes. The tee in isPtr caches v in `t` for reuse.
229
+ const isPtrKind = kind => {
230
+ const isPtr = ['f64.ne', ['local.tee', `$${t}`, va], ['local.get', `$${t}`]]
231
+ const isKind = ptrTypeEq(['local.get', `$${t}`], kind)
232
+ return both(isPtr, isKind)
233
+ }
234
+ // Static fold for known-VAL operands of "boolean"/"bigint" — saves a runtime branch.
235
+ const staticFold = (target) => {
236
+ const vt = resolveValType(typeofExpr, valTypeOf, lookupValType)
237
+ if (vt) return typed(['i32.const', (vt === target) === eq ? 1 : 0], 'i32')
238
+ return null
239
+ }
240
+
241
+ if (code === TYPEOF.number) {
242
+ // typeof "number": v===v rejects NaN-box pointers; BOOL carrier is 0/1 → still typeof "boolean".
243
+ if (resolveValType(typeofExpr, valTypeOf, lookupValType) === VAL.BOOL) return typed(['i32.const', eq ? 0 : 1], 'i32')
244
+ return typed([eq ? 'f64.eq' : 'f64.ne', ['local.tee', `$${t}`, va], ['local.get', `$${t}`]], 'i32')
245
+ }
246
+ if (code === TYPEOF.string) return isPtrKind(PTR.STRING)
247
+ if (code === TYPEOF.undefined) return wrap(isNullish(va))
248
+ if (code === TYPEOF.boolean) return staticFold(VAL.BOOL) ?? wrap(isBoolAtom(['local.tee', `$${t}`, va]))
249
+ if (code === TYPEOF.object) {
250
+ // object: a NaN-box whose ptr_type is a heap kind — NOT STRING (typeof "string"),
251
+ // NOT CLOSURE (typeof "function"), and NOT ATOM. The ATOM tag covers null AND undef
252
+ // AND the boolean atoms true/false: excluding it in one ptr_type check is both the
253
+ // null/undef guard and the (previously missing) boolean guard — without it
254
+ // `typeof aBool === "object"` wrongly returned true whenever the operand's static
255
+ // type was unknown (e.g. a value off JSON.parse), since a bool atom is a NaN-box
256
+ // that isn't STRING/CLOSURE/nullish. Numbers (incl. NaN) and bigint aren't NaN-box
257
+ // pointers, so isPtr already rejects them.
258
+ inc('__ptr_type')
259
+ const tt = `${T}${ctx.func.uniq++}`; ctx.func.locals.set(tt, 'i32')
260
+ const isPtr = ['f64.ne', ['local.tee', `$${t}`, va], ['local.get', `$${t}`]]
261
+ const heapKind = ['i32.and',
262
+ ['i32.and',
263
+ ['i32.ne', ['local.tee', `$${tt}`, ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${t}`]]]], ['i32.const', PTR.STRING]],
264
+ ['i32.ne', ['local.get', `$${tt}`], ['i32.const', PTR.CLOSURE]]],
265
+ ['i32.ne', ['local.get', `$${tt}`], ['i32.const', PTR.ATOM]]]
266
+ return wrap(['i32.and', isPtr, heapKind])
267
+ }
268
+ if (code === TYPEOF.function) return isPtrKind(PTR.CLOSURE)
269
+ if (code === TYPEOF.bigint) {
270
+ const fold = staticFold(VAL.BIGINT); if (fold) return fold
271
+ // bigint heuristic: finite, nonzero, sub-normal abs (boxed BigInt carrier).
272
+ const n = ['local.tee', `$${t}`, va]
273
+ return wrap(['i32.and',
274
+ ['f64.eq', n, ['local.get', `$${t}`]],
275
+ ['i32.and',
276
+ ['f64.ne', ['local.get', `$${t}`], ['f64.const', 0]],
277
+ ['f64.lt', ['f64.abs', ['local.get', `$${t}`]], ['f64.const', 2.2250738585072014e-308]]]])
278
+ }
279
+ if (code >= 0) return isPtrKind(code)
280
+ return null
281
+ }
282
+
283
+ /** Stringify a VAL.BOOL operand to "true"/"false" (f64 string pointer). The
284
+ * boolean rides the cheap 0/1 carrier, so we runtime-select between the two
285
+ * interned literals; a constant operand folds to a single literal downstream. */
286
+ export const emitBoolStr = (node) =>
287
+ typed(['select', asF64(emit(['str', 'true'])), asF64(emit(['str', 'false'])), truthyIR(emit(node))], 'f64')
288
+
289
+ const CMP_SET = new Set(['>', '<', '>=', '<=', '==', '!=', '!'])
290
+ const isCmp = n => Array.isArray(n) && CMP_SET.has(n[0])
291
+
292
+ // Map/Set methods whose generic (`.${method}`) emitter assumes a collection
293
+ // receiver and dereferences a key/value argument. Every one needs ≥1 argument
294
+ // (`.get(k)` / `.has(v)` / `.add(v)` / `.delete(v)` / `.set(k[,v])`), so a
295
+ // zero-arg call on a not-proven-collection receiver cannot be the collection
296
+ // op — it is a user/closure method and must not reach the collection emitter.
297
+ const COLLECTION_METHODS = new Set(['get', 'set', 'has', 'add', 'delete'])
298
+
299
+ // String char-index methods bound generically (no `.string:` qualifier — no array
300
+ // name collision). `String.prototype.{charCodeAt,charAt}` each take at most one
301
+ // argument (the index), so a call supplying ≥2 args on a not-proven-string receiver
302
+ // cannot be the string built-in — it is a user method that happens to share the
303
+ // name (e.g. the self-host abi's `ctx.abi.string.ops.charCodeAt(sF64,iI32,ctx,oobNan)`).
304
+ // It must fall through to dynamic dispatch, mirroring COLLECTION_METHODS' arity guard.
305
+ const STR_INDEX_METHODS = new Set(['charCodeAt', 'charAt'])
306
+
307
+ // Pointer kinds for which JS `==` / `!=` is pure reference equality — i.e. i64 bit
308
+ // compare of the NaN-box is equivalent to __eq. Excludes STRING (content compare for
309
+ // heap strings) and BIGINT (content compare).
310
+ const REF_EQ_KINDS = new Set([
311
+ VAL.ARRAY, VAL.OBJECT, VAL.SET, VAL.MAP,
312
+ VAL.BUFFER, VAL.TYPED, VAL.CLOSURE, VAL.REGEX, VAL.DATE,
313
+ ])
314
+
315
+ function stringLiteral(node) {
316
+ if (Array.isArray(node) && node[0] === 'str' && typeof node[1] === 'string') return node[1]
317
+ if (Array.isArray(node) && node[0] == null && typeof node[1] === 'string') return node[1]
318
+ return null
319
+ }
320
+
321
+ // Index expressions where peepholing `s[k] === 'X'` to char-byte compare is
322
+ // semantics-preserving: must produce a non-negative *integer* at run time so
323
+ // `__str_byteLen u> k` bounds-checks the same range JS would. Out-of-range
324
+ // (negative or ≥ len) falls into the `else 0` arm — matches `undefined === 'X'`.
325
+ function intIndexIR(key) {
326
+ const lit = nonNegIntLiteral(key)
327
+ if (lit != null) return ['i32.const', lit]
328
+ // intCertain name: forward-prop says every defining RHS is integer-shaped.
329
+ // Captures loop variables (`for(let i=0;;i++)`), `let k = j + 1`, etc.
330
+ if (typeof key === 'string' && repOf(key)?.intCertain) return asI32(emit(key))
331
+ // intCertain schema slot read `o.x`: every observed write is integer-shaped,
332
+ // so the loaded f64 represents an int — fold into the byte-compare fast path.
333
+ if (Array.isArray(key) && key[0] === '.' && typeof key[1] === 'string' && typeof key[2] === 'string' &&
334
+ ctx.schema.slotIntCertainAt?.(key[1], key[2]) === true) return asI32(emit(key))
335
+ return null
336
+ }
337
+
338
+ /**
339
+ * Emit an array-index expression in i32 arithmetic. A subscript is truncated to
340
+ * i32 at the memory boundary regardless, so `+`/`-`/`*` over i32-typed leaves are
341
+ * computed with wrapping i32 ops instead of the f64 round-trip
342
+ * (`convert_i32 … f64.mul/add … trunc_sat_f64_s`) that `*` of two non-literal
343
+ * i32s would otherwise force (see analyze.js exprType `*`).
344
+ *
345
+ * Correctness: i32 +/-/* preserve the residue mod 2^32, so the result equals the
346
+ * expression's true integer value mod 2^32 — even if an intermediate product
347
+ * overflows. Any valid index is in [0, 2^30) ⊂ [-2^31, 2^31), where two's
348
+ * complement reproduces the true value exactly; out-of-range indices are OOB
349
+ * (already UB — jz truncates the index to i32 at the boundary either way). Bails
350
+ * to the f64 path for any non-i32 leaf (an f64 leaf may be fractional, where
351
+ * trunc-then-add ≠ add-then-trunc) or non-{+,-,*} operator.
352
+ */
353
+ const I32_INDEX_OP = { '+': 'i32.add', '-': 'i32.sub', '*': 'i32.mul' }
354
+ function tryI32Index(e) {
355
+ // Integer literal first — a prepare-wrapped literal `[null, k]` (and a const-int
356
+ // name) is itself an Array, so the operator dispatch below would reject it and
357
+ // bail the WHOLE index to the f64 round-trip. The classic victim is the `+ 1` /
358
+ // `(j + 1)` of a bilinear/stencil gather (`a[(j+1)*W + i + 1]`): one literal leaf
359
+ // forced `convert_i32 … f64.mul/add … trunc_sat_f64_s` across every term.
360
+ const lit = nonNegIntLiteral(e)
361
+ if (lit != null) return typed(['i32.const', lit], 'i32')
362
+ if (Array.isArray(e)) {
363
+ const inner = I32_INDEX_OP[e[0]]
364
+ if (inner && e[2] != null) {
365
+ const a = tryI32Index(e[1]); if (a == null) return null
366
+ const b = tryI32Index(e[2]); if (b == null) return null
367
+ return typed([inner, a, b], 'i32')
368
+ }
369
+ return null
370
+ }
371
+ return exprType(e, ctx.func.locals) === 'i32' ? asI32(emit(e)) : null
372
+ }
373
+ export const emitIndex = (idx) => tryI32Index(idx) ?? asI32(emit(idx))
374
+
375
+ function emitSingleCharIndexCmp(a, b, negate = false) {
376
+ const leftLit = stringLiteral(a)
377
+ const rightLit = stringLiteral(b)
378
+ const aIdx = Array.isArray(a) && a[0] === '[]'
379
+ const bIdx = Array.isArray(b) && b[0] === '[]'
380
+ let indexed, lit
381
+ if (bIdx && leftLit != null) { indexed = b; lit = leftLit }
382
+ else if (aIdx && rightLit != null) { indexed = a; lit = rightLit }
383
+ else return null
384
+
385
+ if (lit.length === 0) return null
386
+ if ([...lit].some(c => c.charCodeAt(0) > 0x7F)) return null
387
+
388
+ const [, obj, key] = indexed
389
+ const idxIR = intIndexIR(key)
390
+ if (idxIR == null) return null
391
+
392
+ const vt = typeof obj === 'string' ? lookupValType(obj) : valTypeOf(obj)
393
+ if (vt && vt !== VAL.STRING) return null
394
+
395
+ const finish = expr => negate ? ['i32.eqz', expr] : expr
396
+
397
+ // Known STRING: s[i] always returns 1-char SSO. Multi-char literal → always false.
398
+ if (vt === VAL.STRING && lit.length > 1) return emitNum(negate ? 1 : 0)
399
+
400
+ // Single-char literal: compare byte directly, skipping __str_idx allocation.
401
+ if (lit.length !== 1 || !ctx.core.stdlib['__char_at'] || !ctx.core.stdlib['__str_byteLen']) return null
402
+
403
+ // Stash the index in a local when it isn't a constant — bounds + load both reference it.
404
+ const isConstIdx = Array.isArray(idxIR) && idxIR[0] === 'i32.const'
405
+ let idxRefIR = idxIR, idxBindIR = null
406
+ if (!isConstIdx) {
407
+ const idxTmp = tempI32('si')
408
+ idxBindIR = ['local.set', `$${idxTmp}`, idxIR]
409
+ idxRefIR = ['local.get', `$${idxTmp}`]
410
+ }
411
+
412
+ const ptr = temp('sc')
413
+ inc('__str_byteLen', '__char_at')
414
+ const charEq = ['if', ['result', 'i32'],
415
+ ['i32.gt_u', ['call', '$__str_byteLen', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]]], idxRefIR],
416
+ ['then', ['i32.eq', ['call', '$__char_at', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]], idxRefIR], ['i32.const', lit.charCodeAt(0)]]],
417
+ ['else', ['i32.const', 0]]]
418
+
419
+ const prelude = idxBindIR ? [['local.set', `$${ptr}`, asF64(emit(obj))], idxBindIR] : [['local.set', `$${ptr}`, asF64(emit(obj))]]
420
+
421
+ if (vt === VAL.STRING) {
422
+ return typed(['block', ['result', 'i32'], ...prelude, finish(charEq)], 'i32')
423
+ }
424
+
425
+ inc('__ptr_type', '__typed_idx', '__eq')
426
+ const genericEq = ['call', '$__eq',
427
+ ['i64.reinterpret_f64', ['call', '$__typed_idx', ['i64.reinterpret_f64', ['local.get', `$${ptr}`]], idxRefIR]],
428
+ asI64(emit(['str', lit]))]
429
+ const cmp = ['if', ['result', 'i32'],
430
+ ptrTypeEq(['local.get', `$${ptr}`], PTR.STRING),
431
+ ['then', charEq],
432
+ ['else', genericEq]]
433
+ return typed(['block', ['result', 'i32'], ...prelude, finish(cmp)], 'i32')
434
+ }
435
+
436
+ // `<str>.{substr,substring,slice}(...) === <other>` whose substring is consumed
437
+ // only by the equality: materialising it (an __alloc + byte copy) is pure waste.
438
+ // Fuse to __str_{substring,slice}_eq, which clamp the range like the method then
439
+ // byte-compare it against `other` in place. Sibling to emitSingleCharIndexCmp,
440
+ // tried at the same `==`/`!=` sites. Motivating hot path: the parser keyword
441
+ // scan, `cur.substr(i,l) === keyword`.
442
+ function emitSubstringEqCmp(a, b, negate = false) {
443
+ // Post-prepare a multi-arg call keeps its args as one comma list; a single
444
+ // arg sits bare. Normalise either (and a flat tail, defensively) to a list.
445
+ const callInfo = node => {
446
+ if (!Array.isArray(node) || node[0] !== '()') return null
447
+ const callee = node[1]
448
+ if (!Array.isArray(callee) || callee[0] !== '.') return null
449
+ const method = callee[2]
450
+ if (method !== 'substr' && method !== 'substring' && method !== 'slice') return null
451
+ let args = node.slice(2)
452
+ if (args.length === 1 && Array.isArray(args[0]) && args[0][0] === ',') args = args[0].slice(1)
453
+ while (args.length && args[args.length - 1] == null) args = args.slice(0, -1)
454
+ return { recv: callee[1], method, args }
455
+ }
456
+
457
+ let info = callInfo(a), other = b, callIsLeft = true
458
+ if (!info) { info = callInfo(b); other = a; callIsLeft = false }
459
+ if (!info) return null
460
+ const { recv, method, args } = info
461
+ if (args.length > 2) return null
462
+ if (!ctx.core.stdlib['__char_at'] || !ctx.core.stdlib['__str_byteLen']) return null
463
+
464
+ // The receiver must be a string. `substr`/`substring` name string-only methods,
465
+ // so an unknown receiver is safe — the normal `.substr`/`.substring` emitter
466
+ // assumes a string too. `slice` is also Array.prototype.slice — require a
467
+ // statically-known STRING there. A known non-string receiver bails always.
468
+ const vt = resolveValType(recv, valTypeOf, lookupValType)
469
+ if (vt && vt !== VAL.STRING) return null
470
+ if (method === 'slice' && vt !== VAL.STRING) return null
471
+
472
+ const helper = method === 'slice' ? '__str_slice_eq' : '__str_substring_eq'
473
+ inc(helper)
474
+
475
+ // Absent end → byteLen: pass i32 max — every clamp arm floors it to the length.
476
+ const TO_END = ['i32.const', 0x7FFFFFFF]
477
+ let startIR, endIR
478
+ if (method === 'substr' && args[1] != null) {
479
+ // substr's 2nd arg is a length: end = start + length, so start reads twice.
480
+ const s = tempI32('subS')
481
+ startIR = ['local.tee', `$${s}`, args[0] == null ? ['i32.const', 0] : asI32(emit(args[0]))]
482
+ endIR = ['i32.add', ['local.get', `$${s}`], asI32(emit(args[1]))]
483
+ } else {
484
+ startIR = args[0] == null ? ['i32.const', 0] : asI32(emit(args[0]))
485
+ endIR = args[1] == null ? TO_END : asI32(emit(args[1]))
486
+ }
487
+
488
+ const finish = expr => negate ? ['i32.eqz', expr] : expr
489
+
490
+ if (callIsLeft)
491
+ return typed(finish(['call', `$${helper}`, asI64(emit(recv)), startIR, endIR, asI64(emit(other))]), 'i32')
492
+
493
+ // `other` is the source-left operand — evaluate it first to preserve order.
494
+ const o = temp('subO')
495
+ return typed(['block', ['result', 'i32'],
496
+ ['local.set', `$${o}`, asF64(emit(other))],
497
+ finish(['call', `$${helper}`, asI64(emit(recv)), startIR, endIR,
498
+ ['i64.reinterpret_f64', ['local.get', `$${o}`]]])], 'i32')
499
+ }
500
+
501
+ // One half of a two-sided range test against a compile-time constant, normalized to
502
+ // an inclusive bound on a *local* `x`: `{ x, lo }` (x ≥ lo) or `{ x, hi }` (x ≤ hi).
503
+ // `>`/`<` fold to the inclusive neighbor; a const on either side is accepted. Returns
504
+ // null for anything else (so the caller leaves the expression untouched).
505
+ function rangeBound(n) {
506
+ if (!Array.isArray(n) || n.length !== 3) return null
507
+ const lc = intLiteralValue(n[1]), rc = intLiteralValue(n[2])
508
+ if (rc != null && typeof n[1] === 'string') { // x op CONST
509
+ if (n[0] === '>=') return { x: n[1], lo: rc }
510
+ if (n[0] === '>') return { x: n[1], lo: rc + 1 }
511
+ if (n[0] === '<=') return { x: n[1], hi: rc }
512
+ if (n[0] === '<') return { x: n[1], hi: rc - 1 }
513
+ }
514
+ if (lc != null && typeof n[2] === 'string') { // CONST op x
515
+ if (n[0] === '<=') return { x: n[2], lo: lc }
516
+ if (n[0] === '<') return { x: n[2], lo: lc + 1 }
517
+ if (n[0] === '>=') return { x: n[2], hi: lc }
518
+ if (n[0] === '>') return { x: n[2], hi: lc - 1 }
519
+ }
520
+ return null
521
+ }
522
+
523
+ // `x >= LO && x <= HI` (x a pure i32 local, LO ≤ HI constants) → `(x - LO) <=u (HI - LO)`.
524
+ // One subtract + one unsigned compare replaces two signed compares, an AND, and the
525
+ // short-circuit branch — the classic range-check trick (valid for any integers via
526
+ // wrapping subtraction). Returns the fused IR, or null to leave `&&` lowering unchanged.
527
+ function fuseRangeCheck(a, b) {
528
+ const ba = rangeBound(a), bb = rangeBound(b)
529
+ if (!ba || !bb || ba.x !== bb.x || (ba.lo != null) === (bb.lo != null)) return null
530
+ const lo = ba.lo ?? bb.lo, hi = ba.hi ?? bb.hi
531
+ if (lo > hi) return null
532
+ const xv = emit(ba.x)
533
+ if (xv.type !== 'i32') return null // f64 (fractional) would mis-fuse
534
+ return typed(['i32.le_u', ['i32.sub', xv, ['i32.const', lo]], ['i32.const', hi - lo]], 'i32')
535
+ }
536
+
537
+ // The complement: `x < LO || x > HI` (the two outside half-checks — one upper-bounded,
538
+ // one lower-bounded, with a gap between) → `(x - LO) >u (HI - LO)`, where [LO, HI] is the
539
+ // inside range. Same trick, negated; returns null to leave `||` lowering unchanged.
540
+ function fuseRangeCheckOr(a, b) {
541
+ const ba = rangeBound(a), bb = rangeBound(b)
542
+ if (!ba || !bb || ba.x !== bb.x || (ba.lo != null) === (bb.lo != null)) return null
543
+ const insideLo = (ba.hi ?? bb.hi) + 1, insideHi = (ba.lo ?? bb.lo) - 1
544
+ if (insideLo > insideHi) return null
545
+ const xv = emit(ba.x)
546
+ if (xv.type !== 'i32') return null
547
+ return typed(['i32.gt_u', ['i32.sub', xv, ['i32.const', insideLo]], ['i32.const', insideHi - insideLo]], 'i32')
548
+ }
549
+
550
+ // Flow-sensitive type refinement moved to ./flow-types.js (extractRefinements,
551
+ // predicateRefinement, mergeRefinement, withRefinements). emit.js imports them
552
+ // from there — see the import block at the top of this file.
553
+
554
+ function unrollSmallConstFor(init, cond, step, body) {
555
+ const end = smallConstForTripCount(init, cond, step)
556
+ if (end == null) return null
557
+ const name = init[1][1]
558
+ if (containsNestedLoop(body)) {
559
+ const nestedMode = ctx.transform.optimize?.nestedSmallConstForUnroll
560
+ if (nestedMode !== true && (nestedMode !== 'auto' || !containsKnownTypedArrayIndex(body))) return null
561
+ if (end * nestedSmallLoopBudget(body) > MAX_NESTED_FOR_UNROLL) return null
562
+ }
563
+ if (hasOwnBreakOrContinue(body) || containsNestedClosure(body) || containsDeclOf(body, name)) return null
564
+ if (isReassigned(body, name)) return null
565
+
566
+ const out = []
567
+ for (let i = 0; i < end; i++) out.push(...emitVoid(cloneWithSubst(body, name, i)))
568
+ return out
569
+ }
570
+
571
+ // Max distinct keys a for-in unrolls over (bounds code size; larger key sets keep
572
+ // the pooled-keys loop, which is already allocation-free via __keys_ro).
573
+ const FORIN_UNROLL_MAX = 16
574
+ // Total-expansion ceiling: unroll emits one body copy per key, so the size cost is
575
+ // keys × body, not keys alone. A large body over many keys (e.g. watr's 15-key
576
+ // schema loop) blows up code size for no deopt win — the pooled fallback is already
577
+ // allocation-free. Cap keys × nodeSize(body); past it, keep the loop. (Tuned above
578
+ // every unroll the corpus actually wants — the 16-key cap test lands at 80.)
579
+ const FORIN_UNROLL_BUDGET = 128
580
+ const forInBodyCost = (node) => {
581
+ if (!Array.isArray(node)) return 1
582
+ let n = 1
583
+ for (let i = 1; i < node.length; i++) n += forInBodyCost(node[i])
584
+ return n
585
+ }
586
+
587
+ // Pull the for-in source out of prepare's keys expression: either a bare
588
+ // `__keys_ro(src)` call or the nullish-guarded `cond ? [] : __keys_ro(src)`.
589
+ function keysRoSrc(node) {
590
+ if (!Array.isArray(node)) return null
591
+ if (node[0] === '()' && node[1] === '__keys_ro') return node[2]
592
+ if (node[0] === '?:' || node[0] === '?') {
593
+ const last = node[node.length - 1]
594
+ if (Array.isArray(last) && last[0] === '()' && last[1] === '__keys_ro') return last[2]
595
+ }
596
+ return null
597
+ }
598
+
599
+ // Unroll `for (k in o)` over a static schema. Prepare lowers for-in to a plain
600
+ // for-loop whose key array comes from the for-in-exclusive `__keys_ro` intrinsic,
601
+ // so a loop carrying it IS a for-in. When `o` is a bare OBJECT var with a complete
602
+ // static schema (no computed-key writes — same gate as __keys_ro pooling), replace
603
+ // the loop with one substituted copy of the body per key: the loop variable becomes
604
+ // a string literal, so `o[k]` folds to a static schema slot — no keys array, no
605
+ // per-element dynamic get. Falls back (returns null) to the pooled loop otherwise.
606
+ function unrollForIn(init, cond, step, body) {
607
+ if (!Array.isArray(init) || init[0] !== 'let' || !Array.isArray(init[1]) || init[1][0] !== '=') return null
608
+ const ksVar = init[1][1]
609
+ const src = keysRoSrc(init[1][2])
610
+ if (typeof src !== 'string') return null
611
+ if (!Array.isArray(cond) || cond[0] !== '<') return null
612
+ const ixVar = cond[1]
613
+ if (!Array.isArray(step) || step[0] !== '++' || step[1] !== ixVar) return null
614
+ // body = [';', ['let', ['=', target, ['[]', ksVar, ixVar]]], ...realBody]
615
+ if (!Array.isArray(body) || body[0] !== ';') return null
616
+ const bind = body[1]
617
+ if (!Array.isArray(bind) || bind[0] !== 'let' || !Array.isArray(bind[1]) || bind[1][0] !== '=') return null
618
+ const target = bind[1][1]
619
+ const acc = bind[1][2]
620
+ if (!Array.isArray(acc) || acc[0] !== '[]' || acc[1] !== ksVar || acc[2] !== ixVar) return null
621
+
622
+ // Unroll only with PROOF the schema is complete: a computed-key write adds
623
+ // enumerable keys, so bail if `src` takes one — or if the fact is unavailable
624
+ // (no proof ⇒ no unroll; unrolling drops the dynamic path, so erring safe matters).
625
+ if (!ctx.types.dynWriteVars || ctx.types.dynWriteVars.has(src)) return null
626
+ if (lookupValType(src) !== VAL.OBJECT) return null
627
+ const keys = ctx.schema.resolve(src)
628
+ if (!keys || !keys.length || keys.length > FORIN_UNROLL_MAX) return null
629
+
630
+ const rest = body.slice(2)
631
+ const realBody = rest.length === 1 ? rest[0] : [';', ...rest]
632
+ // Keep the pooled loop when unrolling would multiply a heavy body across many keys.
633
+ if (keys.length * forInBodyCost(realBody) > FORIN_UNROLL_BUDGET) return null
634
+ // Substitution safety, mirroring unrollSmallConstFor: no reassignment/redeclare
635
+ // of the loop var, no nested closure capturing it (cloneWithSubst skips `=>`),
636
+ // and no break/continue targeting this loop.
637
+ if (hasOwnBreakOrContinue(realBody) || containsNestedClosure(realBody) || containsDeclOf(realBody, target)) return null
638
+ if (isReassigned(realBody, target)) return null
639
+
640
+ const out = []
641
+ for (const key of keys) out.push(...emitVoid(cloneWithSubst(realBody, new Map([[target, ['str', key]]]))))
642
+ return out.length ? out : ['nop']
643
+ }
644
+
645
+ function canThrow(body, seen = new Set()) {
646
+ if (!Array.isArray(body)) return false
647
+ const op = body[0]
648
+ if (op === 'throw') return true
649
+ if (op === '=>') return false
650
+ if (op === '()') {
651
+ const callee = body[1]
652
+ // A call can throw unless we can see the whole callee and prove it can't:
653
+ // only direct calls into a resolvable, non-raw function body are traceable.
654
+ // Indirect/method/builtin calls (callee not a plain name, or a name we can't
655
+ // resolve) are conservatively throwing — a user `try` must wrap them.
656
+ if (typeof callee !== 'string') return true
657
+ const bodyName = ctx.func.directClosures?.get(callee)
658
+ const f = ctx.func.map?.get(bodyName || callee)
659
+ if (!f?.body || f.raw) return true
660
+ if (!seen.has(f.name)) {
661
+ seen.add(f.name)
662
+ if (canThrow(f.body, seen)) return true
663
+ }
664
+ }
665
+ for (let i = 1; i < body.length; i++) if (canThrow(body[i], seen)) return true
666
+ return false
667
+ }
668
+
669
+ // Loop-bound hoisting (see the 'for' emitter): comparison ops whose invariant side
670
+ // is worth lifting, and the test for an immutable, loop-stable `arr.length`. A typed
671
+ // array's length is fixed, so it is loop-invariant whenever `arr` is not reassigned.
672
+ // A plain array's length CAN change (push/pop/index-grow/length=), so it is hoistable
673
+ // only when the loop body provably never mutates it — `mutatesArrayLength` decides that.
674
+ const HOIST_CMP = new Set(['<', '<=', '>', '>='])
675
+ const immutableLenBound = (node, body) => {
676
+ // Unwrap the `| 0` i32 coercion jz wraps a loop bound in (`i < arr.length`
677
+ // emits `i < (arr.length | 0)`).
678
+ if (Array.isArray(node) && node[0] === '|' && Array.isArray(node[2]) && node[2][0] == null && node[2][1] === 0)
679
+ node = node[1]
680
+ if (!(Array.isArray(node) && node[0] === '.' && node[2] === 'length' && typeof node[1] === 'string')) return false
681
+ const vt = lookupValType(node[1])
682
+ if (vt === VAL.TYPED) return !isReassigned(body, node[1])
683
+ if (vt === VAL.ARRAY) return !mutatesArrayLength(body, node[1])
684
+ return false
685
+ }
686
+
687
+ // Pull `const x = <array/object literal>` decls out of a loop body when the literal is
688
+ // deeply constant and `x` is provably read-only + non-escaping in the loop (so a single
689
+ // shared allocation is sound) — otherwise the constant table is re-allocated every
690
+ // iteration. Returns { hoisted: [decl…], body: strippedBody } or null. Only top-level
691
+ // statements of the loop body are considered.
692
+ const extractHoistableLiterals = (body) => {
693
+ let stmts, rebuild
694
+ if (Array.isArray(body) && body[0] === '{}' && Array.isArray(body[1]) && body[1][0] === ';') {
695
+ stmts = body[1].slice(1); rebuild = kept => ['{}', [';', ...kept]]
696
+ } else if (Array.isArray(body) && body[0] === ';') {
697
+ stmts = body.slice(1); rebuild = kept => kept.length === 1 ? kept[0] : [';', ...kept]
698
+ } else return null
699
+ const hoisted = [], kept = []
700
+ for (const s of stmts) {
701
+ const lit = Array.isArray(s) && (s[0] === 'const' || s[0] === 'let') && s.length === 2
702
+ && Array.isArray(s[1]) && s[1][0] === '=' && typeof s[1][1] === 'string' ? s[1][2] : null
703
+ if (lit && Array.isArray(lit) && lit[0] === '[' && isConstLiteral(lit) && constLiteralHoistable(body, s[1][1]))
704
+ hoisted.push(s)
705
+ else kept.push(s)
706
+ }
707
+ return hoisted.length ? { hoisted, body: rebuild(kept) } : null
708
+ }
709
+
710
+ // A source-defined function (carries a body) — as opposed to an imported name,
711
+ // which `ctx.func.names` also holds but which has no body and may legitimately
712
+ // share a name with a built-in emitter (e.g. an imported `parseInt`).
713
+ const isUserFunc = name => !!ctx.func.map.get(name)?.body
714
+
715
+ /** Emit pending `finally` cleanups for an abrupt control-flow exit.
716
+ * Inner cleanups run before outer cleanups. While emitting each cleanup, remove
717
+ * it from the active stack so `return` inside `finally` does not re-enter it. */
718
+ function emitFinalizers() {
719
+ const stack = ctx.func.finallyStack || []
720
+ if (stack.length === 0) return []
721
+ const saved = stack.slice()
722
+ const out = []
723
+ for (let i = saved.length - 1; i >= 0; i--) {
724
+ ctx.func.finallyStack = saved.slice(0, i)
725
+ out.push(...emitVoid(saved[i]))
726
+ }
727
+ ctx.func.finallyStack = saved
728
+ return out
729
+ }
730
+
731
+ function withFinallyStack(stack, fn) {
732
+ const prev = ctx.func.finallyStack || []
733
+ ctx.func.finallyStack = stack
734
+ try { return fn() }
735
+ finally { ctx.func.finallyStack = prev }
736
+ }
737
+
738
+ // withRefinements moved to ./flow-types.js
739
+
740
+ /** Coerce an AST node to an i32 boolean, folding && / || at the boolean boundary. */
741
+ export function toBool(node) {
742
+ const op = Array.isArray(node) ? node[0] : null
743
+ if (CMP_SET.has(op)) return emit(node)
744
+ if (op === '&&') {
745
+ const la = toBool(node[1]), lb = toBool(node[2])
746
+ if (isCmp(node[1]) && isCmp(node[2])) return typed(['i32.and', la, lb], 'i32')
747
+ return typed(['if', ['result', 'i32'], la, ['then', lb], ['else', ['i32.const', 0]]], 'i32')
748
+ }
749
+ if (op === '||') {
750
+ const la = toBool(node[1]), lb = toBool(node[2])
751
+ if (isCmp(node[1]) && isCmp(node[2])) return typed(['i32.or', la, lb], 'i32')
752
+ return typed(['if', ['result', 'i32'], la, ['then', ['i32.const', 1]], ['else', lb]], 'i32')
753
+ }
754
+ return toBoolFromEmitted(emit(node))
755
+ }
756
+
757
+ // `(a / b) | 0` (the JS integer-division idiom) → i32.div_s. jz otherwise lowers `/`
758
+ // to f64.div + ToInt32, paying two i32→f64 converts and the trunc; i32.div_s is
759
+ // direct and lets the wasm backend magic-multiply a constant divisor. Bit-exact for
760
+ // all i32 a,b: |a|<2³³≪2⁵³ so the f64 quotient never rounds across the truncation
761
+ // boundary — EXCEPT b=0 (`(a/0)|0` is ToInt32(±Inf)=0, but i32.div_s traps) and
762
+ // INT_MIN/-1 (ToInt32 wraps to INT_MIN, i32.div_s traps); both guarded. A constant
763
+ // divisor folds the guards away. `exprType==='i32'` excludes unsigned operands
764
+ // (those return 'f64'), where div_s would misread the sign. Returns IR or null.
765
+ const INT_MIN_I32 = -2147483648
766
+ function tryIntDivTrunc(aNode, bNode) {
767
+ const o = ctx.transform.optimize
768
+ if (!o || o.intDivLower === false) return null
769
+ const L = ctx.func.locals
770
+ if (exprType(aNode, L) !== 'i32' || exprType(bNode, L) !== 'i32') return null
771
+ const dv = intLiteralValue(bNode)
772
+ if (dv != null) { // constant divisor — no runtime guard
773
+ const va = asI32(emit(aNode))
774
+ if (dv === 0) return typed(['block', ['result', 'i32'], ['drop', va], ['i32.const', 0]], 'i32')
775
+ if (dv === -1) return typed(['i32.sub', ['i32.const', 0], va], 'i32') // -a, wraps at INT_MIN
776
+ return typed(['i32.div_s', va, ['i32.const', dv | 0]], 'i32')
777
+ }
778
+ // Runtime divisor needs a,b repeated across the guard; only intercept when both are
779
+ // simple re-emittable operands (var / literal) so re-emit is pure and side-effect-free.
780
+ const simple = (n) => typeof n === 'string' || intLiteralValue(n) != null
781
+ if (!simple(aNode) || !simple(bNode)) return null
782
+ const A = () => asI32(emit(aNode)), B = () => asI32(emit(bNode))
783
+ return typed(['if', ['result', 'i32'], ['i32.eqz', B()],
784
+ ['then', ['i32.const', 0]],
785
+ ['else', ['if', ['result', 'i32'],
786
+ ['i32.and', ['i32.eq', A(), ['i32.const', INT_MIN_I32]], ['i32.eq', B(), ['i32.const', -1]]],
787
+ ['then', A()],
788
+ ['else', ['i32.div_s', A(), B()]]]]], 'i32')
789
+ }
790
+
791
+ /** Coerce an emitted arg IR to match a callee param. Param may carry ptrKind (pointer-ABI
792
+ * i32 offset), else falls back to numeric WASM type coercion. */
793
+ function coerceArg(ir, param) {
794
+ if (param?.ptrKind != null) return ptrOffsetIR(ir, param.ptrKind)
795
+ return asParamType(ir, param?.type)
796
+ }
797
+
798
+ /** Pad an emitted-args array up to a signature's arity with type-appropriate
799
+ * defaults (`i32.const 0` for i32 params, `undefExpr()` for f64). Mutates and
800
+ * returns `args` for chaining. */
801
+ function padArgs(args, params) {
802
+ while (args.length < params.length)
803
+ args.push(params[args.length].type === 'i32' ? typed(['i32.const', 0], 'i32') : undefExpr())
804
+ return args
805
+ }
806
+
807
+ /** Emit a node list as call arguments for the given param list: per-param
808
+ * coercion then arity padding. Used at every direct-call site. */
809
+ function emitCallArgs(argNodes, params) {
810
+ return padArgs(argNodes.map((a, k) => coerceArg(emit(a), params[k])), params)
811
+ }
812
+
813
+ /** Stamp a `call` IR with the pointer-ABI / sign metadata its signature carries.
814
+ * Returns `callIR` for chaining. Centralizes the three-property copy every
815
+ * direct-call emission did inline. */
816
+ function attachSigMeta(callIR, sig) {
817
+ if (sig?.ptrKind != null) callIR.ptrKind = sig.ptrKind
818
+ if (sig?.ptrAux != null) callIR.ptrAux = sig.ptrAux
819
+ if (sig?.unsignedResult) callIR.unsigned = true
820
+ return callIR
821
+ }
822
+
823
+ /**
824
+ * Materialize a multi-value function call as a heap array.
825
+ * Call → store each result in temp → copy to allocated array → return pointer.
826
+ */
827
+ export function materializeMulti(callNode) {
828
+ const name = callNode[1]
829
+ const func = ctx.func.map.get(name)
830
+ const n = func.sig.results.length
831
+ const argList = commaList(callNode[2])
832
+ const emittedArgs = emitCallArgs(argList, func.sig.params)
833
+ const temps = Array.from({ length: n }, () => temp())
834
+ const out = allocPtr({ type: 1, len: n, tag: 'marr' })
835
+ const ir = [out.init, ['call', `$${name}`, ...emittedArgs]]
836
+ for (let k = n - 1; k >= 0; k--) ir.push(['local.set', `$${temps[k]}`])
837
+ for (let k = 0; k < n; k++)
838
+ ir.push(['f64.store', ['i32.add', ['local.get', `$${out.local}`], ['i32.const', k * 8]], ['local.get', `$${temps[k]}`]])
839
+ ir.push(out.ptr)
840
+ return block64(...ir)
841
+ }
842
+
843
+ /**
844
+ * Fresh per-iteration heap cells for boxed (closure-captured) locals declared
845
+ * in a loop body. ECMAScript establishes the per-iteration environment at the
846
+ * START of each iteration, so the cell must exist before ANY body statement —
847
+ * including a closure declared *before* the binding (mutual recursion, or a
848
+ * `function` decl jzify hoists above its captures). Allocating at the decl point
849
+ * instead would let an earlier closure capture the previous iteration's (stale)
850
+ * cell while the binding reads/writes the freshly-allocated one. `emitDecl` then
851
+ * stores the initializer into this cell rather than re-allocating (see
852
+ * `frame.loopFresh`). Returns the alloc IR to splice at loop-body entry.
853
+ */
854
+ export function emitLoopFreshBoxed(body, frame) {
855
+ if (!ctx.func.boxed?.size) return []
856
+ const names = new Set()
857
+ ;(function scan(node) {
858
+ if (!Array.isArray(node)) return
859
+ const op = node[0]
860
+ if (op === '=>' || op === 'for' || op === 'for-of' || op === 'for-in' || op === 'while' || op === 'do') return
861
+ if (op === 'let' || op === 'const') {
862
+ for (let i = 1; i < node.length; i++) {
863
+ const d = node[i]
864
+ const nm = Array.isArray(d) && d[0] === '=' ? d[1] : d
865
+ if (typeof nm === 'string' && ctx.func.boxed.has(nm)) names.add(nm)
866
+ }
867
+ }
868
+ for (let i = 1; i < node.length; i++) scan(node[i])
869
+ })(body)
870
+ if (!names.size) return []
871
+ frame.loopFresh = names
872
+ const inits = []
873
+ for (const name of names) {
874
+ const cell = ctx.func.boxed.get(name)
875
+ ctx.func.locals.set(cell, 'i32')
876
+ inits.push(
877
+ ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
878
+ ['f64.store', ['local.get', `$${cell}`], undefExpr()])
879
+ }
880
+ return inits
881
+ }
882
+
883
+ /** Emit let/const initializations as typed local.set instructions. */
884
+ export function emitDecl(...inits) {
885
+ const result = []
886
+ // A `let`/`const` declared inside a loop creates a *fresh* binding each
887
+ // iteration (ECMAScript per-iteration environment). Boxed (closure-captured)
888
+ // locals therefore need a fresh heap cell per iteration — but the cell is
889
+ // allocated at loop-body entry by `emitLoopFreshBoxed` (so a closure declared
890
+ // before the binding captures the right cell), recorded in `frame.loopFresh`.
891
+ // Here we only re-allocate when the loop body did NOT pre-allocate it; a
892
+ // function-level declaration keeps its preboxed cell (forward/mutual-recursion
893
+ // capture relies on it pre-existing).
894
+ const inLoop = ctx.func.stack.some(f => f.loop)
895
+ const loopPrebox = (name) => ctx.func.stack.some(f => f.loopFresh?.has(name))
896
+ for (let ii = 0; ii < inits.length; ii++) {
897
+ const i = inits[ii]
898
+ if (typeof i === 'string') {
899
+ const undef = undefExpr()
900
+ if (ctx.func.boxed.has(i)) {
901
+ const cell = ctx.func.boxed.get(i)
902
+ ctx.func.locals.set(cell, 'i32')
903
+ if (inLoop ? !loopPrebox(i) : !ctx.func.preboxed?.has(i))
904
+ result.push(['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]])
905
+ result.push(['f64.store', ['local.get', `$${cell}`], undef])
906
+ continue
907
+ }
908
+ if (isGlobal(i)) {
909
+ if (!ctx.scope.globalTypes.has(i)) result.push(['global.set', `$${i}`, undef])
910
+ continue
911
+ }
912
+ // An i32-typed local (a narrowed integer index feeder) can't hold the f64
913
+ // NaN-box undef sentinel — and wasm zero-inits locals anyway, so a 0 init is
914
+ // equivalent for the assigned-before-read pattern that earns i32.
915
+ result.push(['local.set', `$${i}`, ctx.func.locals.get(i) === 'i32' ? ['i32.const', 0] : undef])
916
+ continue
917
+ }
918
+ if (!Array.isArray(i) || i[0] !== '=') continue
919
+ const [, name, init] = i
920
+ if (typeof name !== 'string' || init == null) continue
921
+ // Flag bindings initialized to a nullish literal so arithmetic on them coerces (null→0,
922
+ // undefined→NaN) rather than propagating the raw sentinel. See toNumF64 / maybeNullish.
923
+ if (isNullishLit(init)) ctx.func.maybeNullish?.add(name)
924
+
925
+ // SRoA flat object: `let o = {a:1, b:2}` — dissolve fields into `o#i`
926
+ // locals, no heap alloc. Each field local ← asF64(value). Reads/writes are
927
+ // rewritten by the `.`/`[]` flat hooks. See scanFlatObjects (analyze.js).
928
+ // Monotonic-extension fields (`o.newProp = …`) carry no literal value —
929
+ // they init to undefined so a read before the write matches JS.
930
+ const flatDecl = ctx.func.flatObjects?.get(name)
931
+ if (flatDecl && Array.isArray(init) && (init[0] === '{}' || init[0] === '[' || init[0] === '[]')) {
932
+ for (let j = 0; j < flatDecl.names.length; j++)
933
+ result.push(['local.set', `$${name}#${j}`,
934
+ flatDecl.values[j] === undefined ? undefExpr() : asF64(emit(flatDecl.values[j]))])
935
+ continue
936
+ }
937
+
938
+ // Multi-value ephemeral destructuring — skip heap alloc when temp is
939
+ // assigned from a multi-value call then immediately destructured element-by-element.
940
+ if (name.startsWith(T) && Array.isArray(init) && init[0] === '()' && typeof init[1] === 'string'
941
+ && ctx.func.names?.has(init[1])) {
942
+ const func = ctx.func.map.get(init[1])
943
+ const n = func?.sig.results.length
944
+ if (n > 1) {
945
+ const targets = []
946
+ let match = true
947
+ for (let k = 0; k < n && match; k++) {
948
+ const next = inits[ii + 1 + k]
949
+ if (!Array.isArray(next) || next[0] !== '=' || typeof next[1] !== 'string') { match = false; break }
950
+ const rhs = next[2]
951
+ if (!Array.isArray(rhs) || rhs[0] !== '[]' || rhs[1] !== name) { match = false; break }
952
+ const idx = rhs[2]
953
+ if (!Array.isArray(idx) || idx[0] != null || idx[1] !== k) { match = false; break }
954
+ if (ctx.func.boxed.has(next[1]) || isGlobal(next[1])) { match = false; break }
955
+ targets.push(next[1])
956
+ }
957
+ if (match && targets.length === n) {
958
+ const argList = commaList(init[2])
959
+ const emittedArgs = emitCallArgs(argList, func.sig.params)
960
+ result.push(['call', `$${init[1]}`, ...emittedArgs])
961
+ for (let k = n - 1; k >= 0; k--)
962
+ result.push(['local.set', `$${targets[k]}`])
963
+ ii += n
964
+ continue
965
+ }
966
+ }
967
+ }
968
+ // No-copy slice view: `let t = s.slice(...)` whose result scanSliceViews
969
+ // proved never escapes — lower the initializer to a SLICE_BIT view instead
970
+ // of a copying slice. Everything downstream treats `t` as an ordinary
971
+ // string. Gated here (not in the analysis) on a statically-known STRING
972
+ // receiver — param types are settled only by emit time — and on plain-local
973
+ // carriers (boxed/global escape); any miss falls back to the copying slice.
974
+ let viewInit = null
975
+ if (ctx.func.sliceViews?.has(name) && !ctx.func.boxed.has(name) && !isGlobal(name)
976
+ && Array.isArray(init) && init[0] === '()'
977
+ && Array.isArray(init[1]) && init[1][0] === '.' && init[1][2] === 'slice') {
978
+ const recv = init[1][1]
979
+ const recvVt = valTypeOf(recv)
980
+ if (recvVt === VAL.STRING) {
981
+ const raw = init[2]
982
+ const sa = raw == null ? [] : Array.isArray(raw) && raw[0] === ',' ? raw.slice(1) : [raw]
983
+ viewInit = ctx.core.emit['.string:slice#view'](recv, sa[0], sa[1])
984
+ }
985
+ }
986
+
987
+ const isObjLit = Array.isArray(init) && init[0] === '{}'
988
+ if (isObjLit) ctx.schema.targetStack.push({ name, active: true })
989
+ const val = viewInit || emit(init)
990
+ if (isObjLit) ctx.schema.targetStack.pop()
991
+ // Direct-call dispatch for const-bound, non-escaping local closures: skip call_indirect.
992
+ // Gate: not boxed (no mutable cross-fn capture), not global, not reassigned in this body.
993
+ // isReassigned is conservative across nested arrow shadows — we miss the optimization
994
+ // rather than emit a wrong direct call.
995
+ if (Array.isArray(init) && init[0] === '=>' && val?.closureBodyName && !ctx.func.boxed.has(name) && !isGlobal(name)
996
+ && ctx.func.body && !isReassigned(ctx.func.body, name)) {
997
+ if (!ctx.func.directClosures) ctx.func.directClosures = new Map()
998
+ ctx.func.directClosures.set(name, val.closureBodyName)
999
+ }
1000
+ // Copy propagation of a direct closure: `let g = add`, where `add` is a non-escaping
1001
+ // directly-callable closure, makes `g` directly callable too — `g` holds the same
1002
+ // closure value, so `g(…)` calls add's body with g's value as env. This is what
1003
+ // devirtualizes `let arr = [add]; arr[0](…)`: array scalarization rewrites it to
1004
+ // `let g = add; g(…)` before emit (D3), and also covers the explicit `let g = arr[0]`.
1005
+ // Same soundness gate as the direct-closure case: stable binding (not reassigned),
1006
+ // not boxed, not global.
1007
+ if (typeof init === 'string' && ctx.func.directClosures?.has(init) && !ctx.func.boxed.has(name)
1008
+ && !isGlobal(name) && ctx.func.body && !isReassigned(ctx.func.body, name)) {
1009
+ ctx.func.directClosures.set(name, ctx.func.directClosures.get(init))
1010
+ }
1011
+ if (ctx.func.boxed.has(name)) {
1012
+ const cell = ctx.func.boxed.get(name)
1013
+ ctx.func.locals.set(cell, 'i32')
1014
+ if (inLoop ? !loopPrebox(name) : !ctx.func.preboxed?.has(name))
1015
+ result.push(['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]])
1016
+ // i32-narrowed cell stores the raw i32 (see readVar/writeVar). The undef
1017
+ // pre-store stays f64: its NaN atom's low word is 0, which is exactly the
1018
+ // plain-local default an i32 read of an uninitialized cell must see.
1019
+ result.push(ctx.func.cellTypes?.has(name)
1020
+ ? ['i32.store', ['local.get', `$${cell}`], asI32(val)]
1021
+ : ['f64.store', ['local.get', `$${cell}`], asF64(val)])
1022
+ continue
1023
+ }
1024
+ if (isGlobal(name)) {
1025
+ // Unboxed pointer const globals carry the raw i32 offset; init coerces via asPtrOffset.
1026
+ // Only an i32-STORED global is a raw pointer carrier — an f64 global holds a
1027
+ // NaN-boxed value, so coercing its init to an i32 offset (asPtrOffset → i32.wrap)
1028
+ // would store i32 into an f64 global (invalid wasm). Mirror readVar's storage gate.
1029
+ const grep = repOfGlobal(name)
1030
+ if ((ctx.scope.globalTypes.get(name) || 'f64') === 'i32' && grep?.ptrKind != null) {
1031
+ result.push(['global.set', `$${name}`, asPtrOffset(val, grep.ptrKind)])
1032
+ continue
1033
+ }
1034
+ // Pre-folded numeric const globals have their init baked into an *immutable* decl
1035
+ // (`(global $x i32 (i32.const V))`) — skip the runtime init (global.set on an
1036
+ // immutable global is invalid anyway). But a const typed only by integer-global
1037
+ // inference (or a mutable global narrowed to i32) keeps the declareGlobal-default
1038
+ // `(mut … (i32.const 0))` decl, so its real — possibly non-foldable — initializer
1039
+ // must still run (e.g. `const V = NULLISH + 1` where NULLISH is a cross-module /
1040
+ // dynamic const: V is i32-typed but unfolded, and without this it stays 0).
1041
+ if (ctx.scope.globalTypes.has(name)) {
1042
+ if (ctx.scope.consts?.has(name) && !ctx.scope.globals.get(name)?.mut) continue
1043
+ const gt = ctx.scope.globalTypes.get(name)
1044
+ result.push(['global.set', `$${name}`, gt === 'i32' ? asI32(val) : asF64(val)])
1045
+ continue
1046
+ }
1047
+ result.push(['global.set', `$${name}`, asF64(val)])
1048
+ continue
1049
+ }
1050
+ const localType = ctx.func.locals.get(name) || 'f64'
1051
+ let ptrKind = repOf(name)?.ptrKind
1052
+ // Emit-time rep mutation (lifecycle: analysis → emit transition).
1053
+ // Inherit ptrKind from a pointer-ABI RHS: destructure temps (`__d0 = v`) and other
1054
+ // fresh let-bindings whose init is already an unboxed pointer. Without this, readVar
1055
+ // returns an untyped i32 local.get and later `asF64` emits a numeric convert instead
1056
+ // of a ptr-rebox. Safe because emitDecl runs once per let/const binding — no prior
1057
+ // emit-time read could have observed the unset rep.
1058
+ if (ptrKind == null && val.ptrKind != null && localType === 'i32' && !ctx.func.boxed?.has(name)) {
1059
+ updateRep(name, { ptrKind: val.ptrKind })
1060
+ ptrKind = val.ptrKind
1061
+ if (val.ptrAux != null) {
1062
+ updateRep(name, { ptrAux: val.ptrAux })
1063
+ // OBJECT-only: aux *is* the schemaId; mirror to ctx.schema.vars + rep.schemaId so
1064
+ // .prop slot resolution sees a precise binding. TYPED/CLOSURE aux carries other
1065
+ // semantics (elem code / funcIdx) and must not leak into schema lookups.
1066
+ // Poisoned names (shape-disagreeing assignments) must stay schema-free.
1067
+ if (val.ptrKind === VAL.OBJECT && !ctx.schema.vars?.has(name) && !ctx.schema.poisoned?.has(name)) {
1068
+ ctx.schema.vars.set(name, val.ptrAux)
1069
+ updateRep(name, { schemaId: val.ptrAux })
1070
+ }
1071
+ }
1072
+ }
1073
+ let coerced
1074
+ if (ptrKind != null) {
1075
+ // Unboxed pointer local — extract i32 offset from NaN-boxed f64 via reinterpret, not numeric trunc.
1076
+ // CLOSURE init carries funcIdx in val.closureFuncIdx; persist it on the rep so a later
1077
+ // asF64 (escape: store, return, indirect-call rebox) reconstructs the correct table slot.
1078
+ // Emit-time mutation — analyzeValTypes never sees closureFuncIdx.
1079
+ if (ptrKind === VAL.CLOSURE && val.closureFuncIdx != null && repOf(name)?.ptrAux == null)
1080
+ updateRep(name, { ptrAux: val.closureFuncIdx })
1081
+ coerced = val.ptrKind === ptrKind ? val
1082
+ : typed(['i32.wrap_i64', ['i64.reinterpret_f64', asF64(val)]], 'i32')
1083
+ } else {
1084
+ coerced = localType === 'v128' ? val : localType === 'f64' ? asF64(val) : val.type === 'i32' ? val : toI32(val)
1085
+ }
1086
+ // `let x = 0` at function scope is normally elided — WASM zero-inits locals. But loop
1087
+ // unrolling flattens iteration bodies into one scope, so the 2nd+ `let x = 0` are
1088
+ // genuine RE-inits between iterations (e.g. a nested reduce's accumulator). Elide only
1089
+ // the FIRST per name; emit the rest as resets. (Names are preserved — no renaming.)
1090
+ const zeroInit = isLit(coerced) && coerced[1] === 0 && !Object.is(coerced[1], -0) && !ctx.func.stack.length
1091
+ if (!zeroInit || ctx.func.zeroInitSeen?.has(name))
1092
+ result.push(['local.set', `$${name}`, coerced])
1093
+ else (ctx.func.zeroInitSeen ??= new Set()).add(name)
1094
+
1095
+ const schemaId = ctx.schema.idOf?.(name)
1096
+ if (ctx.func.localProps?.has(name) && schemaId != null) {
1097
+ const schema = ctx.schema.resolve(name)
1098
+ if (schema?.[0] === '__inner__') {
1099
+ inc('__alloc_hdr', '__mkptr')
1100
+ const bt = `${T}bx${ctx.func.uniq++}`
1101
+ ctx.func.locals.set(bt, 'i32')
1102
+ const innerName = `${name}${T}inner`
1103
+ ctx.func.locals.set(innerName, 'f64')
1104
+ result.push(
1105
+ ['local.set', `$${innerName}`, ['local.get', `$${name}`]],
1106
+ ['local.set', `$${bt}`, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', Math.max(1, schema.length)]]],
1107
+ ['f64.store', ['local.get', `$${bt}`], ['local.get', `$${name}`]],
1108
+ ...schema.slice(1).map((_, j) =>
1109
+ ['f64.store', ['i32.add', ['local.get', `$${bt}`], ['i32.const', (j + 1) * 8]], ['f64.const', 0]]),
1110
+ ['local.set', `$${name}`, mkPtrIR(PTR.OBJECT, schemaId, ['local.get', `$${bt}`])])
1111
+ }
1112
+ }
1113
+ }
1114
+ return result.length === 0 ? null : result.length === 1 ? result[0] : result
1115
+ }
1116
+
1117
+ /**
1118
+ * Copy a spread source's elements into a destination array.
1119
+ *
1120
+ * `dest` is the destination data-base i32 local; `posLocal` the element index to
1121
+ * start writing at — advanced by the source length on exit. An ARRAY source is a
1122
+ * contiguous block of f64 NaN-boxes, so it copies with a single `memory.copy`; a
1123
+ * string/typed source needs a per-element decode. The source's *type* is
1124
+ * loop-invariant — it cannot change while the spread runs — so when it is not
1125
+ * statically known it is resolved exactly once (one `__ptr_type`) and branched,
1126
+ * never re-checked per element. Returns a list of IR instructions.
1127
+ */
1128
+ function emitSpreadCopy(dest, posLocal, srcLocal, srcLenLocal, staticVT) {
1129
+ const srcI64 = () => ['i64.reinterpret_f64', ['local.get', `$${srcLocal}`]]
1130
+ const destAddr = idx => ['i32.add', ['local.get', `$${dest}`], ['i32.shl', idx, ['i32.const', 3]]]
1131
+ const arrCopy = () => (inc('__ptr_offset'),
1132
+ ['memory.copy', destAddr(['local.get', `$${posLocal}`]),
1133
+ ['call', '$__ptr_offset', srcI64()],
1134
+ ['i32.shl', ['local.get', `$${srcLenLocal}`], ['i32.const', 3]]])
1135
+ const scalarLoop = () => {
1136
+ const sidx = `${T}sidx${ctx.func.uniq++}`
1137
+ ctx.func.locals.set(sidx, 'i32')
1138
+ const loopId = ctx.func.uniq++
1139
+ const elem = ctx.module.modules['string']
1140
+ ? ['if', ['result', 'f64'],
1141
+ ['i32.eq', ['call', '$__ptr_type', srcI64()], ['i32.const', PTR.STRING]],
1142
+ ['then', (inc('__str_idx'), ['call', '$__str_idx', srcI64(), ['local.get', `$${sidx}`]])],
1143
+ ['else', (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])]]
1144
+ : (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])
1145
+ // Reset the counter on each entry — WASM zeroes locals once at function
1146
+ // entry, but this loop re-executes when the spread sits inside a JS loop;
1147
+ // a stale `sidx` (= prior srcLen) would skip the copy entirely.
1148
+ return ['block', `$break${loopId}`,
1149
+ ['local.set', `$${sidx}`, ['i32.const', 0]],
1150
+ ['loop', `$loop${loopId}`,
1151
+ ['br_if', `$break${loopId}`, ['i32.ge_s', ['local.get', `$${sidx}`], ['local.get', `$${srcLenLocal}`]]],
1152
+ ['f64.store', destAddr(['i32.add', ['local.get', `$${posLocal}`], ['local.get', `$${sidx}`]]), elem],
1153
+ ['local.set', `$${sidx}`, ['i32.add', ['local.get', `$${sidx}`], ['i32.const', 1]]],
1154
+ ['br', `$loop${loopId}`]]]
1155
+ }
1156
+ const advance = ['local.set', `$${posLocal}`,
1157
+ ['i32.add', ['local.get', `$${posLocal}`], ['local.get', `$${srcLenLocal}`]]]
1158
+ if (staticVT === VAL.ARRAY) return [arrCopy(), advance]
1159
+ if (staticVT === VAL.STRING || staticVT === VAL.TYPED) return [scalarLoop(), advance]
1160
+ inc('__ptr_type')
1161
+ const tt = tempI32(`${T}spt`)
1162
+ return [
1163
+ ['local.set', `$${tt}`, ['call', '$__ptr_type', srcI64()]],
1164
+ dispatchByPtrType(tt, [[PTR.ARRAY, arrCopy()]], scalarLoop(), null),
1165
+ advance,
1166
+ ]
1167
+ }
1168
+
1169
+ /**
1170
+ * Build an array from items, handling ['__spread', expr] markers.
1171
+ * Split into sections (normal arrays and spreads), then copy all into result.
1172
+ */
1173
+ export function buildArrayWithSpreads(items) {
1174
+ const spreads = []
1175
+ for (let i = 0; i < items.length; i++) {
1176
+ if (Array.isArray(items[i]) && items[i][0] === '__spread') {
1177
+ spreads.push({ pos: i, expr: items[i][1] })
1178
+ }
1179
+ }
1180
+
1181
+ if (spreads.length === 0) {
1182
+ return emit(['[', ...items])
1183
+ }
1184
+
1185
+ const sections = []
1186
+ let currentArray = []
1187
+
1188
+ for (let i = 0; i < items.length; i++) {
1189
+ if (Array.isArray(items[i]) && items[i][0] === '__spread') {
1190
+ if (currentArray.length > 0) {
1191
+ sections.push({ type: 'array', items: currentArray })
1192
+ currentArray = []
1193
+ }
1194
+ sections.push({ type: 'spread', expr: items[i][1] })
1195
+ } else {
1196
+ currentArray.push(items[i])
1197
+ }
1198
+ }
1199
+ if (currentArray.length > 0) {
1200
+ sections.push({ type: 'array', items: currentArray })
1201
+ }
1202
+
1203
+ // A single all-normal section is a plain literal — defer to the `[` emitter.
1204
+ // A single *spread* section is NOT shortcut to `emit(sec.expr)`: that would
1205
+ // alias the source, but `[...x]` must yield a fresh array. It falls through
1206
+ // to the alloc + emitSpreadCopy path below, which copies.
1207
+ if (sections.length === 1 && sections[0].type === 'array') {
1208
+ return emit(['[', ...sections[0].items])
1209
+ }
1210
+
1211
+ const len = tempI32('len')
1212
+ const pos = tempI32('pos')
1213
+ const out = allocPtr({ type: 1, len: ['local.get', `$${len}`], tag: 'arr' })
1214
+ const result = out.local
1215
+
1216
+ const ir = []
1217
+ inc('__len')
1218
+
1219
+ // Pass 1 — evaluate every section IN SOURCE ORDER into temps. JS spread keeps
1220
+ // strict left-to-right order: a later spread whose source mutates an earlier
1221
+ // element's input must still observe the pre-mutation value. Array items
1222
+ // become per-item f64 temps; spreads become a ptr temp + a cached __len.
1223
+ for (const sec of sections) {
1224
+ if (sec.type === 'array') {
1225
+ sec.itemLocals = []
1226
+ for (let i = 0; i < sec.items.length; i++) {
1227
+ const it = `${T}ai${ctx.func.uniq++}`
1228
+ ctx.func.locals.set(it, 'f64')
1229
+ sec.itemLocals.push(it)
1230
+ ir.push(['local.set', `$${it}`, asF64(emit(sec.items[i]))])
1231
+ }
1232
+ } else {
1233
+ sec.local = `${T}sp${ctx.func.uniq++}`
1234
+ ctx.func.locals.set(sec.local, 'f64')
1235
+ sec.lenLocal = `${T}spl${ctx.func.uniq++}`
1236
+ ctx.func.locals.set(sec.lenLocal, 'i32')
1237
+ const n = multiCount(sec.expr)
1238
+ // Normalize a (non-multi) spread source to an index-iterable: Set→keys /
1239
+ // Map→[k,v] arrays, others pass through. Only when `collection` is loaded —
1240
+ // otherwise no Set/Map can exist and the source is already index-iterable.
1241
+ const srcExpr = !n && ctx.module.modules.collection ? ['()', '__iter_arr', sec.expr] : sec.expr
1242
+ // A materialized multi-value is not a statically-typed pointer — let
1243
+ // emitSpreadCopy resolve its kind at runtime via its one-time __ptr_type branch.
1244
+ sec.val = n ? undefined : valTypeOf(srcExpr)
1245
+ ir.push(['local.set', `$${sec.local}`, n ? materializeMulti(sec.expr) : asF64(emit(srcExpr))])
1246
+ // Cache __len once per spread; reused below for total-len sum and the copy.
1247
+ ir.push(['local.set', `$${sec.lenLocal}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]]])
1248
+ }
1249
+ }
1250
+
1251
+ // Pass 2 — total length (array sections statically sized, spreads cached above).
1252
+ ir.push(['local.set', `$${len}`, ['i32.const', 0]])
1253
+ for (const sec of sections) {
1254
+ if (sec.type === 'array') {
1255
+ ir.push(['local.set', `$${len}`, ['i32.add', ['local.get', `$${len}`], ['i32.const', sec.items.length]]])
1256
+ } else {
1257
+ ir.push(['local.set', `$${len}`, ['i32.add', ['local.get', `$${len}`], ['local.get', `$${sec.lenLocal}`]]])
1258
+ }
1259
+ }
1260
+
1261
+ // Pass 3 — allocate exact, then store the pre-evaluated temps.
1262
+ ir.push(out.init, ['local.set', `$${pos}`, ['i32.const', 0]])
1263
+ for (const sec of sections) {
1264
+ if (sec.type === 'array') {
1265
+ for (const it of sec.itemLocals) {
1266
+ ir.push(
1267
+ ['f64.store',
1268
+ ['i32.add', ['local.get', `$${result}`], ['i32.shl', ['local.get', `$${pos}`], ['i32.const', 3]]],
1269
+ ['local.get', `$${it}`]],
1270
+ ['local.set', `$${pos}`, ['i32.add', ['local.get', `$${pos}`], ['i32.const', 1]]]
1271
+ )
1272
+ }
1273
+ } else {
1274
+ ir.push(...emitSpreadCopy(result, pos, sec.local, sec.lenLocal, sec.val))
1275
+ }
1276
+ }
1277
+
1278
+ ir.push(out.ptr)
1279
+ return block64(...ir)
1280
+ }
1281
+
1282
+ /** Emit node in void context: emit + drop any value. Block bodies route through emitBlockBody. */
1283
+ export function emitVoid(node) {
1284
+ if (isBlockBody(node)) return emitBlockBody(node)
1285
+ const ir = emit(node, 'void')
1286
+ const items = flat(ir)
1287
+ if (ir?.type && ir.type !== 'void') items.push('drop')
1288
+ return items
1289
+ }
1290
+
1291
+ /** Emit block body as flat list of WASM instructions. Unwraps {} and delegates to emitVoid per statement.
1292
+ * Also drives early-return refinement: `if (!guard) return/throw` narrows `guard` for the
1293
+ * rest of the enclosing block. Refinements added here are rolled back on block exit. */
1294
+ export function emitBlockBody(node) {
1295
+ const inner = node[1]
1296
+ const stmts = Array.isArray(inner) && inner[0] === ';' ? inner.slice(1) : [inner]
1297
+ const out = []
1298
+ const accumulated = []
1299
+ const prevValOverlay = ctx.func.localValTypesOverlay
1300
+ ctx.func.localValTypesOverlay = new Map(prevValOverlay || [])
1301
+ const setFlowVal = (name, vt) => {
1302
+ if (!isBoundName(name)) return
1303
+ if (vt) ctx.func.localValTypesOverlay.set(name, vt)
1304
+ else ctx.func.localValTypesOverlay.delete(name)
1305
+ }
1306
+ const updateFlowVal = (stmt) => {
1307
+ if (!Array.isArray(stmt)) return
1308
+ const op = stmt[0]
1309
+ if (op === '=' && typeof stmt[1] === 'string') {
1310
+ setFlowVal(stmt[1], valTypeOf(stmt[2]))
1311
+ return
1312
+ }
1313
+ if (op === 'let' || op === 'const') {
1314
+ for (let i = 1; i < stmt.length; i++) {
1315
+ const d = stmt[i]
1316
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string')
1317
+ setFlowVal(d[1], valTypeOf(d[2]))
1318
+ }
1319
+ }
1320
+ }
1321
+ try {
1322
+ for (let i = 0; i < stmts.length; i++) {
1323
+ const s = stmts[i]
1324
+ if (s == null || typeof s === 'number') continue
1325
+ out.push(...emitVoid(s))
1326
+ updateFlowVal(s)
1327
+ // After an `if (cond) terminator` (no else), narrow types from !cond for subsequent statements.
1328
+ // Skip names that are reassigned later — refinement would be unsound past the assignment.
1329
+ if (Array.isArray(s) && s[0] === 'if' && s[3] == null && isTerminator(s[2])) {
1330
+ const refs = extractRefinements(s[1], new Map(), false)
1331
+ for (const [name, fact] of refs) {
1332
+ let reassigned = false
1333
+ for (let j = i + 1; j < stmts.length; j++)
1334
+ if (isReassigned(stmts[j], name)) { reassigned = true; break }
1335
+ if (reassigned) continue
1336
+ const cur = ctx.func.refinements.get(name)
1337
+ accumulated.push([name, cur])
1338
+ // Merge so sibling early-returns layering on the same name compose
1339
+ // (e.g. `if (typeof x === 'string') return; if (Array.isArray(x)) return;`
1340
+ // leaves both `notString: true` and would-be array exclusion stacked).
1341
+ ctx.func.refinements.set(name, cur ? { ...cur, ...fact } : fact)
1342
+ }
1343
+ }
1344
+ }
1345
+ } finally {
1346
+ ctx.func.localValTypesOverlay = prevValOverlay
1347
+ // Restore prior refinements on block exit.
1348
+ for (let i = accumulated.length - 1; i >= 0; i--) {
1349
+ const [name, prev] = accumulated[i]
1350
+ if (prev === undefined) ctx.func.refinements.delete(name); else ctx.func.refinements.set(name, prev)
1351
+ }
1352
+ }
1353
+ return out
1354
+ }
1355
+
1356
+ // A VAL.BOOL value can ride either the cheap 0/1 numeric carrier or, after it has
1357
+ // escaped into an object slot, a boxed boolean atom. `ToNumber(bool)` normalizes
1358
+ // both to 0/1, so for relational / loose-equality coercion a boolean behaves
1359
+ // identically to a number. Normalize it before the type-directed compare dispatch
1360
+ // (the BOOL fact still drives typeof / String / boundary boxing).
1361
+ const numericVal = vt => vt === VAL.BOOL ? VAL.NUMBER : vt
1362
+
1363
+ // Primitive value-type classes for strict-equality type-mismatch folding. Two
1364
+ // operands of different known classes — when at least one is a primitive — can
1365
+ // never be `===` (number/boolean/string/bigint don't cross-coerce under `===`).
1366
+ // Two *reference* kinds (array vs object, …) fall through to the shared ref-eq
1367
+ // path instead, which already resolves distinct pointers to `false`.
1368
+ const STRICT_PRIM = new Set([VAL.NUMBER, VAL.BOOL, VAL.STRING, VAL.BIGINT])
1369
+
1370
+ /**
1371
+ * Strict `===`/`!==`. Unlike loose `==`, no coercion: a statically-known type
1372
+ * mismatch folds to a constant (`true === 1` → false, `"1" === 1` → false). When
1373
+ * the types match — or one side is statically unknown — the result is bit-for-bit
1374
+ * identical to loose `==` on same-type operands, so we delegate to it.
1375
+ *
1376
+ * `null` and `undefined` are distinct NaN-boxed sentinels, so `===` tells them
1377
+ * apart (`null === undefined` is false) even though loose `==` treats both nullish.
1378
+ *
1379
+ * One carrier-level limitation remains (documented gap, not a regression): booleans
1380
+ * and numbers share the 0/1 carrier, so `1 === trueDynamic` can only be told apart
1381
+ * when the boolean's type is statically known.
1382
+ */
1383
+ // A binding the analyzer marked `nullable` (its init or some assignment was a
1384
+ // nullish literal) can hold null/undefined at runtime, so `x === null` / `x == null`
1385
+ // must NOT fold to a constant even when `val` is a definite non-null kind. Only bare
1386
+ // variable reads carry the flag; literals/fresh allocations are inherently non-null.
1387
+ const nullableOperand = (n) =>
1388
+ typeof n === 'string' && !!(repOf(n)?.nullable || repOfGlobal(n)?.nullable)
1389
+
1390
+ function emitLooseEq(a, b, negate) {
1391
+ const eqOp = negate ? 'ne' : 'eq'
1392
+ const sentinel = emitNum(negate ? 1 : 0)
1393
+ const charCmp = emitSingleCharIndexCmp(a, b, negate); if (charCmp) return charCmp
1394
+ const subCmp = emitSubstringEqCmp(a, b, negate); if (subCmp) return subCmp
1395
+ // JS loose nullish equality: x == null / x == undefined.
1396
+ // If the non-literal side has a known non-null VAL type, fold to the sentinel.
1397
+ const nullishOf = (other) => {
1398
+ if (valTypeOf(other) && !nullableOperand(other)) return sentinel
1399
+ const chk = isNullish(asF64(emit(other)))
1400
+ return negate ? typed(['i32.eqz', chk], 'i32') : chk
1401
+ }
1402
+ if (isNullishLit(a)) return nullishOf(b)
1403
+ if (isNullishLit(b)) return nullishOf(a)
1404
+ // typeof x == 'string' → compile-time type check (prepare rewrites string to type code)
1405
+ const tc = emitTypeofCmp(a, b, eqOp); if (tc) return tc
1406
+ const va = emit(a), vb = emit(b)
1407
+ if (va.type === 'i32' && vb.type === 'i32') return typed([`i32.${eqOp}`, va, vb], 'i32')
1408
+ // Either side known-pure NUMBER (literal or typed) → f64.eq/ne is correct regardless
1409
+ // of the other side: jz's `==` is strict (prepare.js:868), and every NaN-boxed pointer
1410
+ // reinterprets to a quiet NaN (0x7FF8… prefix) so f64.eq with any normal float is false.
1411
+ // Catches `closureVar === 34` in jzified hot loops where the unknown side has no VAL.
1412
+ const rawA = resolveValType(a, valTypeOf, lookupValType)
1413
+ const rawB = resolveValType(b, valTypeOf, lookupValType)
1414
+ const vta = numericVal(rawA)
1415
+ const vtb = numericVal(rawB)
1416
+ const numA = () => rawA === VAL.BOOL ? toNumF64(a, va) : asF64(va)
1417
+ const numB = () => rawB === VAL.BOOL ? toNumF64(b, vb) : asF64(vb)
1418
+ if (vta === VAL.NUMBER && needsToNumberCoercion(b, vtb)) return looseNumberEq(numA(), b, vb, negate)
1419
+ if (vtb === VAL.NUMBER && needsToNumberCoercion(a, vta)) return looseNumberEq(numB(), a, va, negate)
1420
+ if (vta === VAL.NUMBER || vtb === VAL.NUMBER) return typed([`f64.${eqOp}`, numA(), numB()], 'i32')
1421
+ // Reference-equal pointer kinds (same kind, non-STRING, non-BIGINT): i64 bit equality.
1422
+ // JS `==` on objects/arrays/sets/maps/etc. is pure reference equality — no content path.
1423
+ // STRING needs __eq (heap strings can be equal by content but different pointers).
1424
+ // BIGINT needs __eq (heap-allocated, content compare).
1425
+ if (vta && vta === vtb && REF_EQ_KINDS.has(vta)) {
1426
+ return typed([`i64.${eqOp}`, ['i64.reinterpret_f64', asF64(va)], ['i64.reinterpret_f64', asF64(vb)]], 'i32')
1427
+ }
1428
+ inc('__eq')
1429
+ const call = typed(['call', '$__eq', asI64(va), asI64(vb)], 'i32')
1430
+ return negate ? typed(['i32.eqz', call], 'i32') : call
1431
+ }
1432
+
1433
+ function emitStrictEq(a, b, negate) {
1434
+ // `typeof x === 'type'` (prepare rewrote the literal to a numeric code) — typeof
1435
+ // always yields a string, so strict and loose agree; reuse the loose lowering.
1436
+ const tc = emitTypeofCmp(a, b, negate ? 'ne' : 'eq'); if (tc) return tc
1437
+ // Strict equality against a `null` or `undefined` literal must match ONLY that
1438
+ // exact sentinel — `undefined === null` is false, unlike loose `==`. prepare
1439
+ // normalizes both to the value-wrapper form `[, v]` (op==null) where the *strict*
1440
+ // value of node[1] is the discriminator (=== null vs === undefined); the loose
1441
+ // isNullLit/isUndefLit predicates use `== null` and can't tell them apart, so key
1442
+ // off node[1] here — exactly as emit()'s literal value path does. A statically
1443
+ // non-nullish operand (known VAL) is neither sentinel, so fold to a constant.
1444
+ const sentinelOf = (n) => {
1445
+ if (!Array.isArray(n) || n[0] != null) return null
1446
+ if (n.length < 2 || n[1] === undefined) return 'undef'
1447
+ if (n[1] === null) return 'null'
1448
+ return null // numeric / string literal value — not a nullish sentinel
1449
+ }
1450
+ const strictSentinel = (other, undef) => {
1451
+ if (valTypeOf(other) && !nullableOperand(other)) return emitNum(negate ? 1 : 0)
1452
+ const chk = (undef ? isUndef : isNull)(asF64(emit(other)))
1453
+ return negate ? typed(['i32.eqz', chk], 'i32') : chk
1454
+ }
1455
+ const sa = sentinelOf(a), sb = sentinelOf(b)
1456
+ if (sb) return strictSentinel(a, sb === 'undef')
1457
+ if (sa) return strictSentinel(b, sa === 'undef')
1458
+ // Known, differing primitive classes can never be strictly equal.
1459
+ const strictA = resolveValType(a, valTypeOf, lookupValType)
1460
+ const strictB = resolveValType(b, valTypeOf, lookupValType)
1461
+ if (strictA && strictB && strictA !== strictB && (STRICT_PRIM.has(strictA) || STRICT_PRIM.has(strictB)))
1462
+ return emitNum(negate ? 1 : 0)
1463
+ // Same type (or dynamic-unknown): identical bits to loose `==`/`!=`.
1464
+ return emitter[negate ? '!=' : '=='](a, b)
1465
+ }
1466
+
1467
+ /** Comparison op factory with constant folding. */
1468
+ const cmpOp = (i32op, f64op, fn) => (a, b) => {
1469
+ const va = emit(a), vb = emit(b)
1470
+ // Skip the const-fold for `.unsigned` operands: `litVal` is the signed bit pattern
1471
+ // (-1, not 4294967295), so folding the order would be wrong. Fall through to the
1472
+ // f64 widen path below, which converts each operand by its own signedness.
1473
+ if (isLit(va) && isLit(vb) && !va.unsigned && !vb.unsigned) return emitNum(fn(litVal(va), litVal(vb)) ? 1 : 0)
1474
+ // String compare: NaN-boxed string pointers compare as NaN under f64.lt/gt
1475
+ // (always false), so without this the spec-correct `"a" < "b"` returns 0.
1476
+ // Route both-STRING operands through __str_cmp's three-way result, then apply
1477
+ // the same i32 sign op as numeric (lt_s/gt_s/le_s/ge_s vs 0).
1478
+ const vta = numericVal(resolveValType(a, valTypeOf, lookupValType))
1479
+ const vtb = numericVal(resolveValType(b, valTypeOf, lookupValType))
1480
+ if (vta === VAL.BIGINT || vtb === VAL.BIGINT) {
1481
+ const op = bigintUnsignedBound(a) || bigintUnsignedBound(b) ? i32op.replace('_s', '_u') : i32op
1482
+ return typed([`i64.${op}`, asI64(va), asI64(vb)], 'i32')
1483
+ }
1484
+ if (vta === VAL.STRING && vtb === VAL.STRING) {
1485
+ return typed([`i32.${i32op}`, stringOps(a).cmp(asF64(va), asF64(vb), ctx), ['i32.const', 0]], 'i32')
1486
+ }
1487
+ // Exactly one operand is a known string; the other has no static type, so it
1488
+ // may hold a string pointer at runtime (e.g. `c >= '0'` where `c` came from
1489
+ // `s[i]` on an untyped receiver). JS relational compare is lexicographic only
1490
+ // when *both* sides are strings, else it ToNumbers both. The f64 path below
1491
+ // would compare the unknown side's NaN-boxed string bits as a float (NaN ⇒
1492
+ // always false), so dispatch at runtime on the unknown side: string → __str_cmp
1493
+ // three-way; else ToNumber both. Mirrors `+`'s __is_str_key string dispatch.
1494
+ // Gated on a *known-string* counterpart, so numeric loops (`i < n`) never pay
1495
+ // the check — comparing against a string literal signals string intent.
1496
+ if (((vta === VAL.STRING && vtb == null) || (vtb === VAL.STRING && vta == null)) && stringOps(a)?.cmp) {
1497
+ const unkIsA = vta == null
1498
+ const ta = temp('cmp'), tb = temp('cmp')
1499
+ inc('__is_str_key')
1500
+ const getA = typed(['local.get', `$${ta}`], 'f64'), getB = typed(['local.get', `$${tb}`], 'f64')
1501
+ const check = ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.get', `$${unkIsA ? ta : tb}`]]]
1502
+ const strCmp = [`i32.${i32op}`, stringOps(a).cmp(getA, getB, ctx), ['i32.const', 0]]
1503
+ const numCmp = [`f64.${f64op}`, toNumF64(a, getA), toNumF64(b, getB)]
1504
+ return typed(['block', ['result', 'i32'],
1505
+ ['local.set', `$${ta}`, asF64(va)],
1506
+ ['local.set', `$${tb}`, asF64(vb)],
1507
+ ['if', ['result', 'i32'], check, ['then', strCmp], ['else', numCmp]]], 'i32')
1508
+ }
1509
+ if (vta === VAL.DATE || vtb === VAL.DATE) {
1510
+ const dateNum = (node, v, vt) => {
1511
+ if (vt !== VAL.DATE) return toNumF64(node, v)
1512
+ const ptr = v.ptrKind === VAL.DATE
1513
+ ? v
1514
+ : ['i32.wrap_i64', ['i64.reinterpret_f64', asF64(v)]]
1515
+ return typed(['f64.load', ptr], 'f64')
1516
+ }
1517
+ return typed([`f64.${f64op}`, dateNum(a, va, vta), dateNum(b, vb, vtb)], 'i32')
1518
+ }
1519
+ if (vtb === VAL.NUMBER && needsToNumberCoercion(a, vta))
1520
+ return typed([`f64.${f64op}`, toNumF64(a, va), asF64(vb)], 'i32')
1521
+ if (vta === VAL.NUMBER && needsToNumberCoercion(b, vtb))
1522
+ return typed([`f64.${f64op}`, asF64(va), toNumF64(b, vb)], 'i32')
1523
+ // An `.unsigned` i32 operand ([0, 2^32)) can't share a signed i32 compare with a
1524
+ // possibly-signed one: mixed sign inverts the order (3 < 0xFFFFFFFF unsigned, but
1525
+ // 3 > -1 signed). Widen to f64, where asF64 converts each operand by its own
1526
+ // signedness (convert_i32_u for unsigned, _s otherwise) to its true numeric value.
1527
+ if (!va.unsigned && !vb.unsigned) {
1528
+ const ai = intConstValue(a), bi = intConstValue(b)
1529
+ if (va.type === 'i32' && bi != null) return typed([`i32.${i32op}`, va, ['i32.const', bi]], 'i32')
1530
+ if (vb.type === 'i32' && ai != null) return typed([`i32.${i32op}`, ['i32.const', ai], vb], 'i32')
1531
+ if (va.type === 'i32' && vb.type === 'i32') return typed([`i32.${i32op}`, va, vb], 'i32')
1532
+ }
1533
+ return typed([`f64.${f64op}`, asF64(va), asF64(vb)], 'i32')
1534
+ }
1535
+
1536
+ /** Both relational (`<` `>=` …) and loose `==`/`!=` need ToNumber on the
1537
+ * unknown side iff it's known-string or might dereference a boxed value. */
1538
+ function needsToNumberCoercion(expr, vt) {
1539
+ if (vt === VAL.STRING) return true
1540
+ if (vt != null) return false
1541
+ return mayReadBoxedValue(expr)
1542
+ }
1543
+
1544
+ function looseNumberEq(numIR, otherNode, otherIR, negate = false) {
1545
+ const t = temp('eq')
1546
+ const other = typed(['local.get', `$${t}`], 'f64')
1547
+ const cmp = ['f64.eq', asF64(numIR), toNumF64(otherNode, other)]
1548
+ return typed(['block', ['result', 'i32'],
1549
+ ['local.set', `$${t}`, asF64(otherIR)],
1550
+ ['if', ['result', 'i32'], isNullish(other),
1551
+ ['then', ['i32.const', negate ? 1 : 0]],
1552
+ ['else', negate ? ['i32.eqz', cmp] : cmp]]], 'i32')
1553
+ }
1554
+
1555
+ function mayReadBoxedValue(expr) {
1556
+ return Array.isArray(expr) && (expr[0] === '.' || expr[0] === '[]' || expr[0] === '?.' || expr[0] === '?.[]')
1557
+ }
1558
+
1559
+ function intConstValue(expr) {
1560
+ if (typeof expr === 'number' && Number.isInteger(expr)) return expr
1561
+ if (Array.isArray(expr) && expr[0] == null && typeof expr[1] === 'number' && Number.isInteger(expr[1])) return expr[1]
1562
+ if (typeof expr === 'string') {
1563
+ const v = repOf(expr)?.intConst
1564
+ if (v != null) return v
1565
+ }
1566
+ return null
1567
+ }
1568
+
1569
+ function bigintUnsignedBound(expr) {
1570
+ // Self-describing literal carries the unsigned-64 decimal (`BigInt.asUintN(64,…)`,
1571
+ // so 1–20 digits, always ≤ 2^64-1). Detect the high-unsigned range (> 2^63-1) by
1572
+ // decimal magnitude — the kernel can't parse large decimals back to BigInt.
1573
+ if (Array.isArray(expr) && expr[0] === 'bigint') {
1574
+ const s = expr[1]
1575
+ return s.length > 19 || (s.length === 19 && s > '9223372036854775807')
1576
+ }
1577
+ const n = bigintConstValue(expr)
1578
+ return n != null && n > 0x7fffffffffffffffn && n <= 0xffffffffffffffffn
1579
+ }
1580
+
1581
+ function bigintConstValue(expr) {
1582
+ if (typeof expr === 'bigint') return expr
1583
+ if (!Array.isArray(expr)) return null
1584
+ if (expr[0] == null && typeof expr[1] === 'bigint') return expr[1]
1585
+ if (expr[0] === 'u-') {
1586
+ const n = bigintConstValue(expr[1])
1587
+ return n == null ? null : -n
1588
+ }
1589
+ return null
1590
+ }
1591
+
1592
+ // === Call IR helpers ===
1593
+
1594
+ /** Split a flat argList into normal positional args + spread positions. */
1595
+ function parseCallArgs(args) {
1596
+ const normal = []
1597
+ const spreads = []
1598
+ for (let i = 0; i < args.length; i++) {
1599
+ const arg = args[i]
1600
+ if (Array.isArray(arg) && arg[0] === '...') {
1601
+ spreads.push({ pos: normal.length, expr: arg[1] })
1602
+ } else {
1603
+ normal.push(arg)
1604
+ }
1605
+ }
1606
+ return { normal, spreads, hasSpread: spreads.length > 0 }
1607
+ }
1608
+
1609
+ /** Bulk `obj.push(...src)` fast path — single trailing spread, no normal args, named
1610
+ * receiver. Amortizes the per-element grow + set_len of the generic loop into one
1611
+ * __arr_grow / __set_len pair, then bulk-copies the source via emitSpreadCopy.
1612
+ * Hot path in watr's `out.push(...HANDLER[op](...))` (~24M bytes/iter on raycast). */
1613
+ function emitBulkPushSpread(objArg, parsed) {
1614
+ const spreadExpr = parsed.spreads[0].expr
1615
+ inc('__len'); inc('__arr_grow'); inc('__set_len'); inc('__ptr_offset')
1616
+ const o = `${T}po${ctx.func.uniq++}`,
1617
+ sa = `${T}psa${ctx.func.uniq++}`,
1618
+ sl = `${T}psl${ctx.func.uniq++}`,
1619
+ ol = `${T}pol${ctx.func.uniq++}`,
1620
+ si = `${T}psi${ctx.func.uniq++}`,
1621
+ base = `${T}pb${ctx.func.uniq++}`
1622
+ ctx.func.locals.set(o, 'f64'); ctx.func.locals.set(sa, 'f64')
1623
+ ctx.func.locals.set(sl, 'i32'); ctx.func.locals.set(ol, 'i32')
1624
+ ctx.func.locals.set(si, 'i32'); ctx.func.locals.set(base, 'i32')
1625
+
1626
+ const objIsArr = lookupValType(objArg) === VAL.ARRAY
1627
+ const n = multiCount(spreadExpr)
1628
+ // Normalize a (non-multi) spread source to an index-iterable: Set→keys /
1629
+ // Map→[k,v] arrays, others pass through. Only when `collection` is loaded.
1630
+ const srcExpr = !n && ctx.module.modules.collection ? ['()', '__iter_arr', spreadExpr] : spreadExpr
1631
+ // A materialized multi-value is not a statically-typed pointer — let
1632
+ // emitSpreadCopy resolve its kind once at runtime.
1633
+ const srcVT = n ? undefined : valTypeOf(srcExpr)
1634
+ const ir = []
1635
+ ir.push(['local.set', `$${o}`, asF64(emit(objArg))])
1636
+ ir.push(['local.set', `$${sa}`, n ? materializeMulti(spreadExpr) : asF64(emit(srcExpr))])
1637
+ ir.push(['local.set', `$${sl}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sa}`]]]])
1638
+ // Old length: inline as `i32.load (off-8)` if obj is known ARRAY (matches .push handler).
1639
+ if (objIsArr) {
1640
+ ir.push(['local.set', `$${ol}`,
1641
+ ['i32.load', ['i32.sub', ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${o}`]]], ['i32.const', 8]]]])
1642
+ } else {
1643
+ ir.push(['local.set', `$${ol}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${o}`]]]])
1644
+ }
1645
+ // Single grow for the full spread (vs per-element grow check in the generic loop).
1646
+ ir.push(['local.set', `$${o}`, ['call', '$__arr_grow', ['i64.reinterpret_f64', ['local.get', `$${o}`]],
1647
+ ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${sl}`]]]])
1648
+ // base captured AFTER grow (grow may relocate the array).
1649
+ ir.push(['local.set', `$${base}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${o}`]]]])
1650
+ // Bulk-copy the spread: an ARRAY source is a contiguous f64 block → memory.copy.
1651
+ ir.push(['local.set', `$${si}`, ['local.get', `$${ol}`]])
1652
+ ir.push(...emitSpreadCopy(base, si, sa, sl, srcVT))
1653
+ // Single set_len for the full spread.
1654
+ ir.push(['call', '$__set_len', ['i64.reinterpret_f64', ['local.get', `$${o}`]],
1655
+ ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${sl}`]]])
1656
+ // Update source variable: grow may have moved the pointer.
1657
+ ir.push(persistBindingPtr(objArg, ['local.get', `$${o}`]))
1658
+ ir.push(['f64.convert_i32_s', ['i32.add', ['local.get', `$${ol}`], ['local.get', `$${sl}`]]])
1659
+ return block64(...ir)
1660
+ }
1661
+
1662
+ /** Single trailing spread, with optional preceding normal args. Calls methodEmitter
1663
+ * once for the normal args (if any), then loops methodEmitter over each spread
1664
+ * element. `unshift` walks the spread end-to-start so prepend order matches JS. */
1665
+ /** Emit a per-element loop over `spreadExpr`: allocate arr/len/idx locals, seed
1666
+ * the arr rep when the spread VT is known, run `bodyFn(arr, idx, len)` once per
1667
+ * element. When `reverse` is set, walks the spread from end to start (used by
1668
+ * `unshift` to preserve argument order under successive prepends). Returns the
1669
+ * IR instruction list (caller embeds it into its own block64). */
1670
+ function emitSpreadElementLoop(spreadExpr, bodyFn, { reverse = false } = {}) {
1671
+ const arr = `${T}sp${ctx.func.uniq++}`
1672
+ const len = `${T}splen${ctx.func.uniq++}`
1673
+ const idx = `${T}spidx${ctx.func.uniq++}`
1674
+ ctx.func.locals.set(arr, 'f64'); ctx.func.locals.set(len, 'i32'); ctx.func.locals.set(idx, 'i32')
1675
+ // Emit-time rep seeding for a fresh spread-staging local (no prior reader).
1676
+ // Without this, the loop body's `[]` read on `arr` falls back to polymorphic
1677
+ // dispatch — VAL.* on the rep elides STRING gate for ARRAY/TYPED spreads.
1678
+ const spreadVT = valTypeOf(spreadExpr)
1679
+ if (spreadVT) updateRep(arr, { val: spreadVT })
1680
+ inc('__len')
1681
+ const n = multiCount(spreadExpr)
1682
+ const loopId = ctx.func.uniq++
1683
+ const exhausted = reverse
1684
+ ? ['i32.lt_s', ['local.get', `$${idx}`], ['i32.const', 0]]
1685
+ : ['i32.ge_u', ['local.get', `$${idx}`], ['local.get', `$${len}`]]
1686
+ return [
1687
+ ['local.set', `$${arr}`, n ? materializeMulti(spreadExpr) : asF64(emit(spreadExpr))],
1688
+ ['local.set', `$${len}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${arr}`]]]],
1689
+ ['local.set', `$${idx}`, reverse ? ['i32.sub', ['local.get', `$${len}`], ['i32.const', 1]] : ['i32.const', 0]],
1690
+ ['block', `$break${loopId}`,
1691
+ ['loop', `$continue${loopId}`,
1692
+ ['br_if', `$break${loopId}`, exhausted],
1693
+ ...bodyFn(arr, idx, len),
1694
+ ['local.set', `$${idx}`, ['i32.add', ['local.get', `$${idx}`], ['i32.const', reverse ? -1 : 1]]],
1695
+ ['br', `$continue${loopId}`]]],
1696
+ ]
1697
+ }
1698
+
1699
+ function emitSingleSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1700
+ const inPlace = SPREAD_MUTATORS.has(method)
1701
+ // unshift prepends each arg to the front — forward iteration reverses intent.
1702
+ const reverse = method === 'unshift'
1703
+ const acc = `${T}acc${ctx.func.uniq++}`
1704
+ ctx.func.locals.set(acc, 'f64')
1705
+ const ir = [['local.set', `$${acc}`, asF64(emit(objArg))]]
1706
+ if (parsed.normal.length > 0) {
1707
+ const r = asF64(methodEmitter(objArg, ...parsed.normal))
1708
+ ir.push(inPlace ? ['drop', r] : ['local.set', `$${acc}`, r])
1709
+ }
1710
+ ir.push(...emitSpreadElementLoop(parsed.spreads[0].expr, (arr, idx) => {
1711
+ const body = asF64(methodEmitter(inPlace ? objArg : acc, ['[]', arr, idx]))
1712
+ return [inPlace ? ['drop', body] : ['local.set', `$${acc}`, body]]
1713
+ }, { reverse }))
1714
+ ir.push(inPlace ? asF64(emit(objArg)) : ['local.get', `$${acc}`])
1715
+ return block64(...ir)
1716
+ }
1717
+
1718
+ /** General spread mix: iterate combined args in original order, batch contiguous
1719
+ * normal args into a single methodEmitter call, emit a per-element loop for each
1720
+ * spread. For in-place methods chains via `objArg` (source variable); otherwise
1721
+ * threads through an accumulator local. */
1722
+ function emitMultiSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1723
+ const inPlace = SPREAD_MUTATORS.has(method)
1724
+ const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
1725
+ // Accumulator (only used when not in-place); recv passed to methodEmitter is the live target.
1726
+ const acc = inPlace ? null : `${T}acc${ctx.func.uniq++}`
1727
+ if (acc) ctx.func.locals.set(acc, 'f64')
1728
+ const recv = inPlace ? objArg : acc
1729
+ const ir = inPlace ? [] : [['local.set', `$${acc}`, asF64(emit(objArg))]]
1730
+ let batch = []
1731
+ const flushBatch = () => {
1732
+ if (!batch.length) return
1733
+ const r = asF64(methodEmitter(recv, ...batch))
1734
+ ir.push(inPlace ? ['drop', r] : ['local.set', `$${acc}`, r])
1735
+ batch = []
1736
+ }
1737
+ for (const item of combined) {
1738
+ if (Array.isArray(item) && item[0] === '__spread') {
1739
+ flushBatch()
1740
+ ir.push(...emitSpreadElementLoop(item[1], (arr, idx) => {
1741
+ const body = asF64(methodEmitter(recv, ['[]', arr, idx]))
1742
+ return [inPlace ? ['drop', body] : ['local.set', `$${acc}`, body]]
1743
+ }))
1744
+ } else {
1745
+ batch.push(item)
1746
+ }
1747
+ }
1748
+ flushBatch()
1749
+ ir.push(inPlace ? asF64(emit(objArg)) : ['local.get', `$${acc}`])
1750
+ return block64(...ir)
1751
+ }
1752
+
1753
+ /** Method-emitter call: directly, or via one of the spread fast paths. */
1754
+ function emitMethodCallSpread(objArg, methodEmitter, parsed, method) {
1755
+ if (!parsed.hasSpread) return methodEmitter(objArg, ...parsed.normal)
1756
+ if (method === 'push' && parsed.normal.length === 0 &&
1757
+ parsed.spreads.length === 1 && typeof objArg === 'string')
1758
+ return emitBulkPushSpread(objArg, parsed)
1759
+ if (parsed.spreads.length === 1 && parsed.spreads[0].pos === parsed.normal.length)
1760
+ return emitSingleSpreadMethodCall(objArg, parsed, method, methodEmitter)
1761
+ return emitMultiSpreadMethodCall(objArg, parsed, method, methodEmitter)
1762
+ }
1763
+
1764
+ /** Hoist `headExpr` into a temp, evaluate it once, and yield `body(t)` when the
1765
+ * temp is non-nullish, else `undefined`. Shared by every `?.`-shaped optional
1766
+ * emitter (chain-lift, `?.`, `?.[]`, `?.()` via `evalOnce` + this helper) so
1767
+ * the nullish-guard scaffold stays in one place. */
1768
+ function withNullGuard(headExpr, body, tag = 'ng') {
1769
+ const t = temp(tag)
1770
+ return block64(
1771
+ ['local.set', `$${t}`, headExpr],
1772
+ ['if', ['result', 'f64'],
1773
+ ['i32.eqz', isNullish(['local.get', `$${t}`])],
1774
+ ['then', body(t)],
1775
+ ['else', undefExpr()]])
1776
+ }
1777
+
1778
+ // Leading method-call strategies (chain positions 1–4). Each is *context-free* —
1779
+ // it depends only on the parsed call, not on the receiver-type analysis (`vt` /
1780
+ // `callMethod`) that emitMethodCall computes below — so they factor out into an
1781
+ // ordered, first-match-wins table. A strategy returns its IR, or `undefined` to
1782
+ // fall through to the next. (Positions 5–12 thread shared mid-function state and
1783
+ // stay inline.) New context-free strategies just push onto LEADING_STRATEGIES.
1784
+
1785
+ // 1. SRoA flat object: `o.method(args)` — scanFlatObjects dissolved `o` into
1786
+ // `o#i` field locals and deleted `$o`, so the method closure lives in the field
1787
+ // local, not a heap slot. Read it directly and dispatch. Without this, every
1788
+ // path below loads from `local.get $o`, which no longer exists (watr then reports
1789
+ // "Unknown local $o"). Mirrors the flat `.`/`[]` hooks.
1790
+ function tryFlatObjectMethod(callee, obj, method, parsed) {
1791
+ if (typeof obj === 'string' && ctx.closure.call) {
1792
+ const flat = ctx.func.flatObjects?.get(obj)
1793
+ const fi = flat ? flat.names.indexOf(method) : -1
1794
+ if (fi >= 0) {
1795
+ const propRead = typed(['local.get', `$${obj}#${fi}`], 'f64')
1796
+ if (parsed.hasSpread)
1797
+ return ctx.closure.call(propRead, [buildArrayWithSpreads(reconstructArgsWithSpreads(parsed.normal, parsed.spreads))], true)
1798
+ return ctx.closure.call(propRead, parsed.normal)
1799
+ }
1800
+ }
1801
+ }
1802
+
1803
+ // 2. charCodeAt with a statically in-bounds index — emit the i32 (OOB-impossible)
1804
+ // contract directly; the generic path keeps the f64/NaN JS-spec result. See
1805
+ // analyze.js inBoundsCharCodeAt.
1806
+ function tryCharCodeAtFast(callee, obj, method, parsed) {
1807
+ if (method === 'charCodeAt' && !parsed.hasSpread && parsed.normal.length === 1
1808
+ && stringOps(obj)?.charCodeAt && inBoundsCharCodeAt(ctx).has(callee)) {
1809
+ const recv = emit(obj)
1810
+ // jsstring carrier: receiver is an externref boundary param. Route to
1811
+ // `wasm:js-string.charCodeAt` directly — the in-bounds proof rules out the
1812
+ // OOB trap the builtin would otherwise raise.
1813
+ if (recv?.type === 'externref') {
1814
+ ctx.core.jsstring.add('charCodeAt')
1815
+ return typed(['call', '$__jss_charCodeAt', recv, asI32(emit(parsed.normal[0]))], 'i32')
1816
+ }
1817
+ return typed(stringOps(obj).charCodeAt(
1818
+ asF64(recv), asI32(emit(parsed.normal[0])), ctx, false), 'i32')
1819
+ }
1820
+ }
1821
+
1822
+ // 3. splice(start, deleteCount, ...items): the one array method that both deletes
1823
+ // and inserts. callMethod's spread machinery models per-element mutators
1824
+ // (push/concat), not a single delete+insert, so a spread of inserts would be
1825
+ // misapplied. Handle the full arg list here: delete-only (no inserts) falls
1826
+ // through to the inline `.splice` emitter; any insert items route through
1827
+ // __arr_splice, which grows/shifts in place (the caller's pointer stays valid via
1828
+ // array forwarding) and returns the removed elements. Guard against a spread in
1829
+ // the start/deleteCount slots (`splice(...x)`) — that form has no static arity.
1830
+ function trySpliceInsert(callee, obj, method, parsed) {
1831
+ if (method === 'splice' && ctx.core.emit['.splice']) {
1832
+ const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
1833
+ const inserts = combined.slice(2)
1834
+ const headSpread = combined[0]?.[0] === '__spread' || combined[1]?.[0] === '__spread'
1835
+ if (inserts.length && !headSpread) {
1836
+ inc('__arr_splice')
1837
+ return typed(['call', '$__arr_splice',
1838
+ asI64(emit(obj)),
1839
+ asI32(emit(combined[0])),
1840
+ asI32(emit(combined[1])),
1841
+ asI64(buildArrayWithSpreads(inserts))], 'f64')
1842
+ }
1843
+ }
1844
+ }
1845
+
1846
+ // 4. Function property call: fn.prop(args) → direct call to fn$prop. Skipped when
1847
+ // the property was reassigned (wrapper composition) — then it is a mutable slot
1848
+ // and must be read dynamically before the call.
1849
+ function tryFnPropCall(callee, obj, method, parsed) {
1850
+ if (typeof obj === 'string' && ctx.func.names.has(obj) && !ctx.func.multiProp.has(`${obj}.${method}`)) {
1851
+ const fname = `${obj}$${method}`
1852
+ if (ctx.func.names.has(fname)) {
1853
+ const func = ctx.func.map.get(fname)
1854
+ const emittedArgs = emitCallArgs(parsed.normal, func.sig.params)
1855
+ return attachSigMeta(typed(['call', `$${fname}`, ...emittedArgs], func.sig.results[0]), func.sig)
1856
+ }
1857
+ }
1858
+ }
1859
+
1860
+ const LEADING_STRATEGIES = [tryFlatObjectMethod, tryCharCodeAtFast, trySpliceInsert, tryFnPropCall]
1861
+
1862
+ // Strategies 5–12 share the receiver's resolved value type and the
1863
+ // `callMethod` shim — packaged once into a dispatch-context record `c` =
1864
+ // `{ obj, method, parsed, vt, callMethod }` so each strategy is a named
1865
+ // function in TYPED_STRATEGIES, same first-match-wins contract as
1866
+ // LEADING_STRATEGIES. The last entry (external fallback) is total.
1867
+
1868
+ // 5. Boxed object: delegate method to inner value (slot 0)
1869
+ function tryBoxedDelegate({ obj, method, callMethod }) {
1870
+ if (typeof obj === 'string' && ctx.schema.isBoxed?.(obj)) {
1871
+ const innerVt = repOf(obj)?.val
1872
+ const innerEmitter = ctx.core.emit[`.${innerVt}:${method}`] || ctx.core.emit[`.${method}`]
1873
+ if (innerEmitter) {
1874
+ const innerName = `${obj}${T}inner`
1875
+ if (!ctx.func.locals.has(innerName)) ctx.func.locals.set(innerName, 'f64')
1876
+ const boxBase = tempI32('bb')
1877
+ // Load current inner value from boxed object's slot 0 (may have been updated by prior mutations)
1878
+ // Boxed handle is OBJECT-kind, never ARRAY — skip forwarding.
1879
+ const loadInner = [
1880
+ ['local.set', `$${boxBase}`, ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT)],
1881
+ ['local.set', `$${innerName}`, ctx.abi.object.ops.load(['local.get', `$${boxBase}`], 0)]]
1882
+ const result = callMethod(innerName, innerEmitter)
1883
+ // Mutating methods may reallocate; writeback inner value to boxed slot
1884
+ if (BOXED_MUTATORS.has(method)) {
1885
+ const wb = ctx.abi.object.ops.store(['local.get', `$${boxBase}`], 0, ['local.get', `$${innerName}`])
1886
+ return block64(...loadInner, asF64(result), wb)
1887
+ }
1888
+ // Non-mutating: just load inner and call
1889
+ return block64(...loadInner, asF64(result))
1890
+ }
1891
+ }
1892
+ }
1893
+
1894
+ // 6. valueOf/toString are ToPrimitive hooks (ES2024 7.1.1) that an own data
1895
+ // property shadows. An assigned `obj.valueOf`/`obj.toString` must win over
1896
+ // the builtin emitter for any receiver that can carry a dynamic-prop
1897
+ // sidecar — a sidecar-bearing static type (array/typed/object) OR a
1898
+ // statically-unknown receiver (e.g. an array-element read `arr[0]`, whose
1899
+ // type is only known at runtime). Probe the sidecar and call it when it
1900
+ // holds a closure, else fall back to the builtin (generic when untyped:
1901
+ // `.valueOf` returns the receiver, `.toString` runs type-aware __to_str).
1902
+ // Parallels the member-READ check in module/core.js emitPropAccess (which
1903
+ // stays scoped to known sidecar types). (watr's `str()` attaches
1904
+ // `bytes.valueOf = () => s`, recovered via `.valueOf()`.)
1905
+ function trySidecarToPrimitive({ obj, method, parsed, vt, callMethod }) {
1906
+ if ((method === 'valueOf' || method === 'toString') && ctx.closure.call
1907
+ && !parsed.hasSpread && parsed.normal.length === 0
1908
+ && (vt === VAL.ARRAY || vt === VAL.TYPED || vt === VAL.OBJECT || !vt)) {
1909
+ const builtin = (vt && ctx.core.emit[`.${vt}:${method}`]) || ctx.core.emit[`.${method}`]
1910
+ if (builtin) {
1911
+ return sidecarOverride(emit(obj), asI64(emit(['str', method])),
1912
+ (p) => ctx.closure.call(typed(['local.get', `$${p}`], 'f64'), []), // CALL the override
1913
+ (o) => asF64(callMethod(o, builtin))) // else the builtin method
1914
+ }
1915
+ }
1916
+ }
1917
+
1918
+ // 7. Known type → static dispatch
1919
+ function tryStaticDispatch({ obj, method, vt, callMethod }) {
1920
+ if (vt && ctx.core.emit[`.${vt}:${method}`]) {
1921
+ return callMethod(obj, ctx.core.emit[`.${vt}:${method}`])
1922
+ }
1923
+ }
1924
+
1925
+ // 8. Unknown / guessed-array type, both string + generic exist → runtime dispatch by ptr type.
1926
+ // analyze.js defaults untyped `.slice()` results to VAL.ARRAY, which is a guess, not a proof;
1927
+ // runtime dispatch resolves whether the operand is actually a string or an array.
1928
+ // Concretely-typed non-string values (BUFFER, TYPED, MAP, …) fall through to the generic
1929
+ // emitter which already knows how to handle them.
1930
+ function tryRuntimeStringFork({ obj, method, vt, callMethod }) {
1931
+ const strKey = `.string:${method}`, genKey = `.${method}`
1932
+ // VAL.ARRAY is structurally incompatible with PTR.STRING — no fork needed.
1933
+ // Only fork when vt is truly unknown (!vt), not for proven types.
1934
+ if (!vt && ctx.core.emit[strKey] && ctx.core.emit[genKey]) {
1935
+ const t = `${T}rt${ctx.func.uniq++}`, tt = `${T}rtt${ctx.func.uniq++}`
1936
+ ctx.func.locals.set(t, 'f64'); ctx.func.locals.set(tt, 'i32')
1937
+ const strEmitter = ctx.core.emit[strKey]
1938
+ const genEmitter = ctx.core.emit[genKey]
1939
+ // A string/array method is only valid on a NaN-boxed pointer (string/array/…).
1940
+ // `f64.eq(t,t)` is true only for a non-NaN value, so guard the dispatch with
1941
+ // it. A plain-number receiver dispatches the `.number:` emitter when the
1942
+ // method has one (`x.toString(16)` on an untyped x — the kernel-L2 ratchet's
1943
+ // data-segment corruption root: this used to yield `undefined`, and
1944
+ // `'\\' + undefined.padStart(2,'0')` collapsed every escaped byte to \\00);
1945
+ // methods numbers don't have keep yielding `undefined` (spec: `(5).indexOf`
1946
+ // is undefined) instead of feeding number bits to `__ptr_type` → OOB.
1947
+ // Every NaN-boxed receiver still reaches the string-vs-generic fork unchanged.
1948
+ const numEmitter = ctx.core.emit[`.number:${method}`]
1949
+ return block64(
1950
+ ['local.set', `$${t}`, asF64(emit(obj))],
1951
+ ['if', ['result', 'f64'],
1952
+ ['f64.eq', ['local.get', `$${t}`], ['local.get', `$${t}`]],
1953
+ ['then', numEmitter ? asF64(callMethod(t, numEmitter)) : undefExpr()],
1954
+ ['else', block64(
1955
+ ['local.set', `$${tt}`, ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${t}`]]]],
1956
+ ['if', ['result', 'f64'],
1957
+ ['i32.eq', ['local.get', `$${tt}`], ['i32.const', PTR.STRING]],
1958
+ ['then', callMethod(t, strEmitter)],
1959
+ ['else', callMethod(t, genEmitter)]])]])
1960
+ }
1961
+ }
1962
+
1963
+ // 8b. Number-only method (toFixed/toPrecision/toExponential/toString-with-radix
1964
+ // when no string fork applies) on an untyped receiver: a runtime number check
1965
+ // routes to the `.number:` emitter; a NaN-boxed receiver probes the dynamic-prop
1966
+ // sidecar (a user's own `.toFixed` closure must win — ES own-property shadowing)
1967
+ // and otherwise yields `undefined`, the same result the dynamic path produced.
1968
+ function tryRuntimeNumberMethod({ obj, method, parsed, vt, callMethod }) {
1969
+ const numEmitter = ctx.core.emit[`.number:${method}`]
1970
+ if (vt || !numEmitter || parsed.hasSpread || !ctx.closure.call) return
1971
+ const t = `${T}rn${ctx.func.uniq++}`
1972
+ ctx.func.locals.set(t, 'f64')
1973
+ return block64(
1974
+ ['local.set', `$${t}`, asF64(emit(obj))],
1975
+ ['if', ['result', 'f64'],
1976
+ ['f64.eq', ['local.get', `$${t}`], ['local.get', `$${t}`]],
1977
+ ['then', asF64(callMethod(t, numEmitter))],
1978
+ ['else', sidecarOverride(typed(['local.get', `$${t}`], 'f64'), asI64(emit(['str', method])),
1979
+ (p) => ctx.closure.call(typed(['local.get', `$${p}`], 'f64'), parsed.normal),
1980
+ () => undefExpr())]])
1981
+ }
1982
+
1983
+ // 9. Schema property closure call: `x.prop(args)` where prop is a closure slot in
1984
+ // x's schema. Boxed schemas don't currently support spread callers (each box
1985
+ // hands the inner value through), so spread is restricted to the non-boxed path.
1986
+ function trySchemaClosureCall({ obj, method, parsed }) {
1987
+ if (typeof obj === 'string' && ctx.schema.slotOf && ctx.closure.call) {
1988
+ const idx = ctx.schema.slotOf(obj, method)
1989
+ if (idx >= 0) {
1990
+ const propRead = typed(ctx.abi.object.ops.load(ptrOffsetIR(asF64(emit(obj)), lookupValType(obj) || VAL.OBJECT), idx), 'f64')
1991
+ if (parsed.hasSpread && !ctx.schema.isBoxed?.(obj)) {
1992
+ const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
1993
+ return ctx.closure.call(propRead, [buildArrayWithSpreads(combined)], true)
1994
+ }
1995
+ return ctx.closure.call(propRead, parsed.normal)
1996
+ }
1997
+ }
1998
+ }
1999
+
2000
+ // 10. Generic only — but a collection emitter (`.get`/`.set`/`.has`/`.add`/
2001
+ // `.delete`) assumes a Map/Set receiver: a proven collection already
2002
+ // dispatched via `.${vt}:${method}` above, so reaching here means the
2003
+ // receiver is not a proven collection. A zero-arg call then cannot be the
2004
+ // collection op (each needs ≥1 key/value arg) — it is a user/closure
2005
+ // method (e.g. `new C().get()`). Skip the collection emitter so it falls
2006
+ // through to closure/dynamic dispatch instead of crashing on `emit(key)`.
2007
+ function tryGenericEmitter({ obj, method, parsed, vt, callMethod }) {
2008
+ const collectionMisfit = COLLECTION_METHODS.has(method) &&
2009
+ !parsed.hasSpread && parsed.normal.length === 0
2010
+ const strIndexMisfit = STR_INDEX_METHODS.has(method) &&
2011
+ !parsed.hasSpread && parsed.normal.length > 1
2012
+ // A proven plain-object/dict receiver never inherits the Array/collection
2013
+ // builtins these generic emitters serve — an own property of the same name
2014
+ // shadows them (ES prototype semantics). Skip the builtin so the dynamic
2015
+ // property-call dispatch below reads the actual slot/sidecar closure. This
2016
+ // is the type-based generalization of the collection/strIndex arity guards
2017
+ // above: it is what lets self-host user methods whose names collide with
2018
+ // builtins — `ctx.schema.slotOf(o,p)`, `node.map(...)`, `s.get(k)` — dispatch
2019
+ // correctly instead of being hijacked by `Array.prototype.{find,map,…}`.
2020
+ const objectShadow = vt === VAL.OBJECT || vt === VAL.HASH
2021
+ if (ctx.core.emit[`.${method}`] && !collectionMisfit && !strIndexMisfit && !objectShadow) {
2022
+ return callMethod(obj, ctx.core.emit[`.${method}`])
2023
+ }
2024
+ }
2025
+
2026
+ // 11. Dynamic property function call on non-external values. Two emission shapes:
2027
+ // (1) closure-only fork — receiver carries no PTR.EXTERNAL (sidecar-bearing static
2028
+ // types OR wasi target, where __ext_call doesn't exist); and (2) full fork
2029
+ // adding a PTR.EXTERNAL → __ext_call leg for opaque js receivers.
2030
+ function tryDynamicPropCall({ obj, method, parsed, vt }) {
2031
+ if (ctx.closure.call) {
2032
+ if (ctx.transform.strict)
2033
+ err(`strict mode: method call \`${typeof obj === 'string' ? obj : '<expr>'}.${method}(...)\` on a value of unknown type pulls dynamic dispatch stdlib. Annotate the receiver type or pass { strict: false }.`)
2034
+ const objTmp = temp('mobj')
2035
+ const propTmp = temp('mprop')
2036
+ const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2037
+ const arrayIR = buildArrayWithSpreads(combined)
2038
+ const propRead = typed(['f64.reinterpret_i64', ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], asI64(emit(['str', method]))]], 'f64')
2039
+ const closureOnly = usesDynProps(vt) || ctx.transform.host === 'wasi'
2040
+ inc('__dyn_get_expr', '__ptr_type')
2041
+ if (!closureOnly) { inc('__ext_call'); ctx.features.external = true }
2042
+ const extFallback = closureOnly ? undefExpr()
2043
+ : ['if', ['result', 'f64'],
2044
+ ptrTypeEq(['local.get', `$${objTmp}`], PTR.EXTERNAL),
2045
+ ['then', ['f64.reinterpret_i64', ['call', '$__ext_call',
2046
+ ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]],
2047
+ ['i64.reinterpret_f64', asF64(emit(['str', method]))],
2048
+ ['i64.reinterpret_f64', arrayIR]]]],
2049
+ ['else', undefExpr()]]
2050
+ return block64(
2051
+ ['local.set', `$${objTmp}`, asF64(emit(obj))],
2052
+ ['local.set', `$${propTmp}`, propRead],
2053
+ ['if', ['result', 'f64'],
2054
+ ptrTypeEq(['local.get', `$${propTmp}`], PTR.CLOSURE),
2055
+ ['then', ctx.closure.call(typed(['local.get', `$${propTmp}`], 'f64'), [arrayIR], true)],
2056
+ ['else', extFallback]])
2057
+ }
2058
+ }
2059
+
2060
+ // 12. Unknown callee — assume external method. Total: always returns.
2061
+ function externalMethodFallback({ obj, method, parsed }) {
2062
+ if (ctx.transform.strict)
2063
+ err(`strict mode: method call \`${typeof obj === 'string' ? obj : '<expr>'}.${method}(...)\` on a value of unknown type falls through to host \`__ext_call\`. Annotate the receiver type or pass { strict: false }.`)
2064
+ // Under wasi there is no host `__ext_call` — the call lowers to a
2065
+ // no-op returning `undefined`. This is by-design so polymorphic code
2066
+ // can target js and wasi from one source; users who want fail-fast
2067
+ // pass `strict: true` (handled above).
2068
+ if (ctx.transform.host === 'wasi') return undefExpr()
2069
+ warnDeopt('deopt-method', `method call \`${typeof obj === 'string' ? obj : '<expr>'}.${method}(…)\` on a value whose type couldn't be resolved dispatches through the JS host (\`__ext_call\`) — a wasm→JS round-trip per call, orders of magnitude slower than a direct call. Restructure so the receiver's type is provable, or keep it off the hot path.`)
2070
+ inc('__ext_call')
2071
+ ctx.features.external = true
2072
+ const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2073
+ const arrayIR = buildArrayWithSpreads(combined)
2074
+ return typed(['f64.reinterpret_i64', ['call', '$__ext_call',
2075
+ ['i64.reinterpret_f64', asF64(emit(obj))],
2076
+ ['i64.reinterpret_f64', asF64(emit(['str', method]))],
2077
+ ['i64.reinterpret_f64', arrayIR]]], 'f64')
2078
+ }
2079
+
2080
+ const TYPED_STRATEGIES = [
2081
+ tryBoxedDelegate, trySidecarToPrimitive, tryStaticDispatch, tryRuntimeStringFork,
2082
+ tryRuntimeNumberMethod, trySchemaClosureCall, tryGenericEmitter, tryDynamicPropCall,
2083
+ externalMethodFallback,
2084
+ ]
2085
+
2086
+ /** Method-call dispatch: `obj.method(args)`. Linear strategy chain, first
2087
+ * match wins. 1–4 are context-free (LEADING_STRATEGIES); 5–12 share the
2088
+ * resolved receiver type + callMethod shim via the dispatch-context record
2089
+ * (TYPED_STRATEGIES — the last entry is total):
2090
+ * 1. SRoA flat-object method (read closure from `o#i` local)
2091
+ * 2. charCodeAt with statically-proven in-bounds index → i32 fast path
2092
+ * 3. splice with insert items → __arr_splice (the one method that delete+insert)
2093
+ * 4. fn.prop direct call to fn$prop (skipped for reassigned wrapper-composition)
2094
+ * 5. Boxed-schema receiver → delegate to inner value at slot 0 (+ writeback)
2095
+ * 6. valueOf / toString — sidecar own-property shadow check
2096
+ * 7. Known-type static dispatch via .${vt}:${method}
2097
+ * 8. Unknown / guessed-ARRAY runtime ptr-type fork over string vs generic
2098
+ * 9. Schema property closure call
2099
+ * 10. Generic emitter (with collection/strIndex arity guards + object shadow)
2100
+ * 11. Dynamic property closure call (with PTR.EXTERNAL fallback if non-wasi)
2101
+ * 12. External method fallback via __ext_call (or undefined under wasi)
2102
+ */
2103
+ function emitMethodCall(callee, parsed, callArgs) {
2104
+ const [, obj, method] = callee
2105
+
2106
+ // Strategies 1–4 (context-free, order-sensitive, first match wins).
2107
+ for (const strategy of LEADING_STRATEGIES) {
2108
+ const r = strategy(callee, obj, method, parsed)
2109
+ if (r !== undefined) return r
2110
+ }
2111
+
2112
+ let vt = valTypeOf(obj)
2113
+ // A reassigned slice/concat receiver may carry a stale `vt` — a reassignment
2114
+ // inside a nested closure escapes analyzeValTypes' poisoning (its walk stops
2115
+ // at `=>`). Drop to runtime dispatch, but only for guessy types: STRING/ARRAY
2116
+ // dispatch correctly either way, and BUFFER/TYPED are construction proofs
2117
+ // (`new ArrayBuffer`/`new XxxArray`) — the runtime String/Array fallback has
2118
+ // no branch for them, so nulling `vt` would miscompile `ab.slice()` into an
2119
+ // f64-array copy. jzify also splits every `var x = init` into `let x; x = init`,
2120
+ // marking single-assignment vars "reassigned"; keeping definite BUFFER/TYPED
2121
+ // is what keeps `var`-declared buffers correct.
2122
+ if (typeof obj === 'string' && isReassigned(ctx.func.body, obj)
2123
+ && (method === 'slice' || method === 'concat')
2124
+ && vt !== VAL.STRING && vt !== VAL.ARRAY
2125
+ && vt !== VAL.BUFFER && vt !== VAL.TYPED) vt = null
2126
+
2127
+ // Method-emitter shim — threads parsed/method through the shared dispatcher so
2128
+ // strategies keep the simple `callMethod(receiver, emitter)` shape.
2129
+ const c = {
2130
+ obj, method, parsed, vt,
2131
+ callMethod: (objArg, methodEmitter) => emitMethodCallSpread(objArg, methodEmitter, parsed, method),
2132
+ }
2133
+ for (const strategy of TYPED_STRATEGIES) {
2134
+ const r = strategy(c)
2135
+ if (r !== undefined) return r
2136
+ }
2137
+ }
2138
+
2139
+ /** Builtin / module-emitter call: `Math.max(...)`, `JSON.parse(...)`, etc. The
2140
+ * emitter accepts the same `...args` flat shape as the AST (with `['...', x]`
2141
+ * spread markers re-inserted in original position). */
2142
+ function emitBuiltinCall(callee, parsed) {
2143
+ if (parsed.hasSpread) {
2144
+ const allArgs = []
2145
+ let ni = 0
2146
+ for (const s of parsed.spreads) {
2147
+ while (ni < s.pos) allArgs.push(parsed.normal[ni++])
2148
+ allArgs.push(['...', s.expr])
2149
+ }
2150
+ while (ni < parsed.normal.length) allArgs.push(parsed.normal[ni++])
2151
+ return ctx.core.emit[callee](...allArgs)
2152
+ }
2153
+ return ctx.core.emit[callee](...parsed.normal)
2154
+ }
2155
+
2156
+ /** Direct call to a known top-level user function — emits `(call $callee args)`.
2157
+ * Handles rest params (collect into trailing array), in-spread fixed params
2158
+ * (runtime split), default-param padding, multi-value return materialization. */
2159
+ function emitDirectFunctionCall(callee, parsed, callArgs) {
2160
+ const func = ctx.func.map.get(callee)
2161
+
2162
+ // Rest param case: collect all args (including expanded spreads) into array
2163
+ if (func?.rest) {
2164
+ const fixedParamCount = func.sig.params.length - 1
2165
+ // A spread positioned within the fixed-param range supplies fixed params from
2166
+ // inside the spread — they can't be sliced out statically. Build the full args
2167
+ // array A and split it at runtime: fixed[k] = A[k], rest = A.slice(fixedParamCount).
2168
+ // (Otherwise the static slice below is exact and skips the extra alloc + copy.)
2169
+ if (fixedParamCount > 0 && parsed.spreads.some(s => s.pos < fixedParamCount)) {
2170
+ const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2171
+ const aVal = temp('ra'), aOff = tempI32('rao'), aLen = tempI32('ral'), rLen = tempI32('rln')
2172
+ const rest = allocPtr({ type: PTR.ARRAY, len: ['local.get', `$${rLen}`], tag: 'rr' })
2173
+ const fixedLoads = []
2174
+ for (let k = 0; k < fixedParamCount; k++) {
2175
+ const load = typed(['if', ['result', 'f64'],
2176
+ ['i32.gt_s', ['local.get', `$${aLen}`], ['i32.const', k]],
2177
+ ['then', ['f64.load', ['i32.add', ['local.get', `$${aOff}`], ['i32.const', k * 8]]]],
2178
+ ['else', undefExpr()]], 'f64')
2179
+ fixedLoads.push(coerceArg(load, func.sig.params[k]))
2180
+ }
2181
+ const callIR = typed(['block', ['result', func.sig.results[0]],
2182
+ ['local.set', `$${aVal}`, asF64(buildArrayWithSpreads(combined))],
2183
+ ['local.set', `$${aOff}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${aVal}`]]]],
2184
+ ['local.set', `$${aLen}`, ['i32.load', ['i32.sub', ['local.get', `$${aOff}`], ['i32.const', 8]]]],
2185
+ ['local.set', `$${rLen}`, ['select',
2186
+ ['i32.sub', ['local.get', `$${aLen}`], ['i32.const', fixedParamCount]],
2187
+ ['i32.const', 0],
2188
+ ['i32.gt_s', ['local.get', `$${aLen}`], ['i32.const', fixedParamCount]]]],
2189
+ rest.init,
2190
+ ['memory.copy', ['local.get', `$${rest.local}`],
2191
+ ['i32.add', ['local.get', `$${aOff}`], ['i32.const', fixedParamCount * 8]],
2192
+ ['i32.shl', ['local.get', `$${rLen}`], ['i32.const', 3]]],
2193
+ ['call', `$${callee}`, ...fixedLoads, rest.ptr]], func.sig.results[0])
2194
+ return attachSigMeta(callIR, func.sig)
2195
+ }
2196
+ // Pad missing fixed args with `undefined` so default-param init triggers per spec.
2197
+ const fixedParams = func.sig.params.slice(0, fixedParamCount)
2198
+ const emittedFixed = emitCallArgs(parsed.normal.slice(0, fixedParamCount), fixedParams)
2199
+
2200
+ // Reconstruct with spreads, then take rest args
2201
+ const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2202
+ const restArgsFinal = combined.slice(fixedParamCount)
2203
+
2204
+ // Build array: emit code for normal args + code to expand spreads
2205
+ const arrayIR = buildArrayWithSpreads(restArgsFinal)
2206
+ return attachSigMeta(typed(['call', `$${callee}`, ...emittedFixed, arrayIR], func.sig.results[0]), func.sig)
2207
+ }
2208
+
2209
+ // Regular function call without rest params
2210
+ if (parsed.hasSpread) err(`Spread not supported in calls to non-variadic function ${callee}`)
2211
+ // Pad missing args with `undefined` so default-param init triggers per spec
2212
+ // (only undefined, not null, should trigger defaults). Drop extras to match
2213
+ // JS calling convention — emitting them anyway produces an invalid call
2214
+ // when the callee is a fixed-arity import (e.g. `_interp`-registered host
2215
+ // stubs) since wasm validates arg count. Use ?? rather than || so a
2216
+ // legitimate 0-arity callee isn't bypassed.
2217
+ const params = func?.sig.params ?? []
2218
+ const args = func ? emitCallArgs(parsed.normal, params)
2219
+ : parsed.normal.map(a => coerceArg(emit(a), undefined))
2220
+ if (func && args.length > params.length) args.length = params.length
2221
+ // Multi-value return: materialize as heap array (caller expects single pointer).
2222
+ // Reuse the canonical comma-wrapped arg slot — materializeMulti re-reads args
2223
+ // via commaList(node[2]); a spread-form `[…, ...parsed.normal]` would drop every
2224
+ // argument past the first.
2225
+ if (func?.sig.results.length > 1) return materializeMulti(['()', callee, callArgs])
2226
+ // attachSigMeta also handles the unsigned-uint32 flag (every tail was `>>>`),
2227
+ // so consumer's asF64 uses `f64.convert_i32_u` instead of `_s` ([0, 2^32) range).
2228
+ const callIR = attachSigMeta(typed(['call', `$${callee}`, ...args], func?.sig.results[0] || 'f64'), func?.sig)
2229
+ return callIR
2230
+ }
2231
+
2232
+ /** Const-bound, non-escaping closure — direct call to its body, skipping
2233
+ * call_indirect. emitDecl registered name→bodyName when it saw the closure.make
2234
+ * IR. Returns null if arity exceeds the closure-table slot width (caller falls
2235
+ * through to the generic closure path). */
2236
+ function tryDirectClosureCall(callee, parsed) {
2237
+ const bodyName = ctx.func.directClosures.get(callee)
2238
+ const W = ctx.closure.width ?? MAX_CLOSURE_ARITY
2239
+ const n = parsed.normal.length
2240
+ if (n > W) return null
2241
+ // Per-param "every direct call site passed a number" lattice. Every call to a
2242
+ // direct (non-escaping) closure flows through here, so once the body is emitted
2243
+ // (module end, after all calls) a param only ever seen with numeric args is marked
2244
+ // VAL.NUMBER — its body uses then skip __to_num, the same boxing win the numeric
2245
+ // export-param path gives. An arg we can't prove numeric poisons the slot to false.
2246
+ const pt = (ctx.closure.paramTypes ||= new Map())
2247
+ let row = pt.get(bodyName); if (!row) pt.set(bodyName, row = [])
2248
+ // Parallel typed-array ctor lattice: a param passed the SAME typed-array ctor at
2249
+ // every direct call site is a TYPED param, so its body reads (`buf[i]`) take the
2250
+ // typed fast-path instead of the dynamic `__typed_idx`/`__len` route that drags in
2251
+ // the string runtime. `null` (sticky) once two sites disagree or an arg isn't a
2252
+ // known typed array — the same monotone meet as the numeric row. Mirrors the named-fn
2253
+ // applyTypedPointerParamAbi, restricted to non-escaping (directly-called) closures.
2254
+ const tc = (ctx.closure.paramTypedCtors ||= new Map())
2255
+ let tcRow = tc.get(bodyName); if (!tcRow) tc.set(bodyName, tcRow = [])
2256
+ for (let i = 0; i < n; i++) {
2257
+ const numeric = valTypeOf(parsed.normal[i]) === VAL.NUMBER
2258
+ row[i] = row[i] === undefined ? numeric : (row[i] && numeric)
2259
+ const arg = parsed.normal[i]
2260
+ const ctor = typeof arg === 'string' && valTypeOf(arg) === VAL.TYPED ? (ctx.types.typedElem?.get(arg) ?? null) : null
2261
+ if (tcRow[i] === undefined) tcRow[i] = ctor
2262
+ else if (tcRow[i] !== ctor) tcRow[i] = null
2263
+ }
2264
+ // Track the fewest args any call passed: a slot at index ≥ minArgc is omitted by some call
2265
+ // site (padded with UNDEF_NAN), so it may be undefined — emitClosureBody flags it nullable.
2266
+ const mn = (ctx.closure.minArgc ||= new Map())
2267
+ const prev = mn.get(bodyName)
2268
+ mn.set(bodyName, prev === undefined ? n : (n < prev ? n : prev))
2269
+ // Body signature is uniform $ftN: (env f64, argc i32, a0..a{W-1} f64) → f64.
2270
+ // We pass the closure NaN-box itself as env (body extracts captures via __ptr_offset(__env)).
2271
+ const slots = parsed.normal.map(a => asF64(emit(a)))
2272
+ while (slots.length < W) slots.push(undefExpr())
2273
+ return typed(['call', `$${bodyName}`,
2274
+ asF64(emit(callee)),
2275
+ typed(['i32.const', n], 'i32'),
2276
+ ...slots], 'f64')
2277
+ }
2278
+
2279
+ /** Generic closure call: callee is a value holding a NaN-boxed closure pointer.
2280
+ * Uniform convention: fn.call packs all args into an array and trampolines. */
2281
+ function emitGenericClosureCall(callee, parsed) {
2282
+ if (parsed.hasSpread) {
2283
+ const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2284
+ const arrayIR = buildArrayWithSpreads(combined)
2285
+ // Pass pre-built array as single already-emitted arg
2286
+ return ctx.closure.call(emit(callee), [arrayIR], true)
2287
+ }
2288
+ return ctx.closure.call(emit(callee), parsed.normal)
2289
+ }
2290
+
2291
+ /** Last-resort fallback: assume `(call $callee args)` against an import / unknown
2292
+ * identifier. Matches arg count to the env-import signature when known — wasm
2293
+ * validates arity strictly, so JS-style "pad missing / drop extra" needs to be
2294
+ * done here rather than by the host. */
2295
+ function emitUnknownCalleeCall(callee, argList) {
2296
+ let calleeArity = null
2297
+ if (typeof callee === 'string') {
2298
+ const imp = ctx.module.imports?.find(i =>
2299
+ Array.isArray(i) && i[0] === 'import' && i[3]?.[0] === 'func' && i[3]?.[1] === `$${callee}`)
2300
+ if (imp) {
2301
+ let n = 0
2302
+ for (let k = 2; k < imp[3].length; k++) if (Array.isArray(imp[3][k]) && imp[3][k][0] === 'param') n++
2303
+ calleeArity = n
2304
+ }
2305
+ }
2306
+ const emittedArgs = argList.map(a => asF64(emit(a)))
2307
+ if (calleeArity != null) {
2308
+ while (emittedArgs.length < calleeArity) emittedArgs.push(undefExpr())
2309
+ if (emittedArgs.length > calleeArity) emittedArgs.length = calleeArity
2310
+ }
2311
+ return typed(['call', `$${callee}`, ...emittedArgs], 'f64')
2312
+ }
2313
+
2314
+ /** Compound assignment: read → op → write back (via readVar/writeVar). */
2315
+ function compoundAssign(name, val, f64op, i32op) {
2316
+ if (typeof name === 'string' && isConst(name)) err(`Assignment to const '${name}'`)
2317
+ const void_ = ctx.func._expect === 'void'
2318
+ const va = readVar(name), vb = emit(val)
2319
+ // Peel f64.convert_i32_s/u when va is i32 — typed-array integer reads wrap their
2320
+ // i32.load in convert_i32_* by default, but the i32 arithmetic path can use the
2321
+ // raw i32 directly (eliminates per-iter widen + saturating-trunc roundtrip on
2322
+ // hot accumulator loops like `let s = 0; for (...) s += i32arr[i]`).
2323
+ let vbi = vb
2324
+ if (i32op && va.type === 'i32' && vb.type !== 'i32' &&
2325
+ Array.isArray(vb) && (vb[0] === 'f64.convert_i32_s' || vb[0] === 'f64.convert_i32_u')) {
2326
+ const inner = vb[1]
2327
+ vbi = Array.isArray(inner) ? typed(inner, 'i32') : inner
2328
+ }
2329
+ if (i32op && va.type === 'i32' && vbi.type === 'i32')
2330
+ return writeVar(name, i32op(va, vbi), void_)
2331
+ return writeVar(name, f64op(asF64(va), asF64(vb)), void_)
2332
+ }
2333
+
2334
+ // === Core emitter dispatch table ===
2335
+ // ctx.core.emit is seeded with a flat copy of this object on reset;
2336
+ // language modules add or override ops on ctx.core.emit directly.
2337
+
2338
+ /**
2339
+ * Core emitter table. Maps AST ops to WASM IR generators.
2340
+ * @type {Record<string, (...args: any[]) => Array>}
2341
+ */
2342
+ export const emitter = {
2343
+ // === Spread operator ===
2344
+ // Note: spread is handled specially in call contexts; this catches stray uses
2345
+ '...': () => err('Spread (...) can only be used in function/method calls or array literals'),
2346
+
2347
+ // === Statements ===
2348
+
2349
+ ';': (...args) => {
2350
+ const out = []
2351
+ for (const a of args) {
2352
+ out.push(...emitVoid(a))
2353
+ }
2354
+ return out
2355
+ },
2356
+ '{': (...args) => args.map(emit).filter(x => x != null),
2357
+ ',': (...args) => {
2358
+ const results = args.map(emit).filter(x => x != null)
2359
+ if (results.length === 0) return null
2360
+ if (results.length === 1) return results[0]
2361
+ const last = results[results.length - 1]
2362
+ // Flatten: multi-instruction arrays (from ';') need spreading, typed nodes need drop
2363
+ const spread = r => Array.isArray(r) && Array.isArray(r[0]) ? r : [r]
2364
+ const dropSpread = r => r.type ? [['drop', r]] : spread(r)
2365
+ // If last expression is void (store, etc.), add explicit return value
2366
+ if (!last.type) {
2367
+ return block64(
2368
+ ...results.flatMap(dropSpread),
2369
+ ['f64.const', 0])
2370
+ }
2371
+ return typed(['block', ['result', last.type],
2372
+ ...results.slice(0, -1).flatMap(dropSpread), last], last.type)
2373
+ },
2374
+ 'let': emitDecl,
2375
+ 'const': emitDecl,
2376
+ 'export': () => null,
2377
+ // 'block' can appear from jzify transforming labeled blocks or as WASM block IR
2378
+ 'block': (...args) => {
2379
+ // WASM block IR: first arg is ['result', type] → pass through, preserve type
2380
+ if (Array.isArray(args[0]) && args[0][0] === 'result')
2381
+ return typed(['block', ...args], args[0][1])
2382
+ const inner = args.length === 1 ? args[0] : [';', ...args]
2383
+ return emitVoid(['{}', inner])
2384
+ },
2385
+
2386
+ 'throw': expr => {
2387
+ ctx.runtime.throws = ctx.runtime.userThrows = true
2388
+ const thrown = temp()
2389
+ return typed(['block',
2390
+ ['local.set', `$${thrown}`, asF64(emit(expr))],
2391
+ ['global.set', '$__jz_last_err_bits', ['i64.reinterpret_f64', ['local.get', `$${thrown}`]]],
2392
+ ['throw', '$__jz_err', ['local.get', `$${thrown}`]]], 'void')
2393
+ },
2394
+
2395
+ 'catch': (body, errName, handler) => {
2396
+ if (!canThrow(body)) return emitVoid(body)
2397
+
2398
+ ctx.runtime.throws = ctx.runtime.userThrows = true
2399
+ const id = ctx.func.uniq++
2400
+ ctx.func.locals.set(errName, 'f64')
2401
+ const prev = ctx.func.inTry; ctx.func.inTry = true
2402
+ let bodyIR; try { bodyIR = emitVoid(body) } finally { ctx.func.inTry = prev }
2403
+ const handlerIR = emitVoid(handler)
2404
+ return typed(['block', `$outer${id}`, ['result', 'f64'],
2405
+ ['block', `$catch${id}`, ['result', 'f64'],
2406
+ ['try_table', ['catch', '$__jz_err', `$catch${id}`],
2407
+ ...bodyIR],
2408
+ ['f64.const', 0],
2409
+ ['br', `$outer${id}`]],
2410
+ ['local.set', `$${errName}`],
2411
+ ...handlerIR,
2412
+ ['f64.const', 0]], 'f64')
2413
+ },
2414
+
2415
+ 'finally': (body, cleanup) => {
2416
+ if (!canThrow(body)) {
2417
+ const parentStack = ctx.func.finallyStack || []
2418
+ const activeStack = parentStack.concat([cleanup])
2419
+ const bodyIR = withFinallyStack(activeStack, () => emitVoid(body))
2420
+ const cleanupIR = isTerminator(body) ? [] : withFinallyStack(parentStack, () => emitVoid(cleanup))
2421
+ return [...bodyIR, ...cleanupIR]
2422
+ }
2423
+
2424
+ ctx.runtime.throws = ctx.runtime.userThrows = true
2425
+ const id = ctx.func.uniq++
2426
+ const errLocal = temp('err')
2427
+ const parentStack = ctx.func.finallyStack || []
2428
+ const activeStack = parentStack.concat([cleanup])
2429
+
2430
+ const prevTry = ctx.func.inTry
2431
+ ctx.func.inTry = true
2432
+ const bodyIR = withFinallyStack(activeStack, () => {
2433
+ try { return emitVoid(body) }
2434
+ finally { ctx.func.inTry = prevTry }
2435
+ })
2436
+ const normalCleanup = withFinallyStack(parentStack, () => emitVoid(cleanup))
2437
+ const throwCleanup = withFinallyStack(parentStack, () => emitVoid(cleanup))
2438
+
2439
+ return ['block', `$fin_done${id}`,
2440
+ ['block', `$fin_catch${id}`, ['result', 'f64'],
2441
+ ['try_table', ['catch', '$__jz_err', `$fin_catch${id}`],
2442
+ ...bodyIR],
2443
+ ...normalCleanup,
2444
+ ['br', `$fin_done${id}`]],
2445
+ ['local.set', `$${errLocal}`],
2446
+ ...throwCleanup,
2447
+ ['global.set', '$__jz_last_err_bits', ['i64.reinterpret_f64', ['local.get', `$${errLocal}`]]],
2448
+ ['throw', '$__jz_err', ['local.get', `$${errLocal}`]]]
2449
+ },
2450
+
2451
+ 'return': expr => {
2452
+ const finalizers = emitFinalizers()
2453
+ const finalizerBlock = () => [['block', ...finalizers]]
2454
+ if (ctx.func.current?.results.length > 1 && Array.isArray(expr) && expr[0] === '[') {
2455
+ const vals = expr.slice(1).map(e => asF64(emit(e)))
2456
+ if (finalizers.length === 0) return typed(['return', ...vals], 'void')
2457
+ const names = vals.map(() => temp('ret'))
2458
+ return [
2459
+ ...vals.map((v, i) => ['local.set', `$${names[i]}`, v]),
2460
+ ...finalizerBlock(),
2461
+ typed(['return', ...names.map(n => ['local.get', `$${n}`])], 'void'),
2462
+ ]
2463
+ }
2464
+ // A value-less `return;` yields `undefined` per spec (not null). The function
2465
+ // result is never i32-narrowed when a bare return is present (see hasBareReturn
2466
+ // guard in narrowI32Results), so the f64 UNDEF carrier is type-compatible.
2467
+ if (expr == null) return [...finalizers, typed(['return', undefExpr()], 'void')]
2468
+ const rt = ctx.func.current?.results[0] || 'f64'
2469
+ const pk = ctx.func.current?.ptrKind
2470
+ const ir = pk != null ? asPtrOffset(emit(expr), pk) : asParamType(emit(expr), rt)
2471
+ const ty = pk != null ? 'i32' : rt
2472
+ const tcoed = tcoTailRewrite(ir, ty)
2473
+ if (Array.isArray(tcoed) && tcoed[0] === 'return_call' && finalizers.length === 0) {
2474
+ return typed(tcoed, 'void')
2475
+ }
2476
+ if (finalizers.length > 0) {
2477
+ const name = ty === 'i32' ? tempI32('ret') : ty === 'i64' ? tempI64('ret') : temp('ret')
2478
+ return [
2479
+ ['local.set', `$${name}`, tcoed],
2480
+ ...finalizerBlock(),
2481
+ typed(['return', ['local.get', `$${name}`]], 'void'),
2482
+ ]
2483
+ }
2484
+ return typed(['return', tcoed], 'void')
2485
+ },
2486
+
2487
+ // === Assignment ===
2488
+
2489
+ '=': (name, val) => {
2490
+ if (typeof name === 'string' && isConst(name)) err(`Assignment to const '${name}'`)
2491
+ if (Array.isArray(name) && name[0] === '[]') return emitElementAssign(name[1], name[2], val)
2492
+ if (Array.isArray(name) && name[0] === '.') return emitPropertyAssign(name[1], name[2], val)
2493
+ if (typeof name !== 'string') err(`Assignment to non-variable: ${JSON.stringify(name)}`)
2494
+ if (isNullishLit(val)) ctx.func.maybeNullish?.add(name) // null-flow: later arithmetic on this var coerces
2495
+ const void_ = ctx.func._expect === 'void'
2496
+ if (Array.isArray(val) && val[0] === 'u+' && val[1] === name) {
2497
+ inc('__to_num')
2498
+ return writeVar(name, typed(['call', '$__to_num', asI64(emit(name))], 'f64'), void_)
2499
+ }
2500
+ return writeVar(name, emit(val), void_)
2501
+ },
2502
+
2503
+ // Compound assignments: read-modify-write with type coercion
2504
+ '+=': (name, val) => {
2505
+ // Complex LHS (obj.prop, arr[i]) → desugar to side-effect-safe `name = name + val`
2506
+ if (typeof name !== 'string') return emit(['=', name, ['+', name, val]])
2507
+ // String concatenation: desugar to name = name + val (+ handler knows about strings).
2508
+ // Also desugar when either side has unknown type — the `+` operator picks runtime
2509
+ // string/numeric dispatch (`__is_str_key`); compoundAssign would force f64.add and
2510
+ // silently corrupt string concatenations through unknown-typed values.
2511
+ const vt = typeof name === 'string' ? valTypeOf(name) : null
2512
+ const vtB = valTypeOf(val)
2513
+ if (vt === VAL.STRING || vtB === VAL.STRING) return emit(['=', name, ['+', name, val]])
2514
+ if ((vt == null || vtB == null) && ctx.core.stdlib['__str_concat']) return emit(['=', name, ['+', name, val]])
2515
+ return compoundAssign(name, val, (a, b) => typed(['f64.add', a, b], 'f64'), (a, b) => typed(['i32.add', a, b], 'i32'))
2516
+ },
2517
+ ...Object.fromEntries([
2518
+ ['-=', 'sub'], ['*=', 'mul'], ['/=', 'div'],
2519
+ ].map(([op, fn]) => [op, (name, val) => {
2520
+ if (typeof name !== 'string') return emit(['=', name, [op.slice(0, -1), name, val]])
2521
+ return compoundAssign(name, val,
2522
+ (a, b) => typed([`f64.${fn}`, a, b], 'f64'),
2523
+ fn === 'div' ? null : (a, b) => typed([`i32.${fn}`, a, b], 'i32')
2524
+ )
2525
+ }])),
2526
+ '%=': (name, val) => {
2527
+ if (typeof name !== 'string') return emit(['=', name, ['%', name, val]])
2528
+ return compoundAssign(name, val, f64rem, (a, b) => typed(['i32.rem_s', a, b], 'i32'))
2529
+ },
2530
+
2531
+ // Bitwise compound assignments: i32 normally, i64 when either operand is BigInt
2532
+ ...Object.fromEntries([
2533
+ ['&=', 'and'], ['|=', 'or'], ['^=', 'xor'],
2534
+ ['>>=', 'shr_s'], ['<<=', 'shl'], ['>>>=', 'shr_u'],
2535
+ ].map(([op, fn]) => [op, (name, val) => {
2536
+ if (typeof name !== 'string') return emit(['=', name, [op.slice(0, -1), name, val]])
2537
+ if (valTypeOf(name) === VAL.BIGINT || valTypeOf(val) === VAL.BIGINT) {
2538
+ const void_ = ctx.func._expect === 'void'
2539
+ const result = fromI64([`i64.${fn}`, asI64(readVar(name)), asI64(emit(val))])
2540
+ return writeVar(name, result, void_)
2541
+ }
2542
+ return compoundAssign(name, val,
2543
+ (a, b) => asF64(typed([`i32.${fn}`, toI32(a), toI32(b)], 'i32')),
2544
+ (a, b) => typed([`i32.${fn}`, a, b], 'i32')
2545
+ )
2546
+ }])),
2547
+
2548
+ // Logical compound assignments: a ||= b → a = a || b, a &&= b → a = a && b
2549
+ // Logical/nullish compound assignments: read → check → conditionally write
2550
+ // For complex LHS (obj.prop, arr[i]): emit as check(read(lhs)) ? write(lhs, val) : read(lhs)
2551
+ ...Object.fromEntries(['||=', '&&=', '??='].map(op => [op, (name, val) => {
2552
+ // Complex LHS → desugar (side-effect-safe since obj/arr/idx are locals)
2553
+ if (typeof name !== 'string') {
2554
+ const baseOp = op.slice(0, -1) // '||', '&&', '??'
2555
+ return emit([baseOp, name, ['=', name, val]])
2556
+ }
2557
+ if (isConst(name)) err(`Assignment to const '${name}'`)
2558
+ const void_ = ctx.func._expect === 'void'
2559
+ const t = temp()
2560
+ const va = readVar(name)
2561
+ // Condition: ||= → truthy check, &&= → truthy check, ??= → nullish check
2562
+ const lhs = typed(['local.tee', `$${t}`, asF64(va)], 'f64')
2563
+ const cond = op === '??=' ? isNullish(lhs) : truthyIR(lhs)
2564
+ // &&= and ??= assign when cond is true (truthy / nullish); ||= assigns when cond is false
2565
+ const [thenExpr, elseExpr] = op === '||='
2566
+ ? [['local.get', `$${t}`], asF64(emit(val))]
2567
+ : [asF64(emit(val)), ['local.get', `$${t}`]]
2568
+ const result = typed(['if', ['result', 'f64'], cond, ['then', thenExpr], ['else', elseExpr]], 'f64')
2569
+ // Write back — writeVar owns the cell/global/local discipline INCLUDING the
2570
+ // i32-narrowed-cell width (a direct f64.store here desynced narrowed cells).
2571
+ return writeVar(name, result, void_)
2572
+ }])),
2573
+
2574
+ // === Increment/Decrement ===
2575
+ // Postfix resolved in prepare: i++ → (++i) - 1
2576
+
2577
+ ...Object.fromEntries([['++', 'add'], ['--', 'sub']].map(([op, fn]) => [op, name => {
2578
+ if (typeof name === 'string' && isConst(name)) err(`Assignment to const '${name}'`)
2579
+ const void_ = ctx.func._expect === 'void'
2580
+ const v = readVar(name)
2581
+ const one = v.type === 'i32' ? ['i32.const', 1] : ['f64.const', 1]
2582
+ return writeVar(name, typed([`${v.type}.${fn}`, v, one], v.type), void_)
2583
+ }])),
2584
+
2585
+ // === Arithmetic (type-preserving) ===
2586
+
2587
+ // Postfix in void: (++i)-1 / (--i)+1 → just ++i / --i
2588
+ '+': (a, b) => {
2589
+ if (ctx.func._expect === 'void' && isPostfix(a, '--', b)) return emit(a, 'void')
2590
+ // String concatenation: pure string operands skip generic ToString coercion.
2591
+ const vtA = valTypeOf(a)
2592
+ const vtB = valTypeOf(b)
2593
+ if (vtA === VAL.STRING && vtB === VAL.STRING) {
2594
+ // Fused append-byte: `buf += s[i]` skips 1-char SSO construction +
2595
+ // generic concat dispatch when rhs is a string-index. The byte flows
2596
+ // straight from __char_at into memory, and the bump-extend path elides
2597
+ // the alloc+copy when lhs is the heap-top STRING.
2598
+ if (Array.isArray(b) && b[0] === '[]' && ctx.core.stdlib['__str_append_byte'] && ctx.core.stdlib['__char_at']) {
2599
+ if (valTypeOf(b[1]) === VAL.STRING) {
2600
+ inc('__str_append_byte', '__char_at')
2601
+ return typed(['call', '$__str_append_byte',
2602
+ asI64(emit(a)),
2603
+ ctx.abi.string.ops.charCodeAt(asF64(emit(b[1])), asI32(emit(b[2])), ctx),
2604
+ ], 'f64')
2605
+ }
2606
+ }
2607
+ return typed(ctx.abi.string.ops.concatRaw(asF64(emit(a)), asF64(emit(b)), ctx), 'f64')
2608
+ }
2609
+ if (vtA === VAL.STRING || vtB === VAL.STRING) {
2610
+ // An OBJECT operand coerces via ToPrimitive(string) at compile time —
2611
+ // __str_concat's runtime __to_str cannot invoke a user-defined toString.
2612
+ // A BOOL operand renders "true"/"false" rather than its 0/1 carrier.
2613
+ const strOperand = (vt, n) => vt === VAL.OBJECT ? typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
2614
+ : vt === VAL.BOOL ? emitBoolStr(n) : asF64(emit(n))
2615
+ // Coercion-free sides are already strings: a known STRING is raw; OBJECT/BOOL
2616
+ // were stringified by `strOperand`. An unknown side still needs ToString, but
2617
+ // we can apply it *once* (explicit `__to_str` via `strI64`) and join with
2618
+ // concatRaw — equivalent to `__str_concat`'s internal `__to_str` on that side,
2619
+ // while NOT re-coercing the already-string side. This drops the redundant
2620
+ // per-append `__to_str` on the accumulator in `s += part` (s proven STRING):
2621
+ // - both coercion-free → concatRaw(ea, eb)
2622
+ // - one unknown → concatRaw(known, __to_str(unknown))
2623
+ // - both unknown → cat (unchanged; its runtime __to_str covers both)
2624
+ const coercionFree = (vt) => vt === VAL.STRING || vt === VAL.OBJECT || vt === VAL.BOOL
2625
+ const cfA = coercionFree(vtA), cfB = coercionFree(vtB)
2626
+ const strI64 = (n) => typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
2627
+ if (cfA && cfB) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2628
+ if (cfA) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strI64(b), ctx), 'f64')
2629
+ if (cfB) return typed(ctx.abi.string.ops.concatRaw(strI64(a), strOperand(vtB, b), ctx), 'f64')
2630
+ return typed(ctx.abi.string.ops.cat(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2631
+ }
2632
+ if (vtA === VAL.BIGINT || vtB === VAL.BIGINT)
2633
+ return fromI64(['i64.add', asI64(emit(a)), asI64(emit(b))])
2634
+ // Runtime string dispatch when at least one side could be a string. When one side has
2635
+ // a known non-STRING vtype, skip its `__is_str_key` (statically false). Common in
2636
+ // chained additions `s + a*b + c.d` — left grows as `+` (=NUMBER), only the new right
2637
+ // operand needs the runtime check.
2638
+ if ((vtA == null || vtB == null) && ctx.core.stdlib['__str_concat']) {
2639
+ const tA = temp('add'), tB = temp('add')
2640
+ inc('__str_concat', '__is_str_key')
2641
+ const checkA = vtA == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tA}`, asF64(emit(a))]]] : null
2642
+ const checkB = vtB == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tB}`, asF64(emit(b))]]] : null
2643
+ const concat = ['call', '$__str_concat', ['i64.reinterpret_f64', ['local.get', `$${tA}`]], ['i64.reinterpret_f64', ['local.get', `$${tB}`]]]
2644
+ const add = ['f64.add', ['local.get', `$${tA}`], ['local.get', `$${tB}`]]
2645
+ if (checkA && checkB) {
2646
+ return typed(['if', ['result', 'f64'], ['i32.or', checkA, checkB], ['then', concat], ['else', add]], 'f64')
2647
+ }
2648
+ // Exactly one side is checked. Pre-eval the known side first, then the if branches on the unknown.
2649
+ const preEval = vtA == null ? ['local.set', `$${tB}`, asF64(emit(b))] : ['local.set', `$${tA}`, asF64(emit(a))]
2650
+ return block64(
2651
+ preEval,
2652
+ ['if', ['result', 'f64'], checkA ?? checkB, ['then', concat], ['else', add]])
2653
+ }
2654
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a + b)
2655
+ if (_f) return _f
2656
+ // Neither side is a string here (string paths handled above), but either may
2657
+ // still be null/undefined/pointer — numeric `+` performs ToNumber like `-`/`*`.
2658
+ if (isLit(vb) && litVal(vb) === 0) return toNumF64(a, va)
2659
+ if (isLit(va) && litVal(va) === 0) return toNumF64(b, vb)
2660
+ // An `.unsigned` operand is a uint32 (range [0, 2^32)); JS `+` is a float
2661
+ // op whose result can exceed i32, so `i32.add` would wrap (4294967295+1→0).
2662
+ // Widen to f64 — never wrap — matching spec. Only `>>>0`/`|0`/imul wrap.
2663
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.add', va, vb], 'i32')
2664
+ return typed(['f64.add', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2665
+ },
2666
+ '-': (a, b) => {
2667
+ if (ctx.func._expect === 'void' && isPostfix(a, '++', b)) return emit(a, 'void')
2668
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
2669
+ return b === undefined
2670
+ ? fromI64(['i64.sub', ['i64.const', 0], asI64(emit(a))])
2671
+ : fromI64(['i64.sub', asI64(emit(a)), asI64(emit(b))])
2672
+ if (b === undefined) return emitNeg(a)
2673
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a - b)
2674
+ if (_f) return _f
2675
+ if (isLit(vb) && litVal(vb) === 0) return toNumF64(a, va)
2676
+ // Unsigned uint32 operand: JS `-` is float (can go negative / exceed i32),
2677
+ // so avoid the wrapping i32.sub fast-path. See `+` above.
2678
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.sub', va, vb], 'i32')
2679
+ return typed(['f64.sub', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2680
+ },
2681
+ 'u+': a => {
2682
+ if (valTypeOf(a) === VAL.BIGINT)
2683
+ return typed(['f64.convert_i64_s', asI64(emit(a))], 'f64')
2684
+ const v = emit(a)
2685
+ if (v.type === 'i32') return asF64(v)
2686
+ if (valTypeOf(a) === VAL.NUMBER) return toNumF64(a, v)
2687
+ inc('__to_num')
2688
+ return typed(['call', '$__to_num', asI64(v)], 'f64')
2689
+ },
2690
+ 'u-': a => emitNeg(a),
2691
+ '*': (a, b) => {
2692
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
2693
+ return fromI64(['i64.mul', asI64(emit(a)), asI64(emit(b))])
2694
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a * b)
2695
+ if (_f) return _f
2696
+ if (isLit(vb) && litVal(vb) === 1) return toNumF64(a, va)
2697
+ if (isLit(va) && litVal(va) === 1) return toNumF64(b, vb)
2698
+ // `x * 0` → 0 only when the other factor is provably finite (i32, or a finite
2699
+ // literal): JS `NaN*0` / `±Inf*0` are NaN, so a non-finite f64 must fall
2700
+ // through to `f64.mul` (which yields NaN). For finite x the dropped product is
2701
+ // ±0 — and -0 === +0, so consumers are unaffected. The block evaluates x for
2702
+ // its side effects before dropping.
2703
+ const finiteFactor = (v) => isI32Num(v) || (isLit(v) && Number.isFinite(litVal(v)))
2704
+ if (isLit(vb) && litVal(vb) === 0 && finiteFactor(va)) return isLit(va) ? vb : typed(['block', ['result', vb.type], va, 'drop', vb], vb.type)
2705
+ if (isLit(va) && litVal(va) === 0 && finiteFactor(vb)) return isLit(vb) ? va : typed(['block', ['result', va.type], vb, 'drop', va], va.type)
2706
+ // `.unsigned` operand is a uint32 ([0, 2^32)); its product can exceed i32, so
2707
+ // `i32.mul` would wrap ((2^32-1)*2 → -2). Widen to f64 — see `+` above.
2708
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb) && mulFitsI32(va, vb)) return typed(['i32.mul', va, vb], 'i32')
2709
+ return typed(['f64.mul', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2710
+ },
2711
+ '/': (a, b) => {
2712
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
2713
+ return fromI64(['i64.div_s', asI64(emit(a)), asI64(emit(b))])
2714
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a / b, b => b !== 0)
2715
+ if (_f) return _f
2716
+ if (isLit(vb) && litVal(vb) === 1) return toNumF64(a, va)
2717
+ return typed(['f64.div', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2718
+ },
2719
+ '%': (a, b) => {
2720
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
2721
+ return fromI64(['i64.rem_s', asI64(emit(a)), asI64(emit(b))])
2722
+ const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a % b, b => b !== 0)
2723
+ if (_f) return _f
2724
+ // ES remainder by zero is NaN; only the f64 path yields that (a - trunc(a/0)*0).
2725
+ // The i32.rem_s fast path traps on a zero divisor, so divert a literal-zero divisor.
2726
+ if (isLit(vb) && litVal(vb) === 0) return emitNum(NaN)
2727
+ // i32.rem_s is exact for integer operands AND fast, but it TRAPS on a zero
2728
+ // divisor where JS yields NaN. Only take it when the divisor is a literal
2729
+ // integer (necessarily nonzero — literal 0 is handled above); a runtime i32
2730
+ // divisor could be 0, so route it to f64rem (exact for in-range integers,
2731
+ // NaN for 0). The dividend may be a bare i32 or a FAITHFUL signed-convert
2732
+ // wrapper (f64.convert_i32_s X — the i32 view equals the JS value): peel it.
2733
+ // `.unsigned` operand: `i32.rem_s` reads the uint32 as a negative signed value
2734
+ // ((2^32-1)%7 → rem_s(-1,7) = -1, not 3). Widen to f64 — see `+` above.
2735
+ if (isLit(vb) && Number.isInteger(litVal(vb)) && Math.abs(litVal(vb)) < 2 ** 31 && !vb.unsigned) {
2736
+ const pa = isI32Num(va) && !va.unsigned ? va
2737
+ : Array.isArray(va) && va[0] === 'f64.convert_i32_s' && !va.unsigned
2738
+ ? (Array.isArray(va[1]) ? typed(va[1], 'i32') : va[1]) : null
2739
+ if (pa) return typed(['i32.rem_s', pa, ['i32.const', litVal(vb) | 0]], 'i32')
2740
+ }
2741
+ // Fast path: positive literal divisor → inline a - trunc(a/b) * b.
2742
+ // Exact when |a| < 2^53 × |b| (all practical audio/control-range values).
2743
+ // The full __rem handles NaN/±Inf/0 edges exactly; this avoids the call overhead.
2744
+ if (isLit(vb) && litVal(vb) > 0) {
2745
+ const fa = toNumF64(a, va), fb = toNumF64(b, vb)
2746
+ const rem = ta => typed(['f64.sub', ta, ['f64.mul', ['f64.trunc', ['f64.div', ta, fb]], fb]], 'f64')
2747
+ if (isPureIR(fa)) return rem(fa)
2748
+ return withTemp(fa, t => rem(['local.get', `$${t}`]), 'rem')
2749
+ }
2750
+ return f64rem(toNumF64(a, va), toNumF64(b, vb))
2751
+ },
2752
+ // === Comparisons (always i32 result) ===
2753
+
2754
+ '==': (a, b) => emitLooseEq(a, b, false),
2755
+ '!=': (a, b) => emitLooseEq(a, b, true),
2756
+ '===': (a, b) => emitStrictEq(a, b, false),
2757
+ '!==': (a, b) => emitStrictEq(a, b, true),
2758
+ '<': cmpOp('lt_s', 'lt', (a, b) => a < b),
2759
+ '>': cmpOp('gt_s', 'gt', (a, b) => a > b),
2760
+ '<=': cmpOp('le_s', 'le', (a, b) => a <= b),
2761
+ '>=': cmpOp('ge_s', 'ge', (a, b) => a >= b),
2762
+
2763
+ // === Logical ===
2764
+
2765
+ '!': a => {
2766
+ const v = emit(a)
2767
+ if (v.type === 'i32') return typed(['i32.eqz', v], 'i32')
2768
+ // Unboxed pointer offsets: falsy iff zero offset.
2769
+ if (v.ptrKind != null) return typed(['i32.eqz', v], 'i32')
2770
+ // Known pointer-kinded operand: `!x` is just `x is nullish` (null/undefined).
2771
+ // Excludes STRING — empty string '' is a valid (non-null) pointer but is falsy.
2772
+ // VAL.BOOL rides the 0/1 numeric carrier (not a pointer), so normalize it to
2773
+ // NUMBER and let it fall to the truthy path — `!false` must be `true`.
2774
+ const vt = numericVal(resolveValType(a, valTypeOf, lookupValType))
2775
+ if (vt && vt !== VAL.NUMBER && vt !== VAL.BIGINT && vt !== VAL.STRING) {
2776
+ return isNullish(asF64(v))
2777
+ }
2778
+ // Route through truthyIR (not a bare __is_truthy) so a NUMBER operand uses the
2779
+ // NaN-safe f64 test — `!(0/0)` must be `true` on every platform (x86's sign-set
2780
+ // NaN would read as a truthy box through the bit-based __is_truthy).
2781
+ return typed(['i32.eqz', truthyIR(v)], 'i32')
2782
+ },
2783
+
2784
+ '?:': (a, b, c) => {
2785
+ // Constant condition → emit only the live branch
2786
+ const ca = emit(a)
2787
+ if (isLit(ca)) { const v = litVal(ca); return (v !== 0 && v === v) ? emit(b) : emit(c) }
2788
+ const cond = toBoolFromEmitted(ca)
2789
+ // Flow-sensitive refinement: each arm sees narrowing consistent with `a` being truthy / falsy.
2790
+ const thenRefs = extractRefinements(a, new Map(), true)
2791
+ const elseRefs = extractRefinements(a, new Map(), false)
2792
+ const vb = withRefinements(thenRefs, b, () => emit(b))
2793
+ const vc = withRefinements(elseRefs, c, () => emit(c))
2794
+ // `cond ? 1 : 0` is the condition bit itself; `cond ? 0 : 1` its negation. `cond`
2795
+ // (truthyIR) is already canonical 0/1, so the select + two const arms collapse to
2796
+ // the bit. (Both arms are literals here, so dropping their emitted IR is side-effect
2797
+ // free.) Mirrors what `+(x > 0)` already produces.
2798
+ if (isLit(vb) && isLit(vc)) {
2799
+ const lb = litVal(vb), lc = litVal(vc)
2800
+ if (lb === 1 && lc === 0) return typed(cond, 'i32')
2801
+ if (lb === 0 && lc === 1) return typed(['i32.eqz', cond], 'i32')
2802
+ }
2803
+ // L: Use WASM select for pure ternaries — branchless, smaller bytecode
2804
+ if (vb.type === 'i32' && vc.type === 'i32') {
2805
+ // A single i32 select is only sound when BOTH arms' i32 carriers mean the same
2806
+ // thing to the downstream asF64 — otherwise the result is interpreted one way and
2807
+ // the other arm's value is corrupted. Two compatible shapes:
2808
+ // • both non-pointer i32 (numbers/bools) → asF64 numeric-converts, correct; or
2809
+ // • both the SAME pointer kind+aux → result carries that ptrKind so asF64 takes
2810
+ // the NaN-rebox path (and boxPtrIR's single aux slot is the shared one).
2811
+ // Anything else — a pointer arm beside a number/bool arm, two different pointer
2812
+ // kinds, or the same kind with diverging aux (polymorphic OBJECT schemaIds, TYPED
2813
+ // element types) — must fall through to the f64 path, where each arm is asF64'd
2814
+ // independently and reboxed with its own kind/aux in the NaN-box. The pre-4.x bug:
2815
+ // a pointer arm vs a `true`/number arm took the i32 select, dropped the ptrKind,
2816
+ // and `f64.convert_i32_s` numeric-converted the pointer bits — so `cond ? obj : 1`
2817
+ // lost its object-ness (typeof → "number").
2818
+ const bothPlain = vb.ptrKind == null && vc.ptrKind == null
2819
+ const samePtr = vb.ptrKind != null && vb.ptrKind === vc.ptrKind
2820
+ && (vb.ptrAux ?? null) === (vc.ptrAux ?? null)
2821
+ if (bothPlain || samePtr) {
2822
+ const tagPtr = (n) => {
2823
+ if (vb.ptrKind != null && vb.ptrKind === vc.ptrKind) {
2824
+ n.ptrKind = vb.ptrKind
2825
+ if (vb.ptrAux != null && vb.ptrAux === vc.ptrAux) n.ptrAux = vb.ptrAux
2826
+ }
2827
+ return n
2828
+ }
2829
+ if (isPureIR(vb) && isPureIR(vc))
2830
+ return tagPtr(typed(['select', vb, vc, cond], 'i32'))
2831
+ return tagPtr(typed(['if', ['result', 'i32'], cond, ['then', vb], ['else', vc]], 'i32'))
2832
+ }
2833
+ }
2834
+ const fb = asF64(vb), fc = asF64(vc)
2835
+ const vtb = resolveValType(b, valTypeOf, lookupValType)
2836
+ const vtc = resolveValType(c, valTypeOf, lookupValType)
2837
+ const isNaNBoxLit = n => Array.isArray(n) && n[0] === 'f64.const' && typeof n[1] === 'string' && n[1].startsWith('nan:')
2838
+ const refPayload = (vtb && vtb === vtc && REF_EQ_KINDS.has(vtb))
2839
+ || vb.closureFuncIdx != null || vc.closureFuncIdx != null
2840
+ || isNaNBoxLit(fb) || isNaNBoxLit(fc)
2841
+ const numericB = isNumArm(vb, b, vtb)
2842
+ const numericC = isNumArm(vc, c, vtc)
2843
+ // Peephole: `cond ? 1 : 0` (or `cond ? 0 : 1`) is just `f64.convert_i32_s(cond)` —
2844
+ // the select collapses because cond is already 0/1. Saves 5 instructions.
2845
+ const isOneZero = (one, zero) => {
2846
+ const o = one, z = zero
2847
+ return o.type === 'i32' && Array.isArray(o) && o[0] === 'i32.const' && o[1] === 1 &&
2848
+ z.type === 'i32' && Array.isArray(z) && z[0] === 'i32.const' && z[1] === 0
2849
+ }
2850
+ if ((isOneZero(vb, vc) || isOneZero(vc, vb)) && !numericB && !numericC) {
2851
+ const condBool = truthyIR(emit(a))
2852
+ const n = isOneZero(vb, vc)
2853
+ ? typed(['f64.convert_i32_s', condBool], 'f64')
2854
+ : typed(['f64.convert_i32_s', ['i32.eqz', condBool]], 'f64')
2855
+ n.valKind = VAL.NUMBER
2856
+ return n
2857
+ }
2858
+ const branchB = canonArm(fb, numericB, numericC), branchC = canonArm(fc, numericC, numericB)
2859
+ const markNumeric = (n) => {
2860
+ if (numericB && numericC) n.valKind = VAL.NUMBER
2861
+ return n
2862
+ }
2863
+ if (refPayload) {
2864
+ const ib = ['i64.reinterpret_f64', branchB]
2865
+ const ic = ['i64.reinterpret_f64', branchC]
2866
+ const bits = isPureIR(branchB) && isPureIR(branchC)
2867
+ ? ['select', ib, ic, cond]
2868
+ : ['if', ['result', 'i64'], cond, ['then', ib], ['else', ic]]
2869
+ return typed(['f64.reinterpret_i64', bits], 'f64')
2870
+ }
2871
+ if (!refPayload && isPureIR(branchB) && isPureIR(branchC))
2872
+ return markNumeric(typed(['select', branchB, branchC, cond], 'f64'))
2873
+ return markNumeric(typed(['if', ['result', 'f64'], cond, ['then', branchB], ['else', branchC]], 'f64'))
2874
+ },
2875
+
2876
+ '&&': (a, b) => {
2877
+ // Range-check fusion: `x >= LO && x <= HI` (x a pure i32 local, LO ≤ HI compile-time
2878
+ // constants) collapses to one unsigned compare `(x - LO) <=u (HI - LO)` — a subtract
2879
+ // plus a branch instead of two compares, an AND, and a short-circuit branch. This is
2880
+ // the per-char cost in scanners/parsers (digit/alpha classification) and in any
2881
+ // two-sided bounds check. Restricted to a local `x` so evaluating it once (the fused
2882
+ // form) matches the original's twice-read, side-effect-free semantics.
2883
+ const fused = fuseRangeCheck(a, b)
2884
+ if (fused) return fused
2885
+ const va = emit(a)
2886
+ // Constant-folded literal: pre-bind under truthy refinements (b runs only when a was truthy).
2887
+ if (isLit(va)) {
2888
+ const v = litVal(va)
2889
+ if (v !== 0 && v === v) {
2890
+ const refs = extractRefinements(a, new Map(), true)
2891
+ return withRefinements(refs, b, () => emit(b))
2892
+ }
2893
+ return va
2894
+ }
2895
+ // a is truthy in the right-arm — narrow b accordingly. Matches `?:`'s then-arm threading
2896
+ // (`Array.isArray(x) && x[0]` → x[0] sees x as ARRAY, eliding union-rep fallbacks).
2897
+ const rightRefs = extractRefinements(a, new Map(), true)
2898
+ const emitRight = () => withRefinements(rightRefs, b, () => emit(b))
2899
+ // i32 fast path: use i32 tee as cond directly (nonzero=truthy in wasm `if`),
2900
+ // skip f64 round-trip and __is_truthy call entirely.
2901
+ if (va.type === 'i32') {
2902
+ const vb = emitRight()
2903
+ const t = tempI32()
2904
+ if (vb.type === 'i32') {
2905
+ return typed(['if', ['result', 'i32'],
2906
+ ['local.tee', `$${t}`, va],
2907
+ ['then', vb],
2908
+ ['else', ['local.get', `$${t}`]]], 'i32')
2909
+ }
2910
+ return typed(['if', ['result', 'f64'],
2911
+ ['local.tee', `$${t}`, va],
2912
+ ['then', asF64(vb)],
2913
+ ['else', typed(['f64.convert_i32_s', ['local.get', `$${t}`]], 'f64')]], 'f64')
2914
+ }
2915
+ const t = temp()
2916
+ const numA = isNumArm(va, a)
2917
+ const vb = emitRight(), numB = isNumArm(vb, b)
2918
+ // `a` is the else-arm result (returned when falsy — incl NaN), so canon a lone-numeric
2919
+ // `a` before the tee: `$t` then feeds both the result and the cond canonically.
2920
+ const teed = typed(['local.tee', `$${t}`, canonArm(asF64(va), numA, numB)], 'f64')
2921
+ // A numeric left arm tests truthiness NaN-by-value (not __is_truthy, which mis-reads
2922
+ // x86's sign-set NaN as truthy) — tag it so truthyIR takes that path.
2923
+ if (numA) teed.valKind = VAL.NUMBER
2924
+ return typed(['if', ['result', 'f64'], toBoolFromEmitted(teed),
2925
+ ['then', canonArm(asF64(vb), numB, numA)],
2926
+ ['else', ['local.get', `$${t}`]]], 'f64')
2927
+ },
2928
+
2929
+ '||': (a, b) => {
2930
+ // Outside-range fusion (the complement of `&&`): `x < LO || x > HI` → one unsigned
2931
+ // compare `(x - LO) >u (HI - LO)`. Common in validation (`if (c < 'a' || c > 'z') …`).
2932
+ const fusedOr = fuseRangeCheckOr(a, b)
2933
+ if (fusedOr) return fusedOr
2934
+ const va = emit(a)
2935
+ // Constant-folded literal: pre-bind under falsy refinements (b runs only when a was falsy).
2936
+ if (isLit(va)) {
2937
+ const v = litVal(va)
2938
+ if (v !== 0 && v === v) return va
2939
+ const refs = extractRefinements(a, new Map(), false)
2940
+ return withRefinements(refs, b, () => emit(b))
2941
+ }
2942
+ // a is falsy in the right-arm — `x == null || ...` proves x is null/undefined in b;
2943
+ // De Morgan'd via the sense=false branch of extractRefinements (mirrors the ?: else-arm).
2944
+ const rightRefs = extractRefinements(a, new Map(), false)
2945
+ const emitRight = () => withRefinements(rightRefs, b, () => emit(b))
2946
+ if (va.type === 'i32') {
2947
+ const vb = emitRight()
2948
+ const t = tempI32()
2949
+ if (vb.type === 'i32') {
2950
+ return typed(['if', ['result', 'i32'],
2951
+ ['local.tee', `$${t}`, va],
2952
+ ['then', ['local.get', `$${t}`]],
2953
+ ['else', vb]], 'i32')
2954
+ }
2955
+ return typed(['if', ['result', 'f64'],
2956
+ ['local.tee', `$${t}`, va],
2957
+ ['then', typed(['f64.convert_i32_s', ['local.get', `$${t}`]], 'f64')],
2958
+ ['else', asF64(vb)]], 'f64')
2959
+ }
2960
+ const t = temp()
2961
+ const numA = isNumArm(va, a)
2962
+ const vb = emitRight(), numB = isNumArm(vb, b)
2963
+ // `a` (then-arm) is returned only when truthy — hence never NaN — so it needs no canon;
2964
+ // the cond's NaN-safety comes from the valKind tag. Only the else (b) arm can surface
2965
+ // as a numeric NaN.
2966
+ const teed = typed(['local.tee', `$${t}`, asF64(va)], 'f64')
2967
+ if (numA) teed.valKind = VAL.NUMBER // numeric left arm: NaN-safe truthiness (see `&&`)
2968
+ return typed(['if', ['result', 'f64'], toBoolFromEmitted(teed),
2969
+ ['then', ['local.get', `$${t}`]],
2970
+ ['else', canonArm(asF64(vb), numB, numA)]], 'f64')
2971
+ },
2972
+
2973
+ // a ?? b: returns b only if a is nullish
2974
+ '??': (a, b) => {
2975
+ const va = emit(a), vb = emit(b)
2976
+ const t = temp()
2977
+ const numA = isNumArm(va, a), numB = isNumArm(vb, b)
2978
+ // Both arms can surface as the (untyped) result — `a` when non-nullish (a NaN is not
2979
+ // nullish, so it IS returned), `b` otherwise. Canon a lone-numeric arm; `a` before the
2980
+ // tee so `local.get $t` is canonical. The cond is isNullish, robust to non-canon NaN.
2981
+ return typed(['if', ['result', 'f64'],
2982
+ ['i32.eqz', isNullish(['local.tee', `$${t}`, canonArm(asF64(va), numA, numB)])],
2983
+ ['then', ['local.get', `$${t}`]],
2984
+ ['else', canonArm(asF64(vb), numB, numA)]], 'f64')
2985
+ },
2986
+
2987
+ 'void': a => {
2988
+ const v = emit(a)
2989
+ const dropAndUndef = (instr) => block64(instr, 'drop', undefExpr())
2990
+ if (v == null) return undefExpr()
2991
+ const op = Array.isArray(v) ? v[0] : null
2992
+ const wasmVoid = op === 'local.set' || (typeof op === 'string' && op.endsWith('.store'))
2993
+ || op === 'memory.copy' || op === 'global.set'
2994
+ if (wasmVoid)
2995
+ return block64(v, undefExpr())
2996
+ if (v.type && v.type !== 'void')
2997
+ return dropAndUndef(v)
2998
+ return block64(...flat(v), undefExpr())
2999
+ },
3000
+
3001
+ '(': a => emit(a),
3002
+
3003
+ // === Bitwise (i32 for numbers, i64 for BigInt) ===
3004
+
3005
+ // Per ECMAScript ToInt32, bitwise ops first ToNumber-coerce non-numeric operands.
3006
+ // i32 / lit values are already numeric — the toNumF64 wrap is skipped to keep
3007
+ // the numeric fast path at one wasm instruction. Non-numeric (NaN-boxed string,
3008
+ // unknown type) routes through __to_num so "2026" | 0 === 2026.
3009
+ // `~~x` is the idiomatic int32 truncation: the two xor-with-(-1) cancel, leaving
3010
+ // a single toI32 (whose NaN/Infinity guard runs once, unchanged). Fold it here so
3011
+ // DSP/bytebeat `~~` doesn't emit a dead double-xor watr won't remove.
3012
+ '~': a => {
3013
+ if (Array.isArray(a) && a[0] === '~') {
3014
+ const inner = a[1]
3015
+ // ~~x === x for BigInt; the int32-truncation fold below is number-only.
3016
+ if (valTypeOf(inner) === VAL.BIGINT) return emit(inner)
3017
+ const iv = emit(inner)
3018
+ return isLit(iv) ? emitNum(~~litVal(iv)) : typed(toI32(isI32Num(iv) ? iv : toNumF64(inner, iv)), 'i32')
3019
+ }
3020
+ // BigInt complement is the i64 `x ^ -1` (all bits flipped), like emitNeg's i64.sub.
3021
+ if (valTypeOf(a) === VAL.BIGINT) return fromI64(['i64.xor', asI64(emit(a)), ['i64.const', -1]])
3022
+ const v = emit(a); return isLit(v) ? emitNum(~litVal(v)) : typed(['i32.xor', toI32(isI32Num(v) ? v : toNumF64(a, v)), typed(['i32.const', -1], 'i32')], 'i32')
3023
+ },
3024
+ ...Object.fromEntries([
3025
+ ['&', 'and'], ['|', 'or'], ['^', 'xor'], ['<<', 'shl'], ['>>', 'shr_s'],
3026
+ ].map(([op, fn]) => [op, (a, b) => {
3027
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
3028
+ return fromI64([`i64.${fn}`, asI64(emit(a)), asI64(emit(b))])
3029
+ if (op === '|') { // `(x / y) | 0` integer-division idiom → i32.div_s
3030
+ const divN = intLiteralValue(b) === 0 ? a : intLiteralValue(a) === 0 ? b : null
3031
+ if (Array.isArray(divN) && divN[0] === '/') { const r = tryIntDivTrunc(divN[1], divN[2]); if (r) return r }
3032
+ }
3033
+ const va = emit(a), vb = emit(b)
3034
+ if (isLit(va) && isLit(vb)) {
3035
+ const la = litVal(va), lb = litVal(vb)
3036
+ if (op === '&') return emitNum(la & lb); if (op === '|') return emitNum(la | lb)
3037
+ if (op === '^') return emitNum(la ^ lb); if (op === '<<') return emitNum(la << lb)
3038
+ if (op === '>>') return emitNum(la >> lb)
3039
+ }
3040
+ const ca = isI32Num(va) || isLit(va) ? va : toNumF64(a, va)
3041
+ const cb = isI32Num(vb) || isLit(vb) ? vb : toNumF64(b, vb)
3042
+ return typed([`i32.${fn}`, toI32(ca), toI32(cb)], 'i32')
3043
+ }])),
3044
+ '>>>': (a, b) => {
3045
+ const va = emit(a), vb = emit(b)
3046
+ if (isLit(va) && isLit(vb)) {
3047
+ const r = litVal(va) >>> litVal(vb) // JS uint32 result ∈ [0, 2^32)
3048
+ // ≥ 2^31 doesn't fit signed i32: materialize the wrapped bits as an i32 const
3049
+ // tagged `.unsigned` so `asF64` lifts via `convert_i32_u`. Emitting `f64.const r`
3050
+ // here (the old foldConst path) would `trunc_sat_f64_s`-saturate to INT32_MAX
3051
+ // when the enclosing function narrows to an i32 result. Values < 2^31 fold to a
3052
+ // plain i32 const (signed == unsigned, stays foldable downstream).
3053
+ if (r >= 0x80000000) { const node = typed(['i32.const', r | 0], 'i32'); node.unsigned = true; return node }
3054
+ return emitNum(r)
3055
+ }
3056
+ // F: Mark unsigned so `asF64` lifts via `f64.convert_i32_u` (preserving the
3057
+ // [0, 2^32) value range). Without this, `(s >>> 0) / 4294967296` would convert
3058
+ // signed for negative-high-bit s values, flipping sign and breaking the
3059
+ // canonical "uint32 → f64" idiom used in PRNGs and bit-manipulation code.
3060
+ const ca = isI32Num(va) || isLit(va) ? va : toNumF64(a, va)
3061
+ const cb = isI32Num(vb) || isLit(vb) ? vb : toNumF64(b, vb)
3062
+ const node = typed(['i32.shr_u', toI32(ca), toI32(cb)], 'i32')
3063
+ node.unsigned = true
3064
+ return node
3065
+ },
3066
+
3067
+ // === Control flow ===
3068
+
3069
+ 'if': (cond, then, els) => {
3070
+ // Dead branch elimination: constant condition → emit only the live branch
3071
+ const ce = emit(cond)
3072
+ if (isLit(ce)) {
3073
+ const v = litVal(ce), truthy = v !== 0 && v === v
3074
+ if (truthy) return emitVoid(then)
3075
+ if (els != null) return emitVoid(els)
3076
+ return null
3077
+ }
3078
+ const c = ce.type === 'i32' ? ce : toBoolFromEmitted(ce)
3079
+ // Flow-sensitive type refinement: narrow types within each branch based on the guard.
3080
+ const thenRefs = extractRefinements(cond, new Map(), true)
3081
+ const elseRefs = extractRefinements(cond, new Map(), false)
3082
+ const thenBody = withRefinements(thenRefs, then, () => emitVoid(then))
3083
+ if (els != null) {
3084
+ const elseBody = withRefinements(elseRefs, els, () => emitVoid(els))
3085
+ return ['if', c, ['then', ...thenBody], ['else', ...elseBody]]
3086
+ }
3087
+ return ['if', c, ['then', ...thenBody]]
3088
+ },
3089
+
3090
+ 'for': (init, cond, step, body) => {
3091
+ if (body === undefined) return err('for-in/for-of not supported')
3092
+ // An enclosing labeled statement (`outer: for …`) hands its label down so `continue outer`
3093
+ // can target this loop's continue point. The immediately-enclosed loop consumes it.
3094
+ const myLabel = ctx.func.pendingLabel; ctx.func.pendingLabel = null
3095
+ const labeledContinue = myLabel != null && hasLabeledContinueTo(body, myLabel)
3096
+ // Don't unroll a loop that is the target of a `continue <label>` — unrolling would lose the
3097
+ // continue edge. (Plain loops with no labeled-continue still unroll.)
3098
+ if (!labeledContinue && (!ctx.transform.optimize || ctx.transform.optimize.smallConstForUnroll !== false)) {
3099
+ const unrolled = unrollSmallConstFor(init, cond, step, body)
3100
+ if (unrolled) return unrolled
3101
+ }
3102
+ // for-in over a static schema → unroll with key-literal substitution (folds
3103
+ // o[k] to schema slots). Recognized via the for-in-exclusive __keys_ro intrinsic.
3104
+ if (!labeledContinue && (!ctx.transform.optimize || ctx.transform.optimize.forInUnroll !== false)) {
3105
+ const fu = unrollForIn(init, cond, step, body)
3106
+ if (fu) return fu
3107
+ }
3108
+ // Lift constant array/object literals out of the loop (allocate once, not per
3109
+ // iteration) when they are read-only + non-escaping inside it. Strip them from the
3110
+ // body up front so freshBoxed / continue analysis see the reduced body.
3111
+ let preLoopLits = []
3112
+ if (!ctx.transform.optimize || ctx.transform.optimize.hoistConstLit !== false) {
3113
+ const ex = extractHoistableLiterals(body)
3114
+ if (ex) { preLoopLits = ex.hoisted; body = ex.body }
3115
+ }
3116
+ const id = ctx.func.uniq++
3117
+ const brk = `$brk${id}`, loop = `$loop${id}`
3118
+ // The cont wrapper is only needed if the body has a `continue` AND there is a step
3119
+ // expression — `continue` must jump to before the step. Without a step, `continue`
3120
+ // can target the loop label directly, saving a redundant `block`.
3121
+ const needsCont = step && (hasOwnContinue(body) || labeledContinue)
3122
+ const cont = needsCont ? `$cont${id}` : loop
3123
+ ctx.func.stack.push({ brk, loop: cont })
3124
+ const frame = ctx.func.stack[ctx.func.stack.length - 1]
3125
+ if (myLabel != null) frame.contLabel = myLabel // so `continue <myLabel>` targets this loop's step/test
3126
+ // Per-iteration fresh cells for boxed locals declared in the body — allocated
3127
+ // at body entry so a closure declared before its binding captures the right
3128
+ // cell (sets frame.loopFresh; emitDecl then stores rather than re-allocates).
3129
+ const freshBoxed = emitLoopFreshBoxed(body, frame)
3130
+ const result = []
3131
+ if (init != null) result.push(...emitVoid(init))
3132
+ for (const lit of preLoopLits) result.push(...emitVoid(lit)) // allocate hoisted literals once
3133
+ // Hoist a loop-invariant immutable-length bound out of the condition. A typed
3134
+ // array's `.length` is fixed, so `i < arr.length` otherwise reloads the header
3135
+ // (`i32.load (base-8) >> 2`) every iteration for nothing (V8's JIT hoists it).
3136
+ // Compute it once into a temp when `arr` is a typed-array var not reassigned in
3137
+ // the body. Only the simple top-level comparison forms — anything fancier just
3138
+ // keeps the per-iteration eval (correct, only misses the speedup).
3139
+ let condForLoop = cond
3140
+ if (cond && Array.isArray(cond) && HOIST_CMP.has(cond[0])) {
3141
+ const side = immutableLenBound(cond[2], body) ? 2 : immutableLenBound(cond[1], body) ? 1 : 0
3142
+ if (side) {
3143
+ const lt = tempI32('len')
3144
+ result.push(['local.set', `$${lt}`, asI32(emit(cond[side]))])
3145
+ condForLoop = cond.slice(); condForLoop[side] = lt
3146
+ }
3147
+ }
3148
+ const loopBody = []
3149
+ if (condForLoop) loopBody.push(['br_if', brk, ['i32.eqz', toBool(condForLoop)]])
3150
+ loopBody.push(...freshBoxed)
3151
+ if (needsCont) loopBody.push(['block', cont, ...emitVoid(body)])
3152
+ else loopBody.push(...emitVoid(body))
3153
+ if (step) loopBody.push(...emitVoid(step))
3154
+ loopBody.push(['br', loop])
3155
+ result.push(['block', brk, ['loop', loop, ...loopBody]])
3156
+ ctx.func.stack.pop()
3157
+ return result.length === 1 ? result[0] : result
3158
+ },
3159
+
3160
+ 'switch': (discriminant, ...cases) => {
3161
+ const disc = `${T}disc${ctx.func.uniq++}`
3162
+ ctx.func.locals.set(disc, 'f64')
3163
+
3164
+ const result = [['local.set', `$${disc}`, asF64(emit(discriminant))]]
3165
+
3166
+ for (const c of cases) {
3167
+ if (c[0] === 'case') {
3168
+ const [, test, body] = c
3169
+ const skip = `$skip${ctx.func.uniq++}`
3170
+ // Block: skip if discriminant != test, otherwise execute body
3171
+ result.push(['block', skip,
3172
+ ['br_if', skip, typed(['f64.ne', typed(['local.get', `$${disc}`], 'f64'), asF64(emit(test))], 'i32')],
3173
+ ...emitVoid(body)])
3174
+ } else if (c[0] === 'default') {
3175
+ result.push(...emitVoid(c[1]))
3176
+ }
3177
+ }
3178
+
3179
+ return result
3180
+ },
3181
+
3182
+ 'while': (cond, body) => emitter['for'](null, cond, null, body),
3183
+ 'label': (name, body) => {
3184
+ const brk = `$label${ctx.func.uniq++}`
3185
+ ctx.func.stack.push({ label: name, brk })
3186
+ // Hand the label to the immediately-enclosed loop so `continue name` can target it.
3187
+ ctx.func.pendingLabel = name
3188
+ const result = ['block', brk, ...emitVoid(body)]
3189
+ ctx.func.pendingLabel = null // clear if the body wasn't a loop (nothing consumed it)
3190
+ ctx.func.stack.pop()
3191
+ return result
3192
+ },
3193
+ 'break': (label) => {
3194
+ const target = label == null
3195
+ ? loopTop().brk
3196
+ : ctx.func.stack.findLast(frame => frame.label === label)?.brk
3197
+ if (!target) err(`break label '${label}' is not in scope`)
3198
+ return [...emitFinalizers(), ['br', target]]
3199
+ },
3200
+ 'continue': (label) => {
3201
+ if (label == null) return [...emitFinalizers(), ['br', loopTop().loop]]
3202
+ // Labeled continue: target the continue point of the loop that adopted this label.
3203
+ const frame = ctx.func.stack.findLast(f => f.contLabel === label)
3204
+ if (!frame) err(`continue label '${label}' is not in scope`)
3205
+ return [...emitFinalizers(), ['br', frame.loop]]
3206
+ },
3207
+
3208
+ // === Call ===
3209
+
3210
+ // Arrow as value → closure
3211
+ '=>': (rawParams, body) => {
3212
+ if (!ctx.closure.make) err('Closures require fn module (auto-included)')
3213
+
3214
+ const raw = extractParams(rawParams)
3215
+ const params = [], defaults = {}
3216
+ let restParam = null, bodyPrefix = []
3217
+ for (const r of raw) {
3218
+ const c = classifyParam(r)
3219
+ if (c.kind === 'rest') { restParam = c.name; params.push(c.name) }
3220
+ else if (c.kind === 'plain') params.push(c.name)
3221
+ else if (c.kind === 'default') { params.push(c.name); defaults[c.name] = c.defValue }
3222
+ else {
3223
+ const tmp = `${T}p${ctx.func.uniq++}`
3224
+ params.push(tmp)
3225
+ if (c.kind === 'destruct-default') defaults[tmp] = c.defValue
3226
+ bodyPrefix.push(['let', ['=', c.pattern, tmp]])
3227
+ }
3228
+ }
3229
+
3230
+ // Prepend destructuring to body (if any destructured params)
3231
+ if (bodyPrefix.length) {
3232
+ const origBody = body
3233
+ if (Array.isArray(body) && body[0] === '{}' && Array.isArray(body[1]) && body[1][0] === ';')
3234
+ body = ['{}', [';', ...bodyPrefix, ...body[1].slice(1)]]
3235
+ else if (Array.isArray(body) && body[0] === '{}')
3236
+ body = ['{}', [';', ...bodyPrefix, body[1]]]
3237
+ else body = ['{}', [';', ...bodyPrefix, ['return', body]]]
3238
+ if (origBody && origBody._nonEscaping) body._nonEscaping = origBody._nonEscaping
3239
+ }
3240
+
3241
+ // Find free variables in body that aren't params → captures
3242
+ const paramSet = new Set(params)
3243
+ const captures = []
3244
+ findFreeVars(body, paramSet, captures)
3245
+ for (const def of Object.values(defaults)) findFreeVars(def, paramSet, captures)
3246
+
3247
+ // Pass closure info including rest param and defaults
3248
+ const closureInfo = { params, body, captures, restParam }
3249
+ if (Object.keys(defaults).length) closureInfo.defaults = defaults
3250
+ return ctx.closure.make(closureInfo)
3251
+ },
3252
+
3253
+ // Linear callee-kind dispatcher. Each strategy below is its own named function
3254
+ // (extracted to module scope above); this body is just the routing table.
3255
+ '()': (callee, callArgs) => {
3256
+ const argList = commaList(callArgs)
3257
+ const parsed = parseCallArgs(argList)
3258
+
3259
+ // Closure devirtualization: a module-global callee proven (by plan.js) to hold
3260
+ // one statically-known function rewrites to that function, so the
3261
+ // known-top-level-function branch emits a direct `call`, dropping the
3262
+ // indirect/trampoline path.
3263
+ if (typeof callee === 'string' && ctx.func.globalDevirt?.has(callee))
3264
+ callee = ctx.func.globalDevirt.get(callee)
3265
+
3266
+ if (Array.isArray(callee) && callee[0] === '.') return emitMethodCall(callee, parsed, callArgs)
3267
+
3268
+ if (typeof callee === 'string' && ctx.core.emit[callee] && !isBoundName(callee) && !isUserFunc(callee))
3269
+ return emitBuiltinCall(callee, parsed)
3270
+
3271
+ if (typeof callee === 'string' && ctx.func.names.has(callee) && !isBoundName(callee))
3272
+ return emitDirectFunctionCall(callee, parsed, callArgs)
3273
+
3274
+ if (typeof callee === 'string' && !parsed.hasSpread && ctx.func.directClosures?.has(callee)) {
3275
+ const direct = tryDirectClosureCall(callee, parsed)
3276
+ if (direct) return direct
3277
+ }
3278
+
3279
+ if (ctx.closure.call) return emitGenericClosureCall(callee, parsed)
3280
+
3281
+ return emitUnknownCalleeCall(callee, argList)
3282
+ },
3283
+ }
3284
+
3285
+ // === Emit dispatch ===
3286
+
3287
+ // Optional-chain continuation: `a?.b.c` → if `a` nullish then undefined, else `a.b.c`.
3288
+ // Per ECMAScript, an optional access short-circuits the entire continuation, not just
3289
+ // its own access. Without this, `a?.b.c` parses as `(a?.b).c` and `.c` runs on the
3290
+ // nullish result of `a?.b`, returning a wrong value (or trapping in typed lowerings).
3291
+ //
3292
+ // At the outermost `.` / `[]` / `()` whose leftmost descent contains an optional, hoist
3293
+ // the deepest such optional's head into a temp, nullish-guard, and rebuild the chain
3294
+ // with that optional replaced by a regular access. The single guard short-circuits the
3295
+ // whole continuation. Nested optionals further inside the chain are left intact and
3296
+ // handle their own short-circuiting on recursion.
3297
+ function liftOptionalChain(node) {
3298
+ const path = []
3299
+ let cur = node
3300
+ while (Array.isArray(cur) && (cur[0] === '.' || cur[0] === '[]' || cur[0] === '()' ||
3301
+ cur[0] === '?.' || cur[0] === '?.[]' || cur[0] === '?.()')) {
3302
+ path.push(cur)
3303
+ cur = cur[1]
3304
+ }
3305
+ // Find the deepest optional with continuation outside it. optIdx === 0 means the
3306
+ // chain root itself is optional with no continuation — handled by the regular
3307
+ // `?.` / `?.[]` / `?.()` emitters.
3308
+ let optIdx = -1
3309
+ for (let i = path.length - 1; i >= 1; i--) {
3310
+ if (path[i][0] === '?.' || path[i][0] === '?.[]' || path[i][0] === '?.()') {
3311
+ optIdx = i
3312
+ break
3313
+ }
3314
+ }
3315
+ if (optIdx <= 0) return null
3316
+ const opt = path[optIdx]
3317
+ return withNullGuard(asF64(emit(opt[1])), t => {
3318
+ let rebuilt = opt[0] === '?.' ? ['.', t, opt[2]]
3319
+ : opt[0] === '?.[]' ? ['[]', t, opt[2]]
3320
+ : ['()', t, ...opt.slice(2)]
3321
+ for (let i = optIdx - 1; i >= 0; i--) rebuilt = [path[i][0], rebuilt, ...path[i].slice(2)]
3322
+ return asF64(emit(rebuilt))
3323
+ }, 'oc')
3324
+ }
3325
+
3326
+ /**
3327
+ * Emit single AST node to typed WASM IR.
3328
+ * Every returned node has .type = 'i32' | 'f64'.
3329
+ * @param {import('./prepare.js').ASTNode} node
3330
+ * @returns {Array} typed WASM S-expression
3331
+ */
3332
+ export function emit(node, expect) {
3333
+ ctx.func._expect = expect || null
3334
+ if (Array.isArray(node)) {
3335
+ ctx.error.node = node
3336
+ if (node.loc != null) ctx.error.loc = node.loc
3337
+ }
3338
+ if (node == null) return null
3339
+ // Boolean literals carry VAL.BOOL for type observation (valTypeOf reads the
3340
+ // AST), but their working representation is the plain number 0/1 — identical
3341
+ // codegen to the pre-carrier `[, 1]`/`[, 0]` folding, so no perf is paid.
3342
+ if (node === true) return emitNum(1)
3343
+ if (node === false) return emitNum(0)
3344
+ if (typeof node === 'symbol') // JZ_NULL / JZ_UNDEF sentinels → null / undefined NaN
3345
+ return node === JZ_UNDEF ? undefExpr() : nullExpr()
3346
+ if (typeof node === 'bigint') {
3347
+ // Truncate to 64 bits — `BigInt.asUintN(64, …)` semantics, same as the
3348
+ // explicit mask `node & 0xFFFFFFFFFFFFFFFFn`. Decimal form (vs. the prior
3349
+ // unsigned-hex dance) is enough now that watr's optimize.js getConst
3350
+ // handles signed strings correctly (4.6.8 W5 fix).
3351
+ return typed(['f64.reinterpret_i64', ['i64.const', BigInt.asUintN(64, node).toString()]], 'f64')
3352
+ }
3353
+ if (typeof node === 'number') return emitNum(node)
3354
+ if (typeof node === 'string') {
3355
+ // Variable read: boxed / local / param / global (check before emitter table to avoid name collisions)
3356
+ if (ctx.func.boxed?.has(node) || isBoundName(node) || isGlobal(node) || repOf(node)?.intConst != null)
3357
+ return readVar(node)
3358
+ // Top-level function used as value → wrap as closure pointer for call_indirect
3359
+ if (ctx.func.names.has(node) && !isBoundName(node) && ctx.closure.table) {
3360
+ // Trampoline signature: uniform closure ABI (env f64, argc i32, a0..a{MAX-1} f64) → f64.
3361
+ // Forwards the first N inline slots to $func where N = func's fixed param count.
3362
+ const func = ctx.func.map.get(node)
3363
+ const sigParams = func?.sig.params || []
3364
+ if (sigParams.length > MAX_CLOSURE_ARITY) err(`Function ${node} used as closure value has ${sigParams.length} params, exceeds MAX_CLOSURE_ARITY=${MAX_CLOSURE_ARITY}`)
3365
+ const trampolineName = `${T}tramp_${node}`
3366
+ if (!ctx.core.stdlib[trampolineName]) {
3367
+ const W = ctx.closure.width ?? MAX_CLOSURE_ARITY
3368
+ const paramDecls = ['(param $__env f64)', '(param $__argc i32)']
3369
+ for (let i = 0; i < W; i++) paramDecls.push(`(param $__a${i} f64)`)
3370
+ // A rest param (always last) must be packed into a fresh array from the
3371
+ // overflow inline slots — the direct-call path does this via
3372
+ // buildArrayWithSpreads, and `=>` closures via emitClosureBody. Without
3373
+ // it here an indirect caller's single array arg arrives AS the rest array
3374
+ // (spread one level) instead of `[arg]`. len = clamp(argc-restIdx, 0, restSlots).
3375
+ const restIdx = func?.rest ? sigParams.length - 1 : -1
3376
+ let restLocals = '', restPrelude = ''
3377
+ if (restIdx >= 0) {
3378
+ const restSlots = W - restIdx
3379
+ const stores = []
3380
+ for (let i = 0; i < restSlots; i++)
3381
+ stores.push(`(if (i32.gt_s (local.get $__rlen) (i32.const ${i})) (then (f64.store (i32.add (local.get $__roff) (i32.const ${i * 8})) (local.get $__a${restIdx + i}))))`)
3382
+ restLocals = '(local $__rlen i32) (local $__roff i32) '
3383
+ restPrelude =
3384
+ `(local.set $__rlen (select (i32.sub (local.get $__argc) (i32.const ${restIdx})) (i32.const 0) (i32.gt_s (local.get $__argc) (i32.const ${restIdx})))) ` +
3385
+ `(if (i32.gt_s (local.get $__rlen) (i32.const ${restSlots})) (then (local.set $__rlen (i32.const ${restSlots})))) ` +
3386
+ `(local.set $__roff (call $__alloc_hdr (local.get $__rlen) (local.get $__rlen))) ` +
3387
+ stores.join(' ') + ' '
3388
+ }
3389
+ // Forward fixed slots (i32 via trunc_sat); the rest slot → packed array ptr.
3390
+ const fwd = sigParams.map((p, i) =>
3391
+ i === restIdx
3392
+ ? `(call $__mkptr (i32.const ${PTR.ARRAY}) (i32.const 0) (local.get $__roff))`
3393
+ : p.type === 'i32'
3394
+ ? `(i32.trunc_sat_f64_s (local.get $__a${i}))`
3395
+ : `(local.get $__a${i})`).join(' ')
3396
+ if ((func?.sig.results.length || 1) > 1) {
3397
+ const n = func.sig.results.length
3398
+ const arr = `${T}retarr`
3399
+ const temps = Array.from({ length: n }, (_, i) => `${T}ret${i}`)
3400
+ const tempLocals = temps.map(name => `(local $${name} f64)`).join(' ')
3401
+ const stores = temps.map((name, i) =>
3402
+ `(f64.store (i32.add (local.get $${arr}) (i32.const ${i * 8})) (local.get $${name}))`
3403
+ ).join(' ')
3404
+ const capture = temps.slice().reverse().map(name => `(local.set $${name})`).join(' ')
3405
+ ctx.core.stdlib[trampolineName] = `(func $${trampolineName} ${paramDecls.join(' ')} (result f64) (local $${arr} i32) ${tempLocals} ${restLocals}${restPrelude}(call $${node} ${fwd}) ${capture} (local.set $${arr} (call $__alloc (i32.const ${n * 8 + 8}))) (i32.store (local.get $${arr}) (i32.const ${n})) (i32.store (i32.add (local.get $${arr}) (i32.const 4)) (i32.const ${n})) (local.set $${arr} (i32.add (local.get $${arr}) (i32.const 8))) ${stores} (call $__mkptr (i32.const 1) (i32.const 0) (local.get $${arr})))`
3406
+ inc(trampolineName, '__alloc', '__mkptr', ...(restIdx >= 0 ? ['__alloc_hdr'] : []))
3407
+ } else {
3408
+ // Rebox the inner result into the uniform closure ABI (always f64).
3409
+ const resType = func?.sig.results[0]
3410
+ const callExpr = `(call $${node} ${fwd})`
3411
+ // A pointer-returning func carries its result as the raw i32 offset
3412
+ // (sig.ptrKind names the heap kind). Rebox it as a NaN-boxed pointer
3413
+ // with its tag — same as the boundary wrapper (synthesizeBoundaryWrappers).
3414
+ // Numeric `f64.convert_i32_s` here would turn the offset into a plain
3415
+ // number, silently losing the pointer (a Map came back as e.g. 480360.0,
3416
+ // so a caller's `for…of`/`.size` saw a number and read nothing).
3417
+ const ptrResult = func?.sig.ptrKind != null
3418
+ const wrapped = ptrResult
3419
+ ? `(call $__mkptr (i32.const ${valKindToPtr(func.sig.ptrKind)}) (i32.const ${func.sig.ptrAux ?? 0}) ${callExpr})`
3420
+ : resType === 'i32'
3421
+ ? (func.sig.unsignedResult ? `(f64.convert_i32_u ${callExpr})` : `(f64.convert_i32_s ${callExpr})`)
3422
+ : resType === 'i64'
3423
+ ? `(f64.reinterpret_i64 ${callExpr})`
3424
+ : callExpr
3425
+ ctx.core.stdlib[trampolineName] = `(func $${trampolineName} ${paramDecls.join(' ')} (result f64) ${restLocals}${restPrelude}${wrapped})`
3426
+ inc(trampolineName, ...(ptrResult ? ['__mkptr'] : []), ...(restIdx >= 0 ? ['__alloc_hdr', '__mkptr'] : []))
3427
+ }
3428
+ }
3429
+ let idx = ctx.closure.table.indexOf(trampolineName)
3430
+ if (idx < 0) { idx = ctx.closure.table.length; ctx.closure.table.push(trampolineName) }
3431
+ const ir = mkPtrIR(PTR.CLOSURE, idx, 0)
3432
+ ir.closureFuncIdx = idx
3433
+ return ir
3434
+ }
3435
+ // Emitter table: only namespace-resolved names (contain '.', e.g. 'math.PI') — safe from user variable collision.
3436
+ // `handler.length` distinguishes the two flavors of entry: arity-0 handlers
3437
+ // are constants (e.g. `math.PI` → emits `f64.const PI`) and can be invoked
3438
+ // directly here. Arity-≥1 handlers expect the surrounding call node, so
3439
+ // bare-name use of them is a first-class-value reference — wrap as a closure.
3440
+ if (node.includes('.') && ctx.core.emit[node]) {
3441
+ const handler = ctx.core.emit[node]
3442
+ const isCallable = handler.length > 0
3443
+ return isCallable ? builtinFunctionValue(node) : handler()
3444
+ }
3445
+ // Auto-import known host globals (WebAssembly, globalThis, etc.). Emit only
3446
+ // records the usage; the `(import "env" … (global … i64))` node is drained
3447
+ // into ctx.module.imports at assembly (compile/index.js), the same way
3448
+ // ctx.core.jsstring is — emit does not own ctx.scope / ctx.module sections.
3449
+ // Carrier is i64 (not f64) so V8 can't canonicalize the NaN-boxed external-ref
3450
+ // payload across the wasm↔JS global boundary (same hazard as env.print —
3451
+ // see module/console.js header). asF64() reinterprets to f64 at each read.
3452
+ if (HOST_GLOBALS.has(node) && !isBoundName(node) && !isGlobal(node)) {
3453
+ if (ctx.transform.host === 'wasi') err(`host:'wasi': reference to host global \`${node}\` requires an env import. Remove the reference or use host:'js'.`)
3454
+ ctx.features.external = true
3455
+ ctx.core.hostGlobals.add(node)
3456
+ return typed(['global.get', `$${node}`], 'i64')
3457
+ }
3458
+ const t = ctx.func.locals?.get(node) || ctx.func.current?.params.find(p => p.name === node)?.type || 'f64'
3459
+ return typed(['local.get', `$${node}`], t)
3460
+ }
3461
+ if (!Array.isArray(node)) return typed(['f64.const', 0], 'f64')
3462
+
3463
+ const [op, ...args] = node
3464
+ // WASM IR passthrough: internally-generated IR nodes (from statement flattening) pass through
3465
+ if (typeof op === 'string' && !ctx.core.emit[op] && (op.includes('.') || WASM_OPS.has(op))) return node
3466
+
3467
+ // Self-describing bigint literal (`normalizeBigints`, used at the host→kernel AST
3468
+ // boundary). args[0] is the unsigned-64 decimal computed host-side, passed straight
3469
+ // to i64.const — no in-kernel parse, byte-identical to the raw-primitive path above.
3470
+ if (op === 'bigint') return typed(['f64.reinterpret_i64', ['i64.const', args[0]]], 'f64')
3471
+
3472
+ // Self-describing NaN literal — same reason bigints are self-describing: a raw NaN
3473
+ // number is NaN-boxing-ambiguous and degrades to 0 across the self-host kernel's
3474
+ // value/marshalling boundary. The `NaN` global resolves to this (prepare) instead
3475
+ // of a `[, NaN]` literal; watr emits the canonical quiet NaN. (Infinity is a normal
3476
+ // f64 and survives, so it stays a plain literal.)
3477
+ if (op === 'nan') return typed(['f64.const', 'nan'], 'f64')
3478
+
3479
+ // Self-describing boolean literal (`normalizeBigints` at the host→kernel boundary).
3480
+ // A raw `true`/`false` in the marshalled AST coerces to the number 1/0 and loses its
3481
+ // VAL.BOOL kind, so the kernel returns a plain f64 instead of a NaN-boxed boolean.
3482
+ // args[0] is 1/0 (prepare may wrap it as a `[, 1]` literal node) — emit it as that
3483
+ // working rep; the BOOL boxing happens at the boundary via valTypeOf('bool')=VAL.BOOL.
3484
+ if (op === 'bool') return emit(args[0])
3485
+
3486
+ // Literal node [, value] — handle null/undefined values
3487
+ if (op == null && args.length === 1) {
3488
+ const v = args[0]
3489
+ return v === undefined ? undefExpr() : v === null ? nullExpr() : emit(v)
3490
+ }
3491
+
3492
+ // Optional-chain continuation: `a?.b.c` → if `a` nullish then undefined else `a.b.c`.
3493
+ // Lift before dispatch so the regular `.` / `[]` / `()` handler sees the rebuilt chain
3494
+ // with the optional already replaced by a non-optional access on a guarded temp.
3495
+ if (op === '.' || op === '[]' || op === '()') {
3496
+ const lifted = liftOptionalChain(node)
3497
+ if (lifted) return lifted
3498
+ }
3499
+
3500
+ // `let`/`const` dispatch directly to the imported emitDecl rather than through the
3501
+ // ctx.core.emit table reference: under self-host the table reference is a closure value,
3502
+ // and a runtime spread of >8 args into a closure call silently drops arguments — so a
3503
+ // `let` with >8 expression-init declarators (e.g. an SROA prologue loading 16 typed-array
3504
+ // slots) lost everything past the 8th. A direct call to the module-local binding compiles
3505
+ // as a real direct call, which marshals all args.
3506
+ if (op === 'let' || op === 'const') return emitDecl(...args)
3507
+ const handler = ctx.core.emit[op]
3508
+ if (!handler) err(`Unknown op: ${op}`)
3509
+ const ir = handler(...args)
3510
+ if (ir && ir.type === 'f64' && valTypeOf(node) === VAL.NUMBER) ir.valKind = VAL.NUMBER
3511
+ return ir
3512
+ }