jz 0.5.0 → 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 +281 -142
  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 +461 -185
  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 +591 -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} +600 -205
  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 -2997
  77. package/src/jzify.js +0 -1553
  78. package/src/plan.js +0 -2132
  79. package/src/vectorize.js +0 -1088
  80. /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.
@@ -102,7 +95,67 @@ export const asPtrOffset = (n, ptrKind) => {
102
95
  }
103
96
 
104
97
  /** 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)
98
+ export const asParamType = (n, t) => t === 'i32' ? asI32(n) : t === 'i64' ? asI64(n) : t === 'v128' ? n : asF64(n)
99
+
100
+ /**
101
+ * Narrow an f64 arithmetic tree under ToInt32 — the general int-accumulator path.
102
+ *
103
+ * ToInt32 is reduction mod 2^32, and {+, −, ×} form a RING under that modulus:
104
+ * operands may wrap to i32 eagerly and the final result still equals ToInt32 of
105
+ * the JS value — PROVIDED the original f64 computation was exact (no rounding).
106
+ * Exactness is tracked structurally: every interior node's worst-case magnitude
107
+ * (`maxAbs`, real un-wrapped value) must stay below 2^53. Leaves are peeled
108
+ * `f64.convert_i32_*` wrappers (≤2^31/2^32) and integer constants.
109
+ *
110
+ * `/` is NOT a ring op (fractions): it narrows only at the ToInt32 ROOT, with a
111
+ * FAITHFUL numerator (i32 value == JS value — wrapped sums excluded) and a
112
+ * constant integer divisor. i32.div_s truncates toward zero exactly like
113
+ * ToInt32 of the f64 quotient (error < ulp/2 < distance-to-integer for any i32
114
+ * numerator); c ∈ {0,−1,1} are diverted (trap / INT_MIN trap / identity).
115
+ *
116
+ * Returns {node (i32-typed), maxAbs, faithful} or null — callers use `.node`.
117
+ */
118
+ const narrowI32 = (x, isRoot) => {
119
+ if (!Array.isArray(x)) return null
120
+ if (x.type === 'i32') return { node: x, maxAbs: 2 ** 31, faithful: true }
121
+ const op = x[0]
122
+ if (op === 'f64.convert_i32_s' || op === 'f64.convert_i32_u')
123
+ // Peel — same as toI32's peephole. _u values ∈ [0, 2^32): the re-tag IS the
124
+ // wrap (ring-compatible), but the i32 view differs from the JS value above
125
+ // 2^31, so _u is not faithful.
126
+ return {
127
+ node: Array.isArray(x[1]) ? typed(x[1], 'i32') : x[1],
128
+ maxAbs: op === 'f64.convert_i32_s' ? 2 ** 31 : 2 ** 32,
129
+ faithful: op === 'f64.convert_i32_s',
130
+ }
131
+ if (op === 'f64.const' && typeof x[1] === 'number' && Number.isInteger(x[1]) && Math.abs(x[1]) < 2 ** 52)
132
+ return { node: typed(['i32.const', x[1] | 0], 'i32'), maxAbs: Math.abs(x[1]), faithful: Math.abs(x[1]) < 2 ** 31 }
133
+ if (op === 'f64.add' || op === 'f64.sub' || op === 'f64.mul') {
134
+ const a = narrowI32(x[1]), b = narrowI32(x[2])
135
+ if (!a || !b) return null
136
+ const maxAbs = op === 'f64.mul' ? a.maxAbs * b.maxAbs : a.maxAbs + b.maxAbs
137
+ if (maxAbs >= 2 ** 53) return null
138
+ const iop = op === 'f64.add' ? 'i32.add' : op === 'f64.sub' ? 'i32.sub' : 'i32.mul'
139
+ return { node: typed([iop, a.node, b.node], 'i32'), maxAbs, faithful: false }
140
+ }
141
+ if (op === 'f64.neg') {
142
+ const a = narrowI32(x[1])
143
+ if (!a) return null
144
+ return { node: typed(['i32.sub', ['i32.const', 0], a.node], 'i32'), maxAbs: a.maxAbs, faithful: false }
145
+ }
146
+ if (op === 'f64.div' && isRoot) {
147
+ const a = narrowI32(x[1])
148
+ if (!a || !a.faithful) return null
149
+ const c = Array.isArray(x[2]) && x[2][0] === 'f64.const' && typeof x[2][1] === 'number' ? x[2][1] : null
150
+ if (c == null || !Number.isInteger(c) || c === 0 || c === 1 || Math.abs(c) >= 2 ** 31) return null
151
+ // c = −1 would trap on INT_MIN; 0 − x wraps INT_MIN → INT_MIN, matching ToInt32(2^31).
152
+ const node = c === -1
153
+ ? typed(['i32.sub', ['i32.const', 0], a.node], 'i32')
154
+ : typed(['i32.div_s', a.node, ['i32.const', c]], 'i32')
155
+ return { node, maxAbs: 2 ** 31, faithful: c !== -1 }
156
+ }
157
+ return null
158
+ }
106
159
 
107
160
  /** Coerce node to i32 with wrapping (JS `|0` semantics: values > 2^31 wrap to negative).
108
161
  * Per ECMAScript ToInt32, NaN and ±∞ map to 0. `i64.trunc_sat_f64_s` handles NaN
@@ -120,18 +173,22 @@ export const toI32 = n => {
120
173
  }
121
174
  if (Array.isArray(n) && n[0] === 'f64.const' && typeof n[1] === 'number') {
122
175
  const v = n[1]
123
- return typed(['i32.const', Number.isFinite(v) ? v | 0 : 0], 'i32')
176
+ return typed(['i32.const', Number.isFinite(v) ? v | 0 : 0], 'i32') // JS `|0` is ToInt32
124
177
  }
178
+ // General int-arithmetic narrowing: an exact-int f64 tree of {+,−,×,neg,/C}
179
+ // computes in i32 (mod-2^32 ring) — no trunc/guard at all.
180
+ const nw = narrowI32(n, true)
181
+ if (nw) return nw.node
125
182
  // Leaf nodes are cheap to duplicate; for everything else, evaluate once via local.tee.
126
183
  const isLeaf = Array.isArray(n) && n.length <= 2 &&
127
184
  (n[0] === 'f64.const' || n[0] === 'local.get' || n[0] === 'global.get')
185
+ // `i32.wrap_i64(i64.trunc_sat_f64_s x)` is exact ToInt32 for |x| < 2^63 (the
186
+ // overwhelming common range), maps NaN/−∞→0, and +∞ is guarded to 0 by the
187
+ // select. For |x| ≥ 2^63 it saturates rather than wrapping mod 2^32 — a
188
+ // deliberately-allowed asm.js-style boundary (no per-`|0` helper/guard cost).
128
189
  const wrap = x => typed(['i32.wrap_i64', ['i64.trunc_sat_f64_s', x]], 'i32')
129
190
  if (isLeaf) {
130
- return typed(['select',
131
- wrap(n),
132
- ['i32.const', 0],
133
- ['f64.ne', n, ['f64.const', Infinity]]
134
- ], 'i32')
191
+ return typed(['select', wrap(n), ['i32.const', 0], ['f64.ne', n, ['f64.const', Infinity]]], 'i32')
135
192
  }
136
193
  const t = temp('inf')
137
194
  return typed(['select',
@@ -166,16 +223,16 @@ export const fromI64 = n => {
166
223
  * See module/symbol.js for the broader reserved-atom-id scheme.
167
224
  * Distinct from 0, NaN, and all pointers. Triggers default params.
168
225
  * 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')
226
+ export const NULL_NAN = atomNanHex(1)
227
+ export const UNDEF_NAN = atomNanHex(2)
171
228
  /** Boxed-boolean carrier. `false`/`true` are reserved atoms — materialized only
172
229
  * where boolean identity is observed (typeof/String/JSON/host boundary); in
173
230
  * branch/arithmetic position booleans stay raw i32/f64 0/1. The atomId encodes
174
231
  * the truth value in its low bit (4=false, 5=true), so `aux & 1` recovers 0/1
175
232
  * and `4 | bit` boxes it — see boolBoxIR / unboxBoolIR. */
