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