jz 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +315 -318
  2. package/bench/README.md +369 -0
  3. package/bench/bench.svg +102 -0
  4. package/cli.js +104 -30
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +362 -84
  9. package/interop.js +159 -186
  10. package/jz.svg +5 -0
  11. package/jzify/arguments.js +97 -0
  12. package/jzify/bundler.js +382 -0
  13. package/jzify/classes.js +328 -0
  14. package/jzify/hoist-vars.js +177 -0
  15. package/jzify/index.js +51 -0
  16. package/jzify/names.js +37 -0
  17. package/jzify/switch.js +106 -0
  18. package/jzify/transform.js +349 -0
  19. package/layout.js +184 -0
  20. package/module/array.js +377 -176
  21. package/module/collection.js +639 -145
  22. package/module/console.js +55 -43
  23. package/module/core.js +264 -153
  24. package/module/date.js +15 -3
  25. package/module/function.js +73 -6
  26. package/module/index.js +2 -1
  27. package/module/json.js +226 -61
  28. package/module/math.js +551 -187
  29. package/module/number.js +327 -60
  30. package/module/object.js +474 -184
  31. package/module/regex.js +255 -25
  32. package/module/schema.js +24 -6
  33. package/module/simd.js +117 -0
  34. package/module/string.js +621 -226
  35. package/module/symbol.js +1 -1
  36. package/module/timer.js +9 -14
  37. package/module/typedarray.js +459 -73
  38. package/package.json +58 -14
  39. package/src/abi/index.js +39 -23
  40. package/src/abi/string.js +38 -41
  41. package/src/ast.js +460 -0
  42. package/src/autoload.js +29 -24
  43. package/src/bridge.js +111 -0
  44. package/src/compile/analyze-scans.js +824 -0
  45. package/src/compile/analyze.js +1600 -0
  46. package/src/compile/emit-assign.js +411 -0
  47. package/src/compile/emit.js +3512 -0
  48. package/src/compile/flow-types.js +103 -0
  49. package/src/{compile.js → compile/index.js} +778 -128
  50. package/src/{infer.js → compile/infer.js} +62 -98
  51. package/src/compile/loop-divmod.js +155 -0
  52. package/src/{narrow.js → compile/narrow.js} +409 -106
  53. package/src/compile/peel-stencil.js +261 -0
  54. package/src/compile/plan/advise.js +370 -0
  55. package/src/compile/plan/common.js +150 -0
  56. package/src/compile/plan/index.js +122 -0
  57. package/src/compile/plan/inline.js +682 -0
  58. package/src/compile/plan/literals.js +1199 -0
  59. package/src/compile/plan/loops.js +472 -0
  60. package/src/compile/plan/scope.js +649 -0
  61. package/src/compile/program-facts.js +423 -0
  62. package/src/ctx.js +220 -64
  63. package/src/ir.js +589 -172
  64. package/src/kind-traits.js +132 -0
  65. package/src/kind.js +524 -0
  66. package/src/op-policy.js +57 -0
  67. package/src/{optimize.js → optimize/index.js} +1432 -473
  68. package/src/optimize/vectorize.js +4660 -0
  69. package/src/param-reps.js +65 -0
  70. package/src/parse.js +44 -0
  71. package/src/{prepare.js → prepare/index.js} +649 -205
  72. package/src/prepare/lift-iife.js +149 -0
  73. package/src/reps.js +116 -0
  74. package/src/resolve.js +12 -3
  75. package/src/static.js +208 -0
  76. package/src/type.js +651 -0
  77. package/src/{assemble.js → wat/assemble.js} +275 -55
  78. package/src/wat/optimize.js +3938 -0
  79. package/transform.js +21 -0
  80. package/wasi.js +47 -5
  81. package/src/analyze.js +0 -3818
  82. package/src/emit.js +0 -3040
  83. package/src/jzify.js +0 -1580
  84. package/src/plan.js +0 -2132
  85. package/src/vectorize.js +0 -1088
  86. /package/src/{codegen.js → wat/codegen.js} +0 -0
package/src/ir.js CHANGED
@@ -21,19 +21,14 @@
21
21
  */
22
22
 
23
23
  import { ctx, err, inc, PTR, LAYOUT } from './ctx.js'
24
- import { T, VAL, valTypeOf, lookupValType, repOf, repOfGlobal, objLiteralSchemaId } from './analyze.js'
24
+ import { ptrBoxPrefixBigInt, atomNanHex, nanPrefixHex } from '../layout.js'
25
+ import { I32_MIN, I32_MAX, isI32, isLiteralStr, isFuncRef } from './ast.js'
26
+ import { VAL, lookupValType, repOf, repOfGlobal } from './reps.js'
27
+ import { valTypeOf } from './kind.js'
28
+ import { T } from './ast.js'
29
+ import { objLiteralSchemaId } from './static.js'
25
30
 
26
- // === Numeric range ===
27
-
28
- /** Signed-32-bit range. Used everywhere a number value must round-trip through
29
- * wasm `i32` (literal constants, default-arg folding, exprType inference). */
30
- export const I32_MIN = -2147483648
31
- export const I32_MAX = 2147483647
32
-
33
- /** True when `v` is a finite integer that fits in i32 *and* isn't -0 (which i32
34
- * cannot represent). Callers that don't care about -0 can compare against
35
- * I32_MIN/I32_MAX directly. */
36
- export const isI32 = (v) => Number.isInteger(v) && v >= I32_MIN && v <= I32_MAX && !Object.is(v, -0)
31
+ export { I32_MIN, I32_MAX, isI32, isLiteralStr, isFuncRef }
37
32
 
38
33
  // === Type helpers ===
39
34
 
@@ -42,9 +37,7 @@ export const typed = (node, type) => (node.type = type, node)
42
37
 
43
38
  /** NaN-box prefix for a pointer of VAL kind K with aux bits: `0x7FF8 | type<<47 | aux<<32`. */
44
39
  function ptrBoxPrefix(ptrType, aux = 0) {
45
- return (0x7FF8n << 48n)
46
- | ((BigInt(ptrType) & 0xFn) << 47n)
47
- | ((BigInt(aux) & 0x7FFFn) << 32n)
40
+ return ptrBoxPrefixBigInt(ptrType, aux)
48
41
  }
49
42
 
50
43
  /** Build f64 NaN-boxed pointer IR from an i32 offset node of known kind.
@@ -62,6 +55,15 @@ function boxPtrIR(i32node, ptrType, aux = 0) {
62
55
  * `(x >>> 0)` uint32 idiom converts to a positive f64 in [0, 2^32) instead of sign-flipping. */
