jz 0.4.0 → 0.5.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.
package/src/ir.js CHANGED
@@ -21,7 +21,19 @@
21
21
  */
22
22
 
23
23
  import { ctx, err, inc, PTR, LAYOUT } from './ctx.js'
24
- import { T, VAL, valTypeOf, lookupValType, repOf, repOfGlobal } from './analyze.js'
24
+ import { T, VAL, valTypeOf, lookupValType, repOf, repOfGlobal, objLiteralSchemaId } from './analyze.js'
25
+
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)
25
37
 
26
38
  // === Type helpers ===
27
39
 
@@ -52,8 +64,17 @@ export const asF64 = n => {
52
64
  if (n == null) err(`compiler internal: expected emitted IR value in ${ctx.func.current?.name || '<module>'}, got empty value`)
53
65
  if (n.ptrKind != null) return boxPtrIR(n, valKindToPtr(n.ptrKind), n.ptrAux || 0)
54
66
  if (n.type === 'f64') return n
55
- if (n.type === 'i64') return typed(['f64.reinterpret_i64', n], 'f64')
56
- if (n[0] === 'i32.const' && typeof n[1] === 'number') return typed(['f64.const', n[1]], 'f64')
67
+ if (n.type === 'i64') {
68
+ // Cancel the reinterpret round-trip at construction: reinterpret is bit-preserving
69
+ // both ways, so f64.reinterpret_i64(i64.reinterpret_f64(X)) === X. Folding here keeps
70
+ // the pair out of the IR entirely (smaller tree for every downstream pass) instead of
71
+ // letting fusedRewrite untangle it post-emit.
72
+ if (Array.isArray(n) && n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1])) return typed(n[1], 'f64')
73
+ return typed(['f64.reinterpret_i64', n], 'f64')
74
+ }
75
+ // A `.unsigned` const carries its uint32 value as a signed i32 bit pattern, so
76
+ // widen via `>>> 0` (e.g. -1 → 4294967295); a plain const copies through verbatim.
77
+ if (n[0] === 'i32.const' && typeof n[1] === 'number') return typed(['f64.const', n.unsigned ? n[1] >>> 0 : n[1]], 'f64')
57
78
  return typed([n.unsigned ? 'f64.convert_i32_u' : 'f64.convert_i32_s', n], 'f64')
58
79
  }
59
80
 
@@ -72,8 +93,13 @@ export const asI32 = n => {
72
93
  /** Coerce node to i32 offset for a ptr-narrowed return / store. Same-kind unboxed
73
94
  * ptr passes through; otherwise extract low 32 bits from the NaN-boxed f64
74
95
  * (NOT trunc — that would convert numerically). */
75
- export const asPtrOffset = (n, ptrKind) =>
76
- n.ptrKind === ptrKind ? n : typed(['i32.wrap_i64', ['i64.reinterpret_f64', asF64(n)]], 'i32')
96
+ export const asPtrOffset = (n, ptrKind) => {
97
+ if (n.ptrKind === ptrKind) return n
98
+ const f = asF64(n)
99
+ // Peel the inner reinterpret round-trip before wrapping: i64.reinterpret_f64(f64.reinterpret_i64(Y)) === Y.
100
+ const bits = Array.isArray(f) && f[0] === 'f64.reinterpret_i64' && Array.isArray(f[1]) ? f[1] : ['i64.reinterpret_f64', f]
101
+ return typed(['i32.wrap_i64', bits], 'i32')
102
+ }
77
103
 
78
104
  /** Coerce emitted IR to a target WASM param type ('i32' | 'i64' | 'f64'). */
79
105
  export const asParamType = (n, t) => t === 'i32' ? asI32(n) : t === 'i64' ? asI64(n) : asF64(n)
@@ -116,30 +142,71 @@ export const toI32 = n => {
116
142
  }
117
143
 
118
144
  /** Extract i64 from BigInt-as-f64. */
119
- export const asI64 = n => typed(['i64.reinterpret_f64', asF64(n)], 'i64')
145
+ export const asI64 = n => {
146
+ const f = asF64(n)
147
+ // Cancel reinterpret round-trip: i64.reinterpret_f64(f64.reinterpret_i64(Y)) === Y.
148
+ if (Array.isArray(f) && f[0] === 'f64.reinterpret_i64' && Array.isArray(f[1])) return typed(f[1], 'i64')
149
+ return typed(['i64.reinterpret_f64', f], 'i64')
150
+ }
120
151
 
121
152
  /** Wrap i64 result back to BigInt-as-f64. */
122
- export const fromI64 = n => typed(['f64.reinterpret_i64', n], 'f64')
153
+ export const fromI64 = n => {
154
+ // Cancel reinterpret round-trip: f64.reinterpret_i64(i64.reinterpret_f64(X)) === X.
155
+ if (Array.isArray(n) && n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1])) return typed(n[1], 'f64')
156
+ return typed(['f64.reinterpret_i64', n], 'f64')
157
+ }
123
158
 
