jz 0.7.0 → 0.8.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 (53) hide show
  1. package/README.md +37 -33
  2. package/bench/README.md +176 -73
  3. package/bench/bench.svg +58 -71
  4. package/cli.js +12 -5
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +1156 -984
  7. package/index.js +40 -9
  8. package/interop.js +193 -138
  9. package/layout.js +29 -18
  10. package/module/array.js +29 -73
  11. package/module/collection.js +83 -25
  12. package/module/console.js +1 -1
  13. package/module/core.js +161 -15
  14. package/module/json.js +3 -3
  15. package/module/math.js +26 -3
  16. package/module/number.js +247 -13
  17. package/module/object.js +11 -5
  18. package/module/regex.js +8 -7
  19. package/module/string.js +162 -156
  20. package/module/typedarray.js +169 -105
  21. package/package.json +7 -3
  22. package/src/abi/string.js +29 -29
  23. package/src/ast.js +19 -2
  24. package/src/compile/analyze.js +64 -2
  25. package/src/compile/cse-load.js +200 -0
  26. package/src/compile/emit-assign.js +73 -14
  27. package/src/compile/emit.js +253 -23
  28. package/src/compile/index.js +198 -61
  29. package/src/compile/loop-divmod.js +12 -58
  30. package/src/compile/loop-model.js +91 -0
  31. package/src/compile/loop-square.js +102 -0
  32. package/src/compile/narrow.js +169 -32
  33. package/src/compile/peel-stencil.js +18 -64
  34. package/src/compile/plan/common.js +29 -0
  35. package/src/compile/plan/index.js +4 -1
  36. package/src/compile/plan/inline.js +176 -21
  37. package/src/compile/plan/literals.js +93 -19
  38. package/src/ctx.js +51 -12
  39. package/src/helper-counters.js +137 -0
  40. package/src/ir.js +102 -13
  41. package/src/kind-traits.js +7 -3
  42. package/src/kind.js +14 -1
  43. package/src/op-policy.js +5 -2
  44. package/src/ops.js +119 -0
  45. package/src/optimize/index.js +960 -136
  46. package/src/optimize/recurse.js +182 -0
  47. package/src/optimize/vectorize.js +1292 -144
  48. package/src/prepare/index.js +11 -7
  49. package/src/reps.js +4 -1
  50. package/src/type.js +53 -45
  51. package/src/wat/assemble.js +92 -9
  52. package/src/widen.js +21 -0
  53. package/src/wat/optimize.js +0 -3938
@@ -12,7 +12,7 @@ import { emit, idx, deps, call } from '../src/bridge.js'
12
12
  import { valTypeOf } from '../src/kind.js'
13
13
  import { VAL, lookupValType } from '../src/reps.js'
14
14
  import { nanPrefixHex, TYPED_ELEM_NAMES, TYPED_ELEM_CODE, TYPED_ELEM_BIGINT_FLAG, encodeTypedElemAux } from '../layout.js'
15
- import { inc, PTR, LAYOUT, getter } from '../src/ctx.js'
15
+ import { inc, PTR, LAYOUT, registerGetter } from '../src/ctx.js'
16
16
 
17
17
  const _NAN_BITS = nanPrefixHex()
18
18
 
@@ -28,6 +28,12 @@ const STORE = [
28
28
  'i32.store8', 'i32.store8', 'i32.store16', 'i32.store16',
29
29
  'i32.store', 'i32.store', 'f32.store', 'f64.store',
30
30
  ]
31
+ // f64 value → this element's stored representation (paired with STORE). Signed
32
+ // kinds trunc_s, unsigned trunc_u, f32 demotes, f64 stores as-is (null = identity).
33
+ const FROM_F64 = [
34
+ 'i32.trunc_f64_s', 'i32.trunc_f64_u', 'i32.trunc_f64_s', 'i32.trunc_f64_u',
35
+ 'i32.trunc_f64_s', 'i32.trunc_f64_u', 'f32.demote_f64', null,
36
+ ]
31
37
 
32
38
  // SIMD: vector width per element type (elements per v128)
33
39
  const VEC_WIDTH = [16, 16, 8, 8, 4, 4, 4, 2] // 128 bits / element bits
@@ -202,10 +208,17 @@ export default (ctx) => {
202
208
  __to_buffer: ['__ptr_type', '__ptr_offset', '__ptr_aux', '__mkptr'],
203
209
  __typed_set_idx: ['__ptr_aux', '__ptr_offset'],
204
210
  __typed_get_idx: ['__ptr_aux', '__ptr_offset'],
205
- __typed_fill: ['__len', '__typed_set_idx'],
211
+ // __clamp_idx is body-called by every range op (fill/copyWithin/subarray/slice). It has NO
212
+ // other manual-dep edge in the whole stdlib, so it's reachable ONLY via resolveIncludes'
213
+ // auto-scan — which diverges under self-host (jz.wasm), dropping it ("Unknown func
214
+ // $__clamp_idx" on typed .fill/.subarray in the kernel). Declare it manually here so the
215
+ // reliable dep path includes it. Pinned by test/selfhost-includes.js.
216
+ __typed_fill: ['__len', '__typed_set_idx', '__clamp_idx'],
206
217
  __typed_reverse: ['__len', '__typed_get_idx', '__typed_set_idx'],
207
- __typed_copyWithin: ['__len', '__typed_get_idx', '__typed_set_idx'],
218
+ __typed_copyWithin: ['__len', '__typed_get_idx', '__typed_set_idx', '__clamp_idx'],
208
219
  __typed_sort: ['__len', '__typed_get_idx', '__typed_set_idx'],
220
+ __subarray: ['__ptr_aux', '__ptr_offset', '__typed_shift', '__typed_data', '__len', '__mkptr', '__alloc', '__clamp_idx'],
221
+ __typed_slice_rt: ['__ptr_aux', '__typed_shift', '__typed_data', '__len', '__mkptr', '__alloc_hdr_n', '__clamp_idx'],
209
222
  // __str_join uses __typed_idx when typedarray is loaded (plain arrays promoted to
210
223
  // Int32Array by promoteIntArrayLiterals can produce PTR.TYPED results via .map()).
211
224
  __str_join: [...(ctx.core.stdlibDeps.__str_join ?? []), '__typed_idx'],
@@ -266,6 +279,60 @@ export default (ctx) => {
266
279
  (i32.load (i32.add (local.get $off) (i32.const 8)))))
