jz 0.8.1 → 0.9.1

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 (72) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +23 -23
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6271
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +299 -44
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +892 -32
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +301 -84
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +71 -5
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +979 -54
  45. package/src/compile/index.js +271 -29
  46. package/src/compile/infer.js +34 -3
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/narrow.js +543 -15
  49. package/src/compile/peel-stencil.js +5 -0
  50. package/src/compile/plan/index.js +45 -3
  51. package/src/compile/plan/inline.js +8 -1
  52. package/src/compile/plan/literals.js +39 -0
  53. package/src/compile/plan/scope.js +430 -23
  54. package/src/compile/program-facts.js +732 -38
  55. package/src/ctx.js +64 -9
  56. package/src/helper-counters.js +7 -1
  57. package/src/ir.js +113 -5
  58. package/src/kind-traits.js +66 -3
  59. package/src/kind.js +84 -12
  60. package/src/op-policy.js +7 -4
  61. package/src/optimize/index.js +1060 -750
  62. package/src/optimize/recurse.js +2 -2
  63. package/src/optimize/vectorize.js +972 -67
  64. package/src/prepare/index.js +792 -63
  65. package/src/prepare/math-kernel.js +331 -0
  66. package/src/prepare/pre-eval.js +714 -0
  67. package/src/snapshot.js +194 -0
  68. package/src/type.js +1170 -56
  69. package/src/wat/assemble.js +403 -65
  70. package/src/wat/codegen.js +74 -14
  71. package/transform.js +113 -4
  72. package/wasi.js +3 -0
package/module/core.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * @module core
10
10
  */
11
11
 
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'
12
+ import { typed, asF64, asI32, asI64, NULL_NAN, UNDEF_NAN, TOMB_NAN, FALSE_NAN, TRUE_NAN, temp, usesDynProps, ptrOffsetIR, isNullish, valKindToPtr, sidecarOverride, undefExpr, cloneIR } from '../src/ir.js'
13
13
  import { emit, spread, deps, wat } from '../src/bridge.js'
14
14
  import { reconstructArgsWithSpreads } from '../src/ir.js'
15
15
  import { valTypeOf, shapeOf } from '../src/kind.js'
@@ -18,9 +18,9 @@ import { inlineArraySid } from '../src/static.js'
18
18
  import { VAL, lookupValType, lookupNotString, repOf, updateRep } from '../src/reps.js'
19
19
  import { ctx, err, inc, PTR, LAYOUT, HEAP, FORWARDING_MASK, emitArity, followForwardingWat, declGlobal } from '../src/ctx.js'
20
20
  import { ptrOffsetFwdWat, STR_INTERN_BIT } from '../layout.js'
21
- import { nanPrefixHex } from '../layout.js'
21
+ import { nanPrefixHex, nanPrefixMaskHex, ssoBitI64Hex, encodePtrHi, i64Hex } from '../layout.js'
22
22
  import { initSchema } from './schema.js'
23
- import { strHashLiteral } from './collection.js'
23
+ import { strHashLiteral, heapResetWat, LENGTH_SSO_I64 } from './collection.js'
24
24
 
25
25
  const NAN_BITS = nanPrefixHex()
26
26
 
@@ -36,12 +36,28 @@ export default (ctx) => {
36
36
  __is_str_key: ['__ptr_type'],
37
37
  __str_len: ['__ptr_type', '__ptr_offset', '__ptr_aux'],
38
38
  __set_len: ['__ptr_offset_fwd'],
39
- __length: ['__ptr_type', '__str_len', '__len'],
39
+ // Property-fallback arm (`.length` as an ordinary own key on OBJECT/HASH
40
+ // receivers) needs the dyn dispatcher — but only when the program can even
41
+ // HOLD such a property (a schema'd object or dyn/hash machinery exists);
42
+ // string/array-only programs keep the lean undefined arm.
43
+ __length: () => ['__ptr_type', '__str_len', '__len',
44
+ ...(lengthNeedsDynArm() ? ['__dyn_get_expr_t_h'] : [])],
40
45
  __alloc: ['__memgrow'],
41
46
  __alloc_hdr: ['__alloc'],
42
47
  __alloc_hdr_n: ['__alloc'],
43
48
  __coll_order: ['__alloc'],
44
- __obj_clone: ['__ptr_type', '__ptr_aux', '__ptr_offset', '__len', '__cap', '__alloc_hdr', '__alloc_hdr_n', '__mkptr'],
49
+ __hash_keys_ro: ['__ptr_offset', '__coll_order', '__alloc_hdr', '__mkptr'],
50
+ // Durable-receiver global-table merge (see __obj_clone's body) pulls in
51
+ // __ihash_get_local/__is_nullish only when collection.js's dyn-props
52
+ // machinery is actually part of this build (mirrors json.js's __json_obj
53
+ // and array.js's needsArrayDynMove-gated deps thunks).
54
+ __obj_clone: () => ['__ptr_type', '__ptr_aux', '__ptr_offset', '__len', '__cap', '__alloc_hdr', '__alloc_hdr_n', '__mkptr',
55
+ ...(ctx.scope.globals.has('__dyn_props') ? ['__ihash_get_local', '__is_nullish'] : [])],
56
+ __durable_fwd_log: ['__alloc'],
57
+ __durable_fwd_heal: [],
58
+ __durable_slot_log: ['__alloc'],
59
+ __durable_slot_heal: [],
60
+ __is_eph_bits: [],
45
61
  })
46
62
 
47
63
  ctx.core.stdlib['__is_nullish'] = `(func $__is_nullish (param $v i64) (result i32)
@@ -127,9 +143,18 @@ export default (ctx) => {
127
143
  (i64.const ${UNDEF_NAN}))`
128
144
  ctx.core.stdlib['__dyn_get_or'] = `(func $__dyn_get_or (param $obj i64) (param $key i64) (param $fallback i64) (result i64)
129
145
  (local.get $fallback))`
146
+ // Sidecar probe entry (sidecarOverride / the builtin-shadow method fork): with
147
+ // no dyn-props module there are no own props — the probe always misses and the
148
+ // builtin arm runs, which is exactly the stub-world semantics.
149
+ ctx.core.stdlib['__dyn_get_expr'] = `(func $__dyn_get_expr (param $obj i64) (param $key i64) (result i64)
150
+ (i64.const ${UNDEF_NAN}))`
130
151
  ctx.core.stdlib['__dyn_set'] = `(func $__dyn_set (param $obj i64) (param $key i64) (param $val i64) (result i64)
131
152
  (local.get $val))`
132
- ctx.core.stdlib['__dyn_move'] = `(func $__dyn_move (param $oldOff i32) (param $newOff i32))`
153
+ // Signature must match collection.js's real __dyn_move (i32 result: 1 = an
154
+ // entry was found+rekeyed, 0 = no-op) — array.js's grow/shift call sites are
155
+ // built once and call whichever version ends up registered.
156
+ ctx.core.stdlib['__dyn_move'] = `(func $__dyn_move (param $oldOff i32) (param $newOff i32) (result i32)
157
+ (i32.const 0))`
133
158
 
134
159
  // Memory section auto-enabled: compile.js checks ctx.module.modules.ptr
135
160
 