124
159
  // === Nullish sentinels ===
125
160
 
126
161
  /** Reserved atoms (PTR.ATOM tag, offset=0).
127
162
  * aux=1 → null (NULL_NAN)
128
163
  * aux=2 → undefined (UNDEF_NAN)
164
+ * aux=4 → false (FALSE_NAN)
165
+ * aux=5 → true (TRUE_NAN)
129
166
  * See module/symbol.js for the broader reserved-atom-id scheme.
130
167
  * Distinct from 0, NaN, and all pointers. Triggers default params.
131
168
  * At the JS boundary, null and undefined preserve their identity for interop. */
132
169
  export const NULL_NAN = '0x' + (LAYOUT.NAN_PREFIX_BITS | (1n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
133
170
  export const UNDEF_NAN = '0x' + (LAYOUT.NAN_PREFIX_BITS | (2n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
171
+ /** Boxed-boolean carrier. `false`/`true` are reserved atoms — materialized only
172
+ * where boolean identity is observed (typeof/String/JSON/host boundary); in
173
+ * branch/arithmetic position booleans stay raw i32/f64 0/1. The atomId encodes
174
+ * the truth value in its low bit (4=false, 5=true), so `aux & 1` recovers 0/1
175
+ * and `4 | bit` boxes it — see boolBoxIR / unboxBoolIR. */
176
+ 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')
134
179
  /** WAT-template-ready sentinel expressions for use in stdlib template strings.
135
180
  * `f64.const nan:0xHEX` is 3 bytes shorter than `f64.reinterpret_i64 (i64.const ...)`. */
136
181
  export const NULL_WAT = `(f64.const nan:${NULL_NAN})`
137
182
  export const UNDEF_WAT = `(f64.const nan:${UNDEF_NAN})`
138
183
  export const NULL_IR = ['f64.const', `nan:${NULL_NAN}`]
139
184
  export const UNDEF_IR = ['f64.const', `nan:${UNDEF_NAN}`]
185
+ export const FALSE_IR = ['f64.const', `nan:${FALSE_NAN}`]
186
+ export const TRUE_IR = ['f64.const', `nan:${TRUE_NAN}`]
140
187
  export const nullExpr = () => typed(NULL_IR, 'f64')
141
188
  export const undefExpr = () => typed(UNDEF_IR.slice(), 'f64')
142
189
 
190
+ /** Materialize the boxed-boolean carrier from a 0/1-valued expression. The atom
191
+ * is `BOOL_ATOM_BASE | bit`, so boxing is one `i32.or` then an ATOM mkptr; when
192
+ * the input folds to a constant 0/1 we emit the `f64.const nan:` literal directly.
193
+ * Used only at observation/escape sites — never in branch or arithmetic position. */
194
+ export function boolBoxIR(e) {
195
+ const i = truthyIR(e)
196
+ if (Array.isArray(i) && i[0] === 'i32.const') return typed((i[1] ? TRUE_IR : FALSE_IR).slice(), 'f64')
197
+ return mkPtrIR(['i32.const', PTR.ATOM], ['i32.or', ['i32.const', BOOL_ATOM_BASE], i], ['i32.const', 0])
198
+ }
199
+
200
+ /** Recover the 0/1 i32 value of a known boxed-boolean f64 expression: `aux & 1`. */
201
+ export function unboxBoolIR(f64expr) {
202
+ if (Array.isArray(f64expr) && f64expr[0] === 'f64.const') {
203
+ const bits = typeof f64expr[1] === 'string' ? f64expr[1].replace(/^nan:/, '') : null
204
+ if (bits === TRUE_NAN) return typed(['i32.const', 1], 'i32')
205
+ if (bits === FALSE_NAN) return typed(['i32.const', 0], 'i32')
206
+ }
207
+ return typed(['i32.and', ['i32.wrap_i64', ['i64.shr_u', ['i64.reinterpret_f64', f64expr], ['i64.const', String(LAYOUT.AUX_SHIFT)]]], ['i32.const', 1]], 'i32')
208
+ }
209
+
143
210
  // === Constants ===
144
211
 
145
212
  /** Max arity of inline closure slots. Closures are compiled with signature
@@ -149,6 +216,7 @@ export const undefExpr = () => typed(UNDEF_IR.slice(), 'f64')
149
216
  export const MAX_CLOSURE_ARITY = 8
150
217
 
151
218
  /** 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
152
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/
153
221
 
154
222
  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'])
@@ -295,14 +363,14 @@ export const isPostfix = (a, op, b) => Array.isArray(a) && a[0] === op && Array.
295
363
 
296
364
  /** Emit a numeric constant with correct i32/f64 typing.
297
365
  * `-0` is f64-only (i32 has no signed zero) — preserve the sign by emitting f64. */
298
- export const emitNum = v => Number.isInteger(v) && v >= -2147483648 && v <= 2147483647 && !Object.is(v, -0)
366
+ export const emitNum = v => isI32(v)
299
367
  ? typed(['i32.const', v], 'i32') : typed(['f64.const', v], 'f64')
300
368
 
301
369
  // === Temp locals ===
302
370
 
303
371
  /** Allocate a temp local, returns name without $. Optional tag aids WAT readability.
304
- * Skips names already registered (by analyzeLocals from prepare-generated names)
305
- * to avoid collisions that would silently override the pre-analyzed type. */
372
+ * Skips names already registered (by analyzeBody().locals from prepare-generated
373
+ * names) to avoid collisions that would silently override the pre-analyzed type. */
306
374
  export function temp(tag = '') {
307
375
  let name
308
376
  do { name = `${T}${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
@@ -339,12 +407,58 @@ export const f64rem = (a, b) => {
339
407
  ['f64.sub', ga, ['f64.mul', ['f64.trunc', ['f64.div', ga, gb]], gb]]], 'f64')
340
408
  }
341
409
 
410
+ /** Resolve the slot index of a ToPrimitive method (`valueOf`/`toString`) on an
411
+ * OBJECT operand — from a schema-bound variable or an inline object literal.
412
+ * Returns -1 when the method is absent. */
413
+ function primMethodIdx(node, name) {
414
+ if (typeof node === 'string') return ctx.schema.find(node, name)
415
+ const sid = objLiteralSchemaId(node)
416
+ const props = sid != null ? ctx.schema.list[sid] : null
417
+ return props ? props.indexOf(name) : -1
418
+ }
419
+
420
+ /** Emit the ES `OrdinaryToPrimitive` method-fallback chain for an OBJECT operand,
421
+ * returning an i64 IR node holding the resulting primitive — or null when the
422
+ * object exposes none of the hinted methods. `order` is the method-try order
423
+ * (number hint → [valueOf,toString]; string hint → [toString,valueOf]). Each
424
+ * present method is called in turn: a primitive result short-circuits out, a
425
+ * non-primitive (object) result falls through to the next method, and if every
426
+ * method yields a non-primitive a TypeError is thrown — the spec algorithm. */
427
+ function toPrimitiveChain(node, v, order) {
428
+ const present = order.map(name => primMethodIdx(node, name)).filter(i => i >= 0)
429
+ if (!present.length) return null
430
+ ctx.runtime.throws = true
431
+ inc('__is_object')
432
+ const blk = `$tp${ctx.func.uniq++}`
433
+ const prim = tempI64('prim')
434
+ const optr = tempI32('op')
435
+ // Resolve the object's data pointer once — `v` may carry side effects and is
436
+ // referenced once per method slot below.
437
+ const body = [['result', 'i64'],
438
+ ['local.set', `$${optr}`, ptrOffsetIR(v, VAL.OBJECT)]]
439
+ for (const idx of present) {
440
+ const method = typed(ctx.abi.object.ops.load(['local.get', `$${optr}`], idx), 'f64')
441
+ body.push(
442
+ ['local.set', `$${prim}`, asI64(ctx.closure.call(method, []))],
443
+ ['br_if', blk, ['local.get', `$${prim}`],
444
+ ['i32.eqz', ['call', '$__is_object', ['local.get', `$${prim}`]]]])
445
+ }
446
+ // Every method returned a non-primitive — `Cannot convert object to primitive`.
447
+ body.push(['throw', '$__jz_err', ['f64.const', 0]])
448
+ return typed(['block', blk, ...body], 'i64')
449
+ }
450
+
342
451
  /** Coerce an emitted IR value to a plain f64 Number per JS `ToNumber`.
343
452
  * Skips coercion when static type proves the value is already numeric
344
- * (i32 node, compile-time literal, known VAL.NUMBER/VAL.BIGINT) or when
345
- * __to_num is not available (no string module loaded → no strings possible). */
453
+ * (i32 node, compile-time literal, known VAL.NUMBER/VAL.BIGINT). When the full
454
+ * string-parsing `__to_num` isn't loaded (no string module → no strings can
455
+ * exist) nullish *literals* still fold statically (null→+0, undefined→NaN);
456
+ * non-literal values pass through uncoerced. */
346
457
  export function toNumF64(node, v) {
347
- if (v.type === 'i32' || isLit(v)) return asF64(v)
458
+ // An i32 node carrying `.ptrKind` is an *unboxed pointer* (object/array local),
459
+ // not a number — skipping coercion would reinterpret pointer bits as an f64.
460
+ // Only a plain i32 (loop counter, `x|0`) is genuinely already-numeric.
461
+ if ((v.type === 'i32' && v.ptrKind == null) || isLit(v)) return asF64(v)
348
462
  const vt = keyValType(node)
349
463
  if (vt === VAL.NUMBER || vt === VAL.BIGINT) return asF64(v)
350
464
  if (vt === VAL.DATE) {
@@ -353,20 +467,76 @@ export function toNumF64(node, v) {
353
467
  : ['i32.wrap_i64', ['i64.reinterpret_f64', asF64(v)]]
354
468
  return typed(['f64.load', ptr], 'f64')
355
469
  }
470
+ // ToPrimitive (number hint): an OBJECT operand coerces through the
471
+ // `OrdinaryToPrimitive` method chain [valueOf, toString] — `valueOf` is tried
472
+ // first, and when it yields a non-primitive `toString` is tried; if both
473
+ // yield non-primitives a TypeError is thrown. The chosen primitive still
474
+ // flows through `__to_num` so a string return ("−7") is parsed. An abrupt
475
+ // completion (throwing method) propagates through the closure call.
476
+ if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.find) {
477
+ const prim = toPrimitiveChain(node, v, ['valueOf', 'toString'])
478
+ if (prim) {
479
+ // No `__to_num` helper → the program provably has no strings, so the
480
+ // primitive is a non-string value already usable as an f64.
481
+ if (!ctx.core.stdlib['__to_num']) return asF64(prim)
482
+ inc('__to_num')
483
+ return typed(['call', '$__to_num', prim], 'f64')
484
+ }
485
+ }
356
486
  // intCertain locals: every reachable def is integer-valued, so the binding
357
487
  // never carries a NaN-boxed pointer — skip the __to_num wrapper.
358
488
  if (typeof node === 'string' && repOf(node)?.intCertain === true) return asF64(v)
489
+ // intCertain schema slot reads `o.x`: every observed write is integer-shaped,
490
+ // so the loaded f64 is a plain number — same justification as the local case.
491
+ if (Array.isArray(node) && node[0] === '.' && typeof node[1] === 'string' && typeof node[2] === 'string') {
492
+ if (ctx.schema.slotIntCertainAt?.(node[1], node[2]) === true) return asF64(v)
493
+ }
359
494
  // IR-level shapes that produce real f64 numbers (never NaN-boxed pointers):
360
495
  // i32→f64 conversions, stdlib clock helper. Skip the __to_num call wrapper.
361
496
  if (Array.isArray(v)) {
362
497
  if (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u') return v
363
498
  if (v[0] === 'call' && v[1] === '$__time_ms') return v
364
499
  }
365
- if (!ctx.core.stdlib['__to_num']) return asF64(v)
500
+ if (!ctx.core.stdlib['__to_num']) {
501
+ // No full ToNumber helper loaded — the program provably has no strings.
502
+ // A nullish *literal* still coerces (null→+0, undefined→NaN) — fold it
503
+ // statically so `Math.log10(null)` & friends are correct at zero cost.
504
+ // Non-literal values fall through to `asF64`: an untyped runtime value
505
+ // *could* be a nullish sentinel, but blanket per-use coercion taxes every
506
+ // numeric kernel (fib, math loops) — nullable-param coercion belongs once
507
+ // at the function boundary (null-flow inference), not at each use site.
508
+ const f = asF64(v)
509
+ if (Array.isArray(f) && f[0] === 'f64.const' && typeof f[1] === 'string') {
510
+ const lit = f[1]
511
+ if (lit.startsWith('nan:')) // NaN-boxed sentinel/pointer
512
+ return typed(['f64.const', lit.slice(4) === NULL_NAN ? 0 : 'nan'], 'f64')
513
+ }
514
+ return f
515
+ }
366
516
  inc('__to_num')
367
517
  return typed(['call', '$__to_num', asI64(v)], 'f64')
368
518
  }
369
519
 
520
+ /** Coerce an emitted IR value to a jz string per JS `ToString`, returning an
521
+ * i64 string value. The mirror of `toNumF64` for the string hint: an OBJECT
522
+ * operand coerces through `OrdinaryToPrimitive(string)` — method chain
523
+ * [toString, valueOf], `toString` first with fallback to `valueOf`, TypeError
524
+ * if both yield non-primitives. The chosen primitive still flows through
525
+ * `__to_str` so a numeric return is rendered. A throwing method propagates as
526
+ * an abrupt completion through the closure call. */
527
+ export function toStrI64(node, v) {
528
+ const vt = keyValType(node)
529
+ if (vt === VAL.OBJECT && ctx.closure.call && ctx.schema.find) {
530
+ const prim = toPrimitiveChain(node, v, ['toString', 'valueOf'])
531
+ if (prim) {
532
+ inc('__to_str')
533
+ return typed(['call', '$__to_str', prim], 'i64')
534
+ }
535
+ }
536
+ inc('__to_str')
537
+ return typed(['call', '$__to_str', asI64(v)], 'i64')
538
+ }
539
+
370
540
  /** Convert already-emitted WASM node to i32 boolean. NaN is falsy (like JS).
371
541
  * Peepholes: i32 → as-is; `f64.convert_i32_*(x)` → x (i32 conversion never NaN);
372
542
  * nested `__is_truthy(x)` → x (already 0/1); literal f64 const folds to 0/1. */
@@ -382,11 +552,18 @@ export function truthyIR(e) {
382
552
  if (e[0] === 'f64.const' && typeof e[1] === 'number') {
383
553
  return typed(['i32.const', (e[1] !== 0 && !Number.isNaN(e[1])) ? 1 : 0], 'i32')
384
554
  }
555
+ // Fold NaN-boxed sentinel literals in `f64.const nan:0x...` form (boolean
556
+ // atoms, null/undefined): TRUE → 1, everything else nullish/false → 0.
557
+ if (e[0] === 'f64.const' && typeof e[1] === 'string' && e[1].startsWith('nan:')) {
558
+ const bits = e[1].slice(4)
559
+ if (bits === TRUE_NAN) return typed(['i32.const', 1], 'i32')
560
+ if (bits === FALSE_NAN || bits === UNDEF_NAN || bits === NULL_NAN) return typed(['i32.const', 0], 'i32')
561
+ }
385
562
  // Fold NaN-boxed pointer literals: UNDEF/NULL/canonical-NaN sentinels are falsy;
386
563
  // all other NaN-boxed pointers (SSO strings, heap ptrs, etc.) are truthy.
387
564
  if (e[0] === 'f64.reinterpret_i64' && Array.isArray(e[1]) && e[1][0] === 'i64.const') {
388
565
  const bits = String(e[1][1])
389
- const FALSY = new Set([UNDEF_NAN, NULL_NAN, '0x7FF8000000000000', '0x7FFA400000000000'])
566
+ const FALSY = new Set([UNDEF_NAN, NULL_NAN, FALSE_NAN, '0x7FF8000000000000', '0x7FFA400000000000'])
390
567
  return typed(['i32.const', FALSY.has(bits) ? 0 : 1], 'i32')
391
568
  }
392
569
  // Fresh pointer constructors never produce nullish. Treat as always truthy.
@@ -428,7 +605,7 @@ export function needsDynShadow(target) {
428
605
  if (!ctx.module.modules.collection) return false
429
606
  // Functions/CLOSURE always need dynamic props so cross-module property
430
607
  // access (fn.parse, i32.parse aliases) sees the same value as schema slots.
431
- const vt = typeof target === 'string' ? (ctx.func.repByLocal?.get(target)?.val || ctx.scope.globalValTypes?.get(target)) : null
608
+ const vt = typeof target === 'string' ? (ctx.func.localReps?.get(target)?.val || ctx.scope.globalValTypes?.get(target)) : null
432
609
  if (vt === 'closure' || usesDynProps(vt)) return true
433
610
  const dyn = ctx.types?.dynKeyVars
434
611
  if (target == null) return ctx.types?.anyDynKey ?? false
@@ -481,6 +658,11 @@ export function readVar(name) {
481
658
  : typed(['f64.const', rep.intConst], 'f64')
482
659
  }
483
660
  const node = typed(['local.get', `$${name}`], t)
661
+ // Proven uint32 accumulator local (narrowUint32): a later asF64 must widen with
662
+ // convert_i32_u (the i32 bit pattern is an unsigned value), not _s. `.wrapSafe`
663
+ // marks it as the always-ToUint32-sunk kind so the arithmetic widening guards
664
+ // keep it on the i32 path — wrapping is its intended semantics, not a leak.
665
+ if (t === 'i32' && rep?.unsigned) { node.unsigned = true; node.wrapSafe = true }
484
666
  if (rep?.ptrKind != null) {
485
667
  node.ptrKind = rep.ptrKind
486
668
  const aux = rep.ptrAux ?? ctx.schema.idOf?.(name)
@@ -531,7 +713,7 @@ export function writeVar(name, valIR, void_) {
531
713
 
532
714
  /** Check if f64 expr is nullish (NULL_NAN or UNDEF_NAN). Returns i32.
533
715
  * Peepholes: fold known NaN-boxed sentinel literals; elide on numeric literals;
534
- * unboxed pointer locals are proven non-null by analyzePtrUnboxable.
716
+ * unboxed pointer locals are proven non-null by unboxablePtrs.
535
717
  * Inlines directly: (i32.or (i64.eq bits NULL_NAN) (i64.eq bits UNDEF_NAN))
536
718
  * rather than calling $__is_nullish — saves WASM call dispatch in V8 JIT. */
537
719
  export const isNullish = (f64expr) => {
@@ -586,22 +768,38 @@ export const isUndef = (f64expr) => {
586
768
  return typed(['i64.eq', ['i64.reinterpret_f64', f64expr], ['i64.const', UNDEF_NAN]], 'i32')
587
769
  }
588
770
 
589
- // === Array layout helpers ===
771
+ /** Mask that clears the boolean atom's truth bit, mapping TRUE_NAN→FALSE_NAN.
772
+ * `(bits & BOOL_ATOM_MASK) === FALSE_NAN` recognizes both in one i64.and+i64.eq. */
773
+ const BOOL_ATOM_MASK = '0x' + BigInt.asUintN(64, ~(1n << BigInt(LAYOUT.AUX_SHIFT))).toString(16).toUpperCase().padStart(16, '0')
774
+
775
+ /** Check if f64 expr is a boxed-boolean atom (TRUE_NAN or FALSE_NAN). Returns i32.
776
+ * 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
+ }
590
787
 
591
- /** Slot address: `base + idx*8` IR. Uses `local.get` directly when idx=0. */
788
+ // === Array layout helpers routed through the array carrier (abi/array.js) ===
789
+
790
+ /** Slot address: element `idx` off `baseLocal`. Constant idx folds the `*8`. */
592
791
  export function slotAddr(baseLocal, idx) {
593
- const base = ['local.get', `$${baseLocal}`]
594
- return idx === 0 ? base : ['i32.add', base, ['i32.const', idx * 8]]
792
+ return ctx.abi.array.ops.addr(['local.get', `$${baseLocal}`], idx)
595
793
  }
596
794
 
597
795
  /** Load f64 element from array data at ptr + i*8. ptr/i are local name strings. */
598
796
  export function elemLoad(ptr, i) {
599
- return ['f64.load', ['i32.add', ['local.get', `$${ptr}`], ['i32.shl', ['local.get', `$${i}`], ['i32.const', 3]]]]
797
+ return ctx.abi.array.ops.load(['local.get', `$${ptr}`], ['local.get', `$${i}`])
600
798
  }
601
799
 
602
800
  /** Store f64 val at array data ptr + i*8. ptr/i are local name strings. */
603
801
  export function elemStore(ptr, i, val) {
604
- return ['f64.store', ['i32.add', ['local.get', `$${ptr}`], ['i32.shl', ['local.get', `$${i}`], ['i32.const', 3]]], val]
802
+ return ctx.abi.array.ops.store(['local.get', `$${ptr}`], ['local.get', `$${i}`], val)
605
803
  }
606
804
 
607
805
  /** Emit a loop iterating over array elements. Returns IR instruction list.
@@ -708,6 +906,76 @@ export function findBodyStart(fn) {
708
906
  return fn.length
709
907
  }
710
908
 
909
+ /**
910
+ * Tail-call rewrite: walks tail positions of an emitted IR tree and replaces
911
+ * direct `(call $name args...)` ops with `(return_call $name args...)`.
912
+ *
913
+ * Tail positions, recursively from the IR root:
914
+ * - the root itself (function's terminal value-producing expression, or the
915
+ * emitted value of an explicit `return X`)
916
+ * - both arms of `(if (result T) cond (then ...) (else ...))`
917
+ * - last instruction of `(block (result T) ...)`
918
+ *
919
+ * Only fires when caller and callee result types match — if they didn't match,
920
+ * `asParamType`/`asPtrOffset` would have wrapped the call in a conversion op,
921
+ * pushing the `call` away from the tail position. We don't recurse into
922
+ * arithmetic / select / loop ops: their results aren't standalone-tail control
923
+ * transfers.
924
+ *
925
+ * Two callers:
926
+ * - `compile.js` runs it on the function's final value-producing IR to TCO
927
+ * expression-bodied arrows like `(n, acc) => n <= 0 ? acc : sum(n-1, acc+n)`
928
+ * where the AST has no `return` keyword.
929
+ * - `emit.js` `'return'` op handler runs it on the emitted return expression
930
+ * so explicit `return cond ? f(x) : g(x)` also gets deep tail rewriting.
931
+ *
932
+ * Returns the input unchanged when no transform applies.
933
+ */
934
+ export const tcoTailRewrite = (ir, resultType) => {
935
+ if (ctx.transform.noTailCall || ctx.func.inTry) return ir
936
+ if (!Array.isArray(ir)) return ir
937
+ const op = ir[0]
938
+ if (op === 'call' && typeof ir[1] === 'string') {
939
+ // IR call name is `$name`; func.map keys are bare `name`.
940
+ const calleeName = ir[1].startsWith('$') ? ir[1].slice(1) : ir[1]
941
+ const callee = ctx.func.map.get(calleeName)
942
+ // If this is a known user func, verify result-type match. Otherwise
943
+ // (closures, imports, runtime helpers — not in `ctx.func.map`) trust the
944
+ // tail-position invariant: emit.js' asParamType/asPtrOffset already wrapped
945
+ // any mismatched call in a conversion op, so a bare `(call $X …)` at the
946
+ // tail of the function/if/block has by construction the same result type
947
+ // as the caller.
948
+ if (callee) {
949
+ if (callee.raw) return ir
950
+ const calleeRT = callee.sig?.results?.[0] ?? 'f64'
951
+ if (calleeRT !== resultType) return ir
952
+ }
953
+ return typed(['return_call', ...ir.slice(1)], resultType)
954
+ }
955
+ if (op === 'if' && Array.isArray(ir[1]) && ir[1][0] === 'result') {
956
+ let changed = false
957
+ const newIr = ir.slice()
958
+ for (let i = 3; i < newIr.length; i++) {
959
+ const arm = newIr[i]
960
+ if (Array.isArray(arm) && (arm[0] === 'then' || arm[0] === 'else') && arm.length > 1) {
961
+ const last = arm[arm.length - 1]
962
+ const rewritten = tcoTailRewrite(last, resultType)
963
+ if (rewritten !== last) {
964
+ newIr[i] = [...arm.slice(0, -1), rewritten]
965
+ changed = true
966
+ }
967
+ }
968
+ }
969
+ return changed ? typed(newIr, ir.type) : ir
970
+ }
971
+ if (op === 'block' && ir.length > 1) {
972
+ const last = ir[ir.length - 1]
973
+ const rewritten = tcoTailRewrite(last, resultType)
974
+ if (rewritten !== last) return typed([...ir.slice(0, -1), rewritten], ir.type)
975
+ }
976
+ return ir
977
+ }
978
+
711
979
  export function reconstructArgsWithSpreads(normal, spreads) {
712
980
  const combined = []
713
981
  let normalIdx = 0