176
233
  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')
234
+ export const FALSE_NAN = atomNanHex(4)
235
+ export const TRUE_NAN = atomNanHex(5)
179
236
  /** WAT-template-ready sentinel expressions for use in stdlib template strings.
180
237
  * `f64.const nan:0xHEX` is 3 bytes shorter than `f64.reinterpret_i64 (i64.const ...)`. */
181
238
  export const NULL_WAT = `(f64.const nan:${NULL_NAN})`
@@ -211,13 +268,20 @@ export function unboxBoolIR(f64expr) {
211
268
 
212
269
  /** Max arity of inline closure slots. Closures are compiled with signature
213
270
  * (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. */
271
+ * Direct (non-spread) calls with more args than MAX error. Spread calls are
272
+ * unbounded: the spread site publishes the full args-array offset in
273
+ * $__closure_spill, and a rest-param callee reads args[MAX..argc-1] from it
274
+ * (see module/function.js spread path + compile/index.js rest collection). */
216
275
  export const MAX_CLOSURE_ARITY = 8
217
276
 
218
277
  /** 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/
278
+ // Any instruction that touches linear memory the module must declare memory.
279
+ // Matches every `memory.*` op (size/grow/copy/fill/init) and every typed load/store
280
+ // incl. width suffixes (load8_u, store16, i64.load32_s, v128.load, …). The old
281
+ // hand-enumerated list silently missed memory.copy/fill, v128.load/store and
282
+ // i64.store8/16/32 (all used in stdlib) — a body using only those would wrongly
283
+ // report no-memory. Broad-but-precise: only `memory.` and `<type>.load|store` match.
284
+ export const MEM_OPS = /\b(memory\.\w+|(?:i32|i64|f32|f64|v128)\.(?:load|store)\w*)\b/
221
285
 
222
286
  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
287
  export const SPREAD_MUTATORS = new Set(['push', 'add', 'set', 'unshift'])
@@ -250,15 +314,22 @@ export function mkPtrIR(type, aux, offset) {
250
314
  return typed(['call', '$__mkptr', tIR, aIR, oIR], 'f64')
251
315
  }
252
316
 
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.
317
+ /** Offset extraction for a NaN-boxed pointer.
318
+ * Goes through `__ptr_offset`, which chases the relocation-forwarding chain
319
+ * (cap == -1 sentinel at off-4 relocated offset at off-8). The chase is a
320
+ * single load+compare for any live (non-forwarded) header, so it is a no-op for
321
+ * fixed-shape receivers (OBJECT/TYPED/…) whose cap word is never -1.
322
+ *
323
+ * We do NOT skip it for "non-ARRAY" static types: that shortcut was unsound on
324
+ * two counts. (1) ARRAY is not the only growable container — HASH/SET/MAP relocate
325
+ * too. (2) jz value types are not always precise: a binding inferred OBJECT (a
326
+ * polymorphic parameter, a widened union) can hold a relocated ARRAY at runtime.
327
+ * Writing through its stale pre-relocation base then clobbers whatever now occupies
328
+ * that freed region — a memory-safety hazard that must not depend on inference
329
+ * precision. Memory safety is unconditional; the forwarding follow stays.
256
330
  * If the node is already an unboxed pointer (ptrKind), return it directly. */
257
331
  export function ptrOffsetIR(valIR, valType) {
258
332
  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
333
  inc('__ptr_offset')
263
334
  return ['call', '$__ptr_offset', ['i64.reinterpret_f64', valIR]]
264
335
  }
@@ -282,48 +353,73 @@ export function ptrTypeIR(valIR, valType) {
282
353
  ['i64.const', 0xF]]]
283
354
  }
284
355
 
356
+ // SELF-HOST CONTRACT: f64 slot BITS travel as canonical '0x'+16-hex STRINGS.
357
+ // A BigInt crossing a function return / array element / object slot is
358
+ // kind-erased in the kernel (raw i64 bits are untagged) and every subsequent
359
+ // op on it misdispatches; BigInt64Array/BigUint64Array views and
360
+ // DataView.{get,set}BigUint64 are a legacy f64-value shim there. Strings are
361
+ // tagged and survive every boundary; BigInt math happens only inside single
362
+ // expressions. (Same contract as wat/optimize.js's i64 VALUE CONTRACT.)
285
363
  const _F64_BITS_BUF = new ArrayBuffer(8)
286
364
  const _F64_BITS_F = new Float64Array(_F64_BITS_BUF)
287
- const _F64_BITS_U = new BigUint64Array(_F64_BITS_BUF)
365
+ const _F64_BITS_U32 = new Uint32Array(_F64_BITS_BUF) // LE halves: [0]=lo, [1]=hi
366
+ const _hx8 = (u) => (u >>> 0).toString(16).padStart(8, '0')
288
367
 
289
368
  /** Return i64 bit pattern (BigInt) of a pure-literal IR node, or null if non-literal. */
290
369
  export function extractF64Bits(node) {
291
370
  if (!Array.isArray(node)) return null
292
371
  if (node[0] === 'f64.const') {
293
- if (typeof node[1] === 'number') { _F64_BITS_F[0] = node[1]; return _F64_BITS_U[0] }
372
+ if (typeof node[1] === 'number') { _F64_BITS_F[0] = node[1]; return '0x' + _hx8(_F64_BITS_U32[1]) + _hx8(_F64_BITS_U32[0]) }
294
373
  if (typeof node[1] === 'string' && node[1].startsWith('nan:')) {
295
- try { return BigInt(node[1].slice(4)) | 0x7FF0000000000000n } catch { return null }
374
+ try {
375
+ const v = BigInt(node[1].slice(4)) | 0x7ff0000000000000n
376
+ return '0x' + v.toString(16).padStart(16, '0')
377
+ } catch { return null }
296
378
  }
297
379
  return null
298
380
  }
299
381
  if (node[0] === 'f64.reinterpret_i64' && Array.isArray(node[1]) && node[1][0] === 'i64.const' && typeof node[1][1] === 'string') {
300
382
  const s = node[1][1]
301
383
  if (s.startsWith('-')) {
302
- const abs = s.slice(1)
303
- try { return ((1n << 64n) - BigInt(abs)) & 0xFFFFFFFFFFFFFFFFn } catch { return null }
384
+ // Two's complement WITHOUT a 2^64 term: (-1 − |v|) + 1 ≡ 2^64 − |v| both
385
+ // natively and on the kernel's mod-2^64 carrier (1n<<64n is unrepresentable
386
+ // there and would silently corrupt).
387
+ try {
388
+ const v = (0xffffffffffffffffn - BigInt(s.slice(1)) + 1n) & 0xffffffffffffffffn
389
+ return '0x' + v.toString(16).padStart(16, '0')
390
+ } catch { return null }
304
391
  }
305
- try { return BigInt(s) } catch { return null }
392
+ try {
393
+ const v = BigInt(s)
394
+ return '0x' + v.toString(16).padStart(16, '0')
395
+ } catch { return null }
306
396
  }
307
397
  return null
308
398
  }