267
280
  (else (call $__mkptr (i32.const ${PTR.BUFFER}) (i32.const 0) (local.get $off)))))))`
268
281
 
282
+ // __subarray(ptr, begin, end, useEnd) — runtime-dispatched `.subarray(...)` for
283
+ // when the receiver's elem type / view-ness isn't statically known (a binding
284
+ // reassigned owned→view: `let c = new T(d); c = c.subarray(n)`). Reads elem type
285
+ // and the view bit off the aux byte, so it is correct whether `ptr` is an owned
286
+ // typed array or an existing view. Mirrors the static `.typed:subarray` emitter:
287
+ // builds the 16-byte descriptor [byteLen][dataOff][rootOff] and tags TYPED|view.
288
+ // Cold path (slicing, not per-element) — correctness over speed.
289
+ ctx.core.stdlib['__subarray'] = `(func $__subarray (param $ptr i64) (param $begin i32) (param $end i32) (param $useEnd i32) (result f64)
290
+ (local $aux i32) (local $shift i32) (local $off i32) (local $data i32) (local $root i32)
291
+ (local $len i32) (local $lo i32) (local $hi i32) (local $n i32) (local $desc i32)
292
+ (local.set $aux (call $__ptr_aux (local.get $ptr)))
293
+ (local.set $shift (call $__typed_shift (i32.and (local.get $aux) (i32.const 7))))
294
+ (local.set $off (call $__ptr_offset (local.get $ptr)))
295
+ (local.set $data (call $__typed_data (local.get $ptr)))
296
+ (local.set $root
297
+ (if (result i32) (i32.and (local.get $aux) (i32.const 8))
298
+ (then (i32.load (i32.add (local.get $off) (i32.const 8))))
299
+ (else (local.get $off))))
300
+ (local.set $len (call $__len (local.get $ptr)))
301
+ (local.set $lo (call $__clamp_idx (local.get $begin) (local.get $len)))
302
+ (if (local.get $useEnd)
303
+ (then
304
+ (local.set $hi (call $__clamp_idx (local.get $end) (local.get $len))))
305
+ (else (local.set $hi (local.get $len))))
306
+ (local.set $n (select (i32.sub (local.get $hi) (local.get $lo)) (i32.const 0) (i32.gt_s (local.get $hi) (local.get $lo))))
307
+ (local.set $desc (call $__alloc (i32.const 16)))
308
+ (i32.store (local.get $desc) (i32.shl (local.get $n) (local.get $shift)))
309
+ (i32.store (i32.add (local.get $desc) (i32.const 4)) (i32.add (local.get $data) (i32.shl (local.get $lo) (local.get $shift))))
310
+ (i32.store (i32.add (local.get $desc) (i32.const 8)) (local.get $root))
311
+ (call $__mkptr (i32.const ${PTR.TYPED}) (i32.or (local.get $aux) (i32.const 8)) (local.get $desc)))`
312
+
313
+ // __typed_slice_rt(ptr, begin, end, useEnd) — runtime-dispatched `.slice(...)` for
314
+ // a receiver whose elem type / view-ness isn't statically known. Unlike subarray
315
+ // this returns a fresh OWNED copy (bit-exact memory.copy, so bigint-safe too). Reads
316
+ // elem type + data address off the aux byte. Cold path — correctness over speed.
317
+ ctx.core.stdlib['__typed_slice_rt'] = `(func $__typed_slice_rt (param $ptr i64) (param $begin i32) (param $end i32) (param $useEnd i32) (result f64)
318
+ (local $aux i32) (local $et i32) (local $shift i32) (local $src i32)
319
+ (local $len i32) (local $lo i32) (local $hi i32) (local $n i32) (local $byteLen i32) (local $dst i32)
320
+ (local.set $aux (call $__ptr_aux (local.get $ptr)))
321
+ (local.set $et (i32.and (local.get $aux) (i32.const 7)))
322
+ (local.set $shift (call $__typed_shift (local.get $et)))
323
+ (local.set $src (call $__typed_data (local.get $ptr)))
324
+ (local.set $len (call $__len (local.get $ptr)))
325
+ (local.set $lo (call $__clamp_idx (local.get $begin) (local.get $len)))
326
+ (if (local.get $useEnd)
327
+ (then
328
+ (local.set $hi (call $__clamp_idx (local.get $end) (local.get $len))))
329
+ (else (local.set $hi (local.get $len))))
330
+ (local.set $n (select (i32.sub (local.get $hi) (local.get $lo)) (i32.const 0) (i32.gt_s (local.get $hi) (local.get $lo))))
331
+ (local.set $byteLen (i32.shl (local.get $n) (local.get $shift)))
332
+ (local.set $dst (call $__alloc_hdr_n (local.get $byteLen) (local.get $byteLen) (i32.const 1)))
333
+ (memory.copy (local.get $dst) (i32.add (local.get $src) (i32.shl (local.get $lo) (local.get $shift))) (local.get $byteLen))
334
+ (call $__mkptr (i32.const ${PTR.TYPED}) (i32.and (local.get $aux) (i32.const 23)) (local.get $dst)))`
335
+
269
336
  // Constructor: new Float64Array(len) | new F64Array(arr) | new F64Array(buf) | new F64Array(buf, off, len)
270
337
  for (const [name, elemType] of Object.entries(TYPED_ELEM_CODE)) {
271
338
  const aux = typedAux(name)
@@ -278,6 +345,7 @@ export default (ctx) => {
278
345
  // and tags the TYPED ptr with aux=elemType|8. Reads/writes alias the parent,
279
346
  // .buffer reconstructs the root BUFFER, .byteOffset = dataOff - parentOff.
280
347
  if (offsetExpr != null && lenExpr2 != null) {
348
+ ctx.features.typedView = true // subview aliases the parent buffer — SLP must not assume disjoint bases
281
349
  const src = temp('tvs')
282
350
  const parentOff = tempI32('tvp')
283
351
  const byteLen = tempI32('tvb')
@@ -303,9 +371,12 @@ export default (ctx) => {
303
371
  // TYPED retagged at the same offset — the byteLen header is shared with the parent.
304
372
  // __len(view) = byteLen >> shift computes elemCount for this view's elemType.
305
373
  if (srcType === VAL.BUFFER || srcType === VAL.TYPED) {
374
+ ctx.features.typedView = true // zero-copy reinterpret aliases the source — SLP must not pack across it
306
375
  return mkPtrIR(PTR.TYPED, aux, ['call', '$__ptr_offset', ['i64.reinterpret_f64', asF64(emit(lenExpr))]])
307
376
  }
308
377
  if (srcType == null && ctx.core.emit[`${name}.from`]) {
378
+ ctx.features.typedView = true // unknown arg: runtime may take the buffer/typed zero-copy-view branch
379
+
309
380
  // Runtime dispatch: number → allocate; array → copy elements; buffer/typed → zero-copy view.
310
381
  const src = temp('ts')
311
382
  const len = tempI32('tl')
@@ -410,7 +481,7 @@ export default (ctx) => {
410
481
  // .buffer — always aliased (zero-copy). BUFFER: passthrough.
411
482
  // Owned TYPED: retag as BUFFER at same offset — the byteLen header is shared.
412
483
  // TYPED view (incl. DataView): BUFFER at descriptor[8] (root parent data offset).
413
- ctx.core.emit['.buffer'] = getter((obj) => {
484
+ registerGetter('.buffer', (obj) => {
414
485
  if (typeof obj === 'string') {
415
486
  const ctor = ctx.types.typedElem?.get(obj)
416
487
  if (ctor === 'new.ArrayBuffer') return asF64(emit(obj))
@@ -430,7 +501,7 @@ export default (ctx) => {
430
501
 
431
502
  // .byteLength — BUFFER: raw __len. Owned TYPED: elemCount * stride.
432
503
  // View TYPED (incl. DataView): descriptor[0], via the __byte_length fallback.
433
- ctx.core.emit['.byteLength'] = getter((obj) => {
504
+ registerGetter('.byteLength', (obj) => {
434
505
  if (typeof obj === 'string') {
435
506
  const ctor = ctx.types.typedElem?.get(obj)
436
507
  if (ctor === 'new.ArrayBuffer') {
@@ -454,7 +525,7 @@ export default (ctx) => {
454
525
  })
455
526
 
456
527
  // .byteOffset — owned: 0. View: descriptor[4] - descriptor[8].
457
- ctx.core.emit['.byteOffset'] = getter((obj) => {
528
+ registerGetter('.byteOffset', (obj) => {
458
529
  if (typeof obj === 'string') {
459
530
  const ctor = ctx.types.typedElem?.get(obj)
460
531
  if (ctor?.endsWith('.view')) {
@@ -543,6 +614,10 @@ export default (ctx) => {
543
614
  // We treat `undefined`/`null`/`0`/`''` as falsy (BE) per ToBoolean.
544
615
  const staticLE = (node) => {
545
616
  if (node === undefined) return false // arg omitted → BE
617
+ // Prepare lowers a literal `true`/`false` endianness flag to `['bool', 0|1]`;
618
+ // fold it so `dv.getInt16(p, true)` is one native load (no per-access LE branch
619
+ // + dead byte-swap arm — the dominant overhead in DataView codec loops).
620
+ if (Array.isArray(node) && node[0] === 'bool') return !!node[1]
546
621
  if (Array.isArray(node) && node[0] == null) {
547
622
  // [] → undefined, [null, x] → literal x
548
623
  if (node.length === 1) return false
@@ -603,7 +678,18 @@ export default (ctx) => {
603
678
  (f64.reinterpret_i64 (i64.const ${_NAN_BITS}))
604
679
  (local.get $v)
605
680
  (f64.ne (local.get $v) (local.get $v))))`
