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/module/core.js CHANGED
@@ -9,32 +9,38 @@
9
9
  * @module core
10
10
  */
11
11
 
12
- import { typed, asF64, asI32, asI64, NULL_NAN, UNDEF_NAN, temp, usesDynProps, ptrOffsetIR, isNullish, valKindToPtr } from '../src/ir.js'
13
- import { emit, buildArrayWithSpreads } from '../src/emit.js'
12
+ import { typed, asF64, asI32, asI64, NULL_NAN, UNDEF_NAN, FALSE_NAN, TRUE_NAN, temp, usesDynProps, ptrOffsetIR, isNullish, valKindToPtr, sidecarOverride, undefExpr } from '../src/ir.js'
13
+ import { emit, spread, deps } from '../src/bridge.js'
14
14
  import { reconstructArgsWithSpreads } from '../src/ir.js'
15
- import { valTypeOf, lookupValType, lookupNotString, VAL, T, repOf, updateRep, shapeOf, inlineArraySid } from '../src/analyze.js'
16
- import { ctx, err, inc, PTR, LAYOUT } from '../src/ctx.js'
15
+ import { valTypeOf, shapeOf } from '../src/kind.js'
16
+ import { T } from '../src/ast.js'
17
+ import { inlineArraySid } from '../src/static.js'
18
+ import { VAL, lookupValType, lookupNotString, repOf, updateRep } from '../src/reps.js'
19
+ import { ctx, err, inc, PTR, LAYOUT, HEAP, emitArity, followForwardingWat, declGlobal } from '../src/ctx.js'
20
+ import { ptrOffsetFwdWat, STR_INTERN_BIT } from '../layout.js'
21
+ import { nanPrefixHex } from '../layout.js'
17
22
  import { initSchema } from './schema.js'
18
23
  import { strHashLiteral } from './collection.js'
19
24
 
20
- // Pre-shifted NaN prefix as a full i64 mask, for `(i64.const ${NAN_BITS})` use.
21
- const NAN_BITS = '0x' + LAYOUT.NAN_PREFIX_BITS.toString(16).toUpperCase().padStart(16, '0')
25
+ const NAN_BITS = nanPrefixHex()
22
26
 