309
399
 
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. */
400
+ /** Append `slots` ('0x'+16-hex bit strings, see contract above) to
401
+ * ctx.runtime.data 8-byte aligned, return raw byte offset of first slot.
402
+ * Slots that look like NaN-boxed pointers are recorded in
403
+ * `ctx.runtime.staticPtrSlots` so the prefix-strip pass can patch their
404
+ * embedded offsets. Writes go through u32 halves — DataView's BigInt
405
+ * accessors are unfaithful in the self-host kernel. */
313
406
  export function appendStaticSlots(slots, headerBytes = 0) {
314
407
  if (!ctx.runtime.data) ctx.runtime.data = ''
315
408
  while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
316
409
  const off = ctx.runtime.data.length
317
410
  const u8 = new Uint8Array(headerBytes + slots.length * 8)
318
411
  const dv = new DataView(u8.buffer)
319
- for (let i = 0; i < slots.length; i++) dv.setBigUint64(headerBytes + i * 8, slots[i], true)
412
+ for (let i = 0; i < slots.length; i++) {
413
+ const h = slots[i]
414
+ dv.setUint32(headerBytes + i * 8, parseInt(h.slice(10), 16) >>> 0, true)
415
+ dv.setUint32(headerBytes + i * 8 + 4, parseInt(h.slice(2, 10), 16) >>> 0, true)
416
+ }
320
417
  let chunk = ''
321
418
  for (let i = 0; i < u8.length; i++) chunk += String.fromCharCode(u8[i])
322
419
  ctx.runtime.data += chunk
323
420
  if (!ctx.runtime.staticPtrSlots) ctx.runtime.staticPtrSlots = []
324
421
  for (let i = 0; i < slots.length; i++) {
325
- const bits = slots[i]
326
- if (((bits >> 48n) & 0xFFF8n) === BigInt(LAYOUT.NAN_PREFIX)) {
422
+ if ((parseInt(slots[i].slice(2, 6), 16) & 0xFFF8) === LAYOUT.NAN_PREFIX) {
327
423
  ctx.runtime.staticPtrSlots.push(off + i * 8)
328
424
  }
329
425
  }
@@ -335,8 +431,8 @@ export function appendStaticSlots(slots, headerBytes = 0) {
335
431
  /** Check if emitted node is a compile-time constant. */
336
432
  export const isLit = n => (n[0] === 'i32.const' || n[0] === 'f64.const') && typeof n[1] === 'number'
337
433
  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
434
+ export const isNullLit = n => Array.isArray(n) && n.length === 2 && n[0] == null && n[1] == null
435
+ export const isUndefLit = n => Array.isArray(n) && n.length === 0
340
436
  export const isNullishLit = n => isNullLit(n) || isUndefLit(n)
341
437
 
342
438
  /** Side-effect-free (safe for WASM select). */
@@ -348,46 +444,173 @@ const PURE_OPS = new Set(['i32.const', 'f64.const', 'local.get', 'global.get',
348
444
  'i32.eq', 'i32.ne', 'i32.lt_s', 'i32.gt_s', 'i32.le_s', 'i32.ge_s', 'i32.eqz'])
349
445
  export const isPureIR = n => Array.isArray(n) && PURE_OPS.has(n[0]) && n.slice(1).every(c => !Array.isArray(c) || isPureIR(c))
350
446
 
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'
447
+ /** Ops whose f64 result is always a plain number (never a NaN-boxed pointer).
448
+ * Used by toNumF64 to skip the __to_num wrapper when the value is provably numeric.
449
+ * NOTE: f64.const is NOT included — it may encode a NaN-boxed pointer. */
450
+ const PURE_F64_OPS = new Set([
451
+ 'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
452
+ 'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
453
+ 'f64.convert_i32_s', 'f64.convert_i32_u', 'f64.promote_f32',
454
+ ])
455
+
456
+ /** True iff `r` provably yields a plain f64 NUMBER (never a NaN-boxed pointer or
457
+ * nullish sentinel). A `block`/`if` is numeric only when its value-producing tail
458
+ * is — so `o.a?.b` (a block whose result is a property value or undef sentinel)
459
+ * is correctly NOT numeric, while `cond ? n*2 : n*3` is. Conservative: any shape
460
+ * not provably numeric (property gets, user calls, local.get, f64.const nan:…)
461
+ * returns false, so the caller keeps the __to_num coercion. */
462
+ const isNumericIR = (r) => {
463
+ if (!Array.isArray(r)) return false
464
+ const op = r[0]
465
+ if (PURE_F64_OPS.has(op)) return true
466
+ if (op === 'call' && typeof r[1] === 'string' && (r[1].startsWith('$math.') || r[1] === '$__time_ms')) return true
467
+ if (op === 'f64.const') return typeof r[1] === 'number' // 'nan:…' carrier ⇒ pointer/sentinel
468
+ if (op === 'block') return isNumericIR(r[r.length - 1]) // block value = its tail expr
469
+ if (op === 'if') { // both arms must be numeric
470
+ const thenArm = r.find(x => Array.isArray(x) && x[0] === 'then')
471
+ const elseArm = r.find(x => Array.isArray(x) && x[0] === 'else')
472
+ return !!thenArm && !!elseArm &&
473
+ isNumericIR(thenArm[thenArm.length - 1]) && isNumericIR(elseArm[elseArm.length - 1])
474
+ }
475
+ return false
476
+ }
353
477
 
354
- /** Resolve compile-time value type from AST node (literal → type, name → lookup). */
478
+ /** Resolve compile-time value type from AST node (literal → name → lookup). */
355
479
  export const resolveValType = (node, valTypeOf, lookupValType) =>
356
480
  valTypeOf(node) ?? (typeof node === 'string' ? lookupValType(node) : null)
357
481
 
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
482
  /** Check if (a, op, b) is a postfix pattern: [op, name] and [, 1] literal. */
362
483
  export const isPostfix = (a, op, b) => Array.isArray(a) && a[0] === op && Array.isArray(b) && b[0] == null && b[1] === 1
363
484
 
364
485
  /** Emit a numeric constant with correct i32/f64 typing.
365
486
  * `-0` is f64-only (i32 has no signed zero) — preserve the sign by emitting f64. */
366
487
  export const emitNum = v => isI32(v)
367
- ? typed(['i32.const', v], 'i32') : typed(['f64.const', v], 'f64')
488
+ ? typed(['i32.const', v], 'i32')
489
+ // Emit NaN via the `nan` token, not the raw JS number: a numeric NaN literal in
490
+ // the IR loses its quiet-mantissa bit (0x7FF8→0x7FF0, i.e. becomes Infinity) when
491
+ // the self-host kernel marshals the IR back across the wasm→host boundary. The
492
+ // `nan` token assembles to the canonical 0x7FF8 number-NaN unambiguously.
493
+ : typed(['f64.const', v !== v ? 'nan' : v], 'f64')
368
494
 
369
495
  // === Temp locals ===
370
496
 
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 = '') {
497
+ /** Allocate a fresh local name with the given tag, registered as `type`. The
498
+ * selfhost compiler doesn't yet handle exported-const arrow factories returning
499
+ * closures, so the three temp() helpers stay as `function` declarations and
500
+ * delegate to this shared core. */
501
+ function freshLocal(type, tag) {
375
502
  let name
376
503
  do { name = `${T}${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
377
- ctx.func.locals.set(name, 'f64')
504
+ ctx.func.locals.set(name, type)
378
505
  return name
379
506
  }
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
507
+ export function temp (tag = '') { return freshLocal('f64', tag) }
508
+ export function tempI32 (tag = '') { return freshLocal('i32', tag) }
509
+ export function tempI64 (tag = '') { return freshLocal('i64', tag) }
510
+
511
+ // === IR scaffolds ===
512
+
513
+ /** Wrap a sequence of statements as a typed `(block (result <type>) …)`.
514
+ * Default result is `f64` (the value-type for most jz emissions).
515
+ * Shorthand for the `typed(['block', ['result', T], …stmts], T)` pattern that
516
+ * appears in nearly every emitter — keeps call sites focused on the body. */
517
+ export const block64 = (...stmts) => typed(['block', ['result', 'f64'], ...stmts], 'f64')
518
+ export const blockTyped = (type, ...stmts) => typed(['block', ['result', type], ...stmts], type)
519
+
520
+ /** Allocate an f64 temp, set it to `val`, run `body(name)` and yield its result.
521
+ * `body` may return either a single IR node (used as the block result) or an
522
+ * array of nodes whose last expression becomes the result. Eliminates the
523
+ * repetitive `const t = temp(); …['local.set', $t, val]; …['local.get', $t]`
524
+ * scaffold around tee-and-use patterns. */
525
+ export function withTemp(val, body, tag = '') {
526
+ const t = temp(tag)
527
+ const out = body(t)
528
+ const tail = Array.isArray(out) && out.every(n => Array.isArray(n)) ? out : [out]
529
+ return block64(['local.set', `$${t}`, val], ...tail)
385
530
  }
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
531
+
532
+ /** Whole-fn structural refcount: walks `fn`, counting how many times each
533
+ * array node is referenced. Used by optimizer passes to skip shared subtrees
534
+ * (watr CSE may leave them) — mutating a node with refcount > 1 would also
535
+ * affect references outside the current region. Single-pass O(N). */
536
+ export function buildRefcount(fn) {
537
+ const refcount = new Map()
538
+ const walk = (node) => {
539
+ if (!Array.isArray(node)) return
540
+ const n = (refcount.get(node) || 0) + 1
541
+ refcount.set(node, n)
542
+ if (n > 1) return // already counted children below
543
+ for (let i = 0; i < node.length; i++) walk(node[i])
544
+ }
545
+ walk(fn)
546
+ return refcount
547
+ }
548
+
549
+ /** Pick the next free `$__<prefix><id>` local-name id by collecting all
550
+ * existing ids in a single walk. Replaces the per-pass
551
+ * `while (fn.some(... === $__prefixK)) k++` (O(K·N)) with one O(N) scan. */
552
+ export function nextLocalId(fn, prefix) {
553
+ const seen = new Set()
554
+ const needle = `$__${prefix}`
555
+ const walk = (n) => {
556
+ if (!Array.isArray(n)) return
557
+ if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith(needle)) {
558
+ const tail = n[1].slice(needle.length)
559
+ if (/^\d+$/.test(tail)) seen.add(+tail)
560
+ }
561
+ for (let i = 0; i < n.length; i++) walk(n[i])
562
+ }
563
+ walk(fn)
564
+ let id = 0
565
+ while (seen.has(id)) id++
566
+ return id
567
+ }
568
+
569
+ /** Single-kind ptr-tag predicate: `__ptr_type(bits) == ptr`. Takes the f64
570
+ * carrier expression and the PTR constant. Use this when guarding one branch;
571
+ * use `dispatchByPtrType` for multi-case forks. Stamps `inc('__ptr_type')`. */
572
+ export function ptrTypeEq(f64Expr, ptr) {
573
+ inc('__ptr_type')
574
+ return typed(['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', f64Expr]], ['i32.const', ptr]], 'i32')
575
+ }
576
+
577
+ /** ToPrimitive sidecar probe (ES2024 7.1.1): an own `valueOf`/`toString` data
578
+ * property shadows the builtin. Reads the dynamic-prop sidecar slot keyed by
579
+ * `nameIR` (an emitted i64 string key) off receiver `objIR`; if it holds a
580
+ * closure, yields `onOverride($p)`, else `onFallback($o)` (both f64). Shared by
581
+ * the member-READ path (module/core.js — onOverride returns the closure value,
582
+ * onFallback calls the arity-≤1 builtin) and the method-CALL path (emit.js —
583
+ * onOverride invokes the closure, onFallback calls the builtin method). */
584
+ export function sidecarOverride(objIR, nameIR, onOverride, onFallback) {
585
+ const o = temp('vo'), p = temp('vp')
586
+ inc('__dyn_get_expr', '__ptr_type')
587
+ return block64(
588
+ ['local.set', `$${o}`, asF64(objIR)],
589
+ ['local.set', `$${p}`, ['f64.reinterpret_i64',
590
+ ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${o}`]], nameIR]]],
591
+ ['if', ['result', 'f64'],
592
+ ptrTypeEq(['local.get', `$${p}`], PTR.CLOSURE),
593
+ ['then', onOverride(p, o)],
594
+ ['else', onFallback(o)]])
595
+ }
596
+
597
+ /** Dispatch on `__ptr_type(bits)` — emits a right-leaning if/else chain over
598
+ * PTR constants. `cases` is `[[PTR.X, ir], …]`; `fallback` is the else IR.
599
+ * `resultType` defaults to `'f64'`; pass `null` for a void dispatch (e.g.
600
+ * pure memory-writing branches). Centralizes the
601
+ * `i32.eq (call $__ptr_type bits) (i32.const PTR.X)` pattern so emitters
602
+ * dispatching by pointer kind stay declarative. */
603
+ export function dispatchByPtrType(typeLocal, cases, fallback, resultType = 'f64') {
604
+ let out = fallback
605
+ const head = resultType ? ['if', ['result', resultType]] : ['if']
606
+ for (let i = cases.length - 1; i >= 0; i--) {
607
+ const [ptr, ir] = cases[i]
608
+ out = [...head,
609
+ ['i32.eq', ['local.get', `$${typeLocal}`], ['i32.const', ptr]],
610
+ ['then', ir],
611
+ ['else', out]]
612
+ }
613
+ return out
391
614
  }