@@ -287,6 +312,23 @@ export default (ctx) => {
287
312
  // OBJECT slots (offsets < heap base) don't misread arbitrary memory at off-16.
288
313
  // Updated by optimizeModule() when data segment exceeds HEAP.START bytes.
289
314
  declGlobal('__heap_start', 'i32', HEAP.START)
315
+ // Current memory limit in BYTES — __alloc's inline fast-path check
316
+ // (`next > __heap_end` → slow __memgrow call) replaces a per-alloc call whose
317
+ // page math always concluded "no grow" (jessie: 1.1M entries/run). __memgrow
318
+ // updates it after any growth; stale-LOW is safe (one extra slow call) and
319
+ // wasm memory never shrinks. 65536-page (4 GiB) memories wrap the shl to 0 —
320
+ // every alloc slow-paths there, still correct. Seeded 0: first alloc pays once.
321
+ declGlobal('__heap_end', 'i32', 0)
322
+ // i64 twin of __heap_end, for the CORRECTNESS-sensitive forwarding-chase bound
323
+ // (layout.js's followForwardingWat/ptrOffsetFwdWat, and their inline collection.js
324
+ // dyn-props copies): those checks gate whether a relocated ARRAY/SET/MAP/HASH's
325
+ // forwarding header gets followed at all, so the __heap_end wraparound-to-0 at the
326
+ // wasm32 4 GiB ceiling — benign for __alloc's slow-path retry — would there instead
327
+ // silently disable forwarding forever (every off > 0 reads as "out of bounds", so the
328
+ // cap=-1 sentinel of an abandoned block is never re-chased and gets misread as a real
329
+ // capacity). __memgrow updates this alongside __heap_end so every hot dereference site
330
+ // pays one $__heap_end64 global read instead of recomputing i64.shl(memory.size,16).
331
+ declGlobal('__heap_end64', 'i64', 0)
290
332
 
291
333
  // Shared memory keeps the heap pointer in linear memory (memory[HEAP.PTR_ADDR]):
292
334
  // wasm globals are per-instance, so threads sharing one memory must share one
@@ -317,16 +359,33 @@ export default (ctx) => {
317
359
  (then (local.set $cur (i32.sub (i32.const 65536) (memory.size))))) ;; cap at wasm32 max
318
360
  (if (i32.eq (memory.grow (local.get $cur)) (i32.const -1))
319
361
  (then (if (i32.eq (memory.grow (i32.sub (local.get $need) (memory.size))) (i32.const -1))
320
- (then (unreachable))))))))`
362
+ (then (unreachable)))))))
363
+ (global.set $__heap_end (i32.shl (memory.size) (i32.const 16)))
364
+ (global.set $__heap_end64 (i64.shl (i64.extend_i32_u (memory.size)) (i64.const 16))))`
321
365
 
