jz 0.8.0 → 0.9.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 (73) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6178
  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 +318 -43
  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 +1032 -145
  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 +428 -93
  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 +82 -11
  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 +1055 -70
  45. package/src/compile/index.js +277 -29
  46. package/src/compile/infer.js +42 -4
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/loop-recurrence.js +167 -0
  49. package/src/compile/narrow.js +555 -18
  50. package/src/compile/peel-stencil.js +5 -0
  51. package/src/compile/plan/index.js +45 -3
  52. package/src/compile/plan/inline.js +8 -1
  53. package/src/compile/plan/literals.js +39 -0
  54. package/src/compile/plan/scope.js +430 -23
  55. package/src/compile/program-facts.js +732 -38
  56. package/src/ctx.js +64 -9
  57. package/src/helper-counters.js +7 -1
  58. package/src/ir.js +113 -5
  59. package/src/kind-traits.js +66 -3
  60. package/src/kind.js +84 -12
  61. package/src/op-policy.js +7 -4
  62. package/src/optimize/index.js +1179 -704
  63. package/src/optimize/recurse.js +2 -2
  64. package/src/optimize/vectorize.js +982 -67
  65. package/src/prepare/index.js +809 -67
  66. package/src/prepare/math-kernel.js +331 -0
  67. package/src/prepare/pre-eval.js +714 -0
  68. package/src/snapshot.js +194 -0
  69. package/src/type.js +1170 -56
  70. package/src/wat/assemble.js +403 -65
  71. package/src/wat/codegen.js +74 -14
  72. package/transform.js +113 -4
  73. package/wasi.js +3 -0
package/module/array.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * @module array
9
9
  */
10
10
 
11
- import { typed, asF64, asI64, asI32, NULL_NAN, UNDEF_NAN, temp, tempI32, allocPtr, multiCount, arrayLoop, elemLoad, elemStore, truthyIR, extractF64Bits, appendStaticSlots, mkPtrIR, slotAddr, isLiteralStr, resolveValType, undefExpr, ptrTypeEq } from '../src/ir.js'
11
+ import { typed, asF64, asI64, asI32, NULL_NAN, UNDEF_NAN, temp, tempI32, allocPtr, multiCount, arrayLoop, elemLoad, elemStore, truthyIR, extractF64Bits, appendStaticSlots, mkPtrIR, slotAddr, isLiteralStr, resolveValType, undefExpr, ptrTypeEq, carrierF64, isPureIR } from '../src/ir.js'
12
12
  import { inBoundsArrIdx } from '../src/type.js'
13
13
  import { emit, spread, deps, idx as emitIndex } from '../src/bridge.js'
14
14
  import { valTypeOf } from '../src/kind.js'
@@ -17,7 +17,7 @@ import { staticPropertyKey, staticObjectProps, inlineArraySid, staticIndexKey, i
17
17
  import { VAL, lookupValType, lookupNotString, updateRep } from '../src/reps.js'
18
18
  import { structInline } from '../src/abi/index.js'
19
19
  import { ctx, inc, err, warnDeopt, PTR, LAYOUT, followForwardingWat } from '../src/ctx.js'
20
- import { strHashLiteral } from './collection.js'
20
+ import { strHashLiteral, dynPropsFilterSetIR, durableFwdLogIR } from './collection.js'
21
21
 
22
22
 
23
23
  /** Allocate ARRAY (type=1): header + n*8 data. Returns { local, setup, ptr } where local is data offset. */
@@ -42,7 +42,14 @@ export function staticArrayPtr(slots) {
42
42
  dv.setInt32(8, len, true); dv.setInt32(12, len, true) // off-8: len, off-4: cap (props word at 0..7 stays 0)
43
43
  for (let i = 0; i < 16; i++) ctx.runtime.data += String.fromCharCode(hdr[i])
44
44
  appendStaticSlots(slots)
45
- return mkPtrIR(PTR.ARRAY, 0, headerOff + 16)
45
+ const ptr = mkPtrIR(PTR.ARRAY, 0, headerOff + 16)
46
+ // Compile-time identity for the static base/len read fold (see the saArr tag in
47
+ // the '[]' handler + optimize's foldStaticConstArrayReads): a const global bound
48
+ // to this literal reads elements with literal base/len instead of the
49
+ // __ptr_offset call + header load.
50
+ ptr.staticOff = headerOff + 16
51
+ ptr.staticLen = len
52
+ return ptr
46
53
  }
47
54
 
48
55
  function hoistArrayValue(arr) {
@@ -152,7 +159,13 @@ function makeCallback(fn, argReps) {
152
159
  const result = emit(subst)
153
160
  // Preserve i32 result type so callers (truthyIR, etc.) can skip f64↔i32 round-trips.
154
161
  const ty = result.type === 'i32' ? 'i32' : 'f64'
155
- return typed(['block', ['result', ty], ...stmts, result], ty)
162
+ const wrapped = typed(['block', ['result', ty], ...stmts, result], ty)
163
+ // An i32 result carrying ptrKind is an UNBOXED POINTER (a narrowed-return
164
+ // callee: the caller must rebox via this metadata) — the block wrapper must
165
+ // carry it through or downstream asF64 numeric-converts the raw offset
166
+ // (map stored [1104,1128] instead of the objects a named ctor fn returned).
167
+ if (result.ptrKind != null) { wrapped.ptrKind = result.ptrKind; wrapped.ptrAux = result.ptrAux }
168
+ return wrapped
156
169
  },
157
170
  }
158
171
  }
@@ -198,26 +211,61 @@ const arrMethod = (name, nArgs = 0) => (...args) => {
198
211
  }
199
212
 
200
213
  const needsArrayDynMove = () => ctx.core.includes.has('__dyn_set')