23
27
  export default (ctx) => {
24
- Object.assign(ctx.core.stdlibDeps, {
28
+ deps({
25
29
  __eq: ['__str_eq', '__ptr_type'],
26
30
  __typeof: ['__ptr_type', '__is_nullish'],
27
- __len: ['__typed_shift'],
31
+ __len: ['__typed_shift', '__ptr_offset', '__ptr_offset_fwd'],
28
32
  __cap: ['__typed_shift', '__ptr_type', '__ptr_offset', '__ptr_aux'],
29
33
  __typed_data: ['__ptr_offset', '__ptr_aux'],
30
- __ptr_offset: [],
34
+ __ptr_offset: ['__ptr_offset_fwd'],
35
+ __ptr_offset_fwd: [],
31
36
  __is_str_key: ['__ptr_type'],
32
37
  __str_len: ['__ptr_type', '__ptr_offset', '__ptr_aux'],
33
- __set_len: [],
38
+ __set_len: ['__ptr_offset_fwd'],
34
39
  __length: ['__ptr_type', '__ptr_offset', '__str_len', '__len'],
35
- __typeof: ['__ptr_type', '__is_nullish'],
40
+ __alloc: ['__memgrow'],
36
41
  __alloc_hdr: ['__alloc'],
37
42
  __alloc_hdr_n: ['__alloc'],
43
+ __coll_order: ['__alloc'],
38
44
  })
39
45
 
40
46
  ctx.core.stdlib['__is_nullish'] = `(func $__is_nullish (param $v i64) (result i32)
@@ -62,15 +68,26 @@ export default (ctx) => {
62
68
  (f64.eq (local.get $fb) (local.get $fb)))
63
69
  (then (f64.eq (local.get $fa) (local.get $fb)))
64
70
  (else
65
- ;; At least one operand is a NaN-box. Both STRING (heap or SSO) __str_eq
66
- ;; handles content compare and SSO fast-fail internally.
71
+ ;; At least one operand is a NaN-box (the && above failed). For both to
72
+ ;; be strings BOTH must be NaN-boxed: tag bits are only meaningful on a
73
+ ;; NaN-box, so a normal number whose exponent bits happen to alias the
74
+ ;; STRING tag (e.g. ASCII content read as f64) must NOT route to __str_eq
75
+ ;; — that would deref garbage. number-vs-string is simply false.
67
76
  (local.set $ta (i32.wrap_i64 (i64.and (i64.shr_u (local.get $a) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))
68
77
  (local.set $tb (i32.wrap_i64 (i64.and (i64.shr_u (local.get $b) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))
69
78
  (if (result i32)
70
79
  (i32.and
71
- (i32.eq (local.get $ta) (i32.const ${PTR.STRING}))
72
- (i32.eq (local.get $tb) (i32.const ${PTR.STRING})))
73
- (then (call $__str_eq (local.get $a) (local.get $b)))
80
+ (i32.and (f64.ne (local.get $fa) (local.get $fa)) (i32.eq (local.get $ta) (i32.const ${PTR.STRING})))
81
+ (i32.and (f64.ne (local.get $fb) (local.get $fb)) (i32.eq (local.get $tb) (i32.const ${PTR.STRING}))))
82
+ (then
83
+ ;; both canonical interned (bit-ne already known) ⇒ unequal —
84
+ ;; skip the __str_eq call entirely (see STR_INTERN_BIT, layout.js)
85
+ (if (result i32)
86
+ (i32.and
87
+ (i32.eq (i32.and (i32.wrap_i64 (i64.shr_u (local.get $a) (i64.const ${LAYOUT.AUX_SHIFT}))) (i32.const ${LAYOUT.SSO_BIT | LAYOUT.SLICE_BIT | STR_INTERN_BIT})) (i32.const ${STR_INTERN_BIT}))
88
+ (i32.eq (i32.and (i32.wrap_i64 (i64.shr_u (local.get $b) (i64.const ${LAYOUT.AUX_SHIFT}))) (i32.const ${LAYOUT.SSO_BIT | LAYOUT.SLICE_BIT | STR_INTERN_BIT})) (i32.const ${STR_INTERN_BIT})))
89
+ (then (i32.const 0))
90
+ (else (call $__str_eq (local.get $a) (local.get $b)))))
74
91
  (else (i32.const 0))))))))`
75
92
 
76
93
  ctx.core.stdlib['__is_null'] = `(func $__is_null (param $v i64) (result i32)
@@ -86,11 +103,13 @@ export default (ctx) => {
86
103
  (else
87
104
  (i32.and
88
105
  (i32.and
89
- (i64.ne (local.get $v) (i64.const ${NAN_BITS}))
90
- (i64.ne (local.get $v) (i64.const ${NULL_NAN})))
91
- (i32.and
92
- (i64.ne (local.get $v) (i64.const ${UNDEF_NAN}))
93
- (i64.ne (local.get $v) (i64.const 0x7FFA400000000000)))))))`
106
+ (i32.and
107
+ (i64.ne (local.get $v) (i64.const ${NAN_BITS}))
108
+ (i64.ne (local.get $v) (i64.const ${NULL_NAN})))
109
+ (i32.and
110
+ (i64.ne (local.get $v) (i64.const ${UNDEF_NAN}))
111
+ (i64.ne (local.get $v) (i64.const 0x7FFA400000000000))))
112
+ (i64.ne (local.get $v) (i64.const ${FALSE_NAN}))))))`
94
113
 
95
114
  ctx.core.stdlib['__is_str_key'] = `(func $__is_str_key (param $v i64) (result i32)
96
115
  (local $f f64)
@@ -124,29 +143,56 @@ export default (ctx) => {
124
143
  (i64.shl (i64.and (i64.extend_i32_u (local.get $aux)) (i64.const ${LAYOUT.AUX_MASK})) (i64.const ${LAYOUT.AUX_SHIFT}))
125
144
  (i64.and (i64.extend_i32_u (local.get $offset)) (i64.const ${LAYOUT.OFFSET_MASK})))))))`
126
145
 
146
+ ctx.core.stdlib['__ptr_offset_fwd'] = ptrOffsetFwdWat()
147
+
127
148
  ctx.core.stdlib['__ptr_offset'] = `(func $__ptr_offset (param $ptr i64) (result i32)
128
- (local $bits i64) (local $off i32)
149
+ (local $bits i64) (local $off i32) (local $t i32)
129
150
  (local.set $bits (local.get $ptr))
130
151
  (local.set $off (i32.wrap_i64 (i64.and (local.get $bits) (i64.const ${LAYOUT.OFFSET_MASK}))))
131
- ;; Arrays can be reallocated during growth; follow forwarding pointer (cap=-1 sentinel).
132
- ;; Bounds are checked inside the loop so non-array ptrs skip them entirely, and well-formed
133
- ;; ARRAY ptrs without forwarding still pay only one bounds check before the cap load.
134
- (if (i32.eq
135
- (i32.wrap_i64 (i64.and (i64.shr_u (local.get $bits) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK})))
136
- (i32.const ${PTR.ARRAY}))
152
+ ;; ARRAY/SET/MAP/HASH can be reallocated on growth; follow the forwarding pointer
153
+ ;; (cap=-1 sentinel at -4, new offset at -8). Other types never forward, so they skip
154
+ ;; the loop; a well-formed ptr without forwarding pays one bounds + cap check per hop.
155
+ (local.set $t (i32.wrap_i64 (i64.and (i64.shr_u (local.get $bits) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))
156
+ (if (i32.or (i32.eq (local.get $t) (i32.const ${PTR.ARRAY}))
157
+ (i32.or (i32.eq (local.get $t) (i32.const ${PTR.HASH}))
158
+ (i32.or (i32.eq (local.get $t) (i32.const ${PTR.SET})) (i32.eq (local.get $t) (i32.const ${PTR.MAP})))))
137
159
  (then
138
- (block $done
139
- (loop $follow
140
- (br_if $done (i32.lt_u (local.get $off) (i32.const 8)))
141
- (br_if $done (i32.gt_u (local.get $off) (i32.shl (memory.size) (i32.const 16))))
142
- (br_if $done (i32.ne (i32.load (i32.sub (local.get $off) (i32.const 4))) (i32.const -1)))
143
- (local.set $off (i32.load (i32.sub (local.get $off) (i32.const 8))))
144
- (br $follow)))))
160
+ ${followForwardingWat('$off', { lowGuard: true })}))
145
161
  (local.get $off))`
146
162
 
147
163
  ctx.core.stdlib['__ptr_aux'] = `(func $__ptr_aux (param $ptr i64) (result i32)
148
164
  (i32.wrap_i64 (i64.and (i64.shr_u (local.get $ptr) (i64.const ${LAYOUT.AUX_SHIFT})) (i64.const ${LAYOUT.AUX_MASK}))))`
149
165
 
166
+ // Exact JS `%` (fmod) for the f64 path. wasm has no f64 remainder, and the
167
+ // textbook `a - b*trunc(a/b)` is both INEXACT (rounding in trunc/mul/sub for
168
+ // large a/b) and WRONG on the IEEE edges. This does the spec exactly:
169
+ // NaN if a or b is NaN, a is ±Inf, or b is 0; a if b is ±Inf or |a|<|b|;
170
+ // otherwise binary long division — scale |b| up to ≤|a|, then subtract-and-
171
+ // halve back down to |b|. Every step (×2, ×0.5, aligned subtraction) is
172
+ // exact in f64, so the remainder is bit-identical to JS. Sign follows the
173
+ // dividend (copysign), matching `(-5)%3 === -2`, `5%(-3) === 2`, `-0%3 === -0`.
174
+ ctx.core.stdlib['__rem'] = `(func $__rem (param $a f64) (param $b f64) (result f64)
175
+ (local $x f64) (local $y f64)
176
+ (if (f64.ne (local.get $a) (local.get $a)) (then (return (local.get $a))))
177
+ (if (f64.ne (local.get $b) (local.get $b)) (then (return (local.get $b))))
178
+ (local.set $x (f64.abs (local.get $a)))
179
+ (local.set $y (f64.abs (local.get $b)))
180
+ (if (i32.or (f64.eq (local.get $x) (f64.const inf)) (f64.eq (local.get $y) (f64.const 0)))
181
+ (then (return (f64.div (f64.const 0) (f64.const 0)))))
182
+ (if (i32.or (f64.eq (local.get $y) (f64.const inf)) (f64.lt (local.get $x) (local.get $y)))
183
+ (then (return (local.get $a))))
184
+ (block $up (loop $ul
185
+ (br_if $up (f64.gt (f64.mul (local.get $y) (f64.const 2)) (local.get $x)))
186
+ (local.set $y (f64.mul (local.get $y) (f64.const 2)))
187
+ (br $ul)))
188
+ (block $dn (loop $dl
189
+ (br_if $dn (f64.lt (local.get $y) (f64.abs (local.get $b))))
190
+ (if (f64.ge (local.get $x) (local.get $y)) (then (local.set $x (f64.sub (local.get $x) (local.get $y)))))
191
+ (local.set $y (f64.mul (local.get $y) (f64.const 0.5)))
192
+ (br $dl)))
193
+ (f64.copysign (local.get $x) (local.get $a)))`
194
+
195
+
150
196
  ctx.core.stdlib['__ptr_type'] = `(func $__ptr_type (param $ptr i64) (result i32)
151
197
  (i32.wrap_i64 (i64.and (i64.shr_u (local.get $ptr) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))`
152
198
 
@@ -167,66 +213,115 @@ export default (ctx) => {
167
213
 
168
214
  // Heap-base watermark: gates header-backed propsPtr fast paths so static-data
169
215
  // OBJECT slots (offsets < heap base) don't misread arbitrary memory at off-16.
170
- // Updated by optimizeModule() when data segment exceeds 1024 bytes.
171
- ctx.scope.globals.set('__heap_start', '(global $__heap_start (mut i32) (i32.const 1024))')
216
+ // Updated by optimizeModule() when data segment exceeds HEAP.START bytes.
217
+ declGlobal('__heap_start', 'i32', HEAP.START)
172
218
 
173
- // Shared memory keeps the heap pointer in linear memory (memory[1020]):
219
+ // Shared memory keeps the heap pointer in linear memory (memory[HEAP.PTR_ADDR]):
174
220
  // wasm globals are per-instance, so threads sharing one memory must share one
175
221
  // pointer cell. Non-shared memory (incl. alloc:false) uses the `$__heap`
176
222
  // global — exported so the JS-side adapter (memory.String etc) bumps the same
177
223
  // pointer. Storing it in memory would collide with the static data section
178
- // whenever the data exceeds 1020 bytes.
224
+ // whenever the data exceeds HEAP.PTR_ADDR bytes.
225
+ // Geometric memory growth shared by `__alloc` and the in-place string
226
+ // bump-extend paths (string.js). Ensures linear memory covers byte offset
227
+ // `$next`, growing when short. Growing one page at a time turns a long-running
228
+ // embedding (watr called thousands of times) into O(n²) — each memory.grow may
229
+ // relocate and copy the whole heap — so we request at least the current size
230
+ // (≥2× total) in one shot; only on hitting the declared maximum do we fall back
231
+ // to the bare minimum. `$need` is the TOTAL pages required to cover $next; the
232
+ // byte size of memory ((memory.size)<<16) is computed in i64 because it
233
+ // overflows i32 at the wasm32 max of 65536 pages (4 GiB) — without that,
234
+ // capacity reads as 0 and every allocation spuriously tries to grow past the
235
+ // ceiling, trapping near 4 GiB.
236
+ ctx.core.stdlib['__memgrow'] = `(func $__memgrow (param $next i32)
237
+ (local $cur i32) (local $need i32)
238
+ (local.set $need (i32.wrap_i64 (i64.shr_u (i64.add (i64.extend_i32_u (local.get $next)) (i64.const 65535)) (i64.const 16))))
239
+ (if (i32.gt_u (local.get $need) (memory.size))
240
+ (then
241
+ (if (i64.gt_u (i64.extend_i32_u (local.get $need)) (i64.const 65536)) (then (unreachable)))
242
+ (local.set $cur (i32.sub (local.get $need) (memory.size))) ;; minimum delta
243
+ (if (i32.lt_u (local.get $cur) (memory.size)) (then (local.set $cur (memory.size)))) ;; geometric
244
+ (if (i32.gt_u (i32.add (local.get $cur) (memory.size)) (i32.const 65536))
245
+ (then (local.set $cur (i32.sub (i32.const 65536) (memory.size))))) ;; cap at wasm32 max
246
+ (if (i32.eq (memory.grow (local.get $cur)) (i32.const -1))
247
+ (then (if (i32.eq (memory.grow (i32.sub (local.get $need) (memory.size))) (i32.const -1))
248
+ (then (unreachable))))))))`
249
+
179
250
  if (ctx.memory.shared) {
180
- // Heap offset stored at memory[1020] (i32), just before heap start at 1024.
181
- // We still want to grow memory when running out of pages — without growth a
182
- // host-run binary traps on the first allocation that exceeds the initial
183
- // 64 KB. Use the same geometric-growth strategy as the owned-memory variant.
251
+ // Heap offset stored at memory[HEAP.PTR_ADDR] (i32), just before heap start at
252
+ // HEAP.START. Threads sharing one memory must share one pointer cell.
184
253
  ctx.core.stdlib['__alloc'] = `(func $__alloc (param $bytes i32) (result i32)
185
- (local $ptr i32) (local $next i32) (local $cur i32) (local $need i32)
186
- (local.set $ptr (i32.load (i32.const 1020)))
254
+ (local $ptr i32) (local $next i32)
255
+ (local.set $ptr (i32.load (i32.const ${HEAP.PTR_ADDR})))
187
256
  (local.set $next (i32.and (i32.add (i32.add (local.get $ptr) (local.get $bytes)) (i32.const 7)) (i32.const -8)))
188
- (local.set $cur (i32.shl (memory.size) (i32.const 16)))
189
- (if (i32.gt_u (local.get $next) (local.get $cur))
190
- (then
191
- (local.set $need (i32.shr_u (i32.add (i32.sub (local.get $next) (local.get $cur)) (i32.const 65535)) (i32.const 16)))
192
- (if (i32.lt_u (local.get $need) (memory.size)) (then (local.set $need (memory.size))))
193
- (if (i32.eq (memory.grow (local.get $need)) (i32.const -1))
194
- (then (if (i32.eq (memory.grow
195
- (i32.shr_u (i32.add (i32.sub (local.get $next) (local.get $cur)) (i32.const 65535)) (i32.const 16)))
196
- (i32.const -1)) (then (unreachable)))))))
197
- (i32.store (i32.const 1020) (local.get $next))
257
+ (call $__memgrow (local.get $next))
258
+ (i32.store (i32.const ${HEAP.PTR_ADDR}) (local.get $next))
198
259
  (local.get $ptr))`
199
260
  ctx.core.stdlib['__clear'] = `(func $__clear
200
- (i32.store (i32.const 1020) (i32.const 1024)))`
261
+ (i32.store (i32.const ${HEAP.PTR_ADDR}) (i32.const ${HEAP.START})))`
201
262
  } else {
202
- // Own memory: heap offset in a global, auto-grow when needed. Exported so
203
- // the JS-side adapter (alloc:false, no `_alloc` export) shares the pointer.
204
- ctx.scope.globals.set('__heap', '(global $__heap (export "__heap") (mut i32) (i32.const 1024))')
205
- // Bump allocator with geometric growth. Growing one page at a time turns a
206
- // long-running embedding (e.g. watr called thousands of times) into O(n²) —
207
- // each memory.grow may relocate and copy the whole heap. So when we must
208
- // grow, request at least the current size (≥2× total) in one shot; only on
209
- // hitting the declared maximum do we fall back to the bare minimum.
263
+ // Own memory: heap offset in a global, exported so the JS-side adapter
264
+ // (alloc:false, no `_alloc` export) shares the pointer.
265
+ declGlobal('__heap', 'i32', HEAP.START, { export: '__heap' })
210
266
  ctx.core.stdlib['__alloc'] = `(func $__alloc (param $bytes i32) (result i32)
211
- (local $ptr i32) (local $next i32) (local $cur i32) (local $need i32)
267
+ (local $ptr i32) (local $next i32)
212
268
  (local.set $ptr (global.get $__heap))
213
- ;; Align next allocation to 8 bytes
214
269
  (local.set $next (i32.and (i32.add (i32.add (local.get $ptr) (local.get $bytes)) (i32.const 7)) (i32.const -8)))
215
- (local.set $cur (i32.shl (memory.size) (i32.const 16)))
216
- (if (i32.gt_u (local.get $next) (local.get $cur))
217
- (then
218
- (local.set $need (i32.shr_u (i32.add (i32.sub (local.get $next) (local.get $cur)) (i32.const 65535)) (i32.const 16)))
219
- (if (i32.lt_u (local.get $need) (memory.size)) (then (local.set $need (memory.size))))
220
- (if (i32.eq (memory.grow (local.get $need)) (i32.const -1))
221
- (then (if (i32.eq (memory.grow
222
- (i32.shr_u (i32.add (i32.sub (local.get $next) (local.get $cur)) (i32.const 65535)) (i32.const 16)))
223
- (i32.const -1)) (then (unreachable)))))))
270
+ (call $__memgrow (local.get $next))
224
271
  (global.set $__heap (local.get $next))
225
272
  (local.get $ptr))`
226
273
  ctx.core.stdlib['__clear'] = `(func $__clear
227
- (global.set $__heap (i32.const 1024)))`
274
+ (global.set $__heap (i32.const ${HEAP.START})))`
228
275
  }
229
276
 
277
+ // Build an insertion-ordered list of live slot offsets for a Set/Map/HASH
278
+ // backing table at $off (cap slots of $stride bytes). Returns a fresh i32 array
279
+ // (live-count entries) of slot offsets sorted by packed sequence (the insertion
280
+ // counter rides in each entry's hash-word high 32 bits — see collection.js's
281
+ // seqStore). Every order-sensitive iteration (keys/values/entries, for-in,
282
+ // spread, JSON, Map copy) walks this instead of raw slot order, so jz matches
283
+ // the JS spec's insertion order. Lives in core (not collection) because object
284
+ // and json iterate HASH tables without pulling the collection module. Insertion
285
+ // sort: enumerated collections are small, and it stays branch-light when sorted.
286
+ ctx.core.stdlib['__coll_order'] = `(func $__coll_order (param $off i32) (param $cap i32) (param $stride i32) (result i32)
287
+ (local $i i32) (local $n i32) (local $slot i32) (local $buf i32)
288
+ (local $j i32) (local $k i32) (local $cur i32) (local $sq i32)
289
+ ;; A null/empty backing pointer (off below the heap base) has no live slots —
290
+ ;; ordering it yields the empty list. Guard before the $off-8 length read so a
291
+ ;; degenerate receiver returns an empty buffer instead of faulting on load(-8).
292
+ (if (i32.lt_u (local.get $off) (i32.const ${HEAP.START})) (then (return (call $__alloc (i32.const 0)))))
293
+ (local.set $buf (call $__alloc (i32.shl (i32.load (i32.sub (local.get $off) (i32.const 8))) (i32.const 2))))
294
+ ;; gather live slot offsets (occupied ⇔ hash word ≠ 0)
295
+ (block $gd (loop $gl
296
+ (br_if $gd (i32.ge_s (local.get $i) (local.get $cap)))
297
+ (local.set $slot (i32.add (local.get $off) (i32.mul (local.get $i) (local.get $stride))))
298
+ (if (i64.ne (i64.load (local.get $slot)) (i64.const 0))
299
+ (then
300
+ (i32.store (i32.add (local.get $buf) (i32.shl (local.get $n) (i32.const 2))) (local.get $slot))
301
+ (local.set $n (i32.add (local.get $n) (i32.const 1)))))
302
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
303
+ (br $gl)))
304
+ ;; insertion-sort buf[0..n) ascending by sequence = hash-word high 32 bits
305
+ (local.set $j (i32.const 1))
306
+ (block $sd (loop $sl
307
+ (br_if $sd (i32.ge_s (local.get $j) (local.get $n)))
308
+ (local.set $cur (i32.load (i32.add (local.get $buf) (i32.shl (local.get $j) (i32.const 2)))))
309
+ (local.set $sq (i32.wrap_i64 (i64.shr_u (i64.load (local.get $cur)) (i64.const 32))))
310
+ (local.set $k (i32.sub (local.get $j) (i32.const 1)))
311
+ (block $id (loop $il
312
+ (br_if $id (i32.lt_s (local.get $k) (i32.const 0)))
313
+ (br_if $id (i32.le_u
314
+ (i32.wrap_i64 (i64.shr_u (i64.load (i32.load (i32.add (local.get $buf) (i32.shl (local.get $k) (i32.const 2))))) (i64.const 32)))
315
+ (local.get $sq)))
316
+ (i32.store (i32.add (local.get $buf) (i32.shl (i32.add (local.get $k) (i32.const 1)) (i32.const 2)))
317
+ (i32.load (i32.add (local.get $buf) (i32.shl (local.get $k) (i32.const 2)))))
318
+ (local.set $k (i32.sub (local.get $k) (i32.const 1)))
319
+ (br $il)))
320
+ (i32.store (i32.add (local.get $buf) (i32.shl (i32.add (local.get $k) (i32.const 1)) (i32.const 2))) (local.get $cur))
321
+ (local.set $j (i32.add (local.get $j) (i32.const 1)))
322
+ (br $sl)))
323
+ (local.get $buf))`
324
+
230
325
  // === Memory-based length/cap helpers (C-style headers) ===
231
326
 
232
327
  // Array/TypedArray/Buffer: [-8:len(i32)][-4:cap(i32)][data...]
@@ -263,12 +358,7 @@ export default (ctx) => {
263
358
  (if (result i32)
264
359
  (i32.and (i32.eq (local.get $t) (i32.const 1)) (i32.ge_u (local.get $off) (i32.const 8)))
265
360
  (then
266
- (block $done
267
- (loop $follow
268
- (br_if $done (i32.gt_u (local.get $off) (i32.shl (memory.size) (i32.const 16))))
269
- (br_if $done (i32.ne (i32.load (i32.sub (local.get $off) (i32.const 4))) (i32.const -1)))
270
- (local.set $off (i32.load (i32.sub (local.get $off) (i32.const 8))))
271
- (br $follow)))
361
+ ${followForwardingWat('$off', { lowGuard: false })}
272
362
  (i32.load (i32.sub (local.get $off) (i32.const 8))))
273
363
  (else
274
364
  (if (result i32)
@@ -288,7 +378,9 @@ export default (ctx) => {
288
378
  (call $__typed_shift (i32.and (local.get $aux) (i32.const 7)))))
289
379
  (else (i32.shr_u (i32.load (i32.sub (local.get $off) (i32.const 8)))
290
380
  (call $__typed_shift (i32.and (local.get $aux) (i32.const 7)))))))
291
- (else (i32.load (i32.sub (local.get $off) (i32.const 8))))))
381
+ ;; HASH/SET/MAP/BUFFER: re-resolve offset so grown SET/MAP follow the
382
+ ;; forwarding chain (HASH/BUFFER never forward → same inline offset).
383
+ (else (i32.load (i32.sub (call $__ptr_offset (local.get $ptr)) (i32.const 8))))))
292
384
  (else (i32.const 0))))))`
293
385
 
294
386
  ctx.core.stdlib['__cap'] = `(func $__cap (param $ptr i64) (result i32)
@@ -349,13 +441,7 @@ export default (ctx) => {
349
441
  (then
350
442
  (if (i32.eq (local.get $t) (i32.const 1))
351
443
  (then
352
- (block $done
353
- (loop $follow
354
- (br_if $done (i32.lt_u (local.get $off) (i32.const 8)))
355
- (br_if $done (i32.gt_u (local.get $off) (i32.shl (memory.size) (i32.const 16))))
356
- (br_if $done (i32.ne (i32.load (i32.sub (local.get $off) (i32.const 4))) (i32.const -1)))
357
- (local.set $off (i32.load (i32.sub (local.get $off) (i32.const 8))))
358
- (br $follow)))))
444
+ ${followForwardingWat('$off', { lowGuard: true })}))
359
445
  (i32.store (i32.sub (local.get $off) (i32.const 8)) (local.get $len)))))`
360
446
 
361
447
  // Alloc header(16) + data(cap*stride). Layout: [propsPtr@-16(f64=0), len@-8, cap@-4],
@@ -377,12 +463,18 @@ export default (ctx) => {
377
463
 
378
464
  // Generic header allocator for non-8 strides: Set (16), Map probe (24), TypedArray raw (1).
379
465
  // Same 16-byte header layout as __alloc_hdr; per-entry stride is passed dynamically.
466
+ // Header (16B) + cap*stride slots. Collections (Set/Map/HASH) key "empty slot"
467
+ // off a zero hash word, so the slot region MUST start zeroed. The bump allocator
468
+ // reuses memory after a heap reset (__clear) without re-zeroing, so we cannot
469
+ // lean on fresh-page zeroing here — clear the slots explicitly. Also covers the
470
+ // grow path, which rehashes into a freshly-allocated table expecting empties.
380
471
  ctx.core.stdlib['__alloc_hdr_n'] = `(func $__alloc_hdr_n (param $len i32) (param $cap i32) (param $stride i32) (result i32)
381
472
  (local $ptr i32)
382
473
  (local.set $ptr (call $__alloc (i32.add (i32.const 16) (i32.mul (local.get $cap) (local.get $stride)))))
383
474
  (i64.store (local.get $ptr) (i64.const 0))
384
475
  (i32.store (i32.add (local.get $ptr) (i32.const 8)) (local.get $len))
385
476
  (i32.store (i32.add (local.get $ptr) (i32.const 12)) (local.get $cap))
477
+ (memory.fill (i32.add (local.get $ptr) (i32.const 16)) (i32.const 0) (i32.mul (local.get $cap) (local.get $stride)))
386
478
  (i32.add (local.get $ptr) (i32.const 16)))`
387
479
 
388
480
  // Allocator + exports are deferred: only included when memory is actually needed.
@@ -460,7 +552,14 @@ export default (ctx) => {
460
552
  // Slot val-types reach the emit-time consumer via valTypeOf → ctx.schema.slotVT
461
553
  // (read on the AST `.prop` node), not via tagging this IR node.
462
554
  function emitSchemaSlotRead(baseExpr, idx) {
463
- const base = baseExpr?.type === 'f64' ? baseExpr : typed(baseExpr, 'f64')
555
+ // An unboxed proven-non-ARRAY pointer (a structInline element cell, a narrowed local)
556
+ // reaches ptrOffsetIR raw so it returns the offset directly — no `__ptr_offset` call.
557
+ // Pre-boxing via asF64 strips ptrKind and forces every field read onto the call path
558
+ // (the dcbb433 perf cliff on object/struct kernels — `p.x,p.y,p.z` per loop iteration).
559
+ // A NaN-box or untyped value still routes through f64 for the reinterpret/forwarding path.
560
+ const base = (baseExpr?.ptrKind != null && baseExpr.ptrKind !== VAL.ARRAY)
561
+ ? baseExpr
562
+ : (baseExpr?.type === 'f64' ? baseExpr : asF64(baseExpr))
464
563
  return typed(ctx.abi.object.ops.load(ptrOffsetIR(base, VAL.OBJECT), idx), 'f64')
465
564
  }
466
565
 
@@ -542,7 +641,7 @@ export default (ctx) => {
542
641
  }
543
642
 
544
643
  /** Emit .prop access for a WASM f64 node using schema or HASH fallback. */
545
- function emitPropAccess(va, obj, prop) {
644
+ function emitPropAccess(va, obj, prop, fromOptional = false) {
546
645
  // Anonymous-literal fast path: when `obj` resolves at compile time to an
547
646
  // object literal `{...}` (either directly, or through a `.prop` chain
548
647
  // walked back to one), use the literal's slot index instead of falling
@@ -553,21 +652,21 @@ export default (ctx) => {
553
652
  // `({a:{b:1}}).a.b` where the receiver is anonymous. Spread sources
554
653
  // (`{...x}`) shift slot ordering and would need their own resolution.
555
654
  const slot = literalSlot(obj, prop)
556
- if (slot >= 0) return emitSchemaSlotRead(asF64(va), slot)
655
+ if (slot >= 0) return emitSchemaSlotRead(va, slot)
557
656
  // Receiver IR is an unboxed OBJECT pointer carrying its own schema (a
558
657
  // structInline element cell, a narrowed local): resolve the field's fixed
559
658
  // slot directly from `ptrAux` — more precise than the structural
560
- // `ctx.schema.find(null, …)` and never falls to the dyn dispatcher.
659
+ // `ctx.schema.slotOf(null, …)` and never falls to the dyn dispatcher.
561
660
  if (va?.ptrKind === VAL.OBJECT && va.ptrAux != null && typeof prop === 'string') {
562
661
  const sch = ctx.schema.list[va.ptrAux]
563
662
  const si = sch ? sch.indexOf(prop) : -1
564
- if (si >= 0) return emitSchemaSlotRead(asF64(va), si)
663
+ if (si >= 0) return emitSchemaSlotRead(va, si)
565
664
  }
566
- let schemaIdx = typeof obj === 'string' ? ctx.schema.find(obj, prop) : ctx.schema.find(null, prop)
665
+ let schemaIdx = typeof obj === 'string' ? ctx.schema.slotOf(obj, prop) : ctx.schema.slotOf(null, prop)
567
666
  // Chain receiver (e.g. `o.meta.bias`): when the chain resolves to a known
568
667
  // OBJECT shape via JSON-shape propagation, the parent shape's `names`
569
668
  // gives the slot directly. Avoids the structural ambiguity of
570
- // ctx.schema.find(null, prop) when multiple registered schemas share a key.
669
+ // ctx.schema.slotOf(null, prop) when multiple registered schemas share a key.
571
670
  if (schemaIdx < 0 && typeof obj !== 'string') {
572
671
  const sh = shapeOf(obj)
573
672
  if ((sh?.val === VAL.OBJECT || sh?.val === VAL.HASH) && sh.names) {
@@ -576,7 +675,7 @@ export default (ctx) => {
576
675
  }
577
676
  }
578
677
  const key = asI64(emit(['str', prop]))
579
- if (schemaIdx >= 0) return emitSchemaSlotRead(asF64(va), schemaIdx)
678
+ if (schemaIdx >= 0) return emitSchemaSlotRead(va, schemaIdx)
580
679
  if (typeof obj === 'string') {
581
680
  const vt = lookupValType(obj)
582
681
  if (usesDynProps(vt)) {
@@ -595,9 +694,16 @@ export default (ctx) => {
595
694
  // In WASI mode, values are always JSON-derived (never PTR.EXTERNAL host objects).
596
695
  // Skip the external branch and dispatch through the typed HASH/OBJECT path.
597
696
  if (ctx.transform.host === 'wasi') return emitDynGetExprTyped(va, key, vt, prop)
598
- ctx.features.external = true
697
+ // `fromOptional` (a `?.prop` read) short-circuits on nullish, so its
698
+ // PTR.EXTERNAL arm is dead unless host externals are already in play —
699
+ // don't force the __ext_prop import just for an optional read.
700
+ if (!fromOptional) ctx.features.external = true
599
701
  return emitDynGetAnyTyped(va, key, vt, prop)
600
702
  }
703
+ // Primitive receiver (number/boolean/bigint): no dynamic props — `(5).foo` is
704
+ // undefined. Without this the value falls to the __hash_get fallback, which
705
+ // reinterprets the primitive's bits as a HASH pointer and reads heap → OOB.
706
+ if (vt === VAL.NUMBER || vt === VAL.BOOL || vt === VAL.BIGINT) return undefExpr()
601
707
  inc('__hash_get', '__str_hash', '__str_eq')
602
708
  return typed(['f64.reinterpret_i64', ['call', '$__hash_get', asI64(va), key]], 'f64')
603
709
  }
@@ -662,8 +768,8 @@ export default (ctx) => {
662
768
  const inner = ctx.schema.emitInner(obj)
663
769
  return typed(['f64.convert_i32_s', ['call', '$__len', ['i64.reinterpret_f64', inner]]], 'f64')
664
770
  }
665
- const idx = ctx.schema.find(obj, prop)
666
- if (idx >= 0) return emitSchemaSlotRead(asF64(emit(obj)), idx)
771
+ const idx = ctx.schema.slotOf(obj, prop)
772
+ if (idx >= 0) return emitSchemaSlotRead(emit(obj), idx)
667
773
  }
668
774
 
669
775
  if (prop === 'length') {
@@ -691,7 +797,7 @@ export default (ctx) => {
691
797
  // UTF-8 and __str_byteLen returns byte count, so this matches the runtime
692
798
  // semantics. Skips the call + NaN-unbox round-trip entirely.
693
799
  if (Array.isArray(obj) && (obj[0] === 'str' || obj[0] == null) && typeof obj[1] === 'string') {
694
- return typed(['f64.const', Buffer.byteLength(obj[1], 'utf8')], 'f64')
800
+ return typed(['f64.const', new TextEncoder().encode(obj[1]).length], 'f64')
695
801
  }
696
802
  // structInline Array<S>: the header `len` counts physical f64 cells (K
697
803
  // per element), so the JS array length is `physicalLen / K`.
@@ -712,20 +818,42 @@ export default (ctx) => {
712
818
  }
713
819
 
714
820
  // Type-specific property emitter (`.regex:source`, …) — the property-read
715
- // mirror of the `.vt:method` method-dispatch table. Property emitters take
716
- // a single arg (the receiver); arity 2 entries are method emitters and
717
- // must not be routed here (reading `re.test` as a value is not a call).
821
+ // mirror of the `.vt:method` method-dispatch table. Only entries tagged as
822
+ // getters (via `getter()`) fire here: reading `re.source` yields a value,
823
+ // but reading `m.keys`/`re.test` is not a call and must not invoke the
824
+ // method (which would materialize a view / run the probe).
718
825
  const ptRep = typeof obj === 'string' ? repOf(obj) : null
719
826
  const ptVt = ptRep ? ptRep.val : valTypeOf(obj)
720
827
  if (ptVt) {
721
828
  const tpEmitter = ctx.core.emit[`.${ptVt}:${prop}`]
722
- if (tpEmitter && tpEmitter.length <= 1) return tpEmitter(obj)
829
+ if (tpEmitter?.getter) return tpEmitter(obj)
723
830
  }
724
831
 
725
- // Module-registered property emitter (.size, etc.)
832
+ // valueOf/toString are ToPrimitive hooks (ES2024 7.1.1) that an own data
833
+ // property shadows. On a heap receiver carrying a dynamic-prop sidecar
834
+ // (array/typed/object), reading `obj.valueOf`/`obj.toString` must return an
835
+ // assigned override when present, else the inherited builtin. Without this,
836
+ // the arity-1 builtin emitter below (returns the receiver) masks the
837
+ // override. The method-call path in src/emit.js runs the parallel probe and
838
+ // additionally covers statically-unknown receivers (e.g. `arr[0].valueOf()`);
839
+ // a bare read of an unknown-type receiver can't yield a builtin-as-value
840
+ // here anyway, so this read path stays scoped to known sidecar types.
841
+ if ((prop === 'valueOf' || prop === 'toString') && ctx.closure.call &&
842
+ (ptVt === VAL.ARRAY || ptVt === VAL.TYPED || ptVt === VAL.OBJECT)) {
843
+ const builtin = ctx.core.emit[`.${ptVt}:${prop}`] || ctx.core.emit[`.${prop}`]
844
+ if (builtin && emitArity(builtin) <= 1) {
845
+ return sidecarOverride(emit(obj), asI64(emit(['str', prop])),
846
+ (p) => ['local.get', `$${p}`], // READ: yield the override closure value
847
+ (o) => asF64(builtin(o))) // else the arity-≤1 builtin's value
848
+ }
849
+ }
850
+
851
+ // Module-registered property getter (.size, .byteLength, …). Methods sharing
852
+ // the bare-`.prop` table (`.values`, `.pop`, date getters) are untagged and
853
+ // fall through to a real property read — `m.values` reads the "values" field.
726
854
  const propKey = `.${prop}`
727
855
  const propEmitter = ctx.core.emit[propKey]
728
- if (propEmitter && propEmitter.length <= 1) return propEmitter(obj)
856
+ if (propEmitter?.getter) return propEmitter(obj)
729
857
 
730
858
  return emitPropAccess(emit(obj), obj, prop)
731
859
  }
@@ -757,7 +885,13 @@ export default (ctx) => {
757
885
  return optionalGuard(t, va, useFn(t))
758
886
  }
759
887
 
760
- // Optional chaining: obj?.prop → null if obj is null, else obj.prop
888
+ // Optional chaining: obj?.prop → undefined if obj is nullish, else obj.prop.
889
+ // Delegate the property read to emitPropAccess — the SAME resolution the plain
890
+ // `.` emitter uses (passing the hoisted temp's value for the load, but the
891
+ // original `obj` name for schema/valType lookup). The previous hand-rolled copy
892
+ // diverged: it lacked emitPropAccess's `VAL.OBJECT off-schema → __dyn_get_expr`
893
+ // branch and fell to `__hash_get`, which mis-reads fixed-shape OBJECT memory
894
+ // (a self-host miscompile — `o?.x` returned undefined under the kernel).
761
895
  ctx.core.emit['?.'] = (obj, prop) => evalOnce(obj, (t) => {
762
896
  const rep = typeof obj === 'string' ? repOf(obj) : null
763
897
  const vt = rep ? rep.val : valTypeOf(obj)
@@ -765,27 +899,19 @@ export default (ctx) => {
765
899
  const notString = vt == null && typeof obj === 'string' && lookupNotString(obj)
766
900
  return emitLengthAccess(['local.get', `$${t}`], vt, notString)
767
901
  }
768
- const propIdx = typeof obj === 'string' ? ctx.schema.find(obj, prop) : -1
769
- if (propIdx >= 0) return emitSchemaSlotRead(['local.get', `$${t}`], propIdx)
770
- if (typeof obj === 'string') {
771
- const objType = lookupValType(obj)
772
- if (usesDynProps(objType))
773
- return emitDynGetExprTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), objType, prop)
774
- if (objType === VAL.HASH)
775
- return emitHashGetLocalConst(['local.get', `$${t}`], asI64(emit(['str', prop])), prop)
776
- if (objType == null)
777
- // Unknown receiver — in WASI mode use typed dispatch (no PTR.EXTERNAL values).
778
- // In JS host mode use __dyn_get_any_t but don't force features.external here
779
- // since ?.prop short-circuits on nullish (EXTERNAL arm is dead unless already on).
780
- return ctx.transform.host === 'wasi'
781
- ? emitDynGetExprTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), objType, prop)
782
- : emitDynGetAnyTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), objType, prop)
783
- inc('__hash_get', '__str_hash', '__str_eq')
784
- return ['f64.reinterpret_i64', ['call', '$__hash_get', ['i64.reinterpret_f64', ['local.get', `$${t}`]], asI64(emit(['str', prop]))]]
902
+ // Type-specific + module-registered property getters (`.size`, `.byteLength`,
903
+ // `.regex:source`, ) the SAME getter dispatch the plain `.` emitter runs
904
+ // (only entries tagged via `getter()` fire; untagged `.values`/`.pop` stay a
905
+ // field read). Read the already-hoisted, null-guarded temp `t` rather than
906
+ // re-emitting `obj`. Without this `s?.size` fell straight to emitPropAccess (a
907
+ // plain field read) and returned undefined — a Set/Map size getter never ran.
908
+ if (vt) {
909
+ const tg = ctx.core.emit[`.${vt}:${prop}`]
910
+ if (tg?.getter) return tg(t)
785
911
  }
786
- if (valTypeOf(obj) === VAL.HASH)
787
- return emitHashGetLocalConst(['local.get', `$${t}`], asI64(emit(['str', prop])), prop)
788
- return emitDynGetExprTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), valTypeOf(obj), prop)
912
+ const g = ctx.core.emit[`.${prop}`]
913
+ if (g?.getter) return g(t)
914
+ return emitPropAccess(typed(['local.get', `$${t}`], 'f64'), obj, prop, true)
789
915
  })
790
916
 
791
917
  // Optional index: arr?.[i] → null if arr is null, else arr[i]
@@ -810,14 +936,21 @@ export default (ctx) => {
810
936
  // as a direct method call. The outer optional short-circuits when the receiver
811
937
  // is nullish — the method itself is statically known to exist.
812
938
  if (Array.isArray(callee) && (callee[0] === '.' || callee[0] === '?.') && typeof callee[2] === 'string') {
813
- const methodEmit = ctx.core.emit[`.${callee[2]}`]
814
- if (methodEmit) {
939
+ const method = callee[2]
940
+ if (ctx.core.emit[`.${method}`]) {
815
941
  const recv = callee[1]
816
942
  return evalOnce(recv, (t) => {
817
- // Emit-time rep seed on fresh `?.()` recv-temp so methodEmit's dispatch fast-paths fire.
943
+ // Emit-time rep seed on fresh `?.()` recv-temp so the dispatch fast-paths fire.
818
944
  const vt = typeof recv === 'string' ? repOf(recv)?.val : valTypeOf(recv)
819
945
  if (vt) updateRep(t, { val: vt })
820
- return asF64(methodEmit(t, ...args))
946
+ // Re-enter the full `()` method dispatch (runtime string/array dispatch,
947
+ // charCodeAt, schema, …) rather than the bare generic `.${method}` emitter
948
+ // — that emitter is the *array* `includes`/`indexOf`/… and would mis-run on
949
+ // a string receiver. Mirrors `?.[]`'s re-entry into `[]`. The method is
950
+ // statically known to exist, so the inner optional is moot; `t` is already
951
+ // nullish-guarded by evalOnce. Args re-bundle into the `()` arg slot.
952
+ const callArgs = args.length === 0 ? null : args.length === 1 ? args[0] : [',', ...args]
953
+ return asF64(ctx.core.emit['()'](['.', t, method], callArgs))
821
954
  })
822
955
  }
823
956
  }
@@ -836,7 +969,7 @@ export default (ctx) => {
836
969
  else normal.push(a)
837
970
  }
838
971
  const combined = reconstructArgsWithSpreads(normal, spreads)
839
- const arrayIR = buildArrayWithSpreads(combined)
972
+ const arrayIR = spread(combined)
840
973
  callResult = ctx.closure.call(typed(['local.get', `$${t}`], 'f64'), [arrayIR], true)
841
974
  } else {
842
975
  callResult = ctx.closure.call(typed(['local.get', `$${t}`], 'f64'), args)
@@ -865,9 +998,9 @@ export default (ctx) => {
865
998
  // boolean; isBoolExpr additionally catches `Boolean(x)` and parenthesized forms.
866
999
  if (valTypeOf(a) === VAL.BOOL || isBoolExpr(a)) return emit(['str', 'boolean'])
867
1000
  if (!ctx.runtime.typeofStrs) {
868
- ctx.runtime.typeofStrs = ['number', 'undefined', 'string', 'function', 'symbol', 'object']
1001
+ ctx.runtime.typeofStrs = ['number', 'undefined', 'string', 'function', 'symbol', 'object', 'boolean']
869
1002
  for (const s of ctx.runtime.typeofStrs)
870
- ctx.scope.globals.set(`__tof_${s}`, `(global $__tof_${s} (mut f64) (f64.const 0))`)
1003
+ declGlobal(`__tof_${s}`, 'f64')
871
1004
  }
872
1005
  inc('__typeof')
873
1006
  // Receiver type unknown; enable branches that wouldn't otherwise be reachable.
@@ -890,7 +1023,7 @@ export default (ctx) => {
890
1023
  ;; That bit pattern is the math NaN value, not a tagged pointer — treat as "number".
891
1024
  ;; Negative-NaN bit patterns (sign bit set) don't match NAN_PREFIX so are uniquely numeric.
892
1025
  (if (i32.or
893
- (i64.eq (local.get $v) (i64.const 0x7FF8000000000000))
1026
+ (i64.eq (local.get $v) (i64.const ${NAN_BITS}))
894
1027
  (i64.eq (i64.and (local.get $v) (i64.const 0xFFF0000000000000))
895
1028
  (i64.const 0xFFF0000000000000)))
896
1029
  (then (return (global.get $__tof_number))))
@@ -899,6 +1032,9 @@ export default (ctx) => {
899
1032
  ;; typeof null === "object" — the historical JS quirk, distinct from undefined.
900
1033
  (if (i64.eq (local.get $v) (i64.const ${NULL_NAN}))
901
1034
  (then (return (global.get $__tof_object))))
1035
+ ;; Boolean atoms (FALSE_NAN / TRUE_NAN) — carry at the JS boundary.
1036
+ (if (i64.eq (i64.and (local.get $v) (i64.const 0xFFFFFFFEFFFFFFFF)) (i64.const ${FALSE_NAN}))
1037
+ (then (return (global.get $__tof_boolean))))
902
1038
  (local.set $t (call $__ptr_type (local.get $v)))
903
1039
  (if ${stringTest}
904
1040
  (then (return (global.get $__tof_string))))
@@ -911,11 +1047,14 @@ export default (ctx) => {
911
1047
  // === Schema helpers (centralized in module/schema.js) ===
912
1048
  initSchema(ctx)
913
1049
 
914
- // Low-level pointer helpers callable from jz code
915
- ctx.core.emit['__mkptr'] = (t, a, o) => typed(['call', '$__mkptr', asI32(emit(t)), asI32(emit(a)), asI32(emit(o))], 'f64')
916
- ctx.core.emit['__ptr_type'] = (p) => typed(['f64.convert_i32_s', ['call', '$__ptr_type', asI64(emit(p))]], 'f64')
917
- ctx.core.emit['__ptr_aux'] = (p) => typed(['f64.convert_i32_s', ['call', '$__ptr_aux', asI64(emit(p))]], 'f64')
918
- ctx.core.emit['__ptr_offset'] = (p) => typed(['f64.convert_i32_s', ['call', '$__ptr_offset', asI64(emit(p))]], 'f64')
1050
+ // Low-level pointer helpers callable from jz code. Each handler inc()'s its
1051
+ // stdlib func so the call resolves at every optimize level. At opt≥1 fusedRewrite
1052
+ // inlines `call $__ptr_*` to bit-ops (the func is then dead-code-eliminated), but
1053
+ // the inc() must fire first so pullStdlib has the body when watr assembles at opt0.
1054
+ ctx.core.emit['__mkptr'] = (t, a, o) => (inc('__mkptr'), typed(['call', '$__mkptr', asI32(emit(t)), asI32(emit(a)), asI32(emit(o))], 'f64'))
1055
+ ctx.core.emit['__ptr_type'] = (p) => (inc('__ptr_type'), typed(['f64.convert_i32_s', ['call', '$__ptr_type', asI64(emit(p))]], 'f64'))
1056
+ ctx.core.emit['__ptr_aux'] = (p) => (inc('__ptr_aux'), typed(['f64.convert_i32_s', ['call', '$__ptr_aux', asI64(emit(p))]], 'f64'))
1057
+ ctx.core.emit['__ptr_offset'] = (p) => (inc('__ptr_offset'), typed(['f64.convert_i32_s', ['call', '$__ptr_offset', asI64(emit(p))]], 'f64'))
919
1058
 
920
1059
  // Error(msg) — passthrough (throw handles any value). Subclasses share the
921
1060
  // same shape: jz doesn't model typed-error dispatch, so SyntaxError/TypeError/