322
366
  if (ctx.memory.shared) {
323
367
  // Heap offset stored at memory[HEAP.PTR_ADDR] (i32), just before heap start at
324
368
  // HEAP.START. Threads sharing one memory must share one pointer cell.
325
- ctx.core.stdlib['__alloc'] = `(func $__alloc (param $bytes i32) (result i32)
369
+ // TRULY-shared memory (opts.sharedMemory ctx.memory.atomic): the bump is a
370
+ // CAS retry loop — a plain load/store pair would hand two racing threads the
371
+ // same block. Plain imported memory keeps the cheap non-atomic bump.
372
+ ctx.core.stdlib['__alloc'] = ctx.memory.atomic ? `(func $__alloc (param $bytes i32) (result i32)
373
+ (local $ptr i32) (local $next i32)
374
+ (block $done (loop $retry
375
+ (local.set $ptr (i32.atomic.load (i32.const ${HEAP.PTR_ADDR})))
376
+ (local.set $next (i32.and (i32.add (i32.add (local.get $ptr) (local.get $bytes)) (i32.const 7)) (i32.const -8)))
377
+ (if (i32.gt_u (local.get $next) (global.get $__heap_end))
378
+ (then (call $__memgrow (local.get $next))))
379
+ (br_if $done (i32.eq
380
+ (i32.atomic.rmw.cmpxchg (i32.const ${HEAP.PTR_ADDR}) (local.get $ptr) (local.get $next))
381
+ (local.get $ptr)))
382
+ (br $retry)))
383
+ (local.get $ptr))` : `(func $__alloc (param $bytes i32) (result i32)
326
384
  (local $ptr i32) (local $next i32)
327
385
  (local.set $ptr (i32.load (i32.const ${HEAP.PTR_ADDR})))
328
386
  (local.set $next (i32.and (i32.add (i32.add (local.get $ptr) (local.get $bytes)) (i32.const 7)) (i32.const -8)))
329
- (call $__memgrow (local.get $next))
387
+ (if (i32.gt_u (local.get $next) (global.get $__heap_end))
388
+ (then (call $__memgrow (local.get $next))))
330
389
  (i32.store (i32.const ${HEAP.PTR_ADDR}) (local.get $next))
331
390
  (local.get $ptr))`
332
391
  // NOTE: shared memory rewinds to the raw HEAP.START, NOT a post-init high-water
@@ -337,7 +396,7 @@ export default (ctx) => {
337
396
  // clock at 0, heap ptr at HEAP.PTR_ADDR). Owned memory (the self-host + default
338
397
  // case) is the one fixed below; revisit shared if a thread-pooled reset hits it.
339
398
  ctx.core.stdlib['__clear'] = `(func $__clear
340
- (i32.store (i32.const ${HEAP.PTR_ADDR}) (i32.const ${HEAP.START})))`
399
+ (${ctx.memory.atomic ? 'i32.atomic.store' : 'i32.store'} (i32.const ${HEAP.PTR_ADDR}) (i32.const ${HEAP.START})))`
341
400
  } else {
342
401
  // Own memory: heap offset in a global, exported so the JS-side adapter
343
402
  // (alloc:false, no `_alloc` export) shares the pointer.
@@ -356,11 +415,133 @@ export default (ctx) => {
356
415
  (local $ptr i32) (local $next i32)
357
416
  (local.set $ptr (global.get $__heap))
358
417
  (local.set $next (i32.and (i32.add (i32.add (local.get $ptr) (local.get $bytes)) (i32.const 7)) (i32.const -8)))
359
- (call $__memgrow (local.get $next))
418
+ (if (i32.gt_u (local.get $next) (global.get $__heap_end))
419
+ (then (call $__memgrow (local.get $next))))
360
420
  (global.set $__heap (local.get $next))
361
421
  (local.get $ptr))`
422
+ // __clear rewinds the bump arena, but __dyn_props/__dyn_get_cache_* (declared
423
+ // unconditionally whenever the collection module loads — module/collection.js)
424
+ // cache pointers/offsets INTO that arena across calls, so a warm compile-clear-
425
+ // compile loop needs them reset too — see the post-hoc patch in
426
+ // src/wat/assemble.js (search "__dyn_props reset") for WHY this can't gate on
427
+ // `ctx.scope.globals.has(...)` at declaration time: that's true whenever
428
+ // collection is loaded AT ALL, even for a program that never touches dynamic
429
+ // props, and __clear's own resolved text is scanned by reachableStdlib — an
430
+ // unconditional `global.set $__dyn_props` line here would leak that (dead,
431
+ // for this program) name into non-dyn-prop output, both wasting bytes and
432
+ // (worse) tripping WAT-substring test assertions like
433
+ // `!/__dyn_get/.test(wat)` (test/closures.js) since __dyn_get_cache_off/props
434
+ // contain that substring. The real gate — whether __dyn_set (the only writer
435
+ // of __dyn_props) is actually reachable — isn't known until AFTER
436
+ // reachableStdlib runs, so the reset is injected post-hoc once that's settled.
362
437
  ctx.core.stdlib['__clear'] = `(func $__clear
363
438
  (global.set $__heap (global.get $__heap_reset)))`
439
+
440
+ // Durable relocation log — see collection.js's durableFwdLogIR for the full
441
+ // rationale (array/hash/set/map growth forwards a DURABLE header into an
442
+ // EPHEMERAL new block; `_clear` must heal that back before rewinding the arena
443
+ // or the durable alias dangles forever). `__durable_fwd_buf` is allocated
444
+ // lazily (raw `__alloc`, no forwarding-capable header of its own — it must
445
+ // never recurse into the bug it exists to fix) on the first durable grow of a
446
+ // round; `__durable_fwd_heal` (wired into `__clear` post-hoc, see
447
+ // src/wat/assemble.js) restores every logged header to its pre-grow (len, cap)
448
+ // and resets both globals to 0 so the buffer is re-allocated fresh next round —
449
+ // it only needs to survive from "logged this round" to "healed at this round's
450
+ // `_clear`", never across a reset. 256 entries is a trap-on-overflow ceiling
451
+ // for a count that is 0 in the overwhelmingly common program (real durable-
452
+ // growth sites are a handful of compiler-internal structures, not user data).
453
+ declGlobal('__durable_fwd_buf', 'i32')
454
+ declGlobal('__durable_fwd_n', 'i32')
455
+ ctx.core.stdlib['__durable_fwd_log'] = `(func $__durable_fwd_log (param $off i32) (param $len i32) (param $cap i32)
456
+ (local $base i32) (local $n i32)
457
+ (if (i32.eqz (global.get $__durable_fwd_buf))
458
+ (then (global.set $__durable_fwd_buf (call $__alloc (i32.const 3072)))))
459
+ (local.set $n (global.get $__durable_fwd_n))
460
+ (if (i32.ge_s (local.get $n) (i32.const 256)) (then (unreachable)))
461
+ (local.set $base (i32.add (global.get $__durable_fwd_buf) (i32.mul (local.get $n) (i32.const 12))))
462
+ (i32.store (local.get $base) (local.get $off))
463
+ (i32.store (i32.add (local.get $base) (i32.const 4)) (local.get $len))
464
+ (i32.store (i32.add (local.get $base) (i32.const 8)) (local.get $cap))
465
+ (global.set $__durable_fwd_n (i32.add (local.get $n) (i32.const 1))))`
466
+ ctx.core.stdlib['__durable_fwd_heal'] = `(func $__durable_fwd_heal
467
+ (local $i i32) (local $n i32) (local $base i32) (local $off i32)
468
+ (local.set $n (global.get $__durable_fwd_n))
469
+ (block $done (loop $l
470
+ (br_if $done (i32.ge_s (local.get $i) (local.get $n)))
471
+ (local.set $base (i32.add (global.get $__durable_fwd_buf) (i32.mul (local.get $i) (i32.const 12))))
472
+ (local.set $off (i32.load (local.get $base)))
473
+ (i32.store (i32.sub (local.get $off) (i32.const 8)) (i32.load (i32.add (local.get $base) (i32.const 4))))
474
+ (i32.store (i32.sub (local.get $off) (i32.const 4)) (i32.load (i32.add (local.get $base) (i32.const 8))))
475
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
476
+ (br $l)))
477
+ (global.set $__durable_fwd_n (i32.const 0))
478
+ (global.set $__durable_fwd_buf (i32.const 0)))`
479
+
480
+ // Durable SLOT log — the value-write sibling of the relocation log above. A
481
+ // collection whose storage is DURABLE (init-created dict, off < __heap_reset)
482
+ // can receive an EPHEMERAL boxed value at runtime (a memo caching this round's
483
+ // parsed node, a registry entry) — the slot then dangles across \`_clear\` and
484
+ // the next round reads reused-arena garbage through it (the corpus-wide warm
485
+ // trap: a durable literal-text→node dict handing round-1 node arrays into
486
+ // round-2's tree). Writers call \`__durable_slot_log(addr)\` when storing an
487
+ // ephemeral value into a durable slot (see collection.js durableSlotLogIR);
488
+ // \`__durable_slot_heal\` (wired into \`__clear\` post-hoc, like the fwd heal)
489
+ // overwrites every logged slot with \`undefined\` — the pointed-at data dies
490
+ // with the arena, so entry-death is the only sound semantics. Same lazy-buffer
491
+ // + trap-ceiling design as the fwd log; slots are 4 bytes each so one page
492
+ // covers 1024 writes (durable-receiver writes are rare by construction).
493
+ declGlobal('__durable_slot_buf', 'i32')
494
+ declGlobal('__durable_slot_n', 'i32')
495
+ ctx.core.stdlib['__is_eph_bits'] = `(func $__is_eph_bits (param $b i64) (result i32)
496
+ (local $t i32)
497
+ ;; boxed heap pointer: quiet-NaN prefix, heap-kind tag, non-SSO, offset past the durable watermark
498
+ (if (i64.ne (i64.and (local.get $b) (i64.const ${nanPrefixMaskHex()})) (i64.const ${nanPrefixHex()}))
499
+ (then (return (i32.const 0))))
500
+ (local.set $t (i32.wrap_i64 (i64.and (i64.shr_u (local.get $b) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))
501
+ ;; heap kinds {ARRAY,BUFFER,TYPED,STRING,OBJECT,HASH,SET,MAP,CLOSURE} = bits 1-4,6-10 → 0x7DE
502
+ (if (i32.eqz (i32.and (i32.shl (i32.const 1) (local.get $t)) (i32.const 0x7DE)))
503
+ (then (return (i32.const 0))))
504
+ (if (i32.and (i32.eq (local.get $t) (i32.const ${PTR.STRING}))
505
+ (i64.ne (i64.and (local.get $b) (i64.const ${ssoBitI64Hex()})) (i64.const 0)))
506
+ (then (return (i32.const 0))))
507
+ (i32.ge_u (i32.wrap_i64 (i64.and (local.get $b) (i64.const 0xFFFFFFFF))) (global.get $__heap_reset)))`
508
+ ctx.core.stdlib['__durable_slot_log'] = `(func $__durable_slot_log (param $addr i32) (param $tbl i32)
509
+ (local $n i32) (local $base i32)
510
+ (if (i32.eqz (global.get $__durable_slot_buf))
511
+ (then (global.set $__durable_slot_buf (call $__alloc (i32.const 8192)))))
512
+ (local.set $n (global.get $__durable_slot_n))
513
+ (if (i32.ge_s (local.get $n) (i32.const 1024)) (then (unreachable)))
514
+ (local.set $base (i32.add (global.get $__durable_slot_buf) (i32.shl (local.get $n) (i32.const 3))))
515
+ (i32.store (local.get $base) (local.get $addr))
516
+ (i32.store (i32.add (local.get $base) (i32.const 4)) (local.get $tbl))
517
+ (global.set $__durable_slot_n (i32.add (local.get $n) (i32.const 1))))`
518
+ ctx.core.stdlib['__durable_slot_heal'] = `(func $__durable_slot_heal
519
+ (local $i i32) (local $n i32) (local $a i32) (local $base i32) (local $tbl i32)
520
+ (local.set $n (global.get $__durable_slot_n))
521
+ (block $done (loop $l
522
+ (br_if $done (i32.ge_s (local.get $i) (local.get $n)))
523
+ (local.set $base (i32.add (global.get $__durable_slot_buf) (i32.shl (local.get $i) (i32.const 3))))
524
+ (local.set $a (i32.load (local.get $base)))
525
+ (local.set $tbl (i32.load (i32.add (local.get $base) (i32.const 4))))
526
+ (if (i32.and (local.get $a) (i32.const 1))
527
+ ;; bit0: ENTRY heal — this round INSERTED the entry into durable storage; a
528
+ ;; fresh instance would not have it. Zombie it (key TOMB, value undefined —
529
+ ;; probes pass over, __coll_order skips) and decrement the table len so
530
+ ;; len-sized iteration and .size agree. Runs AFTER __durable_fwd_heal, so a
531
+ ;; grown-then-healed table's len is already its restored pre-grow value.
532
+ (then
533
+ (local.set $a (i32.and (local.get $a) (i32.const -2)))
534
+ (i64.store (i32.add (local.get $a) (i32.const 8)) (i64.const ${TOMB_NAN}))
535
+ (i64.store (i32.add (local.get $a) (i32.const 16)) (i64.const ${UNDEF_NAN}))
536
+ (i32.store (i32.sub (local.get $tbl) (i32.const 8))
537
+ (i32.sub (i32.load (i32.sub (local.get $tbl) (i32.const 8))) (i32.const 1))))
538
+ ;; plain: VALUE heal — the entry pre-existed durably; its old value is
539
+ ;; unrecoverable, undefined is the honest read.
540
+ (else (i64.store (local.get $a) (i64.const ${UNDEF_NAN}))))
541
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
542
+ (br $l)))
543
+ (global.set $__durable_slot_n (i32.const 0))
544
+ (global.set $__durable_slot_buf (i32.const 0)))`
364
545
  }
365
546
 
366
547
  // Build an insertion-ordered list of live slot offsets for a Set/Map/HASH
@@ -384,7 +565,10 @@ export default (ctx) => {
384
565
  (block $gd (loop $gl
385
566
  (br_if $gd (i32.ge_s (local.get $i) (local.get $cap)))
386
567
  (local.set $slot (i32.add (local.get $off) (i32.mul (local.get $i) (local.get $stride))))
387
- (if (i64.ne (i64.load (local.get $slot)) (i64.const 0))
568
+ (if (i32.and
569
+ (i64.ne (i64.load (local.get $slot)) (i64.const 0))
570
+ ;; skip healed zombie entries (durable-slot heal: key = TOMB sentinel)
571
+ (i64.ne (i64.load (i32.add (local.get $slot) (i32.const 8))) (i64.const ${TOMB_NAN})))
388
572
  (then
389
573
  (i32.store (i32.add (local.get $buf) (i32.shl (local.get $n) (i32.const 2))) (local.get $slot))
390
574
  (local.set $n (i32.add (local.get $n) (i32.const 1)))))
@@ -411,6 +595,43 @@ export default (ctx) => {
411
595
  (br $sl)))
412
596
  (local.get $buf))`
413
597
 
598
+ // for-in's HASH key enumeration with a 1-slot enum cache (V8's EnumCache analog).
599
+ // A for-in over an unchanged dict re-derives the same key array every entry —
600
+ // __coll_order buffer + out-array alloc + cap scan + sort per loop entry (the
601
+ // dominant cost of any per-call `for (k in cfg)` pattern: jessie's comment
602
+ // wrapper paid this per TOKEN). Cache the boxed key array keyed by
603
+ // (table off, live len): an insert changes len, so it misses naturally with no
604
+ // insert-side hook; the only len-preserving key-set change is delete-then-insert,
605
+ // so the HASH delete (genDelete, collection.js) clears the cache unconditionally;
606
+ // `__clear` resets it (arena rewind can re-issue the cached off to a new table).
607
+ // Grow/remint relocation is safe hook-free: reads resolve forwarding to the new
608
+ // off (≠ cached), and a husk off is never re-issued within an arena epoch.
609
+ // ONLY sound for for-in (`__keys_ro`), whose result is read-only by construction —
610
+ // Object.keys must keep fresh-array semantics (callers may mutate the result).
611
+ ctx.core.stdlib['__hash_keys_ro'] = `(func $__hash_keys_ro (param $hbits i64) (result f64)
612
+ (local $off i32) (local $n i32) (local $ord i32) (local $i i32) (local $out i32)
613
+ (local.set $off (call $__ptr_offset (local.get $hbits)))
614
+ ;; degenerate/null backing (off below heap base): empty result, uncached
615
+ (if (i32.lt_u (local.get $off) (i32.const ${HEAP.START}))
616
+ (then (return (call $__mkptr (i32.const ${PTR.ARRAY}) (i32.const 0) (call $__alloc_hdr (i32.const 0) (i32.const 0))))))
617
+ (local.set $n (i32.load (i32.sub (local.get $off) (i32.const 8))))
618
+ (if (i32.and (i32.eq (local.get $off) (global.get $__enumc_off))
619
+ (i32.eq (local.get $n) (global.get $__enumc_len)))
620
+ (then (return (global.get $__enumc_arr))))
621
+ (local.set $ord (call $__coll_order (local.get $off)
622
+ (i32.load (i32.sub (local.get $off) (i32.const 4))) (i32.const 24)))
623
+ (local.set $out (call $__alloc_hdr (local.get $n) (local.get $n)))
624
+ (block $brk (loop $l
625
+ (br_if $brk (i32.ge_s (local.get $i) (local.get $n)))
626
+ (i64.store (i32.add (local.get $out) (i32.shl (local.get $i) (i32.const 3)))
627
+ (i64.load (i32.add (i32.load (i32.add (local.get $ord) (i32.shl (local.get $i) (i32.const 2)))) (i32.const 8))))
628
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
629
+ (br $l)))
630
+ (global.set $__enumc_off (local.get $off))
631
+ (global.set $__enumc_len (local.get $n))
632
+ (global.set $__enumc_arr (call $__mkptr (i32.const ${PTR.ARRAY}) (i32.const 0) (local.get $out)))
633
+ (global.get $__enumc_arr))`
634
+
414
635
  // === Memory-based length/cap helpers (C-style headers) ===
415
636
 
416
637
  // Array/TypedArray/Buffer: [-8:len(i32)][-4:cap(i32)][data...]
@@ -582,7 +803,10 @@ export default (ctx) => {
582
803
  // - HASH: copy header + every probe slot wholesale (entries hold immutable
583
804
  // string keys + scalar/pointer values — a byte copy is an independent dict).
584
805
  // - anything else (primitive): nothing to clone, return as-is.
585
- ctx.core.stdlib['__obj_clone'] = `(func $__obj_clone (param $v f64) (result f64)
806
+ // Thunked (not a plain template string) so heapResetWat()/the __dyn_props
807
+ // presence check below read the FINAL declaration state — see collection.js's
808
+ // heapResetWat comment for why.
809
+ ctx.core.stdlib['__obj_clone'] = () => `(func $__obj_clone (param $v f64) (result f64)
586
810
  (local $bits i64) (local $t i32) (local $sid i32) (local $n i32) (local $cap i32)
587
811
  (local $src i32) (local $dst i32) (local $props i64)
588
812
  (local.set $bits (i64.reinterpret_f64 (local.get $v)))
@@ -598,12 +822,36 @@ export default (ctx) => {
598
822
  (local.set $cap (i32.add (local.get $n) (i32.eqz (local.get $n))))
599
823
  (local.set $dst (call $__alloc_hdr (i32.const 0) (local.get $cap)))
600
824
  (memory.copy (local.get $dst) (local.get $src) (i32.shl (local.get $n) (i32.const 3)))
601
- (if (i32.ge_u (local.get $src) (global.get $__heap_start))
602
- (then
603
- (local.set $props (i64.load (i32.sub (local.get $src) (i32.const 16))))
604
- (if (i32.eq (call $__ptr_type (local.get $props)) (i32.const ${PTR.HASH}))
605
- (then (i64.store (i32.sub (local.get $dst) (i32.const 16))
606
- (i64.reinterpret_f64 (call $__obj_clone (f64.reinterpret_i64 (local.get $props)))))))))
825
+ ;; Dyn-props (off-schema keys added by o[k]=v): heap-allocated sources
826
+ ;; (src >= __heap_start) carry them at src-16 as a HASH sidecar
827
+ ;; (populated by an init-time write, or by any write at all on an
828
+ ;; EPHEMERAL source) and/or in the global __dyn_props table (populated
829
+ ;; by a RUNTIME/post-init write on a DURABLE source — see
830
+ ;; collection.js's heapResetWat for the full policy). Static-segment
831
+ ;; sources (src < __heap_start) have no header — both checks below
832
+ ;; are gated on src >= __heap_start so neither reads neighbor static
833
+ ;; data. Prefers the sidecar when present (authoritative for a
834
+ ;; DURABLE source's untouched init-time keys, and the only source for
835
+ ;; an ephemeral one); falls back to the global entry otherwise. A
836
+ ;; source with keys split across BOTH (some at init, more added at
837
+ ;; runtime) clones only the sidecar's — a known narrow gap versus
838
+ ;; Object.keys/JSON.stringify's full merge, accepted here because a
839
+ ;; spread of such a genuinely mixed durable dict is materially rarer.
840
+ (local.set $props (i64.load (i32.sub (local.get $src) (i32.const 16))))
841
+ (if (i32.and (i32.ge_u (local.get $src) (global.get $__heap_start))
842
+ (i32.eq (call $__ptr_type (local.get $props)) (i32.const ${PTR.HASH})))
843
+ (then (i64.store (i32.sub (local.get $dst) (i32.const 16))
844
+ (i64.reinterpret_f64 (call $__obj_clone (f64.reinterpret_i64 (local.get $props))))))${ctx.scope.globals.has('__dyn_props') ? `
845
+ (else
846
+ (if (i32.and (i32.ge_u (local.get $src) (global.get $__heap_start))
847
+ (i32.lt_u (local.get $src) ${heapResetWat()}))
848
+ (then
849
+ (if (f64.ne (global.get $__dyn_props) (f64.const 0))
850
+ (then
851
+ (local.set $props (call $__ihash_get_local (i64.reinterpret_f64 (global.get $__dyn_props)) (i64.reinterpret_f64 (f64.convert_i32_s (local.get $src)))))
852
+ (if (i32.eqz (call $__is_nullish (local.get $props)))
853
+ (then (i64.store (i32.sub (local.get $dst) (i32.const 16))
854
+ (i64.reinterpret_f64 (call $__obj_clone (f64.reinterpret_i64 (local.get $props)))))))))))` : ''}))
607
855
  (return (call $__mkptr (i32.const ${PTR.OBJECT}) (local.get $sid) (local.get $dst)))))
608
856
  (if (i32.eq (local.get $t) (i32.const ${PTR.HASH}))
609
857
  (then
@@ -640,8 +888,8 @@ export default (ctx) => {
640
888
  // === Shared dispatch helpers ===
641
889
 
642
890
  /** Emit .length access for a WASM f64 node. Monomorphize by vt, or runtime dispatch.
643
- * ARRAY/SET/MAP share a single layout: length is i32 at offset-8. We inline that load
644
- * directly instead of calling __len which re-dispatches on type. ptrOffsetIR handles
891
+ * ARRAY length is i32 at offset-8 inline that load directly instead of calling
892
+ * __len which re-dispatches on type. ptrOffsetIR handles
645
893
  * ARRAY forwarding (non-ARRAY skips the forwarding loop). TYPED has a variable-width
646
894
  * layout depending on the aux typed-element shift, so it still routes through __len.
647
895
  * `notString` (from rep.notString — write-shape evidence rules out primitive string)
@@ -657,10 +905,13 @@ export default (ctx) => {
657
905
  ctx.core.jsstring.add('length')
658
906
  return typed(['f64.convert_i32_s', ['call', '$__jss_length', va]], 'f64')
659
907
  }
660
- if (vt === VAL.ARRAY || vt === VAL.SET || vt === VAL.MAP) {
908
+ if (vt === VAL.ARRAY) {
661
909
  const off = ptrOffsetIR(va, vt)
662
910
  return typed(['f64.convert_i32_s', ['i32.load', ['i32.sub', off, ['i32.const', 8]]]], 'f64')
663
911
  }
912
+ // Set/Map have no .length in JS — their count is `.size`. (The former
913
+ // shared-layout __len shortcut returned the entry count here.)
914
+ if (vt === VAL.SET || vt === VAL.MAP) return undefExpr()
664
915
  if (vt === VAL.TYPED)
665
916
  return typed(['f64.convert_i32_s', ['call', '$__len', ['i64.reinterpret_f64', va]]], 'f64')
666
917
  // Known string → byteLen via the active string rep. Pass the slot
@@ -691,7 +942,7 @@ export default (ctx) => {
691
942
  // for ad-hoc props on pointer-backed values, so schema reads should bypass it.
692
943
  // Slot val-types reach the emit-time consumer via valTypeOf → ctx.schema.slotVT
693
944
  // (read on the AST `.prop` node), not via tagging this IR node.
694
- function emitSchemaSlotRead(baseExpr, idx) {
945
+ function emitSchemaSlotRead(baseExpr, idx, i32Certain) {
695
946
  // An unboxed proven-non-ARRAY pointer (a structInline element cell, a narrowed local)
696
947
  // reaches ptrOffsetIR raw so it returns the offset directly — no `__ptr_offset` call.
697
948
  // Pre-boxing via asF64 strips ptrKind and forces every field read onto the call path
@@ -700,7 +951,80 @@ export default (ctx) => {
700
951
  const base = (baseExpr?.ptrKind != null && baseExpr.ptrKind !== VAL.ARRAY)
701
952
  ? baseExpr
702
953
  : (baseExpr?.type === 'f64' ? baseExpr : asF64(baseExpr))
703
- return typed(ctx.abi.object.ops.load(ptrOffsetIR(base, VAL.OBJECT), idx), 'f64')
954
+ const load = ctx.abi.object.ops.load(ptrOffsetIR(base, VAL.OBJECT), idx)
955
+ // Strict-int32 slot (ctx.schema.slotI32CertainAt — every censused write is
956
+ // exactly-int32, never -0): land the value directly in i32. trunc_sat of
957
+ // such an f64 is a value-exact round-trip, and every int consumer skips
958
+ // the ToInt32 guard/convert battery the f64 route pays (the immutable
959
+ // kernel's per-field cost); f64 consumers convert back at one op.
960
+ if (i32Certain) return typed(['i32.trunc_sat_f64_s', load], 'i32')
961
+ return typed(load, 'f64')
962
+ }
963
+
964
+ // Top 32 bits of the i64 NaN-box carrier: NAN_PREFIX | PTR tag (TAG_SHIFT=47)
965
+ // | schemaId aux (AUX_SHIFT=32) — layout.js packs all three above bit 31, so
966
+ // masking the whole high word and comparing to encodePtrHi(OBJECT, sid) proves
967
+ // "is an OBJECT" AND "is exactly this schema" in one i64 compare; the low
968
+ // word (this instance's heap offset) is irrelevant and stays unmasked.
969
+ const OBJECT_SCHEMA_HI_MASK = '0xFFFFFFFF00000000'
970
+ const objectSchemaGuardHex = (sid) => i64Hex(BigInt(encodePtrHi(PTR.OBJECT, sid)) << 32n)
971
+
972
+ /** Monomorphic schema-slot devirtualization for a receiver whose static type
973
+ * is fully unknown (emitPropAccess's `vt == null` case, the __dyn_get_any_t_h
974
+ * path). `guard` (from ctx.schema.guardedSlotOf) proves `prop` names a field
975
+ * on exactly one registered schema program-wide: the subscript dispatch-
976
+ * descriptor pattern (`d.op`/`d.l`/`d.word`) and jz's own emit-table/IR-node
977
+ * reads under self-host are both a hot dot-read whose receiver is ALWAYS
978
+ * that one schema in practice, even though it flows through a parameter or
979
+ * array element the static analysis never pins to VAL.OBJECT.
980
+ *
981
+ * Emits a single masked i64 compare (OBJECT_SCHEMA_HI_MASK) then a direct
982
+ * payload-slot load; any other receiver (a different schema, or not an
983
+ * OBJECT at all) falls to `slow()` — the exact call this site would have
984
+ * emitted with no guard at all, so this can only ever be as fast, never
985
+ * wrong. Soundness: collection.js's __dyn_set schema arm
986
+ * (buildObjectSchemaSetArm) mirrors every dynamic write to a schema-named
987
+ * key into the payload slot, so the slot stays authoritative even after an
988
+ * `obj[k] = v` write through the dyn-props sidecar/global table — schema
989
+ * fields are never shadowed by a dynamic write. */
990
+ function emitSchemaSlotGuarded(va, guard, slow, prop) {
991
+ // Clone: `slow` is a closure the CALLER built over the same `va` object
992
+ // (emitPropAccess's `const slow = () => …emitDynGetAnyTyped(va, …)`), so this
993
+ // function's own use of `va` below and the caller's use inside `slow()` would
994
+ // otherwise be the SAME node appearing twice in the final if/then/else tree —
995
+ // one logical read, but two tree positions. A later pass that walks the tree
996
+ // once per node identity (not per position) — e.g. local-slot lifetime/reuse
997
+ // analysis — sees only the FIRST occurrence's use and can free/reassign the
998
+ // local behind the second, producing whatever locals happen to be resident at
999
+ // that point (see cloneIR's doc; ir.js). va is a pure receiver read (a local/
1000
+ // cell load), safe to duplicate as long as each occurrence is its own object.
1001
+ va = cloneIR(va)
1002
+ const bits = asI64(va?.type ? va : typed(va, 'f64'))
1003
+ const cond = ['i64.eq',
1004
+ ['i64.and', bits, ['i64.const', OBJECT_SCHEMA_HI_MASK]],
1005
+ ['i64.const', objectSchemaGuardHex(guard.sid)]]
1006
+ // PTR.OBJECT never forwards (FORWARDING_MASK — ctx.js — only ARRAY/HASH/
1007
+ // SET/MAP headers relocate on growth), so once the guard above has proven
1008
+ // the tag, the payload offset is a bare mask: no __ptr_offset call needed.
1009
+ // ptrOffsetIR (src/ir.js) always emits that call for an untyped node — it
1010
+ // has no way to know the forwarding check is dead here — so this inlines
1011
+ // the same extraction __ptr_offset itself would perform for an OBJECT tag.
1012
+ const off = ['i32.wrap_i64', ['i64.and', bits, ['i64.const', LAYOUT.OFFSET_MASK]]]
1013
+ const fast = typed(ctx.abi.object.ops.load(off, guard.slot), 'f64')
1014
+ const ir = typed(['if', ['result', 'f64'],
1015
+ cond,
1016
+ ['then', fast],
1017
+ ['else', slow()]], 'f64')
1018
+ // Slot-kind stamp for ToNumber sinking (toNumF64): when the ONE schema
1019
+ // this guard proves censuses the slot as NUMBER — and the prop is never
1020
+ // written anywhere (a write could store any kind) — the guard-HIT arm's
1021
+ // raw load is already a plain number. toNumF64 then coerces only the
1022
+ // dyn-miss arm instead of wrapping the whole read in __to_num — the
1023
+ // shapes-dispatch pattern's per-field coercion collapses on the hot path.
1024
+ if (ctx.schema.slotTypes?.get(guard.sid)?.[guard.slot] === VAL.NUMBER
1025
+ && ctx.types.writtenProps && !ctx.types.writtenProps.has(prop))
1026
+ ir.guardedNumSlot = true
1027
+ return ir
704
1028
  }
705
1029
 
706
1030
  function emitHashGetLocalConst(base, key, prop) {
@@ -709,11 +1033,15 @@ export default (ctx) => {
709
1033
  return typed(['f64.reinterpret_i64', ['call', '$__hash_get_local_h', receiver, key, ['i32.const', strHashLiteral(prop)]]], 'f64')
710
1034
  }
711
1035
 
1036
+ // Every call site embeds `receiver` a SECOND time directly (as the dyn-get
1037
+ // call's own receiver arg) alongside this function's result — clone so the two
1038
+ // occurrences are distinct node objects (see emitSchemaSlotGuarded's comment /
1039
+ // cloneIR's doc for why aliased IR nodes are unsound to leave standing).
712
1040
  function emitTypeTag(receiver, vt) {
713
1041
  const p = valKindToPtr(vt)
714
1042
  if (p != null) return ['i32.const', p]
715
1043
  inc('__ptr_type')
716
- return ['call', '$__ptr_type', receiver]
1044
+ return ['call', '$__ptr_type', cloneIR(receiver)]
717
1045
  }
718
1046
 
719
1047
  function emitDynGetExprTyped(base, key, vt, prop) {
@@ -722,7 +1050,12 @@ export default (ctx) => {
722
1050
  // prehashed body — no __str_hash on every access.
723
1051
  if (typeof prop === 'string') {
724
1052
  inc('__dyn_get_expr_t_h')
725
- return typed(['f64.reinterpret_i64', ['call', '$__dyn_get_expr_t_h', receiver, key, emitTypeTag(receiver, vt), ['i32.const', strHashLiteral(prop)]]], 'f64')
1053
+ const call = ['call', '$__dyn_get_expr_t_h', receiver, key, emitTypeTag(receiver, vt), ['i32.const', strHashLiteral(prop)]]
1054
+ // Schema-set devirt marker — same contract as emitDynGetAnyTyped below
1055
+ // (identical 4-arg layout); without it the wasi host (features.external
1056
+ // off routes reads here) never devirtualizes megamorphic prop reads.
1057
+ if (ctx.transform.optimize) call.dvProp = prop
1058
+ return typed(['f64.reinterpret_i64', call], 'f64')
726
1059
  }
727
1060
  inc('__dyn_get_expr_t')
728
1061
  return typed(['f64.reinterpret_i64', ['call', '$__dyn_get_expr_t', receiver, key, emitTypeTag(receiver, vt)]], 'f64')
@@ -734,7 +1067,14 @@ export default (ctx) => {
734
1067
  // prehashed body — no __str_hash on every access (hot for `parse.step` etc).
735
1068
  if (typeof prop === 'string') {
736
1069
  inc('__dyn_get_any_t_h')
737
- return typed(['f64.reinterpret_i64', ['call', '$__dyn_get_any_t_h', receiver, key, emitTypeTag(receiver, vt), ['i32.const', strHashLiteral(prop)]]], 'f64')
1070
+ const call = ['call', '$__dyn_get_any_t_h', receiver, key, emitTypeTag(receiver, vt), ['i32.const', strHashLiteral(prop)]]
1071
+ // Schema-set devirt marker: the optimizer (devirtSchemaReads) rewrites this
1072
+ // megamorphic probe into a br_table over the module's registered schemas —
1073
+ // direct slot loads per schema, this call as the always-sound default arm.
1074
+ // Tagged here (not built) because schema.list is still growing while
1075
+ // function bodies emit; the pass runs after module init completes.
1076
+ if (ctx.transform.optimize) call.dvProp = prop
1077
+ return typed(['f64.reinterpret_i64', call], 'f64')
738
1078
  }
739
1079
  inc('__dyn_get_any_t')
740
1080
  return typed(['f64.reinterpret_i64', ['call', '$__dyn_get_any_t', receiver, key, emitTypeTag(receiver, vt)]], 'f64')
@@ -800,7 +1140,7 @@ export default (ctx) => {
800
1140
  if (va?.ptrKind === VAL.OBJECT && va.ptrAux != null && typeof prop === 'string') {
801
1141
  const sch = ctx.schema.list[va.ptrAux]
802
1142
  const si = sch ? sch.indexOf(prop) : -1
803
- if (si >= 0) return emitSchemaSlotRead(va, si)
1143
+ if (si >= 0) return emitSchemaSlotRead(va, si, ctx.schema.slotI32CertainBySid?.(va.ptrAux, prop))
804
1144
  }
805
1145
  let schemaIdx = typeof obj === 'string' ? ctx.schema.slotOf(obj, prop) : ctx.schema.slotOf(null, prop)
806
1146
  // Chain receiver (e.g. `o.meta.bias`): when the chain resolves to a known
@@ -815,7 +1155,8 @@ export default (ctx) => {
815
1155
  }
816
1156
  }
817
1157
  const key = asI64(emit(['str', prop]))
818
- if (schemaIdx >= 0) return emitSchemaSlotRead(va, schemaIdx)
1158
+ if (schemaIdx >= 0) return emitSchemaSlotRead(va, schemaIdx,
1159
+ typeof obj === 'string' && ctx.schema.slotI32CertainAt?.(obj, prop))
819
1160
  if (typeof obj === 'string') {
820
1161
  const vt = lookupValType(obj)
821
1162
  if (usesDynProps(vt)) {
@@ -833,12 +1174,18 @@ export default (ctx) => {
833
1174
  if (vt == null) {
834
1175
  // In WASI mode, values are always JSON-derived (never PTR.EXTERNAL host objects).
835
1176
  // Skip the external branch and dispatch through the typed HASH/OBJECT path.
836
- if (ctx.transform.host === 'wasi') return emitDynGetExprTyped(va, key, vt, prop)
1177
+ const isWasi = ctx.transform.host === 'wasi'
837
1178
  // `fromOptional` (a `?.prop` read) short-circuits on nullish, so its
838
1179
  // PTR.EXTERNAL arm is dead unless host externals are already in play —
839
1180
  // don't force the __ext_prop import just for an optional read.
840
- if (!fromOptional) ctx.features.external = true
841
- return emitDynGetAnyTyped(va, key, vt, prop)
1181
+ if (!isWasi && !fromOptional) ctx.features.external = true
1182
+ const slow = () => isWasi ? emitDynGetExprTyped(va, key, vt, prop) : emitDynGetAnyTyped(va, key, vt, prop)
1183
+ // Monomorphic schema-slot devirtualization (see emitSchemaSlotGuarded):
1184
+ // `prop` uniquely identifies one registered schema program-wide, so
1185
+ // guard on it instead of always paying the full dynamic dispatch
1186
+ // (durable-receiver check + ihash probe + schema-table scan).
1187
+ const guard = ctx.schema.guardedSlotOf(prop)
1188
+ return guard ? emitSchemaSlotGuarded(va, guard, slow, prop) : slow()
842
1189
  }
843
1190
  // Primitive receiver (number/boolean/bigint): no dynamic props — `(5).foo` is
844
1191
  // undefined. Without this the value falls to the __hash_get fallback, which
@@ -862,16 +1209,35 @@ export default (ctx) => {
862
1209
  // this program (features.* + hash-stdlib presence). ARRAY is always live; STRING and
863
1210
  // number are always dispatched. The __len disjunction collapses to whichever of
864
1211
  // ARRAY/TYPED/HASH/SET/MAP are reachable. STRING covers both heap and SSO via __str_len.
1212
+ // Can `.length` on an unproven receiver resolve to an ORDINARY own property?
1213
+ // Only when the program can hold one: a registered object schema, or the
1214
+ // dyn-prop/hash machinery. Otherwise the fallback arm stays plain undefined
1215
+ // and __length pulls no dyn dispatcher. The dispatcher lives in
1216
+ // module/collection.js — gate on its registration too, so a build whose
1217
+ // autoload never pulled that module keeps the lean arm instead of
1218
+ // requesting an unregistered stdlib. Consulted at pull time (factory +
1219
+ // deps thunk), when schemas, includes, and module set are final — same
1220
+ // pattern as the old HASH-arm includes probe.
1221
+ const lengthNeedsDynArm = () =>
1222
+ ctx.core.stdlib['__dyn_get_expr_t_h'] != null &&
1223
+ (ctx.schema?.list?.length > 0 || ctx.core.includes.has('__hash_new') ||
1224
+ ctx.core.includes.has('__dyn_set') || ctx.core.includes.has('__hash_set'))
1225
+
865
1226
  ctx.core.stdlib['__length'] = () => {
866
1227
  const types = [PTR.ARRAY]
867
1228
  if (ctx.features.typedarray) types.push(PTR.TYPED)
868
- if (ctx.core.includes.has('__hash_new') || ctx.core.includes.has('__dyn_set') || ctx.core.includes.has('__hash_set'))
869
- types.push(PTR.HASH)
870
- if (ctx.features.set) types.push(PTR.SET)
871
- if (ctx.features.map) types.push(PTR.MAP)
872
1229
  const eqT = (n) => `(i32.eq (local.get $t) (i32.const ${n}))`
873
1230
  let disj = eqT(types[0])
874
1231
  for (let i = 1; i < types.length; i++) disj = `(i32.or ${disj} ${eqT(types[i])})`
1232
+ // Everything that is not a string/array/typed reads `length` as an ordinary
1233
+ // property: OBJECT schema slot, HASH key, sidecar — or undefined. (Set/Map
1234
+ // have NO .length in JS — their count is `.size`; the old HASH/SET/MAP
1235
+ // __len arms returned the entry count, which no JS engine does.) The dyn
1236
+ // dispatcher guards real-number receivers itself; gated to keep the lean
1237
+ // undefined arm in programs that can't hold such a property at all.
1238
+ const propArm = lengthNeedsDynArm()
1239
+ ? `(f64.reinterpret_i64 (call $__dyn_get_expr_t_h (local.get $v) (i64.const ${LENGTH_SSO_I64}) (local.get $t) (i32.const ${strHashLiteral('length')})))`
1240
+ : `(f64.const nan:${UNDEF_NAN})`
875
1241
  const lenArm = `(block (result f64)
876
1242
  (local.set $off (i32.wrap_i64 (i64.and (local.get $v) (i64.const ${LAYOUT.OFFSET_MASK}))))
877
1243
  (if (result f64) ${disj}
@@ -879,7 +1245,7 @@ export default (ctx) => {
879
1245
  (if (result f64) (i32.ge_u (local.get $off) (i32.const 8))
880
1246
  (then (f64.convert_i32_s (call $__len (local.get $v))))
881
1247
  (else (f64.const nan:${UNDEF_NAN}))))
882
- (else (f64.const nan:${UNDEF_NAN}))))`
1248
+ (else ${propArm})))`
883
1249
  const stringArm = `(if (result f64) (i32.eq (local.get $t) (i32.const ${PTR.STRING}))
884
1250
  (then (f64.convert_i32_s (call $__str_len (local.get $v))))
885
1251
  (else ${lenArm}))`
@@ -910,7 +1276,7 @@ export default (ctx) => {
910
1276
  return typed(['f64.convert_i32_s', ['call', '$__len', ['i64.reinterpret_f64', inner]]], 'f64')
911
1277
  }
912
1278
  const idx = ctx.schema.slotOf(obj, prop)
913
- if (idx >= 0) return emitSchemaSlotRead(emit(obj), idx)
1279
+ if (idx >= 0) return emitSchemaSlotRead(emit(obj), idx, ctx.schema.slotI32CertainAt?.(obj, prop))
914
1280
  }
915
1281
 
916
1282
  if (prop === 'length') {
@@ -950,6 +1316,10 @@ export default (ctx) => {
950
1316
  }
951
1317
  const rep = typeof obj === 'string' ? repOf(obj) : null
952
1318
  const vt = rep ? rep.val : valTypeOf(obj)
1319
+ // Proven OBJECT/HASH receiver: `.length` is an ordinary own property
1320
+ // (schema slot / hash key), never a builtin length — resolve statically
1321
+ // instead of paying __length's runtime dispatch.
1322
+ if (vt === VAL.OBJECT || vt === VAL.HASH) return emitPropAccess(emit(obj), obj, 'length')
953
1323
  const notString = vt == null && typeof obj === 'string' && lookupNotString(obj)
954
1324
  // jsstring carrier: keep the externref-typed IR so emitLengthAccess can
955
1325
  // dispatch to `wasm:js-string.length` instead of forcing through f64.
@@ -993,9 +1363,13 @@ export default (ctx) => {
993
1363
  // Module-registered property getter (.size, .byteLength, …). Methods sharing
994
1364
  // the bare-`.prop` table (`.values`, `.pop`, date getters) are untagged and
995
1365
  // fall through to a real property read — `m.values` reads the "values" field.
1366
+ // A PROVEN OBJECT/HASH receiver never has builtin accessors in JS — its
1367
+ // `.size`/`.byteLength` are ordinary own properties, so skip the getter and
1368
+ // fall through to the real property read below.
996
1369
  const propKey = `.${prop}`
997
1370
  const propEmitter = ctx.core.emit[propKey]
998
- if (propEmitter && ctx.core.getters.has(propKey)) return propEmitter(obj)
1371
+ if (propEmitter && ctx.core.getters.has(propKey) &&
1372
+ ptVt !== VAL.OBJECT && ptVt !== VAL.HASH) return propEmitter(obj)
999
1373
 
1000
1374
  return emitPropAccess(emit(obj), obj, prop)
1001
1375
  }
@@ -1006,12 +1380,15 @@ export default (ctx) => {
1006
1380
  // notNullish inlines an isNullish check — (i32.or (i64.eq X NULL)
1007
1381
  // (i64.eq X UNDEF)) — that duplicates its operand, so a tee'd
1008
1382
  // side-effecting value would run twice.
1383
+ // asF64 on the taken arm: the dispatched access may come back i32-narrowed
1384
+ // (an int-certain slot read at O0 keeps its raw i32), and the f64-typed if
1385
+ // fails validation ("type error in fallthru: expected f64, got i32").
1009
1386
  const optionalGuard = (t, va, thenIR) =>
1010
1387
  typed(['block', ['result', 'f64'],
1011
1388
  ['local.set', `$${t}`, va],
1012
1389
  ['if', ['result', 'f64'],
1013
1390
  notNullish(typed(['local.get', `$${t}`], 'f64')),
1014
- ['then', thenIR],
1391
+ ['then', asF64(thenIR)],
1015
1392
  ['else', ['f64.const', `nan:${UNDEF_NAN}`]]]], 'f64')
1016
1393
 
1017
1394
  // Receiver-evaluate-once: allocate a fresh hoist-temp `$t`, emit `value` into
@@ -1075,6 +1452,20 @@ export default (ctx) => {
1075
1452
 
1076
1453
  // Optional call: fn?.(...args) → null if fn is null, else call fn
1077
1454
  ctx.core.emit['?.()'] = (callee, ...args) => {
1455
+ // Statically-lifted func-prop callee: `p.step?.()` where prepare lifted
1456
+ // `p.step = arrow` into the named function `p$step`. Non-nullish by
1457
+ // construction, so the optional is moot — delegate to the full `()` dispatch
1458
+ // (direct call). Without this arm the dead-write-drop plan (which assumes
1459
+ // call sites lower to direct calls) drops the write while this emitter read
1460
+ // the never-written dyn table → undefined. multiProp (reassigned) slots stay
1461
+ // dynamic: their live value is the prop-global and may legitimately be nullish.
1462
+ if (Array.isArray(callee) && callee[0] === '.' && typeof callee[1] === 'string' && typeof callee[2] === 'string') {
1463
+ const base = ctx.scope.chain[callee[1]] || callee[1]
1464
+ if (ctx.func.names.has(`${base}$${callee[2]}`) && !ctx.func.multiProp.has(`${base}.${callee[2]}`)) {
1465
+ const callArgs = args.length === 0 ? null : args.length === 1 ? args[0] : [',', ...args]
1466
+ return asF64(ctx.core.emit['()'](callee, callArgs))
1467
+ }
1468
+ }
1078
1469
  // Method-reference callee: `recv.m(...)` or `recv?.m(...)` form. Methods are
1079
1470
  // statically registered emitters and aren't real closure values, so route them
1080
1471
  // as a direct method call. The outer optional short-circuits when the receiver