214
+ // Whether durableFwdLogIR emits a real call (vs '' — see its own comment): only when
215
+ // __heap_reset exists (owned-memory builds; shared memory never declares it — core.js).
216
+ // Gates the deps() edge below the SAME way, so a shared-memory build (where core.js
217
+ // never registers __durable_fwd_log/__durable_fwd_heal) never requests a name nothing
218
+ // delivers — see collection.js's durableFwdLogIR comment for the full rationale.
219
+ const needsDurableFwdLog = () => ctx.scope.globals.has('__heap_reset')
220
+ // knownArray=true (__arr_grow_known): the raw offset + inline forwarding chase (see
221
+ // arrGrow below) calls $__ptr_offset_fwd directly, not the generic $__ptr_offset.
222
+ // '__durable_fwd_log' is an EXPLICIT edge (not left to the auto-dep scan): arrGrow's
223
+ // body always contains a durableFwdLogIR() call, but self-host's realize/regex-scan
224
+ // auto-deps path silently drops a helper reachable only that way (the exact
225
+ // "Unknown func $__clamp_idx" shape documented in test/selfhost-includes.js) — that
226
+ // test would fail (and the kernel would trap) without this line.
201
227
  const arrayGrowDeps = (knownArray = false) => () => [
202
- ...(knownArray ? [] : ['__ptr_type']),
203
- '__ptr_offset', '__alloc_hdr', '__mkptr',
228
+ ...(knownArray ? ['__ptr_offset_fwd'] : ['__ptr_type', '__ptr_offset']),
229
+ '__alloc_hdr', '__mkptr',
230
+ ...(needsDurableFwdLog() ? ['__durable_fwd_log'] : []),
204
231
  ...(needsArrayDynMove() ? ['__dyn_move'] : []),
205
232
  ]
206
233
 
234
+ // Marks an ARRAY header slot ($off-16) as "props live in the global __dyn_props
235
+ // table, not here" — written whenever a shift/grow migrates or rekeys an entry
236
+ // there. Any nonzero, non-HASH-tagged i64 works: __dyn_get_t_h / __dyn_set /
237
+ // __dyn_del's ARRAY arms already treat "off-16 nonzero and not HASH-tagged" as
238
+ // "fall through to the global hash" (their only fast-accept is a HASH tag; their
239
+ // only fast-reject is exact zero). -1 decodes to tag 15, which is never PTR.HASH.
240
+ // Without this, a shift migrates props to the global table leaving nonzero
241
+ // leftover header bytes (which happens to satisfy "not zero, so check global") —
242
+ // but a *subsequent grow* allocates a fresh, zeroed header block, erasing that
243
+ // accidental signal and making __dyn_get_t_h wrongly conclude "no props" without
244
+ // ever consulting the global table. Writing this sentinel explicitly (instead of
245
+ // relying on incidental nonzero garbage) closes that gap for both shift and grow.
246
+ const DYN_PROPS_GLOBAL_SENTINEL = '(i64.const -1)'
247
+
207
248
  // Arrays keep dynamic props in the global table because old/new array storage can
208
- // be forwarded. Relocate that entry when growth moves the backing store.
249
+ // be forwarded. Relocate that entry when growth moves the backing store; mark the
250
+ // new header so a later read/write/delete knows to consult the global table (the
251
+ // fresh __alloc_hdr block zeroes $newOff-16, which alone would read as "no props").
209
252
  const maybeDynMoveIR = () => needsArrayDynMove()
210
- ? '(call $__dyn_move (local.get $off) (local.get $newOff))'
253
+ ? `(if (i32.eq (call $__dyn_move (local.get $off) (local.get $newOff)) (i32.const 1))
254
+ (then (i64.store (i32.sub (local.get $newOff) (i32.const 16)) ${DYN_PROPS_GLOBAL_SENTINEL})))`
211
255
  : ''
212
256
 
213
257
  // Per-object propsPtr lives in the 16-byte header at $off-16. On grow we copy it
214
258
  // from old to new header (still HASH-tagged → unshifted ARRAY case). On shift we
215
259
  // migrate it to the global __dyn_props because the forwarding writes overwrite