392
615
 
393
616
  // === Numeric helpers ===
@@ -395,23 +618,17 @@ export function tempI64(tag = '') {
395
618
  /** WASM has no f64.rem — implement as a - trunc(a/b) * b.
396
619
  * Both `a` and `b` appear twice in the expansion; cache non-pure operands
397
620
  * 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
- }
621
+ // JS `%` on the f64 path. Delegates to the exact `__rem` (binary fmod) stdlib
622
+ // the textbook `a - b*trunc(a/b)` is inexact for large a/b and wrong on the
623
+ // ±Inf / 0 / NaN edges. The i32.rem_s fast path in emit.js handles the common
624
+ // integer-with-nonzero-literal-divisor case; everything else lands here.
625
+ export const f64rem = (a, b) => (inc('__rem'), typed(['call', '$__rem', a, b], 'f64'))
409
626
 
410
627
  /** Resolve the slot index of a ToPrimitive method (`valueOf`/`toString`) on an
411
628
  * OBJECT operand — from a schema-bound variable or an inline object literal.
412
629
  * Returns -1 when the method is absent. */
413
630
  function primMethodIdx(node, name) {
414
- if (typeof node === 'string') return ctx.schema.find(node, name)
631
+ if (typeof node === 'string') return ctx.schema.slotOf(node, name)
415
632
  const sid = objLiteralSchemaId(node)
416
633
  const props = sid != null ? ctx.schema.list[sid] : null
417
634
  return props ? props.indexOf(name) : -1
@@ -448,18 +665,40 @@ function toPrimitiveChain(node, v, order) {
448
665
  return typed(['block', blk, ...body], 'i64')
449
666
  }
450
667
 
668
+ const cloneIR = (n) => Array.isArray(n) ? n.map(cloneIR) : n
669
+
670
+ /** ToNumber for a runtime value that may carry a nullish sentinel: null→+0, undefined→NaN,
671
+ * anything else → itself. `valIR` must be side-effect-free (a local read) — it is duplicated,
672
+ * so each occurrence gets a fresh clone. Used for bindings flagged in ctx.func.maybeNullish;
673
+ * a real number isn't either sentinel, so it falls through the `else` unchanged. */
674
+ const coerceNullishToNum = (valIR) => typed(
675
+ ['if', ['result', 'f64'],
676
+ ['i64.eq', ['i64.reinterpret_f64', cloneIR(valIR)], ['i64.const', NULL_NAN]],
677
+ ['then', ['f64.const', 0]],
678
+ ['else', ['if', ['result', 'f64'],
679
+ ['i64.eq', ['i64.reinterpret_f64', cloneIR(valIR)], ['i64.const', UNDEF_NAN]],
680
+ ['then', ['f64.const', 'nan']],
681
+ ['else', cloneIR(valIR)]]]],
682
+ 'f64')
683
+
451
684
  /** Coerce an emitted IR value to a plain f64 Number per JS `ToNumber`.
452
685
  * Skips coercion when static type proves the value is already numeric
453
686
  * (i32 node, compile-time literal, known VAL.NUMBER/VAL.BIGINT). When the full
454
687
  * string-parsing `__to_num` isn't loaded (no string module → no strings can
455
688
  * exist) nullish *literals* still fold statically (null→+0, undefined→NaN);
456
- * non-literal values pass through uncoerced. */
689
+ * non-literal values pass through uncoerced — except bindings flagged
690
+ * maybeNullish, which get a runtime nullish coerce (null-flow correctness). */
457
691
  export function toNumF64(node, v) {
458
692
  // An i32 node carrying `.ptrKind` is an *unboxed pointer* (object/array local),
459
693
  // not a number — skipping coercion would reinterpret pointer bits as an f64.
460
694
  // Only a plain i32 (loop counter, `x|0`) is genuinely already-numeric.
461
695
  if ((v.type === 'i32' && v.ptrKind == null) || isLit(v)) return asF64(v)
462
- const vt = keyValType(node)
696
+ // A binding assigned a nullish literal may hold null/undefined here — coerce per ToNumber
697
+ // (null→+0, undefined→NaN); a real number falls through unchanged. Only flagged bindings pay
698
+ // this, so the numeric kernels jz optimizes for (which never assign null) stay untouched.
699
+ if (typeof node === 'string' && ctx.func.maybeNullish?.has(node)) return coerceNullishToNum(asF64(v))
700
+ const vt = valTypeOf(node)
701
+ if (vt === VAL.BOOL) return typed(['f64.convert_i32_s', truthyIR(v)], 'f64')
463
702
  if (vt === VAL.NUMBER || vt === VAL.BIGINT) return asF64(v)
464
703
  if (vt === VAL.DATE) {
465
704
  const ptr = v.ptrKind === VAL.DATE
@@ -473,7 +712,7 @@ export function toNumF64(node, v) {
473
712
  // yield non-primitives a TypeError is thrown. The chosen primitive still
474
713
  // flows through `__to_num` so a string return ("−7") is parsed. An abrupt
475
714
  // completion (throwing method) propagates through the closure call.
476
- if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.find) {
715
+ if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.slotOf) {
477
716
  const prim = toPrimitiveChain(node, v, ['valueOf', 'toString'])
478
717
  if (prim) {
479
718
  // No `__to_num` helper → the program provably has no strings, so the
@@ -497,6 +736,19 @@ export function toNumF64(node, v) {
497
736
  if (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u') return v
498
737
  if (v[0] === 'call' && v[1] === '$__time_ms') return v
499
738
  }
739
+ // f64 arithmetic ops and math intrinsics never produce NaN-boxed pointers — the
740
+ // result is always a plain f64 number. Skip __to_num for these, eliminating the
741
+ // call overhead that dominates tight numeric kernels (floatbeats, matrix loops).
742
+ // A `block`/`if` qualifies only when its value-producing tail is provably numeric
743
+ // (`isNumericIR`): `cond ? n*2 : n*3` skips, but `o.a?.b` (block yielding a
744
+ // property value / undef sentinel) does NOT — else `o.a?.b > 6` would compare the
745
+ // boxed string's NaN bits (NaN > 6 → false). User function calls are excluded too
746
+ // (may return dynamic-property strings); only $math.* is provably numeric.
747
+ if (v.type === 'f64' && Array.isArray(v) && (
748
+ PURE_F64_OPS.has(v[0]) ||
749
+ (v[0] === 'call' && typeof v[1] === 'string' && v[1].startsWith('$math.')) ||
750
+ ((v[0] === 'block' || v[0] === 'if') && isNumericIR(v))
751
+ )) return v
500
752
  if (!ctx.core.stdlib['__to_num']) {
501
753
  // No full ToNumber helper loaded — the program provably has no strings.
502
754
  // A nullish *literal* still coerces (null→+0, undefined→NaN) — fold it
@@ -525,8 +777,8 @@ export function toNumF64(node, v) {
525
777
  * `__to_str` so a numeric return is rendered. A throwing method propagates as
526
778
  * an abrupt completion through the closure call. */
527
779
  export function toStrI64(node, v) {
528
- const vt = keyValType(node)
529
- if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.find) {
780
+ const vt = valTypeOf(node)
781
+ if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.slotOf) {
530
782
  const prim = toPrimitiveChain(node, v, ['toString', 'valueOf'])
531
783
  if (prim) {
532
784
  inc('__to_str')
@@ -540,8 +792,37 @@ export function toStrI64(node, v) {
540
792
  /** Convert already-emitted WASM node to i32 boolean. NaN is falsy (like JS).
541
793
  * Peepholes: i32 → as-is; `f64.convert_i32_*(x)` → x (i32 conversion never NaN);
542
794
  * nested `__is_truthy(x)` → x (already 0/1); literal f64 const folds to 0/1. */
795
+ // f64 ops whose result is always a plain NUMBER (never a NaN-boxed carrier) and can
796
+ // be NaN — their truthiness must test NaN by value, not by bit pattern (see truthyIR).
797
+ const NUM_F64_TRUTHY_OPS = new Set([
798
+ 'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
799
+ 'f64.min', 'f64.max', 'f64.ceil', 'f64.floor', 'f64.trunc', 'f64.nearest', 'f64.copysign',
800
+ ])
801
+
802
+ const numericTruthy = e => {
803
+ const t = temp('tb')
804
+ const g = () => typed(['local.get', `$${t}`], 'f64')
805
+ return typed(['block', ['result', 'i32'],
806
+ ['local.set', `$${t}`, e],
807
+ ['i32.and', ['f64.ne', g(), ['f64.const', 0]], ['f64.eq', g(), g()]]], 'i32')
808
+ }
809
+
810
+ // i32 ops whose result is already a 0/1 boolean (comparisons + eqz) — safe to use
811
+ // directly as a truthiness without a redundant `!= 0`.
812
+ const I32_BOOL_OPS = new Set(['i32.eq', 'i32.ne', 'i32.lt_s', 'i32.lt_u', 'i32.gt_s', 'i32.gt_u',
813
+ 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u', 'i32.eqz'])
814
+
543
815
  export function truthyIR(e) {
544
- if (e.type === 'i32') return e
816
+ // An i32 *constant* is a concrete number, not a known 0/1 boolean — fold it to its
817
+ // truthiness (nonzero → 1).
818
+ if (Array.isArray(e) && e[0] === 'i32.const') return typed(['i32.const', e[1] ? 1 : 0], 'i32')
819
+ if (e.type === 'i32') {
820
+ // A comparison/eqz result is already 0/1 → use directly. Any *other* i32 may be a
821
+ // concrete narrowed integer (e.g. `Boolean(n)` where n is an i32 number), which is
822
+ // NOT a 0/1 boolean — normalize via `!= 0` so its truthiness is correct.
823
+ if (Array.isArray(e) && I32_BOOL_OPS.has(e[0])) return e
824
+ return typed(['i32.ne', e, ['i32.const', 0]], 'i32')
825
+ }
545
826
  // Unboxed pointer offsets: truthy iff non-zero offset.
546
827
  if (e.ptrKind != null) return typed(['i32.ne', e, ['i32.const', 0]], 'i32')
547
828
  if (Array.isArray(e)) {
@@ -563,7 +844,7 @@ export function truthyIR(e) {
563
844
  // all other NaN-boxed pointers (SSO strings, heap ptrs, etc.) are truthy.
564
845
  if (e[0] === 'f64.reinterpret_i64' && Array.isArray(e[1]) && e[1][0] === 'i64.const') {
565
846
  const bits = String(e[1][1])
566
- const FALSY = new Set([UNDEF_NAN, NULL_NAN, FALSE_NAN, '0x7FF8000000000000', '0x7FFA400000000000'])
847
+ const FALSY = new Set([UNDEF_NAN, NULL_NAN, FALSE_NAN, nanPrefixHex(), '0x7FFA400000000000'])
567
848
  return typed(['i32.const', FALSY.has(bits) ? 0 : 1], 'i32')
568
849
  }
569
850
  // Fresh pointer constructors never produce nullish. Treat as always truthy.
@@ -581,8 +862,25 @@ export function truthyIR(e) {
581
862
  vt === VAL.CLOSURE || vt === VAL.TYPED || vt === VAL.BUFFER || vt === VAL.REGEX || vt === VAL.DATE) {
582
863
  return typed(['i32.eqz', isNullish(e)], 'i32')
583
864
  }
865
+ // A plain NUMBER is truthy iff non-zero AND not NaN. `f64.eq x x` tests NaN by
866
+ // VALUE (false for ANY NaN bits), so this is correct on every platform — unlike
867
+ // __is_truthy, which bit-compares the canonical number-NaN and so mis-reads
868
+ // x86's sign-set 0xFFF8.. NaN (from f64.div(0,0) / %) as a truthy box. (local.get
869
+ // is pure → duplicated, not teed.) Bigint carriers are reinterpret/i64 shapes
870
+ // and never reach here as VAL.NUMBER.
871
+ if (vt === VAL.NUMBER) {
872
+ const g = () => typed(['local.get', e[1]], 'f64')
873
+ return typed(['i32.and', ['f64.ne', g(), ['f64.const', 0]], ['f64.eq', g(), g()]], 'i32')
874
+ }
584
875
  }
876
+ // Direct number-producing f64 expression (arithmetic, or the `%` / __rem helper):
877
+ // same NaN-safe test, single-evaluated through a temp (the value may be a call).
878
+ if (NUM_F64_TRUTHY_OPS.has(e[0]) || (e[0] === 'call' && e[1] === '$__rem')) return numericTruthy(e)
585
879
  }
880
+ // Composite IR tagged by emit as a definite NUMBER. Use value-based NaN
881
+ // truthiness; opaque f64 carriers (strings/objects/bigints/nullish/booleans)
882
+ // remain on __is_truthy so NaN-boxed payloads stay truthy/falsy by tag.
883
+ if (e.valKind === VAL.NUMBER) return numericTruthy(e)
586
884
  inc('__is_truthy')
587
885
  return typed(['call', '$__is_truthy', asI64(e)], 'i32')
588
886
  }
@@ -590,10 +888,6 @@ export const toBoolFromEmitted = truthyIR
590
888
 
591
889
  // === Value-type classification ===
592
890
 
593
- export function keyValType(node) {
594
- return typeof node === 'string' ? lookupValType(node) : valTypeOf(node)
595
- }
596
-
597
891
  export function usesDynProps(vt) {
598
892
  return vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.CLOSURE
599
893
  || vt === VAL.TYPED || vt === VAL.SET || vt === VAL.MAP || vt === VAL.REGEX
@@ -607,15 +901,27 @@ export function needsDynShadow(target) {
607
901
  // access (fn.parse, i32.parse aliases) sees the same value as schema slots.
608
902
  const vt = typeof target === 'string' ? (ctx.func.localReps?.get(target)?.val || ctx.scope.globalValTypes?.get(target)) : null
609
903
  if (vt === 'closure' || usesDynProps(vt)) return true
904
+ // A module-wide dynamic-key access (`obj[expr]`) means ANY object may later be
905
+ // read through the dyn-props hash (__dyn_get_any), so every object literal is
906
+ // built with a shadow. Mutation sites (Object.assign, `o.k = v`) must mirror
907
+ // into that same shadow or a subsequent hash read returns a stale slot value.
908
+ // Honor anyDynKey for NAMED targets too — not just anonymous (target == null)
909
+ // literals — so construct-time shadowing and mutate-time mirroring agree. They
910
+ // desynced before: a named literal shadowed via anyDynKey, but its assign saw
911
+ // only dynKeyVars (which holds the *dynamically-keyed* vars, not this binding).
912
+ if (ctx.types?.anyDynKey) return true
610
913
  const dyn = ctx.types?.dynKeyVars
611
- if (target == null) return ctx.types?.anyDynKey ?? false
612
- return dyn ? dyn.has(target) : false
914
+ return target != null && dyn ? dyn.has(target) : false
613
915
  }
614
916
 
615
917
  // === Variable storage abstraction ===
616
918
  // Centralizes the boxed/global/local 3-way dispatch (used by =, ++/--, +=, etc.)
617
919
 
618
920
  /** Check if name is a module-scope global (not shadowed by local/param). */
921
+ /** Bound in the current function frame — a declared local or a parameter. */
922
+ export const isBoundName = name =>
923
+ ctx.func.locals?.has(name) || ctx.func.current?.params?.some(p => p.name === name)
924
+
619
925
  export function isGlobal(name) {
620
926
  return ctx.scope.globals.has(name) && !ctx.func.locals?.has(name) && !ctx.func.current?.params?.some(p => p.name === name)
621
927
  }
@@ -630,16 +936,43 @@ export function boxedAddr(name) {
630
936
  return ['local.get', `$${ctx.func.boxed.get(name)}`]
631
937
  }
632
938
 
939
+ // '$'-prefixed name memo. readVar/writeVar run per IR node; rebuilding the
940
+ // `$name` string each time costs an alloc+copy in the self-host kernel AND
941
+ // produces a fresh instance per use — making watr's name-keyed lookups
942
+ // content-compare. The memo returns ONE canonical instance per name, so
943
+ // construction is a map hit and every downstream comparison is bit-eq.
944
+ // Module-level: in-kernel it lives per instance (arena strings are immortal),
945
+ // natively it is a plain cross-compile cache; the name vocabulary is bounded.
946
+ const DOLLAR = new Map()
947
+ export const dollar = (name) => {
948
+ let v = DOLLAR.get(name)
949
+ if (v === undefined) { v = '$' + name; DOLLAR.set(name, v) }
950
+ return v
951
+ }
952
+
633
953
  /** Read variable value: boxed → f64.load, global → global.get, local → local.get.
634
954
  * Unboxed pointer locals (repOf(name).ptrKind) tag the returned node with `.ptrKind`
635
955
  * so downstream coercions know it's an i32 offset, not a numeric. */
636
956
  export function readVar(name) {
637
- if (ctx.func.boxed?.has(name))
957
+ if (ctx.func.boxed?.has(name)) {
958
+ // i32-narrowed cell (closure-capture narrowing — see analyzeFuncForEmit's
959
+ // cellTypes): the cell stores a raw i32, load it directly.
960
+ if (ctx.func.cellTypes?.has(name)) return typed(['i32.load', boxedAddr(name)], 'i32')
638
961
  return typed(['f64.load', boxedAddr(name)], 'f64')
962
+ }
639
963
  if (isGlobal(name)) {
640
- const node = typed(['global.get', `$${name}`], ctx.scope.globalTypes.get(name) || 'f64')
964
+ const gt = ctx.scope.globalTypes.get(name) || 'f64'
965
+ const node = typed(['global.get', dollar(name)], gt)
641
966
  const grep = repOfGlobal(name)
642
- if (grep?.ptrKind != null) {
967
+ if (gt === 'f64' && (lookupValType(name) === VAL.NUMBER || grep?.val === VAL.NUMBER)) node.valKind = VAL.NUMBER
968
+ // ptrKind tags a raw i32 pointer offset — meaningful only for an i32-STORED
969
+ // global (a typed-array/buffer carrier unboxed by unboxConstTypedGlobals). An
970
+ // f64 global holds a NaN-boxed value: object/array reads unbox at the access
971
+ // site via the schema/reinterpret path, never an i32 reinterpret of the storage.
972
+ // Attaching ptrKind to an f64 global makes `asF64` box the f64 *as if it were an
973
+ // i32* (i64.extend_i32_u on a global.get of type f64 → invalid wasm). Gate on the
974
+ // storage type so the tag follows the declared ABI.
975
+ if (gt === 'i32' && grep?.ptrKind != null) {
643
976
  node.ptrKind = grep.ptrKind
644
977
  if (grep.ptrAux != null) node.ptrAux = grep.ptrAux
645
978
  }
@@ -657,7 +990,8 @@ export function readVar(name) {
657
990
  return t === 'i32' ? typed(['i32.const', rep.intConst], 'i32')
658
991
  : typed(['f64.const', rep.intConst], 'f64')
659
992
  }
660
- const node = typed(['local.get', `$${name}`], t)
993
+ const node = typed(['local.get', dollar(name)], t)
994
+ if (t === 'f64' && (lookupValType(name) === VAL.NUMBER || rep?.val === VAL.NUMBER)) node.valKind = VAL.NUMBER
661
995
  // Proven uint32 accumulator local (narrowUint32): a later asF64 must widen with
662
996
  // convert_i32_u (the i32 bit pattern is an unsigned value), not _s. `.wrapSafe`
663
997
  // marks it as the always-ToUint32-sunk kind so the arithmetic widening guards
@@ -676,22 +1010,29 @@ export function readVar(name) {
676
1010
  export function writeVar(name, valIR, void_) {
677
1011
  if (ctx.func.boxed?.has(name)) {
678
1012
  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'],
1013
+ // i32-narrowed cell: store the raw i32 (mirrors the integer-global write
1014
+ // gate below the storage type decides the coercion).
1015
+ const i32Cell = ctx.func.cellTypes?.has(name)
1016
+ const st = i32Cell ? 'i32.store' : 'f64.store'
1017
+ const v = i32Cell ? asI32(valIR) : asF64(valIR)
1018
+ if (void_) return typed(['block', [st, addr, v]], 'void')
1019
+ const t = i32Cell ? tempI32() : temp()
1020
+ return typed(['block', ['result', i32Cell ? 'i32' : 'f64'],
683
1021
  ['local.set', `$${t}`, v],
684
- ['f64.store', addr, ['local.get', `$${t}`]],
685
- ['local.get', `$${t}`]], 'f64')
1022
+ [st, addr, ['local.get', `$${t}`]],
1023
+ ['local.get', `$${t}`]], i32Cell ? 'i32' : 'f64')
686
1024
  }
687
1025
  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'],
1026
+ // Scalar globals are f64 by default, but integer-global inference (plan.js)
1027
+ // narrows purpose-focused counters/sizes to i32 coerce the write to match.
1028
+ const gt = ctx.scope.globalTypes.get(name) || 'f64'
1029
+ const v = gt === 'i32' ? asI32(valIR) : asF64(valIR)
1030
+ if (void_) return typed(['block', ['global.set', dollar(name), v]], 'void')
1031
+ const t = gt === 'i32' ? tempI32() : temp()
1032
+ return typed(['block', ['result', gt],
692
1033
  ['local.set', `$${t}`, v],
693
- ['global.set', `$${name}`, ['local.get', `$${t}`]],
694
- ['local.get', `$${t}`]], 'f64')
1034
+ ['global.set', dollar(name), ['local.get', `$${t}`]],
1035
+ ['local.get', `$${t}`]], gt)
695
1036
  }
696
1037
  const t = ctx.func.locals.get(name) || 'f64'
697
1038
  const ptrKind = repOf(name)?.ptrKind
@@ -703,10 +1044,10 @@ export function writeVar(name, valIR, void_) {
703
1044
  ? valIR
704
1045
  : typed(['i32.wrap_i64', ['i64.reinterpret_f64', asF64(valIR)]], 'i32')
705
1046
  } else {
706
- coerced = t === 'f64' ? asF64(valIR) : asI32(valIR)
1047
+ coerced = t === 'v128' ? valIR : t === 'f64' ? asF64(valIR) : asI32(valIR)
707
1048
  }
708
- if (void_) return typed(['local.set', `$${name}`, coerced], 'void')
709
- const teeNode = typed(['local.tee', `$${name}`, coerced], t)
1049
+ if (void_) return typed(['local.set', dollar(name), coerced], 'void')
1050
+ const teeNode = typed(['local.tee', dollar(name), coerced], t)
710
1051
  if (ptrKind != null) teeNode.ptrKind = ptrKind
711
1052
  return teeNode
712
1053
  }
@@ -716,57 +1057,52 @@ export function writeVar(name, valIR, void_) {
716
1057
  * unboxed pointer locals are proven non-null by unboxablePtrs.
717
1058
  * Inlines directly: (i32.or (i64.eq bits NULL_NAN) (i64.eq bits UNDEF_NAN))
718
1059
  * 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')
1060
+ // Shared peephole for the NaN-box sentinel checks. When the operand's bits are
1061
+ // statically known an unboxed pointer (never an atom → 0), a numeric `f64.const`
1062
+ // (never an atom → 0), or a boxed `(f64.const nan:…)` / `(f64.reinterpret_i64
1063
+ // (i64.const …))` literal — resolve `onBits(bitsHex)` / 0 at compile time; else
1064
+ // hand the expr to `fallback` for the runtime test. One place owns the literal set.
1065
+ const constI32 = (b) => typed(['i32.const', b ? 1 : 0], 'i32')
1066
+ const matchF64Bits = (f64expr, onBits, fallback) => {
1067
+ if (f64expr.ptrKind != null) return constI32(0)
721
1068
  if (Array.isArray(f64expr)) {
722
1069
  if (f64expr[0] === 'f64.const') {
723
- // Check for NaN-boxed sentinel: (f64.const nan:0x...) — matches NULL_IR/UNDEF_IR form.
724
1070
  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
1071
+ return lit.startsWith('nan:') ? onBits(lit.slice(4)) : constI32(0)
730
1072
  }
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')
734
- }
735
- }
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')
1073
+ if (f64expr[0] === 'f64.reinterpret_i64' && Array.isArray(f64expr[1]) && f64expr[1][0] === 'i64.const')
1074
+ return onBits(String(f64expr[1][1]))
743
1075
  }
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')
1076
+ return fallback(f64expr)
747
1077
  }
748
1078
 
1079
+ export const isNullish = (f64expr) => matchF64Bits(f64expr,
1080
+ bits => constI32(bits === NULL_NAN || bits === UNDEF_NAN),
1081
+ (e) => {
1082
+ // (local.get $x): inline the test, reinterpreting twice (V8 CSEs it). Other
1083
+ // exprs call $__is_nullish — keeps binary size stable and evaluates once.
1084
+ if (Array.isArray(e) && e[0] === 'local.get') {
1085
+ const bits = ['i64.reinterpret_f64', e]
1086
+ return typed(['i32.or',
1087
+ ['i64.eq', bits, ['i64.const', NULL_NAN]],
1088
+ ['i64.eq', ['i64.reinterpret_f64', e], ['i64.const', UNDEF_NAN]]], 'i32')
1089
+ }
1090
+ inc('__is_nullish')
1091
+ return typed(['call', '$__is_nullish', ['i64.reinterpret_f64', e]], 'i32')
1092
+ })
1093
+
749
1094
  /** Check if f64 expr is exactly `undefined` (UNDEF_NAN). Returns i32.
750
1095
  * Used by default-param semantics — only `undefined` (or missing arg) triggers
751
1096
  * 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
- }
1097
+ export const isUndef = (f64expr) => matchF64Bits(f64expr,
1098
+ bits => constI32(bits === UNDEF_NAN),
1099
+ (e) => typed(['i64.eq', ['i64.reinterpret_f64', e], ['i64.const', UNDEF_NAN]], 'i32'))
1100
+
1101
+ /** Check if f64 expr is exactly `null` (NULL_NAN). Returns i32.
1102
+ * Strict `=== null` must match only null — not undefined (use isUndef for that). */
1103
+ export const isNull = (f64expr) => matchF64Bits(f64expr,
1104
+ bits => constI32(bits === NULL_NAN),
1105
+ (e) => typed(['i64.eq', ['i64.reinterpret_f64', e], ['i64.const', NULL_NAN]], 'i32'))
770
1106
 
771
1107
  /** Mask that clears the boolean atom's truth bit, mapping TRUE_NAN→FALSE_NAN.
772
1108
  * `(bits & BOOL_ATOM_MASK) === FALSE_NAN` recognizes both in one i64.and+i64.eq. */
@@ -774,16 +1110,11 @@ const BOOL_ATOM_MASK = '0x' + BigInt.asUintN(64, ~(1n << BigInt(LAYOUT.AUX_SHIFT
774
1110
 
775
1111
  /** Check if f64 expr is a boxed-boolean atom (TRUE_NAN or FALSE_NAN). Returns i32.
776
1112
  * 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
- }
1113
+ export const isBoolAtom = (f64expr) => matchF64Bits(f64expr,
1114
+ bits => constI32(bits === TRUE_NAN || bits === FALSE_NAN),
1115
+ (e) => typed(['i64.eq',
1116
+ ['i64.and', ['i64.reinterpret_f64', e], ['i64.const', BOOL_ATOM_MASK]],
1117
+ ['i64.const', FALSE_NAN]], 'i32'))
787
1118
 
788
1119
  // === Array layout helpers — routed through the array carrier (abi/array.js) ===
789
1120
 
@@ -813,7 +1144,7 @@ export function elemStore(ptr, i, val) {
813
1144
  * re-loading from ptr-8.
814
1145
  * Optional `ptrLocal`: caller already has the resolved ARRAY data pointer in
815
1146
  * an i32 local. Reuses it instead of calling __ptr_offset again. */
816
- export function arrayLoop(arrExpr, bodyFn, lenLocal, ptrLocal) {
1147
+ export function arrayLoop(arrExpr, bodyFn, lenLocal, ptrLocal, reverse) {
817
1148
  const arr = ptrLocal ? null : temp('aa'), ptr = ptrLocal ?? tempI32('ap'), i = tempI32('ai'), item = temp('av')
818
1149
  const len = lenLocal ?? tempI32('al')
819
1150
  const id = ctx.func.uniq++
@@ -827,13 +1158,18 @@ export function arrayLoop(arrExpr, bodyFn, lenLocal, ptrLocal) {
827
1158
  }
828
1159
  if (!lenLocal) setup.push(
829
1160
  ['local.set', `$${len}`, ['i32.load', ['i32.sub', ['local.get', `$${ptr}`], ['i32.const', 8]]]])
1161
+ // Forward: i 0→len-1. Reverse (findLast*): i len-1→0, same elem indices.
1162
+ const start = reverse ? ['i32.sub', ['local.get', `$${len}`], ['i32.const', 1]] : ['i32.const', 0]
1163
+ const done = reverse ? ['i32.lt_s', ['local.get', `$${i}`], ['i32.const', 0]]
1164
+ : ['i32.ge_s', ['local.get', `$${i}`], ['local.get', `$${len}`]]
1165
+ const step = ['i32.const', reverse ? -1 : 1]
830
1166
  setup.push(
831
- ['local.set', `$${i}`, ['i32.const', 0]],
1167
+ ['local.set', `$${i}`, start],
832
1168
  ['block', `$brk${id}`, ['loop', `$loop${id}`,
833
- ['br_if', `$brk${id}`, ['i32.ge_s', ['local.get', `$${i}`], ['local.get', `$${len}`]]],
1169
+ ['br_if', `$brk${id}`, done],
834
1170
  ['local.set', `$${item}`, elemLoad(ptr, i)],
835
1171
  ...bodyFn(ptr, len, i, typed(['local.get', `$${item}`], 'f64')),
836
- ['local.set', `$${i}`, ['i32.add', ['local.get', `$${i}`], ['i32.const', 1]]],
1172
+ ['local.set', `$${i}`, ['i32.add', ['local.get', `$${i}`], step]],
837
1173
  ['br', `$loop${id}`]]])
838
1174
  return setup
839
1175
  }
@@ -885,6 +1221,7 @@ export function loopTop() {
885
1221
  export const flat = ir => {
886
1222
  if (ir == null) return []
887
1223
  if (!Array.isArray(ir)) return [ir] // bare 'drop', 'nop', etc.
1224
+ if (ir.length === 0) return []
888
1225
  if (typeof ir[0] === 'string' || ir[0] == null) return [ir] // single instruction: ['op', ...args] or [null, val]
889
1226
  return ir // multi-instruction: [instr1, instr2, ...]
890
1227
  }
@@ -906,6 +1243,38 @@ export function findBodyStart(fn) {
906
1243
  return fn.length
907
1244
  }
908
1245
 
1246
+ /** Debug-mode structural check of a `(func …)` IR node. Catches the bug classes
1247
+ * that otherwise surface as OPAQUE watr errors several phases later — `Duplicate
1248
+ * local $x`, `Unknown local $x` — but here pinned to the exact name (and, via the
1249
+ * caller, the phase + function) that produced them, so a codegen/optimizer bug is
1250
+ * localized at its source instead of at watr. Self-contained: validates every
1251
+ * `local.{get,set,tee}` against the function header's param/local declarations,
1252
+ * and rejects a duplicate declaration. Returns an error string, or null if clean.
1253
+ * (Call-target and type-tag validation need the module symbol table + a type pass;
1254
+ * deferred — locals are the common codegen-bug class and need nothing external.) */
1255
+ export function verifyFn(fn) {
1256
+ if (!Array.isArray(fn) || fn[0] !== 'func') return null
1257
+ const bodyStart = findBodyStart(fn)
1258
+ const declared = new Set()
1259
+ for (let i = 2; i < bodyStart; i++) {
1260
+ const c = fn[i]
1261
+ if (!Array.isArray(c) || (c[0] !== 'param' && c[0] !== 'local') || typeof c[1] !== 'string') continue
1262
+ if (declared.has(c[1])) return `duplicate local/param ${c[1]}`
1263
+ declared.add(c[1])
1264
+ }
1265
+ let bad = null
1266
+ const walk = (n) => {
1267
+ if (bad || !Array.isArray(n)) return
1268
+ const op = n[0]
1269
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && !declared.has(n[1])) {
1270
+ bad = `${op} of undeclared local ${n[1]}`; return
1271
+ }
1272
+ for (let i = 1; i < n.length; i++) walk(n[i])
1273
+ }
1274
+ for (let i = bodyStart; i < fn.length; i++) walk(fn[i])
1275
+ return bad
1276
+ }
1277
+
909
1278
  /**
910
1279
  * Tail-call rewrite: walks tail positions of an emitted IR tree and replaces
911
1280
  * direct `(call $name args...)` ops with `(return_call $name args...)`.