63
56
  export const asF64 = n => {
64
57
  if (n == null) err(`compiler internal: expected emitted IR value in ${ctx.func.current?.name || '<module>'}, got empty value`)
58
+ // A v128 (SIMD) value can't be NaN-boxed into the uniform f64 closure ABI — there is no
59
+ // lossless f64 carrier for 128 bits. This is reached only at a closure boundary: a SIMD
60
+ // value captured by, passed to, returned from, or flowing through a `(…)=>…` used as a
61
+ // VALUE — most commonly an IIFE `(() => f32x4.…)()`, which jz lowers via the closure path.
62
+ // Without this guard the coercion emits `f64.convert_i32_s(<v128>)` and dies in the wasm
63
+ // validator with an opaque type error. Keep SIMD inside a NAMED top-level function called
64
+ // directly (`let sdf = (x) => f32x4.…; sdf(v)` lowers to a typed v128 `call`, no boxing),
65
+ // and extract scalars with `f64x2.lane` / `f32x4.lane` before crossing a closure boundary.
66
+ if (n.type === 'v128') err(`SIMD (v128) values can't cross a closure/IIFE boundary — closures use the uniform f64 ABI. Move the SIMD into a named top-level function called directly, or extract a lane (f64x2.lane / f32x4.lane) to an f64 first.`)
65
67
  if (n.ptrKind != null) return boxPtrIR(n, valKindToPtr(n.ptrKind), n.ptrAux || 0)
66
68
  if (n.type === 'f64') return n
67
69
  if (n.type === 'i64') {
@@ -102,7 +104,83 @@ export const asPtrOffset = (n, ptrKind) => {
102
104
  }
103
105
 
104
106
  /** Coerce emitted IR to a target WASM param type ('i32' | 'i64' | 'f64'). */
105
- export const asParamType = (n, t) => t === 'i32' ? asI32(n) : t === 'i64' ? asI64(n) : asF64(n)
107
+ export const asParamType = (n, t) => t === 'i32' ? asI32(n) : t === 'i64' ? asI64(n) : t === 'v128' ? n : asF64(n)
108
+
109
+ // Sound upper bound on the value of a masking expr (`&` / `>>>`), so a product
110
+ // against it can be proven < 2^53 and narrow to i32.mul instead of the guarded f64
111
+ // path. `& m` with a non-negative mask m clamps the result to [0, m] (regardless of
112
+ // the other operand's sign); `>>> k` is logical, so it's ≤ 2^(32−k). Anything else
113
+ // (signed shift, plain locals, negative mask) stays the full i32 range.
114
+ export const maskBound = (x) => {
115
+ if (!Array.isArray(x)) return 2 ** 31
116
+ if (x[0] === 'i32.const') return x[1] >= 0 ? x[1] : 2 ** 31
117
+ if (x[0] === 'i32.and') return Math.min(maskBound(x[1]), maskBound(x[2]))
118
+ if (x[0] === 'i32.shr_u') {
119
+ const k = Array.isArray(x[2]) && x[2][0] === 'i32.const' ? (x[2][1] & 31) : 0
120
+ return k > 0 ? 2 ** (32 - k) : 2 ** 31
121
+ }
122
+ return 2 ** 31
123
+ }
124
+
125
+ /**
126
+ * Narrow an f64 arithmetic tree under ToInt32 — the general int-accumulator path.
127
+ *
128
+ * ToInt32 is reduction mod 2^32, and {+, −, ×} form a RING under that modulus:
129
+ * operands may wrap to i32 eagerly and the final result still equals ToInt32 of
130
+ * the JS value — PROVIDED the original f64 computation was exact (no rounding).
131
+ * Exactness is tracked structurally: every interior node's worst-case magnitude
132
+ * (`maxAbs`, real un-wrapped value) must stay below 2^53. Leaves are peeled
133
+ * `f64.convert_i32_*` wrappers (≤2^31/2^32) and integer constants.
134
+ *
135
+ * `/` is NOT a ring op (fractions): it narrows only at the ToInt32 ROOT, with a
136
+ * FAITHFUL numerator (i32 value == JS value — wrapped sums excluded) and a
137
+ * constant integer divisor. i32.div_s truncates toward zero exactly like
138
+ * ToInt32 of the f64 quotient (error < ulp/2 < distance-to-integer for any i32
139
+ * numerator); c ∈ {0,−1,1} are diverted (trap / INT_MIN trap / identity).
140
+ *
141
+ * Returns {node (i32-typed), maxAbs, faithful} or null — callers use `.node`.
142
+ */
143
+ const narrowI32 = (x, isRoot) => {
144
+ if (!Array.isArray(x)) return null
145
+ if (x.type === 'i32') return { node: x, maxAbs: 2 ** 31, faithful: true }
146
+ const op = x[0]
147
+ if (op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u')
148
+ // Peel — same as toI32's peephole. _u values ∈ [0, 2^32): the re-tag IS the
149
+ // wrap (ring-compatible), but the i32 view differs from the JS value above
150
+ // 2^31, so _u is not faithful.
151
+ return {
152
+ node: Array.isArray(x[1]) ? typed(x[1], 'i32') : x[1],
153
+ maxAbs: op === 'f64.convert_i32_s' ? 2 ** 31 : 2 ** 32,
154
+ faithful: op === 'f64.convert_i32_s',
155
+ }
156
+ if (op === 'f64.const' && typeof x[1] === 'number' && Number.isInteger(x[1]) && Math.abs(x[1]) < 2 ** 52)
157
+ return { node: typed(['i32.const', x[1] | 0], 'i32'), maxAbs: Math.abs(x[1]), faithful: Math.abs(x[1]) < 2 ** 31 }
158
+ if (op === 'f64.add' || op === 'f64.sub' || op === 'f64.mul') {
159
+ const a = narrowI32(x[1]), b = narrowI32(x[2])
160
+ if (!a || !b) return null
161
+ const maxAbs = op === 'f64.mul' ? a.maxAbs * b.maxAbs : a.maxAbs + b.maxAbs
162
+ if (maxAbs >= 2 ** 53) return null
163
+ const iop = op === 'f64.add' ? 'i32.add' : op === 'f64.sub' ? 'i32.sub' : 'i32.mul'
164
+ return { node: typed([iop, a.node, b.node], 'i32'), maxAbs, faithful: false }
165
+ }
166
+ if (op === 'f64.neg') {
167
+ const a = narrowI32(x[1])
168
+ if (!a) return null
169
+ return { node: typed(['i32.sub', ['i32.const', 0], a.node], 'i32'), maxAbs: a.maxAbs, faithful: false }
170
+ }
171
+ if (op === 'f64.div' && isRoot) {
172
+ const a = narrowI32(x[1])
173
+ if (!a || !a.faithful) return null
174
+ const c = Array.isArray(x[2]) && x[2][0] === 'f64.const' && typeof x[2][1] === 'number' ? x[2][1] : null
175
+ if (c == null || !Number.isInteger(c) || c === 0 || c === 1 || Math.abs(c) >= 2 ** 31) return null
176
+ // c = −1 would trap on INT_MIN; 0 − x wraps INT_MIN → INT_MIN, matching ToInt32(2^31).
177
+ const node = c === -1
178
+ ? typed(['i32.sub', ['i32.const', 0], a.node], 'i32')
179
+ : typed(['i32.div_s', a.node, ['i32.const', c]], 'i32')
180
+ return { node, maxAbs: 2 ** 31, faithful: c !== -1 }
181
+ }
182
+ return null
183
+ }
106
184
 
107
185
  /** Coerce node to i32 with wrapping (JS `|0` semantics: values > 2^31 wrap to negative).
108
186
  * Per ECMAScript ToInt32, NaN and ±∞ map to 0. `i64.trunc_sat_f64_s` handles NaN
@@ -120,18 +198,22 @@ export const toI32 = n => {
120
198
  }
121
199
  if (Array.isArray(n) && n[0] === 'f64.const' && typeof n[1] === 'number') {
122
200
  const v = n[1]
123
- return typed(['i32.const', Number.isFinite(v) ? v | 0 : 0], 'i32')
201
+ return typed(['i32.const', Number.isFinite(v) ? v | 0 : 0], 'i32') // JS `|0` is ToInt32
124
202
  }
203
+ // General int-arithmetic narrowing: an exact-int f64 tree of {+,−,×,neg,/C}
204
+ // computes in i32 (mod-2^32 ring) — no trunc/guard at all.
205
+ const nw = narrowI32(n, true)
206
+ if (nw) return nw.node
125
207
  // Leaf nodes are cheap to duplicate; for everything else, evaluate once via local.tee.
126
208
  const isLeaf = Array.isArray(n) && n.length <= 2 &&
127
209
  (n[0] === 'f64.const' || n[0] === 'local.get' || n[0] === 'global.get')
210
+ // `i32.wrap_i64(i64.trunc_sat_f64_s x)` is exact ToInt32 for |x| < 2^63 (the
211
+ // overwhelming common range), maps NaN/−∞→0, and +∞ is guarded to 0 by the
212
+ // select. For |x| ≥ 2^63 it saturates rather than wrapping mod 2^32 — a
213
+ // deliberately-allowed asm.js-style boundary (no per-`|0` helper/guard cost).
128
214
  const wrap = x => typed(['i32.wrap_i64', ['i64.trunc_sat_f64_s', x]], 'i32')
129
215
  if (isLeaf) {
130
- return typed(['select',
131
- wrap(n),
132
- ['i32.const', 0],
133
- ['f64.ne', n, ['f64.const', Infinity]]
134
- ], 'i32')
216
+ return typed(['select', wrap(n), ['i32.const', 0], ['f64.ne', n, ['f64.const', Infinity]]], 'i32')
135
217
  }
136
218
  const t = temp('inf')
137
219
  return typed(['select',
@@ -166,16 +248,16 @@ export const fromI64 = n => {
166
248
  * See module/symbol.js for the broader reserved-atom-id scheme.
167
249
  * Distinct from 0, NaN, and all pointers. Triggers default params.
168
250
  * At the JS boundary, null and undefined preserve their identity for interop. */
169
- export const NULL_NAN = '0x' + (LAYOUT.NAN_PREFIX_BITS | (1n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
170
- export const UNDEF_NAN = '0x' + (LAYOUT.NAN_PREFIX_BITS | (2n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
251
+ export const NULL_NAN = atomNanHex(1)
252
+ export const UNDEF_NAN = atomNanHex(2)
171
253
  /** Boxed-boolean carrier. `false`/`true` are reserved atoms — materialized only
172
254
  * where boolean identity is observed (typeof/String/JSON/host boundary); in
173
255
  * branch/arithmetic position booleans stay raw i32/f64 0/1. The atomId encodes
174
256
  * the truth value in its low bit (4=false, 5=true), so `aux & 1` recovers 0/1
175
257
  * and `4 | bit` boxes it — see boolBoxIR / unboxBoolIR. */
176
258
  export const BOOL_ATOM_BASE = 4
177
- export const FALSE_NAN = '0x' + (LAYOUT.NAN_PREFIX_BITS | (4n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
178
- export const TRUE_NAN = '0x' + (LAYOUT.NAN_PREFIX_BITS | (5n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
259
+ export const FALSE_NAN = atomNanHex(4)
260
+ export const TRUE_NAN = atomNanHex(5)
179
261
  /** WAT-template-ready sentinel expressions for use in stdlib template strings.
180
262
  * `f64.const nan:0xHEX` is 3 bytes shorter than `f64.reinterpret_i64 (i64.const ...)`. */
181
263
  export const NULL_WAT = `(f64.const nan:${NULL_NAN})`
@@ -211,13 +293,20 @@ export function unboxBoolIR(f64expr) {
211
293
 
212
294
  /** Max arity of inline closure slots. Closures are compiled with signature
213
295
  * (env f64, argc i32, a0..a{MAX-1} f64) → f64 — no per-call heap alloc.
214
- * Calls with more args than MAX error; rest-param closures receive at most
215
- * MAX rest args when invoked via spread with >MAX dynamic elements. */
296
+ * Direct (non-spread) calls with more args than MAX error. Spread calls are
297
+ * unbounded: the spread site publishes the full args-array offset in
298
+ * $__closure_spill, and a rest-param callee reads args[MAX..argc-1] from it
299
+ * (see module/function.js spread path + compile/index.js rest collection). */
216
300
  export const MAX_CLOSURE_ARITY = 8
217
301
 
218
302
  /** Matches WASM instructions that require a memory section. */
219
- // FIXME: can be simpler regex, just test second part - load vs store vs grow vs size vs memory
220
- export const MEM_OPS = /\b(i32\.load|i32\.store|f64\.load|f64\.store|f32\.load|f32\.store|i64\.load|i64\.store|memory\.size|memory\.grow|i32\.load8|i32\.load16|i32\.store8|i32\.store16)\b/
303
+ // Any instruction that touches linear memory the module must declare memory.
304
+ // Matches every `memory.*` op (size/grow/copy/fill/init) and every typed load/store
305
+ // incl. width suffixes (load8_u, store16, i64.load32_s, v128.load, …). The old
306
+ // hand-enumerated list silently missed memory.copy/fill, v128.load/store and
307
+ // i64.store8/16/32 (all used in stdlib) — a body using only those would wrongly
308
+ // report no-memory. Broad-but-precise: only `memory.` and `<type>.load|store` match.
309
+ export const MEM_OPS = /\b(memory\.\w+|(?:i32|i64|f32|f64|v128)\.(?:load|store)\w*)\b/
221
310
 
222
311
  export const WASM_OPS = new Set(['block','loop','if','then','else','br','br_if','call','call_indirect','return','return_call','throw','try_table','catch','nop','drop','unreachable','select','result','mut','param','func','module','memory','table','elem','data','type','import','export','local','global','ref'])
223
312
  export const SPREAD_MUTATORS = new Set(['push', 'add', 'set', 'unshift'])
@@ -250,15 +339,22 @@ export function mkPtrIR(type, aux, offset) {
250
339
  return typed(['call', '$__mkptr', tIR, aIR, oIR], 'f64')
251
340
  }
252
341
 
253
- /** Offset extraction for a NaN-boxed pointer, specialized on known value type.
254
- * For non-ARRAY pointer kinds we skip `__ptr_offset` entirely arrays are the
255
- * only type that can forward on reallocation, everyone else returns the raw low 32 bits.
342
+ /** Offset extraction for a NaN-boxed pointer.
343
+ * Goes through `__ptr_offset`, which chases the relocation-forwarding chain
344
+ * (cap == -1 sentinel at off-4 relocated offset at off-8). The chase is a
345
+ * single load+compare for any live (non-forwarded) header, so it is a no-op for
346
+ * fixed-shape receivers (OBJECT/TYPED/…) whose cap word is never -1.
347
+ *
348
+ * We do NOT skip it for "non-ARRAY" static types: that shortcut was unsound on
349
+ * two counts. (1) ARRAY is not the only growable container — HASH/SET/MAP relocate
350
+ * too. (2) jz value types are not always precise: a binding inferred OBJECT (a
351
+ * polymorphic parameter, a widened union) can hold a relocated ARRAY at runtime.
352
+ * Writing through its stale pre-relocation base then clobbers whatever now occupies
353
+ * that freed region — a memory-safety hazard that must not depend on inference
354
+ * precision. Memory safety is unconditional; the forwarding follow stays.
256
355
  * If the node is already an unboxed pointer (ptrKind), return it directly. */
257
356
  export function ptrOffsetIR(valIR, valType) {
258
357
  if (valIR.ptrKind != null && valIR.ptrKind !== VAL.ARRAY) return valIR
259
- if (valType != null && valType !== VAL.ARRAY) {
260
- return ['i32.wrap_i64', ['i64.reinterpret_f64', valIR]]
261
- }
262
358
  inc('__ptr_offset')
263
359
  return ['call', '$__ptr_offset', ['i64.reinterpret_f64', valIR]]
264
360
  }
@@ -282,48 +378,73 @@ export function ptrTypeIR(valIR, valType) {
282
378
  ['i64.const', 0xF]]]
283
379
  }
284
380
 
381
+ // SELF-HOST CONTRACT: f64 slot BITS travel as canonical '0x'+16-hex STRINGS.
382
+ // A BigInt crossing a function return / array element / object slot is
383
+ // kind-erased in the kernel (raw i64 bits are untagged) and every subsequent
384
+ // op on it misdispatches; BigInt64Array/BigUint64Array views and
385
+ // DataView.{get,set}BigUint64 are a legacy f64-value shim there. Strings are
386
+ // tagged and survive every boundary; BigInt math happens only inside single
387
+ // expressions. (Same contract as wat/optimize.js's i64 VALUE CONTRACT.)
285
388
  const _F64_BITS_BUF = new ArrayBuffer(8)
286
389
  const _F64_BITS_F = new Float64Array(_F64_BITS_BUF)
287
- const _F64_BITS_U = new BigUint64Array(_F64_BITS_BUF)
390
+ const _F64_BITS_U32 = new Uint32Array(_F64_BITS_BUF) // LE halves: [0]=lo, [1]=hi
391
+ const _hx8 = (u) => (u >>> 0).toString(16).padStart(8, '0')
288
392
 
289
393
  /** Return i64 bit pattern (BigInt) of a pure-literal IR node, or null if non-literal. */
290
394
  export function extractF64Bits(node) {
291
395
  if (!Array.isArray(node)) return null
292
396
  if (node[0] === 'f64.const') {
293
- if (typeof node[1] === 'number') { _F64_BITS_F[0] = node[1]; return _F64_BITS_U[0] }
397
+ if (typeof node[1] === 'number') { _F64_BITS_F[0] = node[1]; return '0x' + _hx8(_F64_BITS_U32[1]) + _hx8(_F64_BITS_U32[0]) }
294
398
  if (typeof node[1] === 'string' && node[1].startsWith('nan:')) {
295
- try { return BigInt(node[1].slice(4)) | 0x7FF0000000000000n } catch { return null }
399
+ try {
400
+ const v = BigInt(node[1].slice(4)) | 0x7ff0000000000000n
401
+ return '0x' + v.toString(16).padStart(16, '0')
402
+ } catch { return null }
296
403
  }
297
404
  return null
298
405
  }
299
406
  if (node[0] === 'f64.reinterpret_i64' && Array.isArray(node[1]) && node[1][0] === 'i64.const' && typeof node[1][1] === 'string') {
300
407
  const s = node[1][1]
301
408
  if (s.startsWith('-')) {
302
- const abs = s.slice(1)
303
- try { return ((1n << 64n) - BigInt(abs)) & 0xFFFFFFFFFFFFFFFFn } catch { return null }
409
+ // Two's complement WITHOUT a 2^64 term: (-1 − |v|) + 1 ≡ 2^64 − |v| both
410
+ // natively and on the kernel's mod-2^64 carrier (1n<<64n is unrepresentable
411
+ // there and would silently corrupt).
412
+ try {
413
+ const v = (0xffffffffffffffffn - BigInt(s.slice(1)) + 1n) & 0xffffffffffffffffn
414
+ return '0x' + v.toString(16).padStart(16, '0')
415
+ } catch { return null }
304
416
  }
305
- try { return BigInt(s) } catch { return null }
417
+ try {
418
+ const v = BigInt(s)
419
+ return '0x' + v.toString(16).padStart(16, '0')
420
+ } catch { return null }
306
421
  }
307
422
  return null
308
423
  }
309
424
 
310
- /** Append `slots` (BigInt i64 each) to ctx.runtime.data 8-byte aligned, return raw byte offset of first slot.
311
- * Slots that look like NaN-boxed pointers are recorded in `ctx.runtime.staticPtrSlots` so the
312
- * prefix-strip pass can patch their embedded offsets. */
425
+ /** Append `slots` ('0x'+16-hex bit strings, see contract above) to
426
+ * ctx.runtime.data 8-byte aligned, return raw byte offset of first slot.
427
+ * Slots that look like NaN-boxed pointers are recorded in
428
+ * `ctx.runtime.staticPtrSlots` so the prefix-strip pass can patch their
429
+ * embedded offsets. Writes go through u32 halves — DataView's BigInt
430
+ * accessors are unfaithful in the self-host kernel. */
313
431
  export function appendStaticSlots(slots, headerBytes = 0) {
314
432
  if (!ctx.runtime.data) ctx.runtime.data = ''
315
433
  while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
316
434
  const off = ctx.runtime.data.length
317
435
  const u8 = new Uint8Array(headerBytes + slots.length * 8)
318
436
  const dv = new DataView(u8.buffer)
319
- for (let i = 0; i < slots.length; i++) dv.setBigUint64(headerBytes + i * 8, slots[i], true)
437
+ for (let i = 0; i < slots.length; i++) {
438
+ const h = slots[i]
439
+ dv.setUint32(headerBytes + i * 8, parseInt(h.slice(10), 16) >>> 0, true)
440
+ dv.setUint32(headerBytes + i * 8 + 4, parseInt(h.slice(2, 10), 16) >>> 0, true)
441
+ }
320
442
  let chunk = ''
321
443
  for (let i = 0; i < u8.length; i++) chunk += String.fromCharCode(u8[i])
322
444
  ctx.runtime.data += chunk
323
445
  if (!ctx.runtime.staticPtrSlots) ctx.runtime.staticPtrSlots = []
324
446
  for (let i = 0; i < slots.length; i++) {
325
- const bits = slots[i]
326
- if (((bits >> 48n) & 0xFFF8n) === BigInt(LAYOUT.NAN_PREFIX)) {
447
+ if ((parseInt(slots[i].slice(2, 6), 16) & 0xFFF8) === LAYOUT.NAN_PREFIX) {
327
448
  ctx.runtime.staticPtrSlots.push(off + i * 8)
328
449
  }
329
450
  }
@@ -335,8 +456,8 @@ export function appendStaticSlots(slots, headerBytes = 0) {
335
456
  /** Check if emitted node is a compile-time constant. */
336
457
  export const isLit = n => (n[0] === 'i32.const' || n[0] === 'f64.const') && typeof n[1] === 'number'
337
458
  export const litVal = n => n[1]
338
- const isNullLit = n => Array.isArray(n) && n.length === 2 && n[0] == null && n[1] == null
339
- const isUndefLit = n => Array.isArray(n) && n.length === 0
459
+ export const isNullLit = n => Array.isArray(n) && n.length === 2 && n[0] == null && n[1] == null
460
+ export const isUndefLit = n => Array.isArray(n) && n.length === 0
340
461
  export const isNullishLit = n => isNullLit(n) || isUndefLit(n)
341
462
 
342
463
  /** Side-effect-free (safe for WASM select). */
@@ -348,46 +469,175 @@ const PURE_OPS = new Set(['i32.const', 'f64.const', 'local.get', 'global.get',
348
469
  'i32.eq', 'i32.ne', 'i32.lt_s', 'i32.gt_s', 'i32.le_s', 'i32.ge_s', 'i32.eqz'])
349
470
  export const isPureIR = n => Array.isArray(n) && PURE_OPS.has(n[0]) && n.slice(1).every(c => !Array.isArray(c) || isPureIR(c))
350
471
 
351
- /** Check if AST node is a literal string: ['str', 'value']. */
352
- export const isLiteralStr = idx => Array.isArray(idx) && idx[0] === 'str' && typeof idx[1] === 'string'
472
+ /** Ops whose f64 result is always a plain number (never a NaN-boxed pointer).
473
+ * Used by toNumF64 to skip the __to_num wrapper when the value is provably numeric.
474
+ * NOTE: f64.const is NOT included — it may encode a NaN-boxed pointer. */
475
+ const PURE_F64_OPS = new Set([
476
+ 'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
477
+ 'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
478
+ 'f64.convert_i32_s', 'f64.convert_i32_u', 'f64.promote_f32',
479
+ ])
480
+
481
+ /** True iff `r` provably yields a plain f64 NUMBER (never a NaN-boxed pointer or
482
+ * nullish sentinel). A `block`/`if` is numeric only when its value-producing tail
483
+ * is — so `o.a?.b` (a block whose result is a property value or undef sentinel)
484
+ * is correctly NOT numeric, while `cond ? n*2 : n*3` is. Conservative: any shape
485
+ * not provably numeric (property gets, user calls, local.get, f64.const nan:…)
486
+ * returns false, so the caller keeps the __to_num coercion. */
487
+ export const isNumericIR = (r) => {
488
+ if (!Array.isArray(r)) return false
489
+ const op = r[0]
490
+ if (PURE_F64_OPS.has(op)) return true
491
+ if (op === 'call' && typeof r[1] === 'string' && (r[1].startsWith('$math.') || r[1] === '$__time_ms')) return true
492
+ if (op === 'f64.const') return typeof r[1] === 'number' // 'nan:…' carrier ⇒ pointer/sentinel
493
+ if (op === 'block') return isNumericIR(r[r.length - 1]) // block value = its tail expr
494
+ if (op === 'if') { // both arms must be numeric
495
+ const thenArm = r.find(x => Array.isArray(x) && x[0] === 'then')
496
+ const elseArm = r.find(x => Array.isArray(x) && x[0] === 'else')
497
+ return !!thenArm && !!elseArm &&
498
+ isNumericIR(thenArm[thenArm.length - 1]) && isNumericIR(elseArm[elseArm.length - 1])
499
+ }
500
+ return false
501
+ }
353
502
 
354
- /** Resolve compile-time value type from AST node (literal → type, name → lookup). */
503
+ /** Resolve compile-time value type from AST node (literal → name → lookup). */
355
504
  export const resolveValType = (node, valTypeOf, lookupValType) =>
356
505
  valTypeOf(node) ?? (typeof node === 'string' ? lookupValType(node) : null)
357
506
 
358
- /** Check if AST node is a string reference to a known function name. */
359
- export const isFuncRef = (node, funcNames) => typeof node === 'string' && funcNames.has(node)
360
-
361
507
  /** Check if (a, op, b) is a postfix pattern: [op, name] and [, 1] literal. */
362
508
  export const isPostfix = (a, op, b) => Array.isArray(a) && a[0] === op && Array.isArray(b) && b[0] == null && b[1] === 1
363
509
 
364
510
  /** Emit a numeric constant with correct i32/f64 typing.
365
511
  * `-0` is f64-only (i32 has no signed zero) — preserve the sign by emitting f64. */
366
512
  export const emitNum = v => isI32(v)
367
- ? typed(['i32.const', v], 'i32') : typed(['f64.const', v], 'f64')
513
+ ? typed(['i32.const', v], 'i32')
514
+ // Emit NaN via the `nan` token, not the raw JS number: a numeric NaN literal in
515
+ // the IR loses its quiet-mantissa bit (0x7FF8→0x7FF0, i.e. becomes Infinity) when
516
+ // the self-host kernel marshals the IR back across the wasm→host boundary. The
517
+ // `nan` token assembles to the canonical 0x7FF8 number-NaN unambiguously.
518
+ : typed(['f64.const', v !== v ? 'nan' : v], 'f64')
368
519
 
369
520
  // === Temp locals ===
370
521
 
371
- /** Allocate a temp local, returns name without $. Optional tag aids WAT readability.
372
- * Skips names already registered (by analyzeBody().locals from prepare-generated
373
- * names) to avoid collisions that would silently override the pre-analyzed type. */
374
- export function temp(tag = '') {
522
+ /** Allocate a fresh local name with the given tag, registered as `type`. The
523
+ * selfhost compiler doesn't yet handle exported-const arrow factories returning
524
+ * closures, so the three temp() helpers stay as `function` declarations and
525
+ * delegate to this shared core. */
526
+ function freshLocal(type, tag) {
375
527
  let name
376
528
  do { name = `${T}${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
377
- ctx.func.locals.set(name, 'f64')
529
+ ctx.func.locals.set(name, type)
378
530
  return name
379
531
  }
380
- export function tempI32(tag = '') {
381
- let name
382
- do { name = `${T}${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
383
- ctx.func.locals.set(name, 'i32')
384
- return name
532
+ export function temp (tag = '') { return freshLocal('f64', tag) }
533
+ export function tempI32 (tag = '') { return freshLocal('i32', tag) }
534
+ export function tempI64 (tag = '') { return freshLocal('i64', tag) }
535
+
536
+ // === IR scaffolds ===
537
+
538
+ /** Wrap a sequence of statements as a typed `(block (result <type>) …)`.
539
+ * Default result is `f64` (the value-type for most jz emissions).
540
+ * Shorthand for the `typed(['block', ['result', T], …stmts], T)` pattern that
541
+ * appears in nearly every emitter — keeps call sites focused on the body. */
542
+ export const block64 = (...stmts) => typed(['block', ['result', 'f64'], ...stmts], 'f64')
543
+ export const blockTyped = (type, ...stmts) => typed(['block', ['result', type], ...stmts], type)
544
+
545
+ /** Allocate an f64 temp, set it to `val`, run `body(name)` and yield its result.
546
+ * `body` may return either a single IR node (used as the block result) or an
547
+ * array of nodes whose last expression becomes the result. Eliminates the
548
+ * repetitive `const t = temp(); …['local.set', $t, val]; …['local.get', $t]`
549
+ * scaffold around tee-and-use patterns. */
550
+ export function withTemp(val, body, tag = '') {
551
+ const t = temp(tag)
552
+ const out = body(t)
553
+ const tail = Array.isArray(out) && out.every(n => Array.isArray(n)) ? out : [out]
554
+ return block64(['local.set', `$${t}`, val], ...tail)
385
555
  }
386
- export function tempI64(tag = '') {
387
- let name
388
- do { name = `${T}${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
389
- ctx.func.locals.set(name, 'i64')
390
- return name
556
+
557
+ /** Whole-fn structural refcount: walks `fn`, counting how many times each
558
+ * array node is referenced. Used by optimizer passes to skip shared subtrees
559
+ * (watr CSE may leave them) — mutating a node with refcount > 1 would also
560
+ * affect references outside the current region. Single-pass O(N). */
561
+ export function buildRefcount(fn) {
562
+ const refcount = new Map()
563
+ const walk = (node) => {
564
+ if (!Array.isArray(node)) return
565
+ const n = (refcount.get(node) || 0) + 1
566
+ refcount.set(node, n)
567
+ if (n > 1) return // already counted children below
568
+ for (let i = 0; i < node.length; i++) walk(node[i])
569
+ }
570
+ walk(fn)
571
+ return refcount
572
+ }
573
+
574
+ /** Pick the next free `$__<prefix><id>` local-name id by collecting all
575
+ * existing ids in a single walk. Replaces the per-pass
576
+ * `while (fn.some(... === $__prefixK)) k++` (O(K·N)) with one O(N) scan. */
577
+ export function nextLocalId(fn, prefix) {
578
+ // HIGH-WATER mark (max existing + 1), NOT the first free id. Callers allocate sequentially
579
+ // (id++), so a first-gap start would walk straight into an existing higher local once watr's
580
+ // coalesce has left non-contiguous numbering (e.g. $__pe0,$__pe1,$__pe5 → start at 2, then
581
+ // mint 3,4,5 and collide on $__pe5 = "Duplicate local"). High-water is always collision-free.
582
+ const needle = `$__${prefix}`
583
+ let id = 0
584
+ const walk = (n) => {
585
+ if (!Array.isArray(n)) return
586
+ if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith(needle)) {
587
+ const tail = n[1].slice(needle.length)
588
+ if (/^\d+$/.test(tail)) { const k = +tail; if (k >= id) id = k + 1 }
589
+ }
590
+ for (let i = 0; i < n.length; i++) walk(n[i])
591
+ }
592
+ walk(fn)
593
+ return id
594
+ }
595
+
596
+ /** Single-kind ptr-tag predicate: `__ptr_type(bits) == ptr`. Takes the f64
597
+ * carrier expression and the PTR constant. Use this when guarding one branch;
598
+ * use `dispatchByPtrType` for multi-case forks. Stamps `inc('__ptr_type')`. */
599
+ export function ptrTypeEq(f64Expr, ptr) {
600
+ inc('__ptr_type')
601
+ return typed(['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', f64Expr]], ['i32.const', ptr]], 'i32')
602
+ }
603
+
604
+ /** ToPrimitive sidecar probe (ES2024 7.1.1): an own `valueOf`/`toString` data
605
+ * property shadows the builtin. Reads the dynamic-prop sidecar slot keyed by
606
+ * `nameIR` (an emitted i64 string key) off receiver `objIR`; if it holds a
607
+ * closure, yields `onOverride($p)`, else `onFallback($o)` (both f64). Shared by
608
+ * the member-READ path (module/core.js — onOverride returns the closure value,
609
+ * onFallback calls the arity-≤1 builtin) and the method-CALL path (emit.js —
610
+ * onOverride invokes the closure, onFallback calls the builtin method). */
611
+ export function sidecarOverride(objIR, nameIR, onOverride, onFallback) {
612
+ const o = temp('vo'), p = temp('vp')
613
+ inc('__dyn_get_expr', '__ptr_type')
614
+ return block64(
615
+ ['local.set', `$${o}`, asF64(objIR)],
616
+ ['local.set', `$${p}`, ['f64.reinterpret_i64',
617
+ ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${o}`]], nameIR]]],
618
+ ['if', ['result', 'f64'],
619
+ ptrTypeEq(['local.get', `$${p}`], PTR.CLOSURE),
620
+ ['then', onOverride(p, o)],
621
+ ['else', onFallback(o)]])
622
+ }
623
+
624
+ /** Dispatch on `__ptr_type(bits)` — emits a right-leaning if/else chain over
625
+ * PTR constants. `cases` is `[[PTR.X, ir], …]`; `fallback` is the else IR.
626
+ * `resultType` defaults to `'f64'`; pass `null` for a void dispatch (e.g.
627
+ * pure memory-writing branches). Centralizes the
628
+ * `i32.eq (call $__ptr_type bits) (i32.const PTR.X)` pattern so emitters
629
+ * dispatching by pointer kind stay declarative. */
630
+ export function dispatchByPtrType(typeLocal, cases, fallback, resultType = 'f64') {
631
+ let out = fallback
632
+ const head = resultType ? ['if', ['result', resultType]] : ['if']
633
+ for (let i = cases.length - 1; i >= 0; i--) {
634
+ const [ptr, ir] = cases[i]
635
+ out = [...head,
636
+ ['i32.eq', ['local.get', `$${typeLocal}`], ['i32.const', ptr]],
637
+ ['then', ir],
638
+ ['else', out]]
639
+ }
640
+ return out
391
641
  }
392
642
 
393
643
  // === Numeric helpers ===
@@ -395,23 +645,17 @@ export function tempI64(tag = '') {
395
645
  /** WASM has no f64.rem — implement as a - trunc(a/b) * b.
396
646
  * Both `a` and `b` appear twice in the expansion; cache non-pure operands
397
647
  * in locals so side effects (e.g. assignments) only execute once. */
398
- export const f64rem = (a, b) => {
399
- const pa = isPureIR(a), pb = isPureIR(b)
400
- if (pa && pb) return typed(['f64.sub', a, ['f64.mul', ['f64.trunc', ['f64.div', a, b]], b]], 'f64')
401
- const ta = pa ? null : temp(), tb = pb ? null : temp()
402
- const ga = pa ? a : ['local.get', `$${ta}`], gb = pb ? b : ['local.get', `$${tb}`]
403
- const pre = []
404
- if (!pa) pre.push(['local.set', `$${ta}`, a])
405
- if (!pb) pre.push(['local.set', `$${tb}`, b])
406
- return typed(['block', ['result', 'f64'], ...pre,
407
- ['f64.sub', ga, ['f64.mul', ['f64.trunc', ['f64.div', ga, gb]], gb]]], 'f64')
408
- }
648
+ // JS `%` on the f64 path. Delegates to the exact `__rem` (binary fmod) stdlib
649
+ // the textbook `a - b*trunc(a/b)` is inexact for large a/b and wrong on the
650
+ // ±Inf / 0 / NaN edges. The i32.rem_s fast path in emit.js handles the common
651
+ // integer-with-nonzero-literal-divisor case; everything else lands here.
652
+ export const f64rem = (a, b) => (inc('__rem'), typed(['call', '$__rem', a, b], 'f64'))
409
653
 
410
654
  /** Resolve the slot index of a ToPrimitive method (`valueOf`/`toString`) on an
411
655
  * OBJECT operand — from a schema-bound variable or an inline object literal.
412
656
  * Returns -1 when the method is absent. */
413
657
  function primMethodIdx(node, name) {
414
- if (typeof node === 'string') return ctx.schema.find(node, name)
658
+ if (typeof node === 'string') return ctx.schema.slotOf(node, name)
415
659
  const sid = objLiteralSchemaId(node)
416
660
  const props = sid != null ? ctx.schema.list[sid] : null
417
661
  return props ? props.indexOf(name) : -1
@@ -448,18 +692,40 @@ function toPrimitiveChain(node, v, order) {
448
692
  return typed(['block', blk, ...body], 'i64')
449
693
  }
450
694
 
695
+ const cloneIR = (n) => Array.isArray(n) ? n.map(cloneIR) : n
696
+
697
+ /** ToNumber for a runtime value that may carry a nullish sentinel: null→+0, undefined→NaN,
698
+ * anything else → itself. `valIR` must be side-effect-free (a local read) — it is duplicated,
699
+ * so each occurrence gets a fresh clone. Used for bindings flagged in ctx.func.maybeNullish;
700
+ * a real number isn't either sentinel, so it falls through the `else` unchanged. */
701
+ const coerceNullishToNum = (valIR) => typed(
702
+ ['if', ['result', 'f64'],
703
+ ['i64.eq', ['i64.reinterpret_f64', cloneIR(valIR)], ['i64.const', NULL_NAN]],
704
+ ['then', ['f64.const', 0]],
705
+ ['else', ['if', ['result', 'f64'],
706
+ ['i64.eq', ['i64.reinterpret_f64', cloneIR(valIR)], ['i64.const', UNDEF_NAN]],
707
+ ['then', ['f64.const', 'nan']],
708
+ ['else', cloneIR(valIR)]]]],
709
+ 'f64')
710
+
451
711
  /** Coerce an emitted IR value to a plain f64 Number per JS `ToNumber`.
452
712
  * Skips coercion when static type proves the value is already numeric
453
713
  * (i32 node, compile-time literal, known VAL.NUMBER/VAL.BIGINT). When the full
454
714
  * string-parsing `__to_num` isn't loaded (no string module → no strings can
455
715
  * exist) nullish *literals* still fold statically (null→+0, undefined→NaN);
456
- * non-literal values pass through uncoerced. */
716
+ * non-literal values pass through uncoerced — except bindings flagged
717
+ * maybeNullish, which get a runtime nullish coerce (null-flow correctness). */
457
718
  export function toNumF64(node, v) {
458
719
  // An i32 node carrying `.ptrKind` is an *unboxed pointer* (object/array local),
459
720
  // not a number — skipping coercion would reinterpret pointer bits as an f64.
460
721
  // Only a plain i32 (loop counter, `x|0`) is genuinely already-numeric.
461
722
  if ((v.type === 'i32' && v.ptrKind == null) || isLit(v)) return asF64(v)
462
- const vt = keyValType(node)
723
+ // A binding assigned a nullish literal may hold null/undefined here — coerce per ToNumber
724
+ // (null→+0, undefined→NaN); a real number falls through unchanged. Only flagged bindings pay
725
+ // this, so the numeric kernels jz optimizes for (which never assign null) stay untouched.
726
+ if (typeof node === 'string' && ctx.func.maybeNullish?.has(node)) return coerceNullishToNum(asF64(v))
727
+ const vt = valTypeOf(node)
728
+ if (vt === VAL.BOOL) return typed(['f64.convert_i32_s', truthyIR(v)], 'f64')
463
729
  if (vt === VAL.NUMBER || vt === VAL.BIGINT) return asF64(v)
464
730
  if (vt === VAL.DATE) {
465
731
  const ptr = v.ptrKind === VAL.DATE
@@ -473,7 +739,7 @@ export function toNumF64(node, v) {
473
739
  // yield non-primitives a TypeError is thrown. The chosen primitive still
474
740
  // flows through `__to_num` so a string return ("−7") is parsed. An abrupt
475
741
  // completion (throwing method) propagates through the closure call.
476
- if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.find) {
742
+ if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.slotOf) {
477
743
  const prim = toPrimitiveChain(node, v, ['valueOf', 'toString'])
478
744
  if (prim) {
479
745
  // No `__to_num` helper → the program provably has no strings, so the
@@ -492,11 +758,29 @@ export function toNumF64(node, v) {
492
758
  if (ctx.schema.slotIntCertainAt?.(node[1], node[2]) === true) return asF64(v)
493
759
  }
494
760
  // IR-level shapes that produce real f64 numbers (never NaN-boxed pointers):
495
- // i32→f64 conversions, stdlib clock helper. Skip the __to_num call wrapper.
761
+ // i32→f64 conversions, stdlib clock helper, length/ptr helpers.
762
+ // Skip the __to_num call wrapper for these — they always return plain f64.
496
763
  if (Array.isArray(v)) {
497
764
  if (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u') return v
498
765
  if (v[0] === 'call' && v[1] === '$__time_ms') return v
766
+ // __length, __str_len return f64.convert_i32_s of an i32 — never a boxed pointer.
767
+ if (v[0] === 'call' && (v[1] === '$__length' || v[1] === '$__len' || v[1] === '$__str_len')) return v
768
+ // __ptr_type returns i32 tag, __ptr_offset returns i32 offset — both numeric.
769
+ if (v[0] === 'call' && (v[1] === '$__ptr_type' || v[1] === '$__ptr_offset')) return v
499
770
  }
771
+ // f64 arithmetic ops and math intrinsics never produce NaN-boxed pointers — the
772
+ // result is always a plain f64 number. Skip __to_num for these, eliminating the
773
+ // call overhead that dominates tight numeric kernels (floatbeats, matrix loops).
774
+ // A `block`/`if` qualifies only when its value-producing tail is provably numeric
775
+ // (`isNumericIR`): `cond ? n*2 : n*3` skips, but `o.a?.b` (block yielding a
776
+ // property value / undef sentinel) does NOT — else `o.a?.b > 6` would compare the
777
+ // boxed string's NaN bits (NaN > 6 → false). User function calls are excluded too
778
+ // (may return dynamic-property strings); only $math.* is provably numeric.
779
+ if (v.type === 'f64' && Array.isArray(v) && (
780
+ PURE_F64_OPS.has(v[0]) ||
781
+ (v[0] === 'call' && typeof v[1] === 'string' && v[1].startsWith('$math.')) ||
782
+ ((v[0] === 'block' || v[0] === 'if') && isNumericIR(v))
783
+ )) return v
500
784
  if (!ctx.core.stdlib['__to_num']) {
501
785
  // No full ToNumber helper loaded — the program provably has no strings.
502
786
  // A nullish *literal* still coerces (null→+0, undefined→NaN) — fold it
@@ -525,14 +809,22 @@ export function toNumF64(node, v) {
525
809
  * `__to_str` so a numeric return is rendered. A throwing method propagates as
526
810
  * an abrupt completion through the closure call. */
527
811
  export function toStrI64(node, v) {
528
- const vt = keyValType(node)
529
- if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.find) {
812
+ const vt = valTypeOf(node)
813
+ if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.slotOf) {
530
814
  const prim = toPrimitiveChain(node, v, ['toString', 'valueOf'])
531
815
  if (prim) {
532
816
  inc('__to_str')
533
817
  return typed(['call', '$__to_str', prim], 'i64')
534
818
  }
535
819
  }
820
+ // Provably-integer operand → render with the i32-only formatter, bypassing __to_str's
821
+ // float machinery (__ftoa/__toExp/__pow10, ~2 KB). A raw i32 value (`n|0`, a bitwise
822
+ // result, a loop counter) carries no NaN-box, so its ToString is just digits + sign.
823
+ // ptrKind != null means it's an unboxed pointer (i32 offset), NOT a number — exclude.
824
+ if (v.type === 'i32' && v.ptrKind == null) {
825
+ inc('__i32_to_str')
826
+ return typed(['i64.reinterpret_f64', ['call', '$__i32_to_str', v]], 'i64')
827
+ }
536
828
  inc('__to_str')
537
829
  return typed(['call', '$__to_str', asI64(v)], 'i64')
538
830
  }
@@ -540,8 +832,45 @@ export function toStrI64(node, v) {
540
832
  /** Convert already-emitted WASM node to i32 boolean. NaN is falsy (like JS).
541
833
  * Peepholes: i32 → as-is; `f64.convert_i32_*(x)` → x (i32 conversion never NaN);
542
834
  * nested `__is_truthy(x)` → x (already 0/1); literal f64 const folds to 0/1. */
835
+ // f64 ops whose result is always a plain NUMBER (never a NaN-boxed carrier) and can
836
+ // be NaN — their truthiness must test NaN by value, not by bit pattern (see truthyIR).
837
+ const NUM_F64_TRUTHY_OPS = new Set([
838
+ 'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
839
+ 'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
840
+ ])
841
+
842
+ const numericTruthy = e => {
843
+ const t = temp('tb')
844
+ const g = () => typed(['local.get', `$${t}`], 'f64')
845
+ return typed(['block', ['result', 'i32'],
846
+ ['local.set', `$${t}`, e],
847
+ ['i32.and', ['f64.ne', g(), ['f64.const', 0]], ['f64.eq', g(), g()]]], 'i32')
848
+ }
849
+
850
+ // i32 ops whose result is already a 0/1 boolean (comparisons + eqz) — safe to use
851
+ // directly as a truthiness without a redundant `!= 0`.
852
+ // Ops whose result is already a canonical i32 boolean (0 or 1) — a condition built
853
+ // from one needs no `i32.ne(_, 0)` normalization. Every wasm comparison returns 0/1,
854
+ // so the f64/f32/i64 relations belong here too (they were missing — a `a > b ? …`
855
+ // f64 compare was wrapped in a dead `i32.ne(f64.gt …, 0)` in every branch/select).
856
+ const I32_BOOL_OPS = new Set(['i32.eq', 'i32.ne', 'i32.lt_s', 'i32.lt_u', 'i32.gt_s', 'i32.gt_u',
857
+ 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u', 'i32.eqz',
858
+ 'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
859
+ 'f32.eq', 'f32.ne', 'f32.lt', 'f32.gt', 'f32.le', 'f32.ge',
860
+ 'i64.eq', 'i64.ne', 'i64.lt_s', 'i64.lt_u', 'i64.gt_s', 'i64.gt_u',
861
+ 'i64.le_s', 'i64.le_u', 'i64.ge_s', 'i64.ge_u', 'i64.eqz'])
862
+
543
863
  export function truthyIR(e) {
544
- if (e.type === 'i32') return e
864
+ // An i32 *constant* is a concrete number, not a known 0/1 boolean — fold it to its
865
+ // truthiness (nonzero → 1).
866
+ if (Array.isArray(e) && e[0] === 'i32.const') return typed(['i32.const', e[1] ? 1 : 0], 'i32')
867
+ if (e.type === 'i32') {
868
+ // A comparison/eqz result is already 0/1 → use directly. Any *other* i32 may be a
869
+ // concrete narrowed integer (e.g. `Boolean(n)` where n is an i32 number), which is
870
+ // NOT a 0/1 boolean — normalize via `!= 0` so its truthiness is correct.
871
+ if (Array.isArray(e) && I32_BOOL_OPS.has(e[0])) return e
872
+ return typed(['i32.ne', e, ['i32.const', 0]], 'i32')
873
+ }
545
874
  // Unboxed pointer offsets: truthy iff non-zero offset.
546
875
  if (e.ptrKind != null) return typed(['i32.ne', e, ['i32.const', 0]], 'i32')
547
876
  if (Array.isArray(e)) {
@@ -563,7 +892,7 @@ export function truthyIR(e) {
563
892
  // all other NaN-boxed pointers (SSO strings, heap ptrs, etc.) are truthy.
564
893
  if (e[0] === 'f64.reinterpret_i64' && Array.isArray(e[1]) && e[1][0] === 'i64.const') {
565
894
  const bits = String(e[1][1])
566
- const FALSY = new Set([UNDEF_NAN, NULL_NAN, FALSE_NAN, '0x7FF8000000000000', '0x7FFA400000000000'])
895
+ const FALSY = new Set([UNDEF_NAN, NULL_NAN, FALSE_NAN, nanPrefixHex(), '0x7FFA400000000000'])
567
896
  return typed(['i32.const', FALSY.has(bits) ? 0 : 1], 'i32')
568
897
  }
569
898
  // Fresh pointer constructors never produce nullish. Treat as always truthy.
@@ -581,8 +910,25 @@ export function truthyIR(e) {
581
910
  vt === VAL.CLOSURE || vt === VAL.TYPED || vt === VAL.BUFFER || vt === VAL.REGEX || vt === VAL.DATE) {
582
911
  return typed(['i32.eqz', isNullish(e)], 'i32')
583
912
  }
913
+ // A plain NUMBER is truthy iff non-zero AND not NaN. `f64.eq x x` tests NaN by
914
+ // VALUE (false for ANY NaN bits), so this is correct on every platform — unlike
915
+ // __is_truthy, which bit-compares the canonical number-NaN and so mis-reads
916
+ // x86's sign-set 0xFFF8.. NaN (from f64.div(0,0) / %) as a truthy box. (local.get
917
+ // is pure → duplicated, not teed.) Bigint carriers are reinterpret/i64 shapes
918
+ // and never reach here as VAL.NUMBER.
919
+ if (vt === VAL.NUMBER) {
920
+ const g = () => typed(['local.get', e[1]], 'f64')
921
+ return typed(['i32.and', ['f64.ne', g(), ['f64.const', 0]], ['f64.eq', g(), g()]], 'i32')
922
+ }
584
923
  }
924
+ // Direct number-producing f64 expression (arithmetic, or the `%` / __rem helper):
925
+ // same NaN-safe test, single-evaluated through a temp (the value may be a call).
926
+ if (NUM_F64_TRUTHY_OPS.has(e[0]) || (e[0] === 'call' && e[1] === '$__rem')) return numericTruthy(e)
585
927
  }
928
+ // Composite IR tagged by emit as a definite NUMBER. Use value-based NaN
929
+ // truthiness; opaque f64 carriers (strings/objects/bigints/nullish/booleans)
930
+ // remain on __is_truthy so NaN-boxed payloads stay truthy/falsy by tag.
931
+ if (e.valKind === VAL.NUMBER) return numericTruthy(e)
586
932
  inc('__is_truthy')
587
933
  return typed(['call', '$__is_truthy', asI64(e)], 'i32')
588
934
  }
@@ -590,10 +936,6 @@ export const toBoolFromEmitted = truthyIR
590
936
 
591
937
  // === Value-type classification ===
592
938
 
593
- export function keyValType(node) {
594
- return typeof node === 'string' ? lookupValType(node) : valTypeOf(node)
595
- }
596
-
597
939
  export function usesDynProps(vt) {
598
940
  return vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.CLOSURE
599
941
  || vt === VAL.TYPED || vt === VAL.SET || vt === VAL.MAP || vt === VAL.REGEX
@@ -607,15 +949,27 @@ export function needsDynShadow(target) {
607
949
  // access (fn.parse, i32.parse aliases) sees the same value as schema slots.
608
950
  const vt = typeof target === 'string' ? (ctx.func.localReps?.get(target)?.val || ctx.scope.globalValTypes?.get(target)) : null
609
951
  if (vt === 'closure' || usesDynProps(vt)) return true
952
+ // A module-wide dynamic-key access (`obj[expr]`) means ANY object may later be
953
+ // read through the dyn-props hash (__dyn_get_any), so every object literal is
954
+ // built with a shadow. Mutation sites (Object.assign, `o.k = v`) must mirror
955
+ // into that same shadow or a subsequent hash read returns a stale slot value.
956
+ // Honor anyDynKey for NAMED targets too — not just anonymous (target == null)
957
+ // literals — so construct-time shadowing and mutate-time mirroring agree. They
958
+ // desynced before: a named literal shadowed via anyDynKey, but its assign saw
959
+ // only dynKeyVars (which holds the *dynamically-keyed* vars, not this binding).
960
+ if (ctx.types?.anyDynKey) return true
610
961
  const dyn = ctx.types?.dynKeyVars
611
- if (target == null) return ctx.types?.anyDynKey ?? false
612
- return dyn ? dyn.has(target) : false
962
+ return target != null && dyn ? dyn.has(target) : false
613
963
  }
614
964
 
615
965
  // === Variable storage abstraction ===
616
966
  // Centralizes the boxed/global/local 3-way dispatch (used by =, ++/--, +=, etc.)
617
967
 
618
968
  /** Check if name is a module-scope global (not shadowed by local/param). */
969
+ /** Bound in the current function frame — a declared local or a parameter. */
970
+ export const isBoundName = name =>
971
+ ctx.func.locals?.has(name) || ctx.func.current?.params?.some(p => p.name === name)
972
+
619
973
  export function isGlobal(name) {
620
974
  return ctx.scope.globals.has(name) && !ctx.func.locals?.has(name) && !ctx.func.current?.params?.some(p => p.name === name)
621
975
  }
@@ -630,16 +984,43 @@ export function boxedAddr(name) {
630
984
  return ['local.get', `$${ctx.func.boxed.get(name)}`]
631
985
  }
632
986
 
987
+ // '$'-prefixed name memo. readVar/writeVar run per IR node; rebuilding the
988
+ // `$name` string each time costs an alloc+copy in the self-host kernel AND
989
+ // produces a fresh instance per use — making watr's name-keyed lookups
990
+ // content-compare. The memo returns ONE canonical instance per name, so
991
+ // construction is a map hit and every downstream comparison is bit-eq.
992
+ // Module-level: in-kernel it lives per instance (arena strings are immortal),
993
+ // natively it is a plain cross-compile cache; the name vocabulary is bounded.
994
+ const DOLLAR = new Map()
995
+ export const dollar = (name) => {
996
+ let v = DOLLAR.get(name)
997
+ if (v === undefined) { v = '$' + name; DOLLAR.set(name, v) }
998
+ return v
999
+ }
1000
+
633
1001
  /** Read variable value: boxed → f64.load, global → global.get, local → local.get.
634
1002
  * Unboxed pointer locals (repOf(name).ptrKind) tag the returned node with `.ptrKind`
635
1003
  * so downstream coercions know it's an i32 offset, not a numeric. */
636
1004
  export function readVar(name) {
637
- if (ctx.func.boxed?.has(name))
1005
+ if (ctx.func.boxed?.has(name)) {
1006
+ // i32-narrowed cell (closure-capture narrowing — see analyzeFuncForEmit's
1007
+ // cellTypes): the cell stores a raw i32, load it directly.
1008
+ if (ctx.func.cellTypes?.has(name)) return typed(['i32.load', boxedAddr(name)], 'i32')
638
1009
  return typed(['f64.load', boxedAddr(name)], 'f64')
1010
+ }
639
1011
  if (isGlobal(name)) {
640
- const node = typed(['global.get', `$${name}`], ctx.scope.globalTypes.get(name) || 'f64')
1012
+ const gt = ctx.scope.globalTypes.get(name) || 'f64'
1013
+ const node = typed(['global.get', dollar(name)], gt)
641
1014
  const grep = repOfGlobal(name)
642
- if (grep?.ptrKind != null) {
1015
+ if (gt === 'f64' && (lookupValType(name) === VAL.NUMBER || grep?.val === VAL.NUMBER)) node.valKind = VAL.NUMBER
1016
+ // ptrKind tags a raw i32 pointer offset — meaningful only for an i32-STORED
1017
+ // global (a typed-array/buffer carrier unboxed by unboxConstTypedGlobals). An
1018
+ // f64 global holds a NaN-boxed value: object/array reads unbox at the access
1019
+ // site via the schema/reinterpret path, never an i32 reinterpret of the storage.
1020
+ // Attaching ptrKind to an f64 global makes `asF64` box the f64 *as if it were an
1021
+ // i32* (i64.extend_i32_u on a global.get of type f64 → invalid wasm). Gate on the
1022
+ // storage type so the tag follows the declared ABI.
1023
+ if (gt === 'i32' && grep?.ptrKind != null) {
643
1024
  node.ptrKind = grep.ptrKind
644
1025
  if (grep.ptrAux != null) node.ptrAux = grep.ptrAux
645
1026
  }
@@ -657,7 +1038,8 @@ export function readVar(name) {
657
1038
  return t === 'i32' ? typed(['i32.const', rep.intConst], 'i32')
658
1039
  : typed(['f64.const', rep.intConst], 'f64')
659
1040
  }
660
- const node = typed(['local.get', `$${name}`], t)
1041
+ const node = typed(['local.get', dollar(name)], t)
1042
+ if (t === 'f64' && (lookupValType(name) === VAL.NUMBER || rep?.val === VAL.NUMBER)) node.valKind = VAL.NUMBER
661
1043
  // Proven uint32 accumulator local (narrowUint32): a later asF64 must widen with
662
1044
  // convert_i32_u (the i32 bit pattern is an unsigned value), not _s. `.wrapSafe`
663
1045
  // marks it as the always-ToUint32-sunk kind so the arithmetic widening guards
@@ -676,22 +1058,29 @@ export function readVar(name) {
676
1058
  export function writeVar(name, valIR, void_) {
677
1059
  if (ctx.func.boxed?.has(name)) {
678
1060
  const addr = boxedAddr(name)
679
- const v = asF64(valIR)
680
- if (void_) return typed(['block', ['f64.store', addr, v]], 'void')
681
- const t = temp()
682
- return typed(['block', ['result', 'f64'],
1061
+ // i32-narrowed cell: store the raw i32 (mirrors the integer-global write
1062
+ // gate below the storage type decides the coercion).
1063
+ const i32Cell = ctx.func.cellTypes?.has(name)
1064
+ const st = i32Cell ? 'i32.store' : 'f64.store'
1065
+ const v = i32Cell ? asI32(valIR) : asF64(valIR)
1066
+ if (void_) return typed(['block', [st, addr, v]], 'void')
1067
+ const t = i32Cell ? tempI32() : temp()
1068
+ return typed(['block', ['result', i32Cell ? 'i32' : 'f64'],
683
1069
  ['local.set', `$${t}`, v],
684
- ['f64.store', addr, ['local.get', `$${t}`]],
685
- ['local.get', `$${t}`]], 'f64')
1070
+ [st, addr, ['local.get', `$${t}`]],
1071
+ ['local.get', `$${t}`]], i32Cell ? 'i32' : 'f64')
686
1072
  }
687
1073
  if (isGlobal(name)) {
688
- const v = asF64(valIR)
689
- if (void_) return typed(['block', ['global.set', `$${name}`, v]], 'void')
690
- const t = temp()
691
- return typed(['block', ['result', 'f64'],
1074
+ // Scalar globals are f64 by default, but integer-global inference (plan.js)
1075
+ // narrows purpose-focused counters/sizes to i32 coerce the write to match.
1076
+ const gt = ctx.scope.globalTypes.get(name) || 'f64'
1077
+ const v = gt === 'i32' ? asI32(valIR) : asF64(valIR)
1078
+ if (void_) return typed(['block', ['global.set', dollar(name), v]], 'void')
1079
+ const t = gt === 'i32' ? tempI32() : temp()
1080
+ return typed(['block', ['result', gt],
692
1081
  ['local.set', `$${t}`, v],
693
- ['global.set', `$${name}`, ['local.get', `$${t}`]],
694
- ['local.get', `$${t}`]], 'f64')
1082
+ ['global.set', dollar(name), ['local.get', `$${t}`]],
1083
+ ['local.get', `$${t}`]], gt)
695
1084
  }
696
1085
  const t = ctx.func.locals.get(name) || 'f64'
697
1086
  const ptrKind = repOf(name)?.ptrKind
@@ -703,10 +1092,10 @@ export function writeVar(name, valIR, void_) {
703
1092
  ? valIR
704
1093
  : typed(['i32.wrap_i64', ['i64.reinterpret_f64', asF64(valIR)]], 'i32')
705
1094
  } else {
706
- coerced = t === 'f64' ? asF64(valIR) : asI32(valIR)
1095
+ coerced = t === 'v128' ? valIR : t === 'f64' ? asF64(valIR) : asI32(valIR)
707
1096
  }
708
- if (void_) return typed(['local.set', `$${name}`, coerced], 'void')
709
- const teeNode = typed(['local.tee', `$${name}`, coerced], t)
1097
+ if (void_) return typed(['local.set', dollar(name), coerced], 'void')
1098
+ const teeNode = typed(['local.tee', dollar(name), coerced], t)
710
1099
  if (ptrKind != null) teeNode.ptrKind = ptrKind
711
1100
  return teeNode
712
1101
  }
@@ -716,57 +1105,52 @@ export function writeVar(name, valIR, void_) {
716
1105
  * unboxed pointer locals are proven non-null by unboxablePtrs.
717
1106
  * Inlines directly: (i32.or (i64.eq bits NULL_NAN) (i64.eq bits UNDEF_NAN))
718
1107
  * rather than calling $__is_nullish — saves WASM call dispatch in V8 JIT. */
719
- export const isNullish = (f64expr) => {
720
- if (f64expr.ptrKind != null) return typed(['i32.const', 0], 'i32')
1108
+ // Shared peephole for the NaN-box sentinel checks. When the operand's bits are
1109
+ // statically known an unboxed pointer (never an atom → 0), a numeric `f64.const`
1110
+ // (never an atom → 0), or a boxed `(f64.const nan:…)` / `(f64.reinterpret_i64
1111
+ // (i64.const …))` literal — resolve `onBits(bitsHex)` / 0 at compile time; else
1112
+ // hand the expr to `fallback` for the runtime test. One place owns the literal set.
1113
+ const constI32 = (b) => typed(['i32.const', b ? 1 : 0], 'i32')
1114
+ const matchF64Bits = (f64expr, onBits, fallback) => {
1115
+ if (f64expr.ptrKind != null) return constI32(0)
721
1116
  if (Array.isArray(f64expr)) {
722
1117
  if (f64expr[0] === 'f64.const') {
723
- // Check for NaN-boxed sentinel: (f64.const nan:0x...) — matches NULL_IR/UNDEF_IR form.
724
1118
  const lit = String(f64expr[1])
725
- if (lit.startsWith('nan:')) {
726
- const bits = lit.slice(4)
727
- return typed(['i32.const', (bits === NULL_NAN || bits === UNDEF_NAN) ? 1 : 0], 'i32')
728
- }
729
- return typed(['i32.const', 0], 'i32') // numeric literal — never nullish
730
- }
731
- if (f64expr[0] === 'f64.reinterpret_i64' && Array.isArray(f64expr[1]) && f64expr[1][0] === 'i64.const') {
732
- const bits = String(f64expr[1][1])
733
- return typed(['i32.const', (bits === NULL_NAN || bits === UNDEF_NAN) ? 1 : 0], 'i32')
1119
+ return lit.startsWith('nan:') ? onBits(lit.slice(4)) : constI32(0)
734
1120
  }
1121
+ if (f64expr[0] === 'f64.reinterpret_i64' && Array.isArray(f64expr[1]) && f64expr[1][0] === 'i64.const')
1122
+ return onBits(String(f64expr[1][1]))
735
1123
  }
736
- // Inline the 3-op nullish test. Tee into a temp i64 so the value is computed once.
737
- // For simple (local.get $x) we can just reinterpret twice — V8 CSEs it.
738
- if (Array.isArray(f64expr) && f64expr[0] === 'local.get') {
739
- const bits = ['i64.reinterpret_f64', f64expr]
740
- return typed(['i32.or',
741
- ['i64.eq', bits, ['i64.const', NULL_NAN]],
742
- ['i64.eq', ['i64.reinterpret_f64', f64expr], ['i64.const', UNDEF_NAN]]], 'i32')
743
- }
744
- // Non-trivial expr: fall back to the helper — keeps binary size stable & preserves eval once.
745
- inc('__is_nullish')
746
- return typed(['call', '$__is_nullish', ['i64.reinterpret_f64', f64expr]], 'i32')
1124
+ return fallback(f64expr)
747
1125
  }
748
1126
 
1127
+ export const isNullish = (f64expr) => matchF64Bits(f64expr,
1128
+ bits => constI32(bits === NULL_NAN || bits === UNDEF_NAN),
1129
+ (e) => {
1130
+ // (local.get $x): inline the test, reinterpreting twice (V8 CSEs it). Other
1131
+ // exprs call $__is_nullish — keeps binary size stable and evaluates once.
1132
+ if (Array.isArray(e) && e[0] === 'local.get') {
1133
+ const bits = ['i64.reinterpret_f64', e]
1134
+ return typed(['i32.or',
1135
+ ['i64.eq', bits, ['i64.const', NULL_NAN]],
1136
+ ['i64.eq', ['i64.reinterpret_f64', e], ['i64.const', UNDEF_NAN]]], 'i32')
1137
+ }
1138
+ inc('__is_nullish')
1139
+ return typed(['call', '$__is_nullish', ['i64.reinterpret_f64', e]], 'i32')
1140
+ })
1141
+
749
1142
  /** Check if f64 expr is exactly `undefined` (UNDEF_NAN). Returns i32.
750
1143
  * Used by default-param semantics — only `undefined` (or missing arg) triggers
751
1144
  * the default; `null` should pass through. */
752
- export const isUndef = (f64expr) => {
753
- if (f64expr.ptrKind != null) return typed(['i32.const', 0], 'i32')
754
- if (Array.isArray(f64expr)) {
755
- if (f64expr[0] === 'f64.const') {
756
- const lit = String(f64expr[1])
757
- if (lit.startsWith('nan:')) {
758
- const bits = lit.slice(4)
759
- return typed(['i32.const', bits === UNDEF_NAN ? 1 : 0], 'i32')
760
- }
761
- return typed(['i32.const', 0], 'i32')
762
- }
763
- if (f64expr[0] === 'f64.reinterpret_i64' && Array.isArray(f64expr[1]) && f64expr[1][0] === 'i64.const') {
764
- const bits = String(f64expr[1][1])
765
- return typed(['i32.const', bits === UNDEF_NAN ? 1 : 0], 'i32')
766
- }
767
- }
768
- return typed(['i64.eq', ['i64.reinterpret_f64', f64expr], ['i64.const', UNDEF_NAN]], 'i32')
769
- }
1145
+ export const isUndef = (f64expr) => matchF64Bits(f64expr,
1146
+ bits => constI32(bits === UNDEF_NAN),
1147
+ (e) => typed(['i64.eq', ['i64.reinterpret_f64', e], ['i64.const', UNDEF_NAN]], 'i32'))
1148
+
1149
+ /** Check if f64 expr is exactly `null` (NULL_NAN). Returns i32.
1150
+ * Strict `=== null` must match only null — not undefined (use isUndef for that). */
1151
+ export const isNull = (f64expr) => matchF64Bits(f64expr,
1152
+ bits => constI32(bits === NULL_NAN),
1153
+ (e) => typed(['i64.eq', ['i64.reinterpret_f64', e], ['i64.const', NULL_NAN]], 'i32'))
770
1154
 
771
1155
  /** Mask that clears the boolean atom's truth bit, mapping TRUE_NAN→FALSE_NAN.
772
1156
  * `(bits & BOOL_ATOM_MASK) === FALSE_NAN` recognizes both in one i64.and+i64.eq. */
@@ -774,16 +1158,11 @@ const BOOL_ATOM_MASK = '0x' + BigInt.asUintN(64, ~(1n << BigInt(LAYOUT.AUX_SHIFT
774
1158
 
775
1159
  /** Check if f64 expr is a boxed-boolean atom (TRUE_NAN or FALSE_NAN). Returns i32.
776
1160
  * Single-eval: masks the truth bit and compares to FALSE_NAN once. */
777
- export const isBoolAtom = (f64expr) => {
778
- if (f64expr.ptrKind != null) return typed(['i32.const', 0], 'i32')
779
- if (Array.isArray(f64expr) && f64expr[0] === 'f64.const' && String(f64expr[1]).startsWith('nan:')) {
780
- const b = String(f64expr[1]).slice(4)
781
- return typed(['i32.const', (b === TRUE_NAN || b === FALSE_NAN) ? 1 : 0], 'i32')
782
- }
783
- return typed(['i64.eq',
784
- ['i64.and', ['i64.reinterpret_f64', f64expr], ['i64.const', BOOL_ATOM_MASK]],
785
- ['i64.const', FALSE_NAN]], 'i32')
786
- }
1161
+ export const isBoolAtom = (f64expr) => matchF64Bits(f64expr,
1162
+ bits => constI32(bits === TRUE_NAN || bits === FALSE_NAN),
1163
+ (e) => typed(['i64.eq',
1164
+ ['i64.and', ['i64.reinterpret_f64', e], ['i64.const', BOOL_ATOM_MASK]],
1165
+ ['i64.const', FALSE_NAN]], 'i32'))
787
1166
 
788
1167
  // === Array layout helpers — routed through the array carrier (abi/array.js) ===
789
1168
 
@@ -813,7 +1192,7 @@ export function elemStore(ptr, i, val) {
813
1192
  * re-loading from ptr-8.
814
1193
  * Optional `ptrLocal`: caller already has the resolved ARRAY data pointer in
815
1194
  * an i32 local. Reuses it instead of calling __ptr_offset again. */
816
- export function arrayLoop(arrExpr, bodyFn, lenLocal, ptrLocal) {
1195
+ export function arrayLoop(arrExpr, bodyFn, lenLocal, ptrLocal, reverse) {
817
1196
  const arr = ptrLocal ? null : temp('aa'), ptr = ptrLocal ?? tempI32('ap'), i = tempI32('ai'), item = temp('av')
818
1197
  const len = lenLocal ?? tempI32('al')
819
1198
  const id = ctx.func.uniq++
@@ -827,13 +1206,18 @@ export function arrayLoop(arrExpr, bodyFn, lenLocal, ptrLocal) {
827
1206
  }
828
1207
  if (!lenLocal) setup.push(
829
1208
  ['local.set', `$${len}`, ['i32.load', ['i32.sub', ['local.get', `$${ptr}`], ['i32.const', 8]]]])
1209
+ // Forward: i 0→len-1. Reverse (findLast*): i len-1→0, same elem indices.
1210
+ const start = reverse ? ['i32.sub', ['local.get', `$${len}`], ['i32.const', 1]] : ['i32.const', 0]
1211
+ const done = reverse ? ['i32.lt_s', ['local.get', `$${i}`], ['i32.const', 0]]
1212
+ : ['i32.ge_s', ['local.get', `$${i}`], ['local.get', `$${len}`]]
1213
+ const step = ['i32.const', reverse ? -1 : 1]
830
1214
  setup.push(
831
- ['local.set', `$${i}`, ['i32.const', 0]],
1215
+ ['local.set', `$${i}`, start],
832
1216
  ['block', `$brk${id}`, ['loop', `$loop${id}`,
833
- ['br_if', `$brk${id}`, ['i32.ge_s', ['local.get', `$${i}`], ['local.get', `$${len}`]]],
1217
+ ['br_if', `$brk${id}`, done],
834
1218
  ['local.set', `$${item}`, elemLoad(ptr, i)],
835
1219
  ...bodyFn(ptr, len, i, typed(['local.get', `$${item}`], 'f64')),
836
- ['local.set', `$${i}`, ['i32.add', ['local.get', `$${i}`], ['i32.const', 1]]],
1220
+ ['local.set', `$${i}`, ['i32.add', ['local.get', `$${i}`], step]],
837
1221
  ['br', `$loop${id}`]]])
838
1222
  return setup
839
1223
  }
@@ -885,6 +1269,7 @@ export function loopTop() {
885
1269
  export const flat = ir => {
886
1270
  if (ir == null) return []
887
1271
  if (!Array.isArray(ir)) return [ir] // bare 'drop', 'nop', etc.
1272
+ if (ir.length === 0) return []
888
1273
  if (typeof ir[0] === 'string' || ir[0] == null) return [ir] // single instruction: ['op', ...args] or [null, val]
889
1274
  return ir // multi-instruction: [instr1, instr2, ...]
890
1275
  }
@@ -906,6 +1291,38 @@ export function findBodyStart(fn) {
906
1291
  return fn.length
907
1292
  }
908
1293
 
1294
+ /** Debug-mode structural check of a `(func …)` IR node. Catches the bug classes
1295
+ * that otherwise surface as OPAQUE watr errors several phases later — `Duplicate
1296
+ * local $x`, `Unknown local $x` — but here pinned to the exact name (and, via the
1297
+ * caller, the phase + function) that produced them, so a codegen/optimizer bug is
1298
+ * localized at its source instead of at watr. Self-contained: validates every
1299
+ * `local.{get,set,tee}` against the function header's param/local declarations,
1300
+ * and rejects a duplicate declaration. Returns an error string, or null if clean.
1301
+ * (Call-target and type-tag validation need the module symbol table + a type pass;
1302
+ * deferred — locals are the common codegen-bug class and need nothing external.) */
1303
+ export function verifyFn(fn) {
1304
+ if (!Array.isArray(fn) || fn[0] !== 'func') return null
1305
+ const bodyStart = findBodyStart(fn)
1306
+ const declared = new Set()
1307
+ for (let i = 2; i < bodyStart; i++) {
1308
+ const c = fn[i]
1309
+ if (!Array.isArray(c) || (c[0] !== 'param' && c[0] !== 'local') || typeof c[1] !== 'string') continue
1310
+ if (declared.has(c[1])) return `duplicate local/param ${c[1]}`
1311
+ declared.add(c[1])
1312
+ }
1313
+ let bad = null
1314
+ const walk = (n) => {
1315
+ if (bad || !Array.isArray(n)) return
1316
+ const op = n[0]
1317
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && !declared.has(n[1])) {
1318
+ bad = `${op} of undeclared local ${n[1]}`; return
1319
+ }
1320
+ for (let i = 1; i < n.length; i++) walk(n[i])
1321
+ }
1322
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i])
1323
+ return bad
1324
+ }
1325
+
909
1326
  /**
910
1327
  * Tail-call rewrite: walks tail positions of an emitted IR tree and replaces
911
1328
  * direct `(call $name args...)` ops with `(return_call $name args...)`.