216
- // the destination's $newOff-16 slot. The HASH-tag check rejects 0 (no props) and
217
- // forwarding garbage from a prior shift, so chained shift→grow is safe — the
218
- // global hash takes over and __dyn_move keeps it in sync.
260
+ // the destination's $newOff-16 slot headerPropsToGlobalIR marks that slot with
261
+ // DYN_PROPS_GLOBAL_SENTINEL so a later grow's fresh (zeroed) header doesn't lose
262
+ // the signal; maybeDynMoveIR marks it again on every subsequent grow/rekey.
219
263
  const headerPropsCopyIR = () => needsArrayDynMove() ? `
220
264
  (local.set $oldProps (f64.load (i32.sub (local.get $off) (i32.const 16))))
265
+ ;; strip the runtime-shadowed marker (collection.js bit0) — the relocated
266
+ ;; header is ephemeral, where markers are meaningless and an odd props
267
+ ;; offset would corrupt the sidecar probe
268
+ (local.set $oldProps (f64.reinterpret_i64 (i64.and (i64.reinterpret_f64 (local.get $oldProps)) (i64.const -2))))
221
269
  (if (i32.eq
222
270
  (i32.wrap_i64 (i64.and (i64.shr_u (i64.reinterpret_f64 (local.get $oldProps)) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK})))
223
271
  (i32.const ${PTR.HASH}))
@@ -227,6 +275,7 @@ const headerPropsToGlobalIR = () => needsArrayDynMove() ? `
227
275
  (if (i32.ge_u (local.get $off) (i32.const 16))
228
276
  (then
229
277
  (local.set $oldProps (f64.load (i32.sub (local.get $off) (i32.const 16))))
278
+ (local.set $oldProps (f64.reinterpret_i64 (i64.and (i64.reinterpret_f64 (local.get $oldProps)) (i64.const -2))))
230
279
  (if (i32.eq
231
280
  (i32.wrap_i64 (i64.and (i64.shr_u (i64.reinterpret_f64 (local.get $oldProps)) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK})))
232
281
  (i32.const ${PTR.HASH}))
@@ -238,7 +287,12 @@ const headerPropsToGlobalIR = () => needsArrayDynMove() ? `
238
287
  (i64.reinterpret_f64 (local.get $root))
239
288
  (i64.reinterpret_f64 (f64.convert_i32_s (local.get $newOff)))
240
289
  (i64.reinterpret_f64 (local.get $oldProps)))))
241
- (global.set $__dyn_props (local.get $root)))))) ` : ''
290
+ (global.set $__dyn_props (local.get $root))
291
+ ${dynPropsFilterSetIR('(local.get $newOff)')}
292
+ ;; for-in enum cache: sidecar props moved into the global table —
293
+ ;; enumeration state changed off the cache's key. Clear (see collection.js).
294
+ (global.set $__enumc_off (i32.const 0))
295
+ (i64.store (i32.sub (local.get $newOff) (i32.const 16)) ${DYN_PROPS_GLOBAL_SENTINEL}))))) ` : ''
242
296
 
243
297
  export default (ctx) => {
244
298
  deps({
@@ -247,9 +301,11 @@ export default (ctx) => {
247
301
  __arr_grow_known: arrayGrowDeps(true),
248
302
  __arr_shift: () => [
249
303
  '__ptr_offset',
304
+ ...(needsDurableFwdLog() ? ['__durable_fwd_log'] : []), // explicit edge — see arrayGrowDeps's comment
250
305
  ...(needsArrayDynMove() ? ['__dyn_move', '__hash_new', '__ihash_set_local'] : []),
251
306
  ],
252
307
  __arr_fill: ['__ptr_offset', '__clamp_idx'], // body-calls __clamp_idx; declare it (self-host auto-scan can't be relied on — see test/selfhost-includes.js)
308
+ __arr_copyWithin: ['__ptr_type', '__ptr_offset', '__clamp_idx'],
253
309
  __arr_set_idx_ptr: ['__arr_grow', '__ptr_offset'],
254
310
  __arr_push1: ['__arr_grow_known', '__ptr_offset_fwd'],
255
311
  __arr_set_length: ['__arr_grow_known', '__ptr_offset', '__ptr_type'],
@@ -270,8 +326,17 @@ export default (ctx) => {
270
326
 
271
327
  inc('__ptr_offset', '__ptr_type', '__len', '__set_len', '__typed_idx', '__is_truthy')
272
328
 
273
- // Array.isArray(x): check ptr_type === PTR.ARRAY
329
+ // Array.isArray(x): check ptr_type === PTR.ARRAY.
330
+ // Statically-known ARRAY values must answer from the FACT, not the carrier —
331
+ // a rep-narrowed array (raw base local, e.g. a slice() result) is not a
332
+ // NaN-box, so the runtime tag test would read a plain number and say false.
274
333
  ctx.core.emit['Array.isArray'] = (x) => {
334
+ const vt = valTypeOf(x)
335
+ if (vt === VAL.ARRAY) {
336
+ const v = emit(x)
337
+ return isPureIR(v) ? typed(['i32.const', 1], 'i32')
338
+ : typed(['block', ['result', 'i32'], ['drop', asF64(v)], ['i32.const', 1]], 'i32')
339
+ }
275
340
  const v = asF64(emit(x))
276
341
  const t = temp('t')
277
342
  return typed(['i32.and',
@@ -449,6 +514,11 @@ export default (ctx) => {
449
514
  // guard (non-array ptr → fresh 4-cap buffer) for untyped call sites; the `_known`
450
515
  // variant skips it for hot paths that already proved the receiver is an array.
451
516
  // Single source of truth: both stdlib entries share the grow/relocate tail.
517
+ // `_known` callers (all `deps()`-verified ARRAY-only: __arr_push1, __arr_set_length,
518
+ // the inline-len array store) already proved the tag, so it inlines the raw offset +
519
+ // forwarding chase (mirrors __arr_idx_known) instead of paying __ptr_offset's
520
+ // tag-extract + FORWARDING_MASK dispatch — ARRAY always needs the chase (it's
521
+ // forwarding-capable) but never needs the re-check this variant would otherwise repeat.
452
522
  const arrGrow = (name, defensive) => `(func $${name} (param $ptr i64) (param $minCap i32) (result f64)
453
523
  ${defensive ? '(local $t i32) ' : ''}(local $off i32) (local $oldCap i32) (local $newCap i32) (local $newOff i32) (local $len i32)
454
524
  ${needsArrayDynMove() ? '(local $oldProps f64)' : ''}
@@ -462,7 +532,8 @@ export default (ctx) => {
462
532
  (then
463
533
  (local.set $newCap (select (local.get $minCap) (i32.const 4) (i32.gt_s (local.get $minCap) (i32.const 4))))
464
534
  (local.set $newOff (call $__alloc_hdr (i32.const 0) (local.get $newCap)))
465
- (return (call $__mkptr (i32.const ${PTR.ARRAY}) (i32.const 0) (local.get $newOff)))))` : `(local.set $off (call $__ptr_offset (local.get $ptr)))`}
535
+ (return (call $__mkptr (i32.const ${PTR.ARRAY}) (i32.const 0) (local.get $newOff)))))` : `(local.set $off (i32.wrap_i64 (i64.and (local.get $ptr) (i64.const ${LAYOUT.OFFSET_MASK}))))
536
+ ${followForwardingWat('$off', { lowGuard: true })}`}
466
537
  (local.set $oldCap (i32.load (i32.sub (local.get $off) (i32.const 4))))
467
538
  (if (i32.ge_s (local.get $oldCap) (local.get $minCap))
468
539
  (then (return (f64.reinterpret_i64 (local.get $ptr)))))