606
- const canonNaN = (vIR) => { inc('__canon_nan'); return typed(['call', '$__canon_nan', vIR], 'f64') }
681
+ // Inline the NaN-canonicalization select rather than a call a DataView float
682
+ // read runs this per sample in codec loops, and the inlined `(select nan v (v≠v))`
683
+ // is the exact idiom the auto-vectorizer recognizes (vs an opaque call).
684
+ const canonNaN = (vIR) => {
685
+ const t = temp('cn')
686
+ return typed(['block', ['result', 'f64'],
687
+ ['local.set', `$${t}`, vIR],
688
+ ['select',
689
+ ['f64.reinterpret_i64', ['i64.const', _NAN_BITS]],
690
+ ['local.get', `$${t}`],
691
+ ['f64.ne', ['local.get', `$${t}`], ['local.get', `$${t}`]]]], 'f64')
692
+ }
607
693
 
608
694
  // ToIndex for DataView byte offsets (spec 25.1.2.x getViewValue/setViewValue):
609
695
  // ToIntegerOrInfinity then reject negatives and >2^53-1 with a RangeError. The
@@ -644,8 +730,25 @@ export default (ctx) => {
644
730
  const dvIndexChecked = (offNode, size, viewSize) => {
645
731
  ctx.runtime.throws = true
646
732
  const idxT = tempI32('dvb')
733
+ const offIR = emit(offNode)
734
+ // Fast path: a proven-i32 byte offset (a loop-advanced `p`, `i*2`, a `|0` value)
735
+ // carries no fraction/NaN/negative-from-ToNumber surprise, so it skips the f64
736
+ // ToIndex round-trip and the `__dv_index` CALL — the dominant per-sample cost in
737
+ // DataView-based codec loops. An i32 range (`<0`) + bounds (`>viewSize-size`)
738
+ // check is all the spec needs here; this lowers `dv.getInt16(p,true)` to one
739
+ // `i32.load16_s`, matching the explicit-typed-view path.
740
+ if (offIR.type === 'i32') {
741
+ return typed(['block', ['result', 'i32'],
742
+ ['local.set', `$${idxT}`, offIR],
743
+ ['if', ['i32.or',
744
+ ['i32.lt_s', ['local.get', `$${idxT}`], ['i32.const', 0]],
745
+ ['i32.gt_s', ['local.get', `$${idxT}`], ['i32.sub', viewSize, ['i32.const', size]]]],
746
+ ['then', ['throw', '$__jz_err', ['f64.const', 0]]]],
747
+ ['local.get', `$${idxT}`]], 'i32')
748
+ }
749
+ inc('__dv_index')
647
750
  return typed(['block', ['result', 'i32'],
648
- ['local.set', `$${idxT}`, dvIndex(offNode)],
751
+ ['local.set', `$${idxT}`, typed(['call', '$__dv_index', toNumF64(offNode, offIR)], 'i32')],
649
752
  ['if', ['i32.gt_s', ['local.get', `$${idxT}`], ['i32.sub', viewSize, ['i32.const', size]]],
650
753
  ['then', ['throw', '$__jz_err', ['f64.const', 0]]]],
651
754
  ['local.get', `$${idxT}`]], 'i32')
@@ -812,16 +915,11 @@ export default (ctx) => {
812
915
  len: ['i32.mul', ['local.get', `$${len}`], ['i32.const', stride]], stride: 1, tag: 'tf' })
813
916
  const t = out.local
814
917
  const id = ctx.func.uniq++
815
- const storeExpr = elemType === 7 ? ['f64.store',
816
- ['i32.add', ['local.get', `$${t}`], ['i32.mul', ['local.get', `$${i}`], ['i32.const', stride]]],
817
- ['f64.load', ['i32.add', ['local.get', `$${off}`], ['i32.shl', ['local.get', `$${i}`], ['i32.const', 3]]]]]
818
- : elemType === 6 ? ['f32.store',
819
- ['i32.add', ['local.get', `$${t}`], ['i32.mul', ['local.get', `$${i}`], ['i32.const', stride]]],
820
- ['f32.demote_f64', ['f64.load', ['i32.add', ['local.get', `$${off}`], ['i32.shl', ['local.get', `$${i}`], ['i32.const', 3]]]]]]
821
- : [store,
822
- ['i32.add', ['local.get', `$${t}`], ['i32.mul', ['local.get', `$${i}`], ['i32.const', stride]]],
823
- [(elemType & 1) ? 'i32.trunc_f64_u' : 'i32.trunc_f64_s',
824
- ['f64.load', ['i32.add', ['local.get', `$${off}`], ['i32.shl', ['local.get', `$${i}`], ['i32.const', 3]]]]]]
918
+ const conv = FROM_F64[elemType]
919
+ const srcF64 = ['f64.load', ['i32.add', ['local.get', `$${off}`], ['i32.shl', ['local.get', `$${i}`], ['i32.const', 3]]]]
920
+ const storeExpr = [store,
921
+ ['i32.add', ['local.get', `$${t}`], ['i32.mul', ['local.get', `$${i}`], ['i32.const', stride]]],
922
+ conv ? [conv, srcF64] : srcF64]
825
923
  return typed(['block', ['result', 'f64'],
826
924
  ['local.set', `$${srcL}`, asF64(emit(src))],
827
925
  ['local.set', `$${off}`, ['call', '$__ptr_offset', ['i64.reinterpret_f64', ['local.get', `$${srcL}`]]]],
@@ -859,7 +957,14 @@ export default (ctx) => {
859
957
  receiver = receiver[1][1]
860
958
  chainOutput = true
861
959
  }
862
- const ctor = typeof receiver === 'string' && ctx.types.typedElem?.get(receiver)
960
+ // Nested receiver `arr[i]` where `arr`'s elements are typed arrays of a known
961
+ // ctor (`Array.from(n, () => new Float32Array())` — codec channelData). The i-th
962
+ // element IS that owned typed array; emit(receiver) already loads its pointer, so
963
+ // the standard typedDataAddr path inlines `arr[i][j]` to a direct load/store.
964
+ const ctor = (Array.isArray(receiver) && receiver[0] === '[]' && receiver.length === 3 && typeof receiver[1] === 'string'
965
+ ? ctx.func.localReps?.get(receiver[1])?.arrayElemTypedCtor
966
+ : null)
967
+ || (typeof receiver === 'string' && ctx.types.typedElem?.get(receiver))
863
968
  if (!ctor) return null
864
969
  const isView = viewOutput || (!chainOutput && ctor.endsWith('.view'))
865
970
  const name = ctor.endsWith('.view') ? ctor.slice(4, -5) : ctor.slice(4)
@@ -887,65 +992,6 @@ export default (ctx) => {
887
992
 
888
993
  // Runtime-dispatch typed index: checks ptr_type + aux to load with correct stride.
889
994
  // For TYPED views (aux bit 3), $off indirects through descriptor[4] to real data.
890
- // Factory — collapses to ARRAY-only f64 indexing when no TYPED pointer can reach here.
891
- // Identical factory in array.js; whichever module loads last wins the registration.
892
- ctx.core.stdlib['__typed_idx'] = () => {
893
- if (!ctx.features.typedarray && !ctx.features.external) {
894
- return `(func $__typed_idx (param $ptr i64) (param $i i32) (result f64)
895
- (local $len i32)
896
- (local.set $len (call $__len (local.get $ptr)))
897
- (if (result f64)
898
- (i32.or
899
- (i32.lt_s (local.get $i) (i32.const 0))
900
- (i32.ge_u (local.get $i) (local.get $len)))
901
- (then (f64.const nan:${UNDEF_NAN}))
902
- (else (f64.load (i32.add (call $__ptr_offset (local.get $ptr)) (i32.shl (local.get $i) (i32.const 3)))))))`
903
- }
904
- return `(func $__typed_idx (param $ptr i64) (param $i i32) (result f64)
905
- (local $off i32) (local $et i32) (local $len i32) (local $aux i32)
906
- (local.set $off (call $__ptr_offset (local.get $ptr)))
907
- ;; ARRAY fast path: __ptr_offset already followed any forwarding — read header len + f64.load, no $__len call.
908
- (if (i32.and (i32.eq (call $__ptr_type (local.get $ptr)) (i32.const ${PTR.ARRAY})) (i32.ge_u (local.get $off) (i32.const 8)))
909
- (then (return (if (result f64)
910
- (i32.and (i32.ge_s (local.get $i) (i32.const 0)) (i32.lt_u (local.get $i) (i32.load (i32.sub (local.get $off) (i32.const 8)))))
911
- (then (f64.load (i32.add (local.get $off) (i32.shl (local.get $i) (i32.const 3)))))
912
- (else (f64.const nan:${UNDEF_NAN}))))))
913
- (local.set $aux (call $__ptr_aux (local.get $ptr)))
914
- (if
915
- (i32.and
916
- (i32.eq (call $__ptr_type (local.get $ptr)) (i32.const ${PTR.TYPED}))
917
- (i32.ne (i32.and (local.get $aux) (i32.const 8)) (i32.const 0)))
918
- (then (local.set $off (i32.load (i32.add (local.get $off) (i32.const 4))))))
919
- (local.set $len (call $__len (local.get $ptr)))
920
- (if (result f64)
921
- (i32.or
922
- (i32.lt_s (local.get $i) (i32.const 0))
923
- (i32.ge_u (local.get $i) (local.get $len)))
924
- (then (f64.const nan:${UNDEF_NAN}))
925
- (else
926
- (if (result f64) (i32.eq (call $__ptr_type (local.get $ptr)) (i32.const ${PTR.TYPED}))
927
- (then
928
- (local.set $et (i32.and (local.get $aux) (i32.const 7)))
929
- (if (result f64) (i32.ge_u (local.get $et) (i32.const 6))
930
- (then (if (result f64) (i32.eq (local.get $et) (i32.const 7))
931
- (then (if (result f64) (i32.and (local.get $aux) (i32.const ${TYPED_ELEM_BIGINT_FLAG}))
932
- (then (f64.reinterpret_i64 (i64.load (i32.add (local.get $off) (i32.shl (local.get $i) (i32.const 3))))))
933
- (else (f64.load (i32.add (local.get $off) (i32.shl (local.get $i) (i32.const 3)))))))
934
- (else (f64.promote_f32 (f32.load (i32.add (local.get $off) (i32.shl (local.get $i) (i32.const 2))))))))
935
- (else (if (result f64) (i32.ge_u (local.get $et) (i32.const 4))
936
- (then (if (result f64) (i32.and (local.get $et) (i32.const 1))
937
- (then (f64.convert_i32_u (i32.load (i32.add (local.get $off) (i32.shl (local.get $i) (i32.const 2))))))
938
- (else (f64.convert_i32_s (i32.load (i32.add (local.get $off) (i32.shl (local.get $i) (i32.const 2))))))))
939
- (else (if (result f64) (i32.ge_u (local.get $et) (i32.const 2))
940
- (then (if (result f64) (i32.and (local.get $et) (i32.const 1))
941
- (then (f64.convert_i32_u (i32.load16_u (i32.add (local.get $off) (i32.shl (local.get $i) (i32.const 1))))))
942
- (else (f64.convert_i32_s (i32.load16_s (i32.add (local.get $off) (i32.shl (local.get $i) (i32.const 1))))))))
943
- (else (if (result f64) (i32.and (local.get $et) (i32.const 1))
944
- (then (f64.convert_i32_u (i32.load8_u (i32.add (local.get $off) (local.get $i)))))
945
- (else (f64.convert_i32_s (i32.load8_s (i32.add (local.get $off) (local.get $i)))))))))))))
946
- (else (f64.load (i32.add (local.get $off) (i32.shl (local.get $i) (i32.const 3)))))))))`
947
- }
948
-
949
995
  ctx.core.stdlib['__typed_set_idx'] = `(func $__typed_set_idx (param $ptr i64) (param $i i32) (param $v f64) (result f64)
950
996
  (local $off i32) (local $aux i32) (local $et i32) (local $bits i32)
951
997
  (local.set $aux (call $__ptr_aux (local.get $ptr)))
@@ -982,12 +1028,8 @@ export default (ctx) => {
982
1028
  ctx.core.stdlib['__typed_fill'] = `(func $__typed_fill (param $ptr i64) (param $val f64) (param $start i32) (param $end i32) (result f64)
983
1029
  (local $len i32) (local $i i32)
984
1030
  (local.set $len (call $__len (local.get $ptr)))
985
- (if (i32.lt_s (local.get $start) (i32.const 0)) (then (local.set $start (i32.add (local.get $len) (local.get $start)))))
986
- (if (i32.lt_s (local.get $start) (i32.const 0)) (then (local.set $start (i32.const 0))))
987
- (if (i32.gt_s (local.get $start) (local.get $len)) (then (local.set $start (local.get $len))))
988
- (if (i32.lt_s (local.get $end) (i32.const 0)) (then (local.set $end (i32.add (local.get $len) (local.get $end)))))
989
- (if (i32.lt_s (local.get $end) (i32.const 0)) (then (local.set $end (i32.const 0))))
990
- (if (i32.gt_s (local.get $end) (local.get $len)) (then (local.set $end (local.get $len))))
1031
+ (local.set $start (call $__clamp_idx (local.get $start) (local.get $len)))
1032
+ (local.set $end (call $__clamp_idx (local.get $end) (local.get $len)))
991
1033
  (local.set $i (local.get $start))
992
1034
  (block $done (loop $fill
993
1035
  (br_if $done (i32.ge_s (local.get $i) (local.get $end)))
@@ -1047,15 +1089,9 @@ export default (ctx) => {
1047
1089
  ctx.core.stdlib['__typed_copyWithin'] = `(func $__typed_copyWithin (param $ptr i64) (param $target i32) (param $start i32) (param $end i32) (result f64)
1048
1090
  (local $len i32) (local $count i32) (local $k i32)
1049
1091
  (local.set $len (call $__len (local.get $ptr)))
1050
- (if (i32.lt_s (local.get $target) (i32.const 0)) (then (local.set $target (i32.add (local.get $len) (local.get $target)))))
1051
- (if (i32.lt_s (local.get $target) (i32.const 0)) (then (local.set $target (i32.const 0))))
1052
- (if (i32.gt_s (local.get $target) (local.get $len)) (then (local.set $target (local.get $len))))
1053
- (if (i32.lt_s (local.get $start) (i32.const 0)) (then (local.set $start (i32.add (local.get $len) (local.get $start)))))
1054
- (if (i32.lt_s (local.get $start) (i32.const 0)) (then (local.set $start (i32.const 0))))
1055
- (if (i32.gt_s (local.get $start) (local.get $len)) (then (local.set $start (local.get $len))))
1056
- (if (i32.lt_s (local.get $end) (i32.const 0)) (then (local.set $end (i32.add (local.get $len) (local.get $end)))))
1057
- (if (i32.lt_s (local.get $end) (i32.const 0)) (then (local.set $end (i32.const 0))))
1058
- (if (i32.gt_s (local.get $end) (local.get $len)) (then (local.set $end (local.get $len))))
1092
+ (local.set $target (call $__clamp_idx (local.get $target) (local.get $len)))
1093
+ (local.set $start (call $__clamp_idx (local.get $start) (local.get $len)))
1094
+ (local.set $end (call $__clamp_idx (local.get $end) (local.get $len)))
1059
1095
  (local.set $count (i32.sub (local.get $end) (local.get $start)))
1060
1096
  (if (i32.gt_s (local.get $count) (i32.sub (local.get $len) (local.get $target)))
1061
1097
  (then (local.set $count (i32.sub (local.get $len) (local.get $target)))))
@@ -1238,20 +1274,28 @@ export default (ctx) => {
1238
1274
  ['f32.store', off, ['f32.demote_f64', ['local.get', `$${vt}`]]],
1239
1275
  ['local.get', `$${vt}`]], void_ ? 'void' : 'f64') // Float32Array
1240
1276
  }
1241
- // Integer store: when the source is already i32-typed (bitwise ops, |0,
1242
- // typed-array load, known-i32 var), store it directly skip the f64
1243
- // detour that costs a sign branch + i64 trunc + i32 wrap on every write.
1244
- if (valIR.type === 'i32') {
1245
- const cheap = Array.isArray(valIR) &&
1246
- ((valIR[0] === 'local.get' && typeof valIR[1] === 'string') ||
1247
- (valIR[0] === 'i32.const' && (typeof valIR[1] === 'number' || typeof valIR[1] === 'string')))
1248
- if (void_ && cheap) return typed([STORE[et], off, valIR], 'void')
1277
+ // Integer store: when the source is already i32-typed (bitwise ops, |0, known-i32 var) —
1278
+ // OR an `f64.convert_i32_*` that peels back to i32 (an Int8/Uint8/Int16/… element READ
1279
+ // materialized as f64 by the universal value model) store the i32 low bits directly,
1280
+ // skipping the f64 detour that costs a sign branch + i64 trunc + i32 wrap on every write.
1281
+ // This eradicates the f64 round-trip on byte/typed-array TRANSFORMS — `out[i] = table[in[j]]`
1282
+ // and `dst[i] = src[j]` (base64, qoi, wav, blur) — where both sides are integer elements.
1283
+ // `store8/16` mask the low bits, so storing the convert's i32 source is bit-identical; the
1284
+ // non-void result reboxes that i32 to f64 (the assignment's RHS value, in element range here).
1285
+ const i32Backed = valIR.type === 'i32' ||
1286
+ (Array.isArray(valIR) && (valIR[0] === 'f64.convert_i32_s' || valIR[0] === 'f64.convert_i32_u'))
1287
+ if (i32Backed) {
1288
+ const vi = asI32(valIR)
1289
+ const cheap = Array.isArray(vi) &&
1290
+ ((vi[0] === 'local.get' && typeof vi[1] === 'string') ||
1291
+ (vi[0] === 'i32.const' && (typeof vi[1] === 'number' || typeof vi[1] === 'string')))
1292
+ if (void_ && cheap) return typed([STORE[et], off, vi], 'void')
1249
1293
  const v32 = tempI32('tw')
1250
1294
  return typed(void_ ? ['block',
1251
- ['local.set', `$${v32}`, valIR],
1295
+ ['local.set', `$${v32}`, vi],
1252
1296
  [STORE[et], off, ['local.get', `$${v32}`]]]
1253
1297
  : ['block', ['result', 'f64'],
1254
- ['local.set', `$${v32}`, valIR],
1298
+ ['local.set', `$${v32}`, vi],
1255
1299
  [STORE[et], off, ['local.get', `$${v32}`]],
1256
1300
  [(et & 1) ? 'f64.convert_i32_u' : 'f64.convert_i32_s', ['local.get', `$${v32}`]]], void_ ? 'void' : 'f64')
1257
1301
  }
@@ -1714,7 +1758,17 @@ export default (ctx) => {
1714
1758
  // Bulk copy via `memory.copy`.
1715
1759
  ctx.core.emit['.typed:slice'] = (arr, start, end) => {
1716
1760
  const r = resolveElem(arr)
1717
- if (!r || r.isBigInt) return null
1761
+ if (!r) {
1762
+ // Elem type / view-ness not statically known (owned→view reassigned binding).
1763
+ // Dispatch off the runtime aux byte instead of crashing on empty IR.
1764
+ inc('__typed_slice_rt')
1765
+ return typed(['call', '$__typed_slice_rt',
1766
+ ['i64.reinterpret_f64', asF64(emit(arr))],
1767
+ start == null ? ['i32.const', 0] : asI32(emit(start)),
1768
+ end == null ? ['i32.const', 0] : asI32(emit(end)),
1769
+ ['i32.const', end == null ? 0 : 1]], 'f64')
1770
+ }
1771
+ if (r.isBigInt) return null
1718
1772
  const { et, isView } = r
1719
1773
  const arrLoc = temp('tsa'), srcPtr = tempI32('tssp'), srcLen = tempI32('tssl')
1720
1774
  const lo = tempI32('tslo'), hi = tempI32('tshi'), n = tempI32('tsn')
@@ -1809,8 +1863,18 @@ export default (ctx) => {
1809
1863
  // NOT a copy). Builds the 16-byte descriptor [byteLen][dataOff][parentOff] and tags the
1810
1864
  // TYPED ptr with aux|view, exactly like new TypedArray(buffer, byteOffset, length).
1811
1865
  ctx.core.emit['.typed:subarray'] = (arr, begin, end) => {
1866
+ ctx.features.typedView = true // zero-copy view aliases the receiver — covers inline `a.subarray(1)[i]=…` the bound-decl path in analyze.js misses
1812
1867
  const r = resolveElem(arr)
1813
- if (!r) return null
1868
+ if (!r) {
1869
+ // Elem type / view-ness not statically known (owned→view reassigned binding).
1870
+ // Dispatch off the runtime aux byte instead of crashing on empty IR.
1871
+ inc('__subarray')
1872
+ return typed(['call', '$__subarray',
1873
+ ['i64.reinterpret_f64', asF64(emit(arr))],
1874
+ begin == null ? ['i32.const', 0] : asI32(emit(begin)),
1875
+ end == null ? ['i32.const', 0] : asI32(emit(end)),
1876
+ ['i32.const', end == null ? 0 : 1]], 'f64')
1877
+ }
1814
1878
  const { et, isView, isBigInt } = r
1815
1879
  const shift = SHIFT[et]
1816
1880
  const viewAux = et | 8 | (isBigInt ? 16 : 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jz",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Ahead-of-time compiler for the numeric core of JavaScript (DSP, audio, math, parsers) to lean GC-free WASM — valid jz is valid JS, no type annotations, no runtime.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -55,10 +55,14 @@
55
55
  "bench:size": "node scripts/bench-size.mjs",
56
56
  "bench:compile": "node scripts/bench-compile.mjs",
57
57
  "bench:startup": "node scripts/bench-startup.mjs",
58
+ "bench:self": "node scripts/bench-selfhost.mjs",
58
59
  "bench:readme": "node scripts/bench-readme.mjs",
60
+ "audit:as": "node scripts/audit-assemblyscript.mjs",
59
61
  "build": "node scripts/build-dist.mjs",
60
62
  "build:examples": "node examples/build.mjs all",
61
- "prepare": "npm run build"
63
+ "prepublishOnly": "npm test && npm run build && npm run test:self",
64
+ "prepare": "npm run build",
65
+ "audit:fixpoint": "node scripts/audit-fixpoint.mjs"
62
66
  },
63
67
  "repository": {
64
68
  "type": "git",
@@ -72,7 +76,7 @@
72
76
  "license": "MIT",
73
77
  "dependencies": {
74
78
  "subscript": "^10.4.17",
75
- "watr": "^4.6.10"
79
+ "watr": "^4.7.1"
76
80
  },
77
81
  "keywords": [
78
82
  "javascript",
package/src/abi/string.js CHANGED
@@ -56,7 +56,7 @@
56
56
  // take. Kept as a one-liner so each op reads as a single `call`.
57
57
  const ssoI64 = (sF64) => ['i64.reinterpret_f64', sF64]
58
58
 
59
- import { isReassigned } from '../ast.js'
59
+ import { isReassigned, isLeaf } from '../ast.js'
60
60
  import { LAYOUT, oobNanIR, ssoBitI64Hex } from '../../layout.js'
61
61
 
62
62
  /** Pre-shifted SSO discriminator — layout.js is cycle-free; memoized at first use. */
@@ -79,10 +79,6 @@ const allocLocalI32 = (ctx, tag) => {
79
79
  return name
80
80
  }
81
81
 
82
- const _isLeaf = (n) => Array.isArray(n) &&
83
- (n[0] === 'local.get' || n[0] === 'global.get' ||
84
- n[0] === 'i32.const' || n[0] === 'i64.const' ||
85
- n[0] === 'f64.const' || n[0] === 'f32.const')
86
82
 
87
83
  /** Per-use cheap form of `charCodeAt` against a pre-decomposed param receiver
88
84
  * (shape 1): a bounds check plus a 2-arm SSO/heap byte select reading the four
@@ -90,22 +86,29 @@ const _isLeaf = (n) => Array.isArray(n) &&
90
86
  * side-effecting `iI32` is spilled to a scratch local first — leaves are safe
91
87
  * to duplicate. `oobNan` picks the OOB contract / result type exactly as in
92
88
  * the generic path. */
93
- function emitDecompCharRead(dec, iI32, ctx, oobNan) {
89
+ function emitDecompCharRead(dec, iI32, ctx, oobNan, inBounds = false) {
94
90
  const rt = oobNan ? 'f64' : 'i32'
95
91
  let idx = iI32, spill = null
96
- if (!_isLeaf(iI32)) { spill = allocLocalI32(ctx, 'ci'); idx = ['local.get', `$${spill}`] }
97
- const ssoByteExpr = ['i32.and',
98
- ['i32.shr_u', ['local.get', `$${dec.base}`], ['i32.shl', idx, ['i32.const', 3]]],
99
- ['i32.const', 0xFF]]
92
+ if (!isLeaf(iI32)) { spill = allocLocalI32(ctx, 'ci'); idx = ['local.get', `$${spill}`] }
93
+ const ssoByteExpr = ['i32.wrap_i64', ['i64.and',
94
+ ['i64.shr_u', ['local.get', `$${dec.ptr64}`], ['i64.mul', ['i64.extend_i32_u', idx], ['i64.const', 7]]],
95
+ ['i64.const', '0x7f']]]
100
96
  const heapByteExpr = ['i32.load8_u', ['i32.add', ['local.get', `$${dec.loadbase}`], idx]]
101
97
  const ccByte = ['if', ['result', 'i32'],
102
98
  ['local.get', `$${dec.sso}`],
103
99
  ['then', ssoByteExpr],
104
100
  ['else', heapByteExpr]]
105
- const use = ['if', ['result', rt],
106
- ['i32.ge_u', idx, ['local.get', `$${dec.len}`]],
107
- ['then', oobNan ? oobNanIR() : ['i32.const', 0]],
108
- ['else', oobNan ? ['f64.convert_i32_u', ccByte] : ccByte]]
101
+ // `inBounds`: the index is proven in [0, len) by an enclosing canonical scan
102
+ // (analyze.js inBoundsCharCodeAt / splitCharScanLoops' in-bounds main loop), so
103
+ // the OOB arm is dead — drop the per-char `i >= len` compare. This is what turns
104
+ // a split char-scan loop into a bare load (the `if(sso)` arm is loop-invariant and
105
+ // V8-folds), matching AS/native on tokenizer-shape scans. Otherwise keep the guard.
106
+ const use = inBounds
107
+ ? (oobNan ? ['f64.convert_i32_u', ccByte] : ccByte)
108
+ : ['if', ['result', rt],
109
+ ['i32.ge_u', idx, ['local.get', `$${dec.len}`]],
110
+ ['then', oobNan ? oobNanIR() : ['i32.const', 0]],
111
+ ['else', oobNan ? ['f64.convert_i32_u', ccByte] : ccByte]]
109
112
  return spill
110
113
  ? ['block', ['result', rt], ['local.set', `$${spill}`, iI32], use]
111
114
  : use
@@ -131,9 +134,8 @@ export function emitCharDecompPrologue(dec) {
131
134
  ['i64.const', 0]]
132
135
  const offMask = `0x${LAYOUT.OFFSET_MASK.toString(16).toUpperCase()}`
133
136
  const off = ['i32.wrap_i64', ['i64.and', ptr, ['i64.const', offMask]]]
134
- const ssoLen = ['i32.and',
135
- ['i32.wrap_i64', ['i64.shr_u', ptr, ['i64.const', LAYOUT.AUX_SHIFT]]],
136
- ['i32.const', LAYOUT.SSO_BIT - 1]]
137
+ // SSO length lives at payload bits 42-44 (7-bit-codec layout; see module/string.js).
138
+ const ssoLen = ['i32.wrap_i64', ['i64.and', ['i64.shr_u', ptr, ['i64.const', 42]], ['i64.const', 7]]]
137
139
  // Heap length is `i32.load(off - 4)`; guard against off<4 (corrupt/non-string
138
140
  // payload) so the load doesn't trap on a wrapped-negative address.
139
141
  const heapLen = ['if', ['result', 'i32'],
@@ -146,6 +148,7 @@ export function emitCharDecompPrologue(dec) {
146
148
  ['then',
147
149
  ['local.set', `$${dec.sso}`, ['i32.const', 1]],
148
150
  ['local.set', `$${dec.base}`, off],
151
+ ['local.set', `$${dec.ptr64}`, ptr], // full payload: SSO chars are 7-bit, span into aux
149
152
  ['local.set', `$${dec.len}`, ssoLen],
150
153
  // SSO: route every per-iter load to address 0 (always valid, byte
151
154
  // discarded by the outer select).
@@ -212,7 +215,7 @@ export const sso = {
212
215
  * `src/compile.js#emitFunc` — it drains `ctx.func.charDecomp` after the
213
216
  * body emit completes and splices an init block between the boxed-param
214
217
  * inits and the user statements. */
215
- charCodeAt: (sF64, iI32, ctx, oobNan = false) => {
218
+ charCodeAt: (sF64, iI32, ctx, oobNan = false, inBounds = false) => {
216
219
  // `oobNan` selects the out-of-bounds semantics and result type:
217
220
  // - false (default): OOB → `i32.const 0`, result i32 — the raw-byte
218
221
  // primitive used by the `buf += s[i]` append-byte fast path.
@@ -256,14 +259,16 @@ export const sso = {
256
259
  // discards it). Pre-computing it in the prologue removes a
257
260
  // per-iter `select` and lets V8 fold the add into the load.
258
261
  const loadbase = `${name}$ccldb`
262
+ const ptr64 = `${name}$ccp64`
259
263
  ctx.func.locals.set(base, 'i32')
260
264
  ctx.func.locals.set(len, 'i32')
261
265
  ctx.func.locals.set(sso, 'i32')
262
266
  ctx.func.locals.set(loadbase, 'i32')
263
- dec = { base, len, sso, loadbase, param: name }
267
+ ctx.func.locals.set(ptr64, 'i64') // full SSO payload for 7-bit char extraction
268
+ dec = { base, len, sso, loadbase, ptr64, param: name }
264
269
  ctx.func.charDecomp.set(name, dec)
265
270
  }
266
- return emitDecompCharRead(dec, iI32, ctx, oobNan)
271
+ return emitDecompCharRead(dec, iI32, ctx, oobNan, inBounds)
267
272
  }
268
273
  }
269
274
 
@@ -277,9 +282,6 @@ export const sso = {
277
282
  // so we MUST spill anything that isn't side-effect-free to a local
278
283
  // before referencing it more than once. Leaves (`local.get`, `*.const`,
279
284
  // `global.get`) are safe to duplicate; everything else is spilled.
280
- const isLeaf = (n) => Array.isArray(n) &&
281
- (n[0] === 'local.get' || n[0] === 'global.get' ||
282
- n[0] === 'i32.const' || n[0] === 'i64.const' || n[0] === 'f64.const' || n[0] === 'f32.const')
283
285
  const sLeaf = isLeaf(sF64)
284
286
  const iLeaf = isLeaf(iI32)
285
287
  const ptrI64Expr = ssoI64(sF64)
@@ -301,12 +303,10 @@ export const sso = {
301
303
  }
302
304
  const offMask = `0x${LAYOUT.OFFSET_MASK.toString(16).toUpperCase()}`
303
305
  const offExpr = () => ['i32.wrap_i64', ['i64.and', getPtr(), ['i64.const', offMask]]]
304
- const ssoLen = ['i32.and',
305
- ['i32.wrap_i64', ['i64.shr_u', getPtr(), ['i64.const', LAYOUT.AUX_SHIFT]]],
306
- ['i32.const', LAYOUT.SSO_BIT - 1]]
307
- const ssoByte = ['i32.and',
308
- ['i32.shr_u', offExpr(), ['i32.mul', getIdx(), ['i32.const', 8]]],
309
- ['i32.const', 0xFF]]
306
+ const ssoLen = ['i32.wrap_i64', ['i64.and', ['i64.shr_u', getPtr(), ['i64.const', 42]], ['i64.const', 7]]]
307
+ const ssoByte = ['i32.wrap_i64', ['i64.and',
308
+ ['i64.shr_u', getPtr(), ['i64.mul', ['i64.extend_i32_u', getIdx()], ['i64.const', 7]]],
309
+ ['i64.const', '0x7f']]]
310
310
  const ssoBranch = ['if', ['result', rt],
311
311
  ['i32.ge_u', getIdx(), ssoLen],
312
312
  ['then', mkOob()],
package/src/ast.js CHANGED
@@ -66,6 +66,11 @@ export const isBlockBody = (body) =>
66
66
  export const isLiteralStr = idx => Array.isArray(idx) && idx[0] === 'str' && typeof idx[1] === 'string'
67
67
  export const isFuncRef = (node, funcNames) => typeof node === 'string' && funcNames.has(node)
68
68
 
69
+ /** A value-leaf IR instruction — `local.get`/`global.get`/any `*.const`. Cheap and
70
+ * side-effect-free, so safe to duplicate without spilling to a temp. The one
71
+ * source of truth for "is this trivially duplicatable" across ir/abi/optimize. */
72
+ export const isLeaf = n => Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'global.get' || n[0].endsWith('.const'))
73
+
69
74
  // === Assignment / reassignment ===
70
75
 
71
76
  /** Assignment operators — shared across analyze, plan, emit, abi. */
@@ -385,9 +390,21 @@ export function cloneNode(node) {
385
390
  /** Structural equality via JSON. AST nodes are JSON-serializable except i64.const
386
391
  * BigInt payloads (NaN-box prefixes — dcbb433 routes pointer offsets through boxed
387
392
  * forms); the replacer stringifies those as `<n>n` (cf. formatErrorNode in ctx.js). */
388
- export const bigintSafeKey = (_k, v) => typeof v === 'bigint' ? `${v}n` : v
393
+ // Replacer for structural node-equality / dedup keys. JSON.stringify is the fast
394
+ // path, but it silently collapses values it can't round-trip: bigint throws, and —
395
+ // the subtle one — Infinity / -Infinity / NaN ALL stringify to `null` while -0
396
+ // stringifies to `0`. Two nodes differing ONLY in such a constant then serialize
397
+ // identically and compare equal — an unsound merge (SLP packs `[Inf,-Inf]` as one
398
+ // splat lane; CSE/LICM dedups distinct invariants). Tag each so it round-trips with
399
+ // Object.is semantics.
400
+ export const stableKey = (_k, v) => {
401
+ if (typeof v === 'bigint') return `${v}n`
402
+ if (typeof v === 'number' && (v !== v || v === Infinity || v === -Infinity || Object.is(v, -0)))
403
+ return v !== v ? '#NaN' : v === Infinity ? '#Inf' : v === -Infinity ? '#-Inf' : '#-0'
404
+ return v
405
+ }
389
406
  export function nodeEqual(a, b) {
390
- return JSON.stringify(a, bigintSafeKey) === JSON.stringify(b, bigintSafeKey)
407
+ return JSON.stringify(a, stableKey) === JSON.stringify(b, stableKey)
391
408
  }
392
409
 
393
410
  /** Property entries of an object-literal AST node (`['{}', …]`). */