@@ -475,6 +546,7 @@ export default (ctx) => {
475
546
  (memory.copy (local.get $newOff) (local.get $off) (i32.shl (local.get $len) (i32.const 3)))
476
547
  ${headerPropsCopyIR()}
477
548
  ${maybeDynMoveIR()}
549
+ ${durableFwdLogIR('off', 'newOff', 'len', 'oldCap')}
478
550
  (i32.store (i32.sub (local.get $off) (i32.const 8)) (local.get $newOff))
479
551
  (i32.store (i32.sub (local.get $off) (i32.const 4)) (i32.const -1))
480
552
  (call $__mkptr (i32.const ${PTR.ARRAY}) (i32.const 0) (local.get $newOff)))`
@@ -574,15 +646,28 @@ export default (ctx) => {
574
646
  // so `const x = [1, 2, 3]` is a data segment, not an alloc.
575
647
  if (ctx.func.atModuleScope && len >= 1 && !ctx.memory.shared) {
576
648
  // asF64 folds i32.const → f64.const literally, so int-literal arrays also qualify.
577
- const slots = elems.map(e => extractF64Bits(asF64(emit(e))))
578
- if (slots.every(b => b !== null)) return staticArrayPtr(slots)
649
+ // carrierF64: a bool literal folds to its TRUE/FALSE atom const — still
650
+ // static-extractable, and the element keeps boolean identity in the segment.
651
+ const vals = elems.map(e => carrierF64(e, emit(e)))
652
+ const slots = vals.map(v => extractF64Bits(v))
653
+ if (slots.every(b => b !== null)) {
654
+ const ptr = staticArrayPtr(slots)
655
+ // Every element a capture-free closure → tag the candidate set (funcIdx +
656
+ // uniform-ABI body name) so a `const ops = [...arrows]` indexed CALL can
657
+ // lower to a br_table of direct calls (emit.js tryConstFnArrayDispatch) —
658
+ // the AOT form of a polymorphic inline cache, with the generic
659
+ // call_indirect as the always-sound default arm.
660
+ if (vals.length && vals.every(v => v.closureBodyName != null && v.closureFuncIdx != null))
661
+ ptr.fnElements = vals.map(v => ({ idx: v.closureFuncIdx, name: v.closureBodyName }))
662
+ return ptr
663
+ }
579
664
  }
580
665
  // L3/'speed' bumps the cap floor (default 4) to skip more growth cycles.
581
666
  const minCap = Math.max(ctx.transform.optimize?.arrayMinCap | 0, 4)
582
667
  const a = allocArray(len, Math.max(len, minCap))
583
668
  const body = [...a.setup]
584
669
  for (let i = 0; i < len; i++)
585
- body.push(['f64.store', slotAddr(a.local, i), asF64(emit(elems[i]))])
670
+ body.push(['f64.store', slotAddr(a.local, i), carrierF64(elems[i], emit(elems[i]))])
586
671
  body.push(a.ptr)
587
672
  return typed(['block', ['result', 'f64'], ...body], 'f64')
588
673
  }
@@ -614,6 +699,14 @@ export default (ctx) => {
614
699
  // Emit-time rep seed on fresh hoist-temp `h` so the recursive emit
615
700
  // below (`ctx.core.emit['[]'](h, idx)`) takes the typed-arr fast path.
616
701
  if (vtArr) updateRep(h, { val: vtArr })
702
+ // Inline field-read receiver (`plan.tw[i]`): carry the schema slot's
703
+ // program-wide typed kind onto the temp — VAL.TYPED alone has no element
704
+ // width, so without the ctor the read decays to the dynamic path.
705
+ if (vtArr === VAL.TYPED && Array.isArray(arr) && (arr[0] === '.' || arr[0] === '?.') &&
706
+ typeof arr[1] === 'string' && typeof arr[2] === 'string' && ctx.schema?.slotTypedCtorAt) {
707
+ const fc = ctx.schema.slotTypedCtorAt(arr[1], arr[2])
708
+ if (fc) (ctx.types.typedElem ||= new Map()).set(h, fc)
709
+ }
617
710
  const setup = ['local.set', `$${h}`, asF64(emit(arr))]
618
711
  const result = ctx.core.emit['[]'](h, idx)
619
712
  return typed(['block', ['result', 'f64'], setup, asF64(result)], 'f64')
@@ -708,23 +801,34 @@ export default (ctx) => {
708
801
  }
709
802
  // HASH receiver with runtime string key: probe the HASH directly via
710
803
  // __hash_get_local. Mirrors the literal-key path above but defers the
711
- // hash computation to runtime. When the key's val-type is unknown at
712
- // compile time, gate on __is_str_key HASH has no numeric-key
713
- // semantics, so non-string keys return undef.
804
+ // hash computation to runtime. Non-string keys (known-numeric, or the
805
+ // runtime-dispatch else-arm) go through __dyn_get_expr, whose entry
806
+ // ToPropertyKey-normalizes `h[97]` reads the '97' slot the (also
807
+ // normalizing) write stored. A known HASH is never an array, so there is
808
+ // no hot array-index path to protect here.
714
809
  if (vt === VAL.HASH) {
715
810
  if (keyType === VAL.STRING) {
716
811
  inc('__hash_get_local')
717
812
  return typed(['f64.reinterpret_i64', ['call', '$__hash_get_local', ['i64.reinterpret_f64', ptrExpr], asI64(emit(idx))]], 'f64')
718
813
  }
719
814
  if (useRuntimeKeyDispatch) {
720
- inc('__hash_get_local', '__is_str_key')
815
+ inc('__hash_get_local', '__is_str_key', '__dyn_get_expr')
721
816
  const keyTmp = temp()
722
817
  return typed(['block', ['result', 'f64'],
723
818
  ['local.set', `$${keyTmp}`, asF64(emit(idx))],
724
819
  ['if', ['result', 'f64'], ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.get', `$${keyTmp}`]]],
725
820
  ['then', ['f64.reinterpret_i64', ['call', '$__hash_get_local', ['i64.reinterpret_f64', ptrExpr], ['i64.reinterpret_f64', ['local.get', `$${keyTmp}`]]]]],
726
- ['else', undefExpr()]]], 'f64')
821
+ ['else', ['f64.reinterpret_i64', ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ptrExpr], ['i64.reinterpret_f64', ['local.get', `$${keyTmp}`]]]]]]], 'f64')
727
822
  }
823
+ inc('__dyn_get_expr')
824
+ return typed(['f64.reinterpret_i64', ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ptrExpr], asI64(emit(idx))]], 'f64')
825
+ }
826
+ // OBJECT receiver with a non-string key: never an array element — route to the
827
+ // ToPropertyKey-normalizing dyn read (the WRITE side already goes to __dyn_set;
828
+ // see the numeric-index design note below, which stays scoped to UNKNOWN receivers).
829
+ if (vt === VAL.OBJECT && keyType !== VAL.STRING) {
830
+ inc('__dyn_get_expr')
831
+ return typed(['f64.reinterpret_i64', ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ptrExpr], asI64(emit(idx))]], 'f64')
728
832
  }
729
833
  // Known array → direct f64 element load, skip string check
730
834
  if (keyType === VAL.STRING)
@@ -785,14 +889,33 @@ export default (ctx) => {
785
889
  const idxProvenInBounds = keyIsNum
786
890
  && typeof arr === 'string' && typeof idx === 'string'
787
891
  && inBoundsArrIdx(ctx).has(arr + '\x00' + idx)
892
+ // Tag reads whose receiver folded to a compile-time constant box: when the
893
+ // decl registers the same bits as a STATIC array (ctx.scope.staticArrs) and
894
+ // the program never resizes/aliases the name, optimize's
895
+ // foldStaticConstArrayReads collapses base+len to literals (the decl's
896
+ // data-segment offset isn't known until module init emits, so emit can
897
+ // only tag — same phasing as the constFnArrays devirt).
898
+ const saTag = (node) => {
899
+ if (typeof arr !== 'string') return node
900
+ // Identity proof, either form: the receiver resolved to a DIRECT read of the
901
+ // module global `arr` (global names are unique — same name the decl registers),
902
+ // or it already folded to a constant box whose bits the decl records. A local
903
+ // shadowing `arr` emits a local.get receiver — neither form matches, no tag.
904
+ if (Array.isArray(ptrExpr) && ptrExpr[0] === 'global.get' && ptrExpr[1] === `$${arr}`) node.saArr = arr
905
+ else {
906
+ const bits = extractF64Bits(ptrExpr)
907
+ if (bits !== null) { node.saArr = arr; node.saBits = bits }
908
+ }
909
+ return node
910
+ }
788
911
  if (idxProvenInBounds) {
789
912
  // base local must be i32. Flat tee form so downstream peepholes can fold
790
913
  // `i32.wrap_i64 (i64.reinterpret_f64 (f64.load …))` → `i32.load …`
791
914
  // when this load feeds a ptrUnboxed OBJECT field.
792
915
  const baseI32 = tempI32('ab')
793
- return typed(ctx.abi.array.ops.load(
916
+ return typed(saTag(ctx.abi.array.ops.load(
794
917
  ['local.tee', `$${baseI32}`, arrBase()],
795
- vi), 'f64')
918
+ vi)), 'f64')
796
919
  }
797
920
  // Known plain array, numeric key, NOT proven in-bounds → inline bounds-checked
798
921
  // load: `idx < len ? load : undefined`. Same semantics as __arr_idx_known but
@@ -801,14 +924,14 @@ export default (ctx) => {
801
924
  // the check would read raw memory for OOB indices (e.g. `a[1]` on a length-1 array).
802
925
  if (keyIsNum) {
803
926
  const baseI32 = tempI32('ab'), idxI32 = tempI32('ai')
804
- return typed(['if', ['result', 'f64'],
927
+ return typed(saTag(['if', ['result', 'f64'],
805
928
  ['i32.lt_u',
806
929
  ['local.tee', `$${idxI32}`, vi],
807
930
  ['i32.load', ['i32.sub',
808
931
  ['local.tee', `$${baseI32}`, arrBase()],
809
932
  ['i32.const', 8]]]],
810
933
  ['then', ctx.abi.array.ops.load(['local.get', `$${baseI32}`], ['local.get', `$${idxI32}`])],
811
- ['else', undefExpr()]], 'f64')
934
+ ['else', undefExpr()]]), 'f64')
812
935
  }
813
936
  const baseTmp = temp()
814
937
  // Numeric key (literal or known-NUMBER name) → skip __is_str_key dispatch;
@@ -913,7 +1036,7 @@ export default (ctx) => {
913
1036
  const isGlobal = !box && ctx.scope.globals.has(arr) && !ctx.func.locals?.has(arr)
914
1037
  const readVar = box ? ['f64.load', ['local.get', `$${box}`]] : isGlobal ? ['global.get', `$${arr}`] : ['local.get', `$${arr}`]
915
1038
  const writeVar = v => box ? ['f64.store', ['local.get', `$${box}`], v] : isGlobal ? ['global.set', `$${arr}`, v] : ['local.set', `$${arr}`, v]
916
- const vv = asF64(emit(vals[0]))
1039
+ const vv = carrierF64(vals[0], emit(vals[0]))
917
1040
  const pushed = ['call', '$__arr_push1', ['i64.reinterpret_f64', readVar], vv]
918
1041
  if (void_) return typed(['block', writeVar(pushed)], 'void')
919
1042
  return typed(['block', ['result', 'f64'],
@@ -966,7 +1089,7 @@ export default (ctx) => {
966
1089
 
967
1090
  // Store each value and increment len
968
1091
  for (const val of vals) {
969
- const vv = asF64(emit(val))
1092
+ const vv = carrierF64(val, emit(val))
970
1093
  body.push(
971
1094
  ['f64.store',
972
1095
  ['i32.add', ['local.get', `$${pushBase}`], ['i32.shl', ['local.get', `$${len}`], ['i32.const', 3]]],
@@ -1019,6 +1142,21 @@ export default (ctx) => {
1019
1142
  ctx.core.emit['.shift'] = (arr) => (inc('__arr_shift'),
1020
1143
  typed(['call', '$__arr_shift', asI64(emit(arr))], 'f64'))
1021
1144
 
1145
+ // durableFwdLogIR on the off->newOff mark below: unlike grow (whose newOff is always
1146
+ // a FRESH allocation, unconditionally ephemeral whenever off reads durable), shift's
1147
+ // newOff is just off+8 — normally still in the SAME durability class as off, so a
1148
+ // shift of a durable array is ordinarily legitimate persistent state that must survive
1149
+ // `_clear`. It only crosses into ephemeral in the one-in-8-bytes edge case where off
1150
+ // sits exactly at __heap_reset-8, which durableFwdLogIR's two-sided check (source
1151
+ // durable AND target ephemeral) catches without misfiring on the common case.
1152
+ // rawOff's OWN path-compression rewrite (off->newOff becomes rawOff->newOff two lines
1153
+ // below the mark) needs no separate log call: if rawOff is durable, its header no
1154
+ // longer holds real (len, cap) at this point — it already holds a forward — so
1155
+ // whichever EARLIER relocation first turned rawOff from real data into a forward
1156
+ // record (the only place a durable rawOff's true pre-relocation state could still be
1157
+ // read) already logged it then, under its own off/newOff names. Healing restores
1158
+ // rawOff's header wholesale from that entry, independent of how many times its
1159
+ // forward target is rewritten afterward by path compression.
1022
1160
  ctx.core.stdlib['__arr_shift'] = () => `(func $__arr_shift (param $arr i64) (result f64)
1023
1161
  (local $rawOff i32) (local $off i32) (local $newOff i32) (local $len i32) (local $cap i32) (local $val f64)
1024
1162
  ${needsArrayDynMove() ? '(local $oldProps f64) (local $root f64)' : ''}
@@ -1039,8 +1177,11 @@ export default (ctx) => {
1039
1177
  (i32.store (i32.add (local.get $off) (i32.const 4))
1040
1178
  (select (i32.sub (local.get $cap) (i32.const 1)) (i32.const 0) (i32.gt_s (local.get $cap) (i32.const 0))))
1041
1179
  ${maybeDynMoveIR()}
1180
+ ${durableFwdLogIR('off', 'newOff', 'len', 'cap')}
1042
1181
  (i32.store (i32.sub (local.get $off) (i32.const 8)) (local.get $newOff))
1043
1182
  (i32.store (i32.sub (local.get $off) (i32.const 4)) (i32.const -1))
1183
+ ;; rawOff path-compression below needs no durableFwdLogIR call of its own —
1184
+ ;; see the comment above this function.
1044
1185
  (if (i32.and (i32.ne (local.get $rawOff) (local.get $off)) (i32.ge_u (local.get $rawOff) (i32.const 8)))
1045
1186
  (then
1046
1187
  (i32.store (i32.sub (local.get $rawOff) (i32.const 8)) (local.get $newOff))
@@ -1054,7 +1195,7 @@ export default (ctx) => {
1054
1195
  inc('__arr_fill')
1055
1196
  return typed(['call', '$__arr_fill',
1056
1197
  asI64(emit(arr)),
1057
- val == null ? undefExpr() : asF64(emit(val)),
1198
+ val == null ? undefExpr() : carrierF64(val, emit(val)),
1058
1199
  start == null ? ['i32.const', 0] : asI32(emit(start)),
1059
1200
  end == null ? ['i32.const', 0x7FFFFFFF] : asI32(emit(end))], 'f64')
1060
1201
  }
@@ -1139,9 +1280,36 @@ export default (ctx) => {
1139
1280
  return typed(['block', ['result', 'f64'], ...body], 'f64')
1140
1281
  }
1141
1282
 
1142
- // .unshift(val) → prepend element, shift existing right
1143
- ctx.core.emit['.unshift'] = (arr, val) => (inc('__arr_unshift'),
1144
- typed(['call', '$__arr_unshift', asI64(emit(arr)), asF64(emit(val))], 'f64'))
1283
+ // .unshift(...vals) → prepend elements, shift existing right. Multi-arg per ES:
1284
+ // `a.unshift(1, 2, 3)` yields [1, 2, 3, …existing] and returns the NEW LENGTH
1285
+ // (the helper's contract it mutates in place; a grow leaves a forwarding
1286
+ // pointer so the receiver box stays valid, no write-back). Args EVALUATE
1287
+ // left-to-right (spilled to temps in source order) but INSERT last-to-first
1288
+ // through the single-value helper so the block lands in argument order; the
1289
+ // receiver is evaluated ONCE. (The old emitter silently DROPPED every argument
1290
+ // past the first — in the self-host kernel that broke assemble.js's own
1291
+ // `inject.unshift(setBase, ...stores)`, the last byte-parity ordering
1292
+ // divergence.)
1293
+ ctx.core.emit['.unshift'] = (arr, ...rawVals) => {
1294
+ inc('__arr_unshift')
1295
+ // Flatten comma-grouped args: [',', v1, v2] → [v1, v2] (same unwrap as '{}')
1296
+ const vals = rawVals.length === 1 && Array.isArray(rawVals[0]) && rawVals[0][0] === ','
1297
+ ? rawVals[0].slice(1) : rawVals
1298
+ if (vals.length <= 1) {
1299
+ const val = vals[0]
1300
+ return typed(['call', '$__arr_unshift', asI64(emit(arr)), val === undefined ? undefExpr() : carrierF64(val, emit(val))], 'f64')
1301
+ }
1302
+ const recv = temp('usr')
1303
+ const temps = vals.map(() => temp('us'))
1304
+ const body = [
1305
+ ['local.set', `$${recv}`, asF64(emit(arr))],
1306
+ ...vals.map((v, i) => ['local.set', `$${temps[i]}`, carrierF64(v, emit(v))]),
1307
+ ]
1308
+ for (let i = vals.length - 1; i >= 1; i--)
1309
+ body.push(['drop', ['call', '$__arr_unshift', ['i64.reinterpret_f64', ['local.get', `$${recv}`]], ['local.get', `$${temps[i]}`]]])
1310
+ body.push(['call', '$__arr_unshift', ['i64.reinterpret_f64', ['local.get', `$${recv}`]], ['local.get', `$${temps[0]}`]])
1311
+ return typed(['block', ['result', 'f64'], ...body], 'f64')
1312
+ }
1145
1313
 
1146
1314
  ctx.core.stdlib['__arr_unshift'] = `(func $__arr_unshift (param $arr i64) (param $val f64) (result f64)
1147
1315
  (local $off i32) (local $len i32) (local $a f64)
@@ -1480,8 +1648,13 @@ export default (ctx) => {
1480
1648
  const recv = hoistArrayValue(arr)
1481
1649
  const acc = temp('ra')
1482
1650
  // reduce cb signature: (acc, item, idx). Item rep mirrors recv's elem val type.
1651
+ // A BIGINT init seeds the acc's kind: the fold can't silently change type
1652
+ // (mixed BigInt arithmetic rejects at compile), and without the seed the
1653
+ // acc param reads as unknown/number and trips the mix guard on jz's own
1654
+ // SWAR packing idiom (`arr.reduce((a, b, k) => a | (BigInt(b) << …), 0n)`).
1483
1655
  const reps = callbackArgReps(arr)
1484
- const cb = makeCallback(fn, [null, reps[0], { val: VAL.NUMBER }])
1656
+ const accRep = init !== undefined && valTypeOf(init) === VAL.BIGINT ? { val: VAL.BIGINT } : null
1657
+ const cb = makeCallback(fn, [accRep, reps[0], { val: VAL.NUMBER }])
1485
1658
  // No initial value: JS seeds the accumulator with element 0 and folds from
1486
1659
  // index 1 — NOT a 0 seed folded over every element. A 0 seed is invisible
1487
1660
  // for `+` (additive identity) but wrong for `*` (→0) and corrupts non-numeric
@@ -1562,8 +1735,10 @@ export default (ctx) => {
1562
1735
  }
1563
1736
 
1564
1737
  // .reverse() → in-place swap arr[i] ↔ arr[len-1-i], returns the array.
1565
- ctx.core.emit['.reverse'] = (arr) => {
1566
- const recv = hoistArrayValue(arr)
1738
+ // Reverse an array VALUE in place, returning it. `.reverse` mutates the
1739
+ // receiver (setup hoists it once); `.toReversed` (ES2023) reverses a fresh
1740
+ // __arr_from copy so the receiver is untouched.
1741
+ function emitArrayReverseInPlace(setup, value) {
1567
1742
  const arrTmp = temp('rv')
1568
1743
  const base = tempI32('rb')
1569
1744
  const len = tempI32('rl')
@@ -1578,8 +1753,8 @@ export default (ctx) => {
1578
1753
  const addr = (idxIR) => ['i32.add', ['local.get', `$${base}`], ['i32.shl', idxIR, ['i32.const', 3]]]
1579
1754
 
1580
1755
  return typed(['block', ['result', 'f64'],
1581
- recv.setup,
1582
- ['local.set', `$${arrTmp}`, recv.value],
1756
+ setup,
1757
+ ['local.set', `$${arrTmp}`, value],
1583
1758
  ['local.set', `$${base}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${arrTmp}`]]]],
1584
1759
  ['local.set', `$${len}`, ['i32.load', ['i32.sub', ['local.get', `$${base}`], ['i32.const', 8]]]],
1585
1760
  ['local.set', `$${i}`, ['i32.const', 0]],
@@ -1596,14 +1771,25 @@ export default (ctx) => {
1596
1771
  ['local.get', `$${arrTmp}`]
1597
1772
  ], 'f64')
1598
1773
  }
1774
+ ctx.core.emit['.reverse'] = (arr) => {
1775
+ const recv = hoistArrayValue(arr)
1776
+ return emitArrayReverseInPlace(recv.setup, recv.value)
1777
+ }
1778
+ ctx.core.emit['.toReversed'] = (arr) => {
1779
+ inc('__arr_from')
1780
+ return emitArrayReverseInPlace(['nop'], typed(['call', '$__arr_from', asI64(emit(arr))], 'f64'))
1781
+ }
1599
1782
 
1600
1783
  // Insertion sort — stable, in-place, O(n²). The comparator is called per
1601
1784
  // shift; positive return → swap. NaN returns become "no swap" via f64.gt's
1602
1785
  // IEEE 754 semantics (NaN compares false), matching the spec's NaN-as-0
1603
1786
  // behavior. When fn is omitted, elements are compared as strings via
1604
1787
  // __to_str → __str_cmp (byte-wise; NOT locale-aware).
1605
- ctx.core.emit['.sort'] = (arr, fn) => {
1606
- const recv = hoistArrayValue(arr)
1788
+ // Insertion-sort an array VALUE in place, returning it. `.sort` mutates the
1789
+ // receiver; `.toSorted` (ES2023) sorts a fresh __arr_from copy. Default (no
1790
+ // comparator) is lexicographic-string order per spec — NOT the numeric
1791
+ // default typed arrays use (see .typed:sort).
1792
+ function emitArraySortInPlace(setup, value, fn) {
1607
1793
  const arrTmp = temp('sr')
1608
1794
  const base = tempI32('sb')
1609
1795
  const len = tempI32('sl')
@@ -1617,6 +1803,9 @@ export default (ctx) => {
1617
1803
 
1618
1804
  let cmpExpr, cmpSetup
1619
1805
  if (fn == null) {
1806
+ // default comparator is ToString + byte compare — both live in the string
1807
+ // module, which an all-numeric program hasn't loaded (dangling inc otherwise)
1808
+ ctx.module.include('string')
1620
1809
  inc('__to_str', '__str_cmp')
1621
1810
  cmpExpr = (aIR, bIR) => typed(['f64.convert_i32_s',
1622
1811
  ['call', '$__str_cmp',
@@ -1640,9 +1829,9 @@ export default (ctx) => {
1640
1829
  const jPlus1 = ['i32.add', ['local.get', `$${j}`], ['i32.const', 1]]
1641
1830
 
1642
1831
  return typed(['block', ['result', 'f64'],
1643
- recv.setup,
1832
+ setup,
1644
1833
  cmpSetup,
1645
- ['local.set', `$${arrTmp}`, recv.value],
1834
+ ['local.set', `$${arrTmp}`, value],
1646
1835
  ['local.set', `$${base}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${arrTmp}`]]]],
1647
1836
  ['local.set', `$${len}`, ['i32.load', ['i32.sub', ['local.get', `$${base}`], ['i32.const', 8]]]],
1648
1837
 
@@ -1673,6 +1862,72 @@ export default (ctx) => {
1673
1862
  ['local.get', `$${arrTmp}`]
1674
1863
  ], 'f64')
1675
1864
  }
1865
+ ctx.core.emit['.sort'] = (arr, fn) => {
1866
+ const recv = hoistArrayValue(arr)
1867
+ return emitArraySortInPlace(recv.setup, recv.value, fn)
1868
+ }
1869
+ ctx.core.emit['.toSorted'] = (arr, fn) => {
1870
+ inc('__arr_from')
1871
+ return emitArraySortInPlace(['nop'], typed(['call', '$__arr_from', asI64(emit(arr))], 'f64'), fn)
1872
+ }
1873
+
1874
+ // .with(index, value) (ES2023) — a COPY with one element replaced. Negative
1875
+ // index counts from the end; an out-of-range index throws (RangeError in JS —
1876
+ // jz collapses Error subclasses to one generic throw, like .typed:with).
1877
+ ctx.core.emit['.with'] = (arr, index, value) => {
1878
+ inc('__arr_from', '__ptr_offset')
1879
+ ctx.runtime.throws = true
1880
+ const c = temp('awc'), base = tempI32('awb'), len = tempI32('awl'), idx = tempI32('awi')
1881
+ return typed(['block', ['result', 'f64'],
1882
+ ['local.set', `$${c}`, typed(['call', '$__arr_from', asI64(emit(arr))], 'f64')],
1883
+ ['local.set', `$${base}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${c}`]]]],
1884
+ ['local.set', `$${len}`, ['i32.load', ['i32.sub', ['local.get', `$${base}`], ['i32.const', 8]]]],
1885
+ ['local.set', `$${idx}`, asI32(emit(index))],
1886
+ ['if', ['i32.lt_s', ['local.get', `$${idx}`], ['i32.const', 0]],
1887
+ ['then', ['local.set', `$${idx}`, ['i32.add', ['local.get', `$${idx}`], ['local.get', `$${len}`]]]]],
1888
+ ['if', ['i32.or',
1889
+ ['i32.lt_s', ['local.get', `$${idx}`], ['i32.const', 0]],
1890
+ ['i32.ge_s', ['local.get', `$${idx}`], ['local.get', `$${len}`]]],
1891
+ ['then', ['throw', '$__jz_err', ['f64.const', 0]]]],
1892
+ ['f64.store',
1893
+ ['i32.add', ['local.get', `$${base}`], ['i32.shl', ['local.get', `$${idx}`], ['i32.const', 3]]],
1894
+ carrierF64(value, emit(value))],
1895
+ ['local.get', `$${c}`]], 'f64')
1896
+ }
1897
+
1898
+ // .copyWithin(target, start, end?) — in-place overlap-safe move, returns the
1899
+ // receiver. memory.copy is memmove-semantic (bulk-memory), so unlike the
1900
+ // element-kind-aware __typed_copyWithin, plain arrays need no direction loop.
1901
+ ctx.core.emit['.copyWithin'] = (arr, target, start, end) => {
1902
+ inc('__arr_copyWithin')
1903
+ return typed(['call', '$__arr_copyWithin', asI64(emit(arr)),
1904
+ target == null ? ['i32.const', 0] : asI32(emit(target)),
1905
+ start == null ? ['i32.const', 0] : asI32(emit(start)),
1906
+ end == null ? ['i32.const', 0x7FFFFFFF] : asI32(emit(end))], 'f64')
1907
+ }
1908
+ ctx.core.stdlib['__arr_copyWithin'] = `(func $__arr_copyWithin (param $arr i64) (param $target i32) (param $start i32) (param $end i32) (result f64)
1909
+ (local $off i32) (local $len i32) (local $count i32)
1910
+ (if (i32.eq (call $__ptr_type (local.get $arr)) (i32.const ${PTR.ARRAY}))
1911
+ (then
1912
+ (local.set $off (call $__ptr_offset (local.get $arr)))
1913
+ (local.set $len (i32.load (i32.sub (local.get $off) (i32.const 8))))
1914
+ (local.set $target (call $__clamp_idx (local.get $target) (local.get $len)))
1915
+ (local.set $start (call $__clamp_idx (local.get $start) (local.get $len)))
1916
+ (local.set $end (call $__clamp_idx (local.get $end) (local.get $len)))
1917
+ (local.set $count (i32.sub (local.get $end) (local.get $start)))
1918
+ (if (i32.gt_s (local.get $count) (i32.sub (local.get $len) (local.get $target)))
1919
+ (then (local.set $count (i32.sub (local.get $len) (local.get $target)))))
1920
+ (if (i32.gt_s (local.get $count) (i32.const 0))
1921
+ (then (memory.copy
1922
+ (i32.add (local.get $off) (i32.shl (local.get $target) (i32.const 3)))
1923
+ (i32.add (local.get $off) (i32.shl (local.get $start) (i32.const 3)))
1924
+ (i32.shl (local.get $count) (i32.const 3)))))))
1925
+ (f64.reinterpret_i64 (local.get $arr)))`
1926
+
1927
+ // Array.of(...items) — spec-identical to the array literal `[...items]`; the
1928
+ // `[` emitter already handles spread-tagged args and the static-data path.
1929
+ // (Distinct from `Array(n)`, which makes a length-n hole array.)
1930
+ ctx.core.emit['Array.of'] = (...items) => ctx.core.emit['['](...items)
1676
1931
 
1677
1932
  // Boxed pointer values (strings/objects/etc.) carry NaN payloads, and
1678
1933
  // f64.eq treats NaN as not-equal to anything — even bit-identical NaN —
@@ -1688,7 +1943,7 @@ export default (ctx) => {
1688
1943
 
1689
1944
  ctx.core.emit['.indexOf'] = (arr, val) => {
1690
1945
  const recv = hoistArrayValue(arr)
1691
- const vv = asF64(emit(val))
1946
+ const vv = carrierF64(val, emit(val))
1692
1947
  const eq = arrEqIR(val)
1693
1948
  const result = tempI32('ix')
1694
1949
  const exit = `$exit${ctx.func.uniq++}`
@@ -1705,7 +1960,7 @@ export default (ctx) => {
1705
1960
 
1706
1961
  ctx.core.emit['.includes'] = (arr, val) => {
1707
1962
  const recv = hoistArrayValue(arr)
1708
- const vv = asF64(emit(val))
1963
+ const vv = carrierF64(val, emit(val))
1709
1964
  const eq = arrEqIR(val)
1710
1965
  const result = tempI32('ic')
1711
1966
  const exit = `$exit${ctx.func.uniq++}`
@@ -1720,6 +1975,26 @@ export default (ctx) => {
1720
1975
  ['f64.convert_i32_s', ['local.get', `$${result}`]]], 'f64')
1721
1976
  }
1722
1977
 
1978
+ // Mirror of .indexOf scanning to the highest matching index — no early break, the last hit wins.
1979
+ // Registering it (alongside .string:lastIndexOf) is what lets lastIndexOf leave STRING_ONLY_METHODS:
1980
+ // an untyped receiver now forks string-vs-array at runtime instead of force-narrowing to string
1981
+ // (which returned -1 for every array). fromIndex is unsupported, matching .indexOf's array path.
1982
+ ctx.core.emit['.lastIndexOf'] = (arr, val) => {
1983
+ const recv = hoistArrayValue(arr)
1984
+ const vv = carrierF64(val, emit(val))
1985
+ const eq = arrEqIR(val)
1986
+ const result = tempI32('lx')
1987
+ const loop = arrayLoop(recv.value, (_ptr, _len, i, item) => [
1988
+ ['if', eq(item, vv),
1989
+ ['then', ['local.set', `$${result}`, ['local.get', `$${i}`]]]]
1990
+ ])
1991
+ return typed(['block', ['result', 'f64'],
1992
+ recv.setup,
1993
+ ['local.set', `$${result}`, ['i32.const', -1]],
1994
+ ...loop,
1995
+ ['f64.convert_i32_s', ['local.get', `$${result}`]]], 'f64')
1996
+ }
1997
+
1723
1998
  // .at(i) → array element with negative index support
1724
1999
  ctx.core.emit['.array:at'] = (arr, idx) => {
1725
2000
  const vt = typeof arr === 'string' ? lookupValType(arr) : valTypeOf(arr)