jz 0.4.0 → 0.5.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.
package/module/core.js CHANGED
@@ -9,25 +9,17 @@
9
9
  * @module core
10
10
  */
11
11
 
12
- import { typed, asF64, asI32, asI64, NULL_NAN, UNDEF_NAN, temp, usesDynProps, ptrOffsetIR, isNullish } from '../src/ir.js'
13
- import { emit } from '../src/emit.js'
14
- import { valTypeOf, lookupValType, VAL, T, repOf, updateRep, shapeOf } from '../src/analyze.js'
15
- import { err, inc, PTR, LAYOUT } from '../src/ctx.js'
12
+ import { typed, asF64, asI32, asI64, NULL_NAN, UNDEF_NAN, temp, usesDynProps, ptrOffsetIR, isNullish, valKindToPtr } from '../src/ir.js'
13
+ import { emit, buildArrayWithSpreads } from '../src/emit.js'
14
+ import { reconstructArgsWithSpreads } from '../src/ir.js'
15
+ import { valTypeOf, lookupValType, lookupNotString, VAL, T, repOf, updateRep, shapeOf, inlineArraySid } from '../src/analyze.js'
16
+ import { ctx, err, inc, PTR, LAYOUT } from '../src/ctx.js'
16
17
  import { initSchema } from './schema.js'
17
18
  import { strHashLiteral } from './collection.js'
18
19
 
19
20
  // Pre-shifted NaN prefix as a full i64 mask, for `(i64.const ${NAN_BITS})` use.
20
21
  const NAN_BITS = '0x' + LAYOUT.NAN_PREFIX_BITS.toString(16).toUpperCase().padStart(16, '0')
21
22
 
22
- const PTR_BY_VAL = {
23
- [VAL.ARRAY]: PTR.ARRAY,
24
- [VAL.OBJECT]: PTR.OBJECT,
25
- [VAL.TYPED]: PTR.TYPED,
26
- [VAL.SET]: PTR.SET,
27
- [VAL.MAP]: PTR.MAP,
28
- [VAL.CLOSURE]: PTR.CLOSURE,
29
- }
30
-
31
23
  export default (ctx) => {
32
24
  Object.assign(ctx.core.stdlibDeps, {
33
25
  __eq: ['__str_eq', '__ptr_type'],
@@ -54,7 +46,10 @@ export default (ctx) => {
54
46
  (local $fa f64) (local $fb f64) (local $ta i32) (local $tb i32)
55
47
  ;; Fast path: bit equality covers identical pointers AND interned/SSO strings (same content
56
48
  ;; → same bits). Failing universal-NaN test catches NaN===NaN→false. Saves the NaN-check
57
- ;; pair (4 f64.eq) on the hottest case in watr (op === 'literal-string').
49
+ ;; pair (4 f64.eq) on the hottest case in watr (op === 'literal-string'). A number-NaN is
50
+ ;; *only ever* the canonical NAN_BITS here: math ops canonicalize at the source (the
51
+ ;; canon helper in module/math.js), so a non-canonical 0xFFF8.. pattern can only be a
52
+ ;; negative BigInt carrier — bit-identical to itself and correctly equal.
58
53
  (if (result i32) (i64.eq (local.get $a) (local.get $b))
59
54
  (then (i64.ne (local.get $a) (i64.const ${NAN_BITS})))
60
55
  (else
@@ -155,6 +150,19 @@ export default (ctx) => {
155
150
  ctx.core.stdlib['__ptr_type'] = `(func $__ptr_type (param $ptr i64) (result i32)
156
151
  (i32.wrap_i64 (i64.and (i64.shr_u (local.get $ptr) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))`
157
152
 
153
+ // True iff a NaN-boxed value is a non-primitive (heap object) — tag is neither
154
+ // ATOM (null/undefined/boolean/symbol) nor STRING. A genuine f64 Number is
155
+ // never NaN-boxed, so `f64.eq(x,x)` holding proves it a primitive. Drives the
156
+ // ES `OrdinaryToPrimitive` method-fallback chain (src/ir.js toPrimitiveChain).
157
+ ctx.core.stdlib['__is_object'] = `(func $__is_object (param $p i64) (result i32)
158
+ (local $t i32)
159
+ (if (f64.eq (f64.reinterpret_i64 (local.get $p)) (f64.reinterpret_i64 (local.get $p)))
160
+ (then (return (i32.const 0))))
161
+ (local.set $t (call $__ptr_type (local.get $p)))
162
+ (i32.and
163
+ (i32.ne (local.get $t) (i32.const ${PTR.ATOM}))
164
+ (i32.ne (local.get $t) (i32.const ${PTR.STRING}))))`
165
+
158
166
  // === Bump allocator ===
159
167
 
160
168
  // Heap-base watermark: gates header-backed propsPtr fast paths so static-data
@@ -162,18 +170,38 @@ export default (ctx) => {
162
170
  // Updated by optimizeModule() when data segment exceeds 1024 bytes.
163
171
  ctx.scope.globals.set('__heap_start', '(global $__heap_start (mut i32) (i32.const 1024))')
164
172
 
173
+ // Shared memory keeps the heap pointer in linear memory (memory[1020]):
174
+ // wasm globals are per-instance, so threads sharing one memory must share one
175
+ // pointer cell. Non-shared memory (incl. alloc:false) uses the `$__heap`
176
+ // global — exported so the JS-side adapter (memory.String etc) bumps the same
177
+ // pointer. Storing it in memory would collide with the static data section
178
+ // whenever the data exceeds 1020 bytes.
165
179
  if (ctx.memory.shared) {
166
- // Shared memory: heap offset stored at memory[1020] (i32), just before heap start at 1024
180
+ // Heap offset stored at memory[1020] (i32), just before heap start at 1024.
181
+ // We still want to grow memory when running out of pages — without growth a
182
+ // host-run binary traps on the first allocation that exceeds the initial
183
+ // 64 KB. Use the same geometric-growth strategy as the owned-memory variant.
167
184
  ctx.core.stdlib['__alloc'] = `(func $__alloc (param $bytes i32) (result i32)
168
- (local $ptr i32)
185
+ (local $ptr i32) (local $next i32) (local $cur i32) (local $need i32)
169
186
  (local.set $ptr (i32.load (i32.const 1020)))
170
- (i32.store (i32.const 1020) (i32.and (i32.add (i32.add (local.get $ptr) (local.get $bytes)) (i32.const 7)) (i32.const -8)))
187
+ (local.set $next (i32.and (i32.add (i32.add (local.get $ptr) (local.get $bytes)) (i32.const 7)) (i32.const -8)))
188
+ (local.set $cur (i32.shl (memory.size) (i32.const 16)))
189
+ (if (i32.gt_u (local.get $next) (local.get $cur))
190
+ (then
191
+ (local.set $need (i32.shr_u (i32.add (i32.sub (local.get $next) (local.get $cur)) (i32.const 65535)) (i32.const 16)))
192
+ (if (i32.lt_u (local.get $need) (memory.size)) (then (local.set $need (memory.size))))
193
+ (if (i32.eq (memory.grow (local.get $need)) (i32.const -1))
194
+ (then (if (i32.eq (memory.grow
195
+ (i32.shr_u (i32.add (i32.sub (local.get $next) (local.get $cur)) (i32.const 65535)) (i32.const 16)))
196
+ (i32.const -1)) (then (unreachable)))))))
197
+ (i32.store (i32.const 1020) (local.get $next))
171
198
  (local.get $ptr))`
172
199
  ctx.core.stdlib['__clear'] = `(func $__clear
173
200
  (i32.store (i32.const 1020) (i32.const 1024)))`
174
201
  } else {
175
- // Own memory: heap offset in a global, auto-grow when needed
176
- ctx.scope.globals.set('__heap', '(global $__heap (mut i32) (i32.const 1024))')
202
+ // Own memory: heap offset in a global, auto-grow when needed. Exported so
203
+ // the JS-side adapter (alloc:false, no `_alloc` export) shares the pointer.
204
+ ctx.scope.globals.set('__heap', '(global $__heap (export "__heap") (mut i32) (i32.const 1024))')
177
205
  // Bump allocator with geometric growth. Growing one page at a time turns a
178
206
  // long-running embedding (e.g. watr called thousands of times) into O(n²) —
179
207
  // each memory.grow may relocate and copy the whole heap. So when we must
@@ -383,18 +411,39 @@ export default (ctx) => {
383
411
  * ARRAY/SET/MAP share a single layout: length is i32 at offset-8. We inline that load
384
412
  * directly instead of calling __len which re-dispatches on type. ptrOffsetIR handles
385
413
  * ARRAY forwarding (non-ARRAY skips the forwarding loop). TYPED has a variable-width
386
- * layout depending on the aux typed-element shift, so it still routes through __len. */
387
- function emitLengthAccess(va, vt) {
414
+ * layout depending on the aux typed-element shift, so it still routes through __len.
415
+ * `notString` (from rep.notString — write-shape evidence rules out primitive string)
416
+ * routes the otherwise-unknown case through __len directly, eliding the STRING arm
417
+ * of __length. __len returns 0 on tags it doesn't recognize, matching JS's
418
+ * `undefined` semantics on non-pointer .length (the binding writes through xs[i]
419
+ * / xs.length, so reaching .length with a non-pointer is unreachable in practice). */
420
+ function emitLengthAccess(va, vt, notString = false) {
421
+ // jsstring carrier: receiver is an externref slot (boundary param tagged
422
+ // `jsstring` by narrow.js phase J). Route to the `wasm:js-string` length
423
+ // builtin directly — no SSO unbox, zero copy.
424
+ if (va?.type === 'externref') {
425
+ ctx.core.jsstring.add('length')
426
+ return typed(['f64.convert_i32_s', ['call', '$__jss_length', va]], 'f64')
427
+ }
388
428
  if (vt === VAL.ARRAY || vt === VAL.SET || vt === VAL.MAP) {
389
429
  const off = ptrOffsetIR(va, vt)
390
430
  return typed(['f64.convert_i32_s', ['i32.load', ['i32.sub', off, ['i32.const', 8]]]], 'f64')
391
431
  }
392
432
  if (vt === VAL.TYPED)
393
433
  return typed(['f64.convert_i32_s', ['call', '$__len', ['i64.reinterpret_f64', va]]], 'f64')
394
- // Known string → byteLen (handles SSO + heap)
434
+ // Known string → byteLen via the active string rep. Pass the slot
435
+ // carrier (f64 under nanbox-sso) — the rep op handles internal
436
+ // reinterpret/wrap. The `?.` call site passes a bare `['local.get', $t]`
437
+ // without a `.type` tag, so coerce defensively to f64.
395
438
  if (vt === VAL.STRING) {
396
- inc('__str_byteLen')
397
- return typed(['f64.convert_i32_s', ['call', '$__str_byteLen', ['i64.reinterpret_f64', va]]], 'f64')
439
+ const f64Va = va?.type === 'f64' ? va : typed(va, 'f64')
440
+ return typed(['f64.convert_i32_s', ctx.abi.string.ops.byteLen(f64Va, ctx)], 'f64')
441
+ }
442
+ // Unknown but proven not-string → __len directly (skips the STRING arm of __length).
443
+ if (notString) {
444
+ inc('__len')
445
+ ctx.features.typedarray = true
446
+ return typed(['f64.convert_i32_s', ['call', '$__len', ['i64.reinterpret_f64', va]]], 'f64')
398
447
  }
399
448
  // Unknown → runtime dispatch via stdlib. Set/Map dispatch arms are pulled
400
449
  // only when user code actually constructs Set/Map (collection.js sets the
@@ -412,7 +461,7 @@ export default (ctx) => {
412
461
  // (read on the AST `.prop` node), not via tagging this IR node.
413
462
  function emitSchemaSlotRead(baseExpr, idx) {
414
463
  const base = baseExpr?.type === 'f64' ? baseExpr : typed(baseExpr, 'f64')
415
- return typed(['f64.load', ['i32.add', ptrOffsetIR(base, VAL.OBJECT), ['i32.const', idx * 8]]], 'f64')
464
+ return typed(ctx.abi.object.ops.load(ptrOffsetIR(base, VAL.OBJECT), idx), 'f64')
416
465
  }
417
466
 
418
467
  function emitHashGetLocalConst(base, key, prop) {
@@ -422,7 +471,7 @@ export default (ctx) => {
422
471
  }
423
472
 
424
473
  function emitTypeTag(receiver, vt) {
425
- const p = PTR_BY_VAL[vt]
474
+ const p = valKindToPtr(vt)
426
475
  if (p != null) return ['i32.const', p]
427
476
  inc('__ptr_type')
428
477
  return ['call', '$__ptr_type', receiver]
@@ -440,9 +489,15 @@ export default (ctx) => {
440
489
  return typed(['f64.reinterpret_i64', ['call', '$__dyn_get_expr_t', receiver, key, emitTypeTag(receiver, vt)]], 'f64')
441
490
  }
442
491
 
443
- function emitDynGetAnyTyped(base, key, vt) {
444
- inc('__dyn_get_any_t')
492
+ function emitDynGetAnyTyped(base, key, vt, prop) {
445
493
  const receiver = asI64(base?.type ? base : typed(base, 'f64'))
494
+ // Constant string key: fold the FNV hash at compile time and call the
495
+ // prehashed body — no __str_hash on every access (hot for `parse.step` etc).
496
+ if (typeof prop === 'string') {
497
+ inc('__dyn_get_any_t_h')
498
+ return typed(['f64.reinterpret_i64', ['call', '$__dyn_get_any_t_h', receiver, key, emitTypeTag(receiver, vt), ['i32.const', strHashLiteral(prop)]]], 'f64')
499
+ }
500
+ inc('__dyn_get_any_t')
446
501
  return typed(['f64.reinterpret_i64', ['call', '$__dyn_get_any_t', receiver, key, emitTypeTag(receiver, vt)]], 'f64')
447
502
  }
448
503
 
@@ -499,6 +554,15 @@ export default (ctx) => {
499
554
  // (`{...x}`) shift slot ordering and would need their own resolution.
500
555
  const slot = literalSlot(obj, prop)
501
556
  if (slot >= 0) return emitSchemaSlotRead(asF64(va), slot)
557
+ // Receiver IR is an unboxed OBJECT pointer carrying its own schema (a
558
+ // structInline element cell, a narrowed local): resolve the field's fixed
559
+ // slot directly from `ptrAux` — more precise than the structural
560
+ // `ctx.schema.find(null, …)` and never falls to the dyn dispatcher.
561
+ if (va?.ptrKind === VAL.OBJECT && va.ptrAux != null && typeof prop === 'string') {
562
+ const sch = ctx.schema.list[va.ptrAux]
563
+ const si = sch ? sch.indexOf(prop) : -1
564
+ if (si >= 0) return emitSchemaSlotRead(asF64(va), si)
565
+ }
502
566
  let schemaIdx = typeof obj === 'string' ? ctx.schema.find(obj, prop) : ctx.schema.find(null, prop)
503
567
  // Chain receiver (e.g. `o.meta.bias`): when the chain resolves to a known
504
568
  // OBJECT shape via JSON-shape propagation, the parent shape's `names`
@@ -506,7 +570,7 @@ export default (ctx) => {
506
570
  // ctx.schema.find(null, prop) when multiple registered schemas share a key.
507
571
  if (schemaIdx < 0 && typeof obj !== 'string') {
508
572
  const sh = shapeOf(obj)
509
- if ((sh?.vt === VAL.OBJECT || sh?.vt === VAL.HASH) && sh.names) {
573
+ if ((sh?.val === VAL.OBJECT || sh?.val === VAL.HASH) && sh.names) {
510
574
  const i = sh.names.indexOf(prop)
511
575
  if (i >= 0) schemaIdx = i
512
576
  }
@@ -532,7 +596,7 @@ export default (ctx) => {
532
596
  // Skip the external branch and dispatch through the typed HASH/OBJECT path.
533
597
  if (ctx.transform.host === 'wasi') return emitDynGetExprTyped(va, key, vt, prop)
534
598
  ctx.features.external = true
535
- return emitDynGetAnyTyped(va, key, vt)
599
+ return emitDynGetAnyTyped(va, key, vt, prop)
536
600
  }
537
601
  inc('__hash_get', '__str_hash', '__str_eq')
538
602
  return typed(['f64.reinterpret_i64', ['call', '$__hash_get', asI64(va), key]], 'f64')
@@ -585,6 +649,13 @@ export default (ctx) => {
585
649
  // === Property dispatch (.length, .prop) ===
586
650
 
587
651
  ctx.core.emit['.'] = (obj, prop) => {
652
+ // SRoA flat object: `o.prop` → `local.get $o#i` (analyze.js scanFlatObjects).
653
+ const flatR = typeof obj === 'string' ? ctx.func.flatObjects?.get(obj) : null
654
+ if (flatR) {
655
+ const fi = flatR.names.indexOf(prop)
656
+ if (fi >= 0) return typed(['local.get', `$${obj}#${fi}`], 'f64')
657
+ }
658
+
588
659
  // Boxed object: delegate .length and .prop to inner value or schema
589
660
  if (typeof obj === 'string' && ctx.schema.isBoxed(obj)) {
590
661
  if (prop === 'length') {
@@ -622,8 +693,59 @@ export default (ctx) => {
622
693
  if (Array.isArray(obj) && (obj[0] === 'str' || obj[0] == null) && typeof obj[1] === 'string') {
623
694
  return typed(['f64.const', Buffer.byteLength(obj[1], 'utf8')], 'f64')
624
695
  }
625
- const vt = typeof obj === 'string' ? repOf(obj)?.val : valTypeOf(obj)
626
- return emitLengthAccess(asF64(emit(obj)), vt)
696
+ // structInline Array<S>: the header `len` counts physical f64 cells (K
697
+ // per element), so the JS array length is `physicalLen / K`.
698
+ const inlSid = inlineArraySid(obj)
699
+ if (inlSid != null) {
700
+ const K = ctx.schema.list[inlSid].length
701
+ const physLen = ['i32.load', ['i32.sub', ptrOffsetIR(asF64(emit(obj)), VAL.ARRAY), ['i32.const', 8]]]
702
+ return typed(['f64.convert_i32_s', K > 1 ? ['i32.div_s', physLen, ['i32.const', K]] : physLen], 'f64')
703
+ }
704
+ const rep = typeof obj === 'string' ? repOf(obj) : null
705
+ const vt = rep ? rep.val : valTypeOf(obj)
706
+ const notString = vt == null && typeof obj === 'string' && lookupNotString(obj)
707
+ // jsstring carrier: keep the externref-typed IR so emitLengthAccess can
708
+ // dispatch to `wasm:js-string.length` instead of forcing through f64.
709
+ const recv = emit(obj)
710
+ if (recv?.type === 'externref') return emitLengthAccess(recv, vt, notString)
711
+ return emitLengthAccess(asF64(recv), vt, notString)
712
+ }
713
+
714
+ // Type-specific property emitter (`.regex:source`, …) — the property-read
715
+ // mirror of the `.vt:method` method-dispatch table. Property emitters take
716
+ // a single arg (the receiver); arity ≥ 2 entries are method emitters and
717
+ // must not be routed here (reading `re.test` as a value is not a call).
718
+ const ptRep = typeof obj === 'string' ? repOf(obj) : null
719
+ const ptVt = ptRep ? ptRep.val : valTypeOf(obj)
720
+ if (ptVt) {
721
+ const tpEmitter = ctx.core.emit[`.${ptVt}:${prop}`]
722
+ if (tpEmitter && tpEmitter.length <= 1) return tpEmitter(obj)
723
+ }
724
+
725
+ // valueOf/toString are ToPrimitive hooks (ES2024 7.1.1) that an own data
726
+ // property shadows. On a heap receiver carrying a dynamic-prop sidecar
727
+ // (array/typed/object), reading `obj.valueOf`/`obj.toString` must return an
728
+ // assigned override when present, else the inherited builtin. Without this,
729
+ // the arity-1 builtin emitter below (returns the receiver) masks the
730
+ // override. The method-call path in src/emit.js runs the parallel probe and
731
+ // additionally covers statically-unknown receivers (e.g. `arr[0].valueOf()`);
732
+ // a bare read of an unknown-type receiver can't yield a builtin-as-value
733
+ // here anyway, so this read path stays scoped to known sidecar types.
734
+ if ((prop === 'valueOf' || prop === 'toString') && ctx.closure.call &&
735
+ (ptVt === VAL.ARRAY || ptVt === VAL.TYPED || ptVt === VAL.OBJECT)) {
736
+ const builtin = ctx.core.emit[`.${ptVt}:${prop}`] || ctx.core.emit[`.${prop}`]
737
+ if (builtin && builtin.length <= 1) {
738
+ const o = temp('vo'), p = temp('vp')
739
+ inc('__dyn_get_expr', '__ptr_type')
740
+ return typed(['block', ['result', 'f64'],
741
+ ['local.set', `$${o}`, asF64(emit(obj))],
742
+ ['local.set', `$${p}`, ['f64.reinterpret_i64',
743
+ ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${o}`]], asI64(emit(['str', prop]))]]],
744
+ ['if', ['result', 'f64'],
745
+ ['i32.eq', ['call', '$__ptr_type', ['i64.reinterpret_f64', ['local.get', `$${p}`]]], ['i32.const', PTR.CLOSURE]],
746
+ ['then', ['local.get', `$${p}`]],
747
+ ['else', asF64(builtin(o))]]], 'f64')
748
+ }
627
749
  }
628
750
 
629
751
  // Module-registered property emitter (.size, etc.)
@@ -634,77 +756,78 @@ export default (ctx) => {
634
756
  return emitPropAccess(emit(obj), obj, prop)
635
757
  }
636
758
 
637
- // Optional chaining: obj?.prop null if obj is null, else obj.prop
638
- ctx.core.emit['?.'] = (obj, prop) => {
639
- const t = temp()
640
- const va = asF64(emit(obj))
641
- const vt = typeof obj === 'string' ? repOf(obj)?.val : valTypeOf(obj)
642
- let access
643
- if (prop === 'length') {
644
- access = emitLengthAccess(['local.get', `$${t}`], vt)
645
- } else {
646
- const propIdx = typeof obj === 'string' ? ctx.schema.find(obj, prop) : -1
647
- if (propIdx >= 0) access = emitSchemaSlotRead(['local.get', `$${t}`], propIdx)
648
- else {
649
- if (typeof obj === 'string') {
650
- const objType = lookupValType(obj)
651
- if (usesDynProps(objType)) {
652
- access = emitDynGetExprTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), objType, prop)
653
- } else if (objType === VAL.HASH) {
654
- access = emitHashGetLocalConst(['local.get', `$${t}`], asI64(emit(['str', prop])), prop)
655
- } else if (objType == null) {
656
- // Unknown receiver — in WASI mode use typed dispatch (no PTR.EXTERNAL values).
657
- // In JS host mode use __dyn_get_any_t but don't force features.external here
658
- // since ?.prop short-circuits on nullish (EXTERNAL arm is dead unless already on).
659
- access = ctx.transform.host === 'wasi'
660
- ? emitDynGetExprTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), objType, prop)
661
- : emitDynGetAnyTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), objType)
662
- } else {
663
- inc('__hash_get', '__str_hash', '__str_eq')
664
- access = ['f64.reinterpret_i64', ['call', '$__hash_get', ['i64.reinterpret_f64', ['local.get', `$${t}`]], asI64(emit(['str', prop]))]]
665
- }
666
- } else {
667
- if (valTypeOf(obj) === VAL.HASH) {
668
- access = emitHashGetLocalConst(['local.get', `$${t}`], asI64(emit(['str', prop])), prop)
669
- } else {
670
- access = emitDynGetExprTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), valTypeOf(obj), prop)
671
- }
672
- }
673
- }
674
- }
675
- // Use local.set + local.get (not local.tee inside guard) because isNullish
676
- // inlines the null/undefined check as (i32.or (i64.eq X NULL) (i64.eq X UNDEF)),
677
- // which duplicates X — a local.tee(block(call $sideEffect)) would run twice.
678
- return typed(['block', ['result', 'f64'],
759
+ // Optional-chain short-circuit: store the receiver/callee into temp `$t`
760
+ // once, evaluate `thenIR` when it is non-nullish, else yield `undefined`.
761
+ // local.set + local.get (never a local.tee feeding the guard) because
762
+ // notNullish inlines an isNullish check — (i32.or (i64.eq X NULL)
763
+ // (i64.eq X UNDEF)) that duplicates its operand, so a tee'd
764
+ // side-effecting value would run twice.
765
+ const optionalGuard = (t, va, thenIR) =>
766
+ typed(['block', ['result', 'f64'],
679
767
  ['local.set', `$${t}`, va],
680
768
  ['if', ['result', 'f64'],
681
769
  notNullish(typed(['local.get', `$${t}`], 'f64')),
682
- ['then', access],
770
+ ['then', thenIR],
683
771
  ['else', ['f64.const', `nan:${UNDEF_NAN}`]]]], 'f64')
772
+
773
+ // Receiver-evaluate-once: allocate a fresh hoist-temp `$t`, emit `value` into
774
+ // it, then call `useFn(t)` to build the consumer IR — wrapping both in an
775
+ // optionalGuard so the consumer runs only when `$t` is non-nullish. Used by
776
+ // `?.` / `?.[]` / `?.()` whose receiver is read twice (the nullish check and
777
+ // the dispatched access) but must evaluate once. Rep-seeding for the temp,
778
+ // when the receiver's value-type drives downstream dispatch, lives inside
779
+ // the useFn callback so it runs before the consumer IR consults reps.
780
+ const evalOnce = (value, useFn) => {
781
+ const t = temp()
782
+ const va = asF64(emit(value))
783
+ return optionalGuard(t, va, useFn(t))
684
784
  }
685
785
 
786
+ // Optional chaining: obj?.prop → null if obj is null, else obj.prop
787
+ ctx.core.emit['?.'] = (obj, prop) => evalOnce(obj, (t) => {
788
+ const rep = typeof obj === 'string' ? repOf(obj) : null
789
+ const vt = rep ? rep.val : valTypeOf(obj)
790
+ if (prop === 'length') {
791
+ const notString = vt == null && typeof obj === 'string' && lookupNotString(obj)
792
+ return emitLengthAccess(['local.get', `$${t}`], vt, notString)
793
+ }
794
+ const propIdx = typeof obj === 'string' ? ctx.schema.find(obj, prop) : -1
795
+ if (propIdx >= 0) return emitSchemaSlotRead(['local.get', `$${t}`], propIdx)
796
+ if (typeof obj === 'string') {
797
+ const objType = lookupValType(obj)
798
+ if (usesDynProps(objType))
799
+ return emitDynGetExprTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), objType, prop)
800
+ if (objType === VAL.HASH)
801
+ return emitHashGetLocalConst(['local.get', `$${t}`], asI64(emit(['str', prop])), prop)
802
+ if (objType == null)
803
+ // Unknown receiver — in WASI mode use typed dispatch (no PTR.EXTERNAL values).
804
+ // In JS host mode use __dyn_get_any_t but don't force features.external here
805
+ // since ?.prop short-circuits on nullish (EXTERNAL arm is dead unless already on).
806
+ return ctx.transform.host === 'wasi'
807
+ ? emitDynGetExprTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), objType, prop)
808
+ : emitDynGetAnyTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), objType, prop)
809
+ inc('__hash_get', '__str_hash', '__str_eq')
810
+ return ['f64.reinterpret_i64', ['call', '$__hash_get', ['i64.reinterpret_f64', ['local.get', `$${t}`]], asI64(emit(['str', prop]))]]
811
+ }
812
+ if (valTypeOf(obj) === VAL.HASH)
813
+ return emitHashGetLocalConst(['local.get', `$${t}`], asI64(emit(['str', prop])), prop)
814
+ return emitDynGetExprTyped(['local.get', `$${t}`], asI64(emit(['str', prop])), valTypeOf(obj), prop)
815
+ })
816
+
686
817
  // Optional index: arr?.[i] → null if arr is null, else arr[i]
687
818
  // Cache base in temp, propagate valType so []'s type dispatch works
688
- ctx.core.emit['?.[]'] = (arr, idx) => {
689
- const t = temp()
690
- const va = asF64(emit(arr))
819
+ ctx.core.emit['?.[]'] = (arr, idx) => evalOnce(arr, (t) => {
820
+ // Emit-time rep seed on fresh `?.[]` hoist-temp (lifecycle: analysis-vs-emit).
691
821
  // Propagate source type to temp so [] dispatch (string, typed, etc.) works
822
+ // when the inner `ctx.core.emit['[]'](t, idx)` re-enters dispatch.
692
823
  const srcType = typeof arr === 'string' ? repOf(arr)?.val : null
693
824
  if (srcType) updateRep(t, { val: srcType })
694
825
  if (typeof arr === 'string' && ctx.types.typedElem?.has(arr)) {
695
826
  if (!ctx.types.typedElem) ctx.types.typedElem = new Map()
696
827
  ctx.types.typedElem.set(t, ctx.types.typedElem.get(arr))
697
828
  }
698
- // Use local.set + local.get (not local.tee inside guard) because isNullish
699
- // inlines the null/undefined check as (i32.or (i64.eq X NULL) (i64.eq X UNDEF)),
700
- // which duplicates X — a local.tee(block(call $sideEffect)) would run twice.
701
- return typed(['block', ['result', 'f64'],
702
- ['local.set', `$${t}`, va],
703
- ['if', ['result', 'f64'],
704
- notNullish(typed(['local.get', `$${t}`], 'f64')),
705
- ['then', asF64(ctx.core.emit['[]'](t, idx))],
706
- ['else', ['f64.const', `nan:${UNDEF_NAN}`]]]], 'f64')
707
- }
829
+ return asF64(ctx.core.emit['[]'](t, idx))
830
+ })
708
831
 
709
832
  // Optional call: fn?.(...args) → null if fn is null, else call fn
710
833
  ctx.core.emit['?.()'] = (callee, ...args) => {
@@ -716,42 +839,57 @@ export default (ctx) => {
716
839
  const methodEmit = ctx.core.emit[`.${callee[2]}`]
717
840
  if (methodEmit) {
718
841
  const recv = callee[1]
719
- const t = temp()
720
- const va = asF64(emit(recv))
721
- const vt = typeof recv === 'string' ? repOf(recv)?.val : valTypeOf(recv)
722
- if (vt) updateRep(t, { val: vt })
723
- const callResult = methodEmit(t, ...args)
724
- // Use local.set + local.get (not local.tee inside guard) because isNullish
725
- // inlines the null/undefined check which duplicates the expression.
726
- return typed(['block', ['result', 'f64'],
727
- ['local.set', `$${t}`, va],
728
- ['if', ['result', 'f64'],
729
- notNullish(typed(['local.get', `$${t}`], 'f64')),
730
- ['then', asF64(callResult)],
731
- ['else', ['f64.const', `nan:${UNDEF_NAN}`]]]], 'f64')
842
+ return evalOnce(recv, (t) => {
843
+ // Emit-time rep seed on fresh `?.()` recv-temp so methodEmit's dispatch fast-paths fire.
844
+ const vt = typeof recv === 'string' ? repOf(recv)?.val : valTypeOf(recv)
845
+ if (vt) updateRep(t, { val: vt })
846
+ return asF64(methodEmit(t, ...args))
847
+ })
732
848
  }
733
849
  }
734
- const t = temp()
735
- const va = asF64(emit(callee))
736
- // If nullish → return NULL_NAN, else call via fn.call
737
850
  if (!ctx.closure.call) err('Optional call requires fn module')
738
- const callResult = ctx.closure.call(typed(['local.get', `$${t}`], 'f64'), args)
739
- // Use local.set + local.get (not local.tee inside guard) because isNullish
740
- // inlines the null/undefined check which duplicates the expression.
741
- return typed(['block', ['result', 'f64'],
742
- ['local.set', `$${t}`, va],
743
- ['if', ['result', 'f64'],
744
- notNullish(typed(['local.get', `$${t}`], 'f64')),
745
- ['then', asF64(callResult)],
746
- ['else', ['f64.const', `nan:${UNDEF_NAN}`]]]], 'f64')
851
+ return evalOnce(callee, (t) => {
852
+ // Spread args: mirror the regular `()` emitter reconstruct the args array
853
+ // and route through `closure.call(_, [arrayIR], prebuiltArray=true)`. Without
854
+ // this, the raw `['...', expr]` node falls through to the bare spread emitter
855
+ // and errors as "Spread (...) can only be used in function/method calls".
856
+ const hasSpread = args.some(a => Array.isArray(a) && a[0] === '...')
857
+ let callResult
858
+ if (hasSpread) {
859
+ const normal = [], spreads = []
860
+ for (const a of args) {
861
+ if (Array.isArray(a) && a[0] === '...') spreads.push({ pos: normal.length, expr: a[1] })
862
+ else normal.push(a)
863
+ }
864
+ const combined = reconstructArgsWithSpreads(normal, spreads)
865
+ const arrayIR = buildArrayWithSpreads(combined)
866
+ callResult = ctx.closure.call(typed(['local.get', `$${t}`], 'f64'), [arrayIR], true)
867
+ } else {
868
+ callResult = ctx.closure.call(typed(['local.get', `$${t}`], 'f64'), args)
869
+ }
870
+ return asF64(callResult)
871
+ })
747
872
  }
748
873
 
749
- // typeof: returns JS-style string. Reachable results are number/undefined/string/function/symbol/object
750
- // (booleans are f64 and hit the number branch; no bigints). Strings are preallocated into globals and
874
+ // Statically boolean-typed operands: `Boolean(x)`, logical-not, and the
875
+ // relational/equality comparisons always yield a JS boolean jz carries it as
876
+ // f64 0/1 but `typeof` must still report "boolean". None of these ops can
877
+ // produce a non-boolean, so the recognizer never false-positives. The `()`
878
+ // arm also unwraps parenthesized expressions (`typeof (a < b)`).
879
+ const BOOL_RESULT_OPS = new Set(['!', '<', '<=', '>', '>=', '==', '!=', '===', '!=='])
880
+ const isBoolExpr = (n) => Array.isArray(n) && (
881
+ BOOL_RESULT_OPS.has(n[0]) ||
882
+ (n[0] === '()' && (n[1] === 'Boolean' || isBoolExpr(n[1]))))
883
+
884
+ // typeof: returns JS-style string. Reachable results are number/undefined/string/function/symbol/object/boolean
885
+ // (booleans without a static type hit the number branch; no bigints). Strings are preallocated into globals and
751
886
  // initialized in __start (see compile.js). Comparison patterns (typeof x === 'string') are optimized
752
887
  // in prepare.js (resolveTypeof) and emitted as direct type checks via emitTypeofCmp, bypassing this path.
753
888
  ctx.core.emit['typeof'] = (a) => {
754
889
  if (valTypeOf(a) === VAL.BIGINT) return emit(['str', 'bigint'])
890
+ // VAL.BOOL covers boolean literals, comparisons, `!` and bindings inferred
891
+ // boolean; isBoolExpr additionally catches `Boolean(x)` and parenthesized forms.
892
+ if (valTypeOf(a) === VAL.BOOL || isBoolExpr(a)) return emit(['str', 'boolean'])
755
893
  if (!ctx.runtime.typeofStrs) {
756
894
  ctx.runtime.typeofStrs = ['number', 'undefined', 'string', 'function', 'symbol', 'object']
757
895
  for (const s of ctx.runtime.typeofStrs)
@@ -782,8 +920,11 @@ export default (ctx) => {
782
920
  (i64.eq (i64.and (local.get $v) (i64.const 0xFFF0000000000000))
783
921
  (i64.const 0xFFF0000000000000)))
784
922
  (then (return (global.get $__tof_number))))
785
- (if (call $__is_nullish (local.get $v))
923
+ (if (i64.eq (local.get $v) (i64.const ${UNDEF_NAN}))
786
924
  (then (return (global.get $__tof_undefined))))
925
+ ;; typeof null === "object" — the historical JS quirk, distinct from undefined.
926
+ (if (i64.eq (local.get $v) (i64.const ${NULL_NAN}))
927
+ (then (return (global.get $__tof_object))))
787
928
  (local.set $t (call $__ptr_type (local.get $v)))
788
929
  (if ${stringTest}
789
930
  (then (return (global.get $__tof_string))))
@@ -802,6 +943,24 @@ export default (ctx) => {
802
943
  ctx.core.emit['__ptr_aux'] = (p) => typed(['f64.convert_i32_s', ['call', '$__ptr_aux', asI64(emit(p))]], 'f64')
803
944
  ctx.core.emit['__ptr_offset'] = (p) => typed(['f64.convert_i32_s', ['call', '$__ptr_offset', asI64(emit(p))]], 'f64')
804
945
 
805
- // Error(msg) — passthrough (throw handles any value)
806
- ctx.core.emit['Error'] = (msg) => asF64(emit(msg))
946
+ // Error(msg) — passthrough (throw handles any value). Subclasses share the
947
+ // same shape: jz doesn't model typed-error dispatch, so SyntaxError/TypeError/
948
+ // RangeError/ReferenceError/URIError/EvalError all lower to the message
949
+ // expression. `instanceof SyntaxError` returning correct results would need
950
+ // proper class machinery; until then, code that throws specific subclasses
951
+ // compiles and the user-visible message is preserved.
952
+ // jz models an error as its message value (passthrough — `throw` accepts any
953
+ // value). A no-arg `new Error()` has no message, so it lowers to `undefined`
954
+ // rather than crashing on a missing argument. Emitting `['str','']` here would
955
+ // drag the whole string module into programs that only `throw new Error()`.
956
+ const passthroughError = (msg) => msg == null
957
+ ? typed(['f64.const', `nan:${UNDEF_NAN}`], 'f64')
958
+ : asF64(emit(msg))
959
+ ctx.core.emit['Error'] = passthroughError
960
+ ctx.core.emit['SyntaxError'] = passthroughError
961
+ ctx.core.emit['TypeError'] = passthroughError
962
+ ctx.core.emit['RangeError'] = passthroughError
963
+ ctx.core.emit['ReferenceError'] = passthroughError
964
+ ctx.core.emit['URIError'] = passthroughError
965
+ ctx.core.emit['EvalError'] = passthroughError
807
966
  }
package/module/date.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * @module date
10
10
  */
11
11
 
12
- import { typed, asF64, toNumF64, allocPtr } from '../src/ir.js'
12
+ import { typed, asF64, toNumF64, allocPtr, temp } from '../src/ir.js'
13
13
  import { emit } from '../src/emit.js'
14
14
  import { inc, PTR } from '../src/ctx.js'
15
15
  import { VAL } from '../src/analyze.js'
@@ -30,7 +30,7 @@ export default (ctx) => {
30
30
  __date_day: [],
31
31
  __date_time_within_day: ['__date_day'],
32
32
  __date_weekday: ['__date_day'],
33
- __date_year_from_time: ['__date_days_from_year'],
33
+ __date_year_from_time: ['__date_day', '__date_days_from_year'],
34
34
  __date_in_leap_year: ['__date_year_from_time', '__date_days_from_year'],
35
35
  __date_day_within_year: ['__date_day', '__date_year_from_time', '__date_days_from_year'],
36
36
  __date_month_from_time: ['__date_day_within_year', '__date_in_leap_year'],
@@ -618,10 +618,14 @@ export default (ctx) => {
618
618
  const emitDateSetTime = (dateExpr, ms) => {
619
619
  inc('__date_time_clip')
620
620
  const d = asF64(emit(dateExpr))
621
- const t = typed(['call', '$__date_time_clip', toNumF64(ms, emit(ms))], 'f64')
621
+ // Clip once into a temp: the result feeds both the store and the block
622
+ // value, and sharing one IR node across two tree positions miscompiles
623
+ // under CSE (it hoists the arg without emitting its local.set).
624
+ const t = temp('st')
622
625
  return typed(['block', ['result', 'f64'],
623
- ['f64.store', ['i32.wrap_i64', ['i64.reinterpret_f64', d]], t],
624
- t], 'f64')
626
+ ['local.set', `$${t}`, typed(['call', '$__date_time_clip', toNumF64(ms, emit(ms))], 'f64')],
627
+ ['f64.store', ['i32.wrap_i64', ['i64.reinterpret_f64', d]], ['local.get', `$${t}`]],
628
+ ['local.get', `$${t}`]], 'f64')
625
629
  }
626
630
 
627
631
  ctx.core.emit['.getTime'] = emitDateGetTime
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { typed, asF64, asI32, mkPtrIR, temp, tempI32, MAX_CLOSURE_ARITY, UNDEF_NAN } from '../src/ir.js'
14
14
  import { emit } from '../src/emit.js'
15
- import { isReassigned } from '../src/ast.js'
15
+ import { isReassigned } from '../src/analyze.js'
16
16
  import { T, lookupValType, repOf, findFreeVars } from '../src/analyze.js'
17
17
  import { PTR, LAYOUT, inc, err } from '../src/ctx.js'
18
18
 
@@ -94,6 +94,14 @@ export default (ctx) => {
94
94
  const captureValTypes = new Map()
95
95
  const captureSchemaVars = new Map()
96
96
  const captureTypedElems = new Map()
97
+ // Propagate parent's intCertain rep across captures: the parent's narrower
98
+ // already guarantees every defining RHS (including assignments inside
99
+ // nested arrows) is integer-valued, so the f64 round-trip through the env
100
+ // slot preserves integer semantics. Consumers inside the inner body —
101
+ // `toNumF64` elision (src/ir.js), math fast paths (module/math.js),
102
+ // bitwise-key indexing (src/emit.js) — fire on captures just as they
103
+ // would on directly-declared locals.
104
+ const captureIntCertain = new Set()
97
105
  // Propagate parent's directClosures across captures: a const-bound closure captured
98
106
  // by an inner arrow can still be direct-dispatched in the inner body (skip
99
107
  // call_indirect on the captured pointer). Gated on isReassigned over the inner body
@@ -108,6 +116,7 @@ export default (ctx) => {
108
116
  if (elemType != null) captureTypedElems.set(name, elemType)
109
117
  const bodyName = ctx.func.directClosures?.get(name)
110
118
  if (bodyName && !isReassigned(body, name)) captureDirectClosures.set(name, bodyName)
119
+ if (repOf(name)?.intCertain === true) captureIntCertain.add(name)
111
120
  }
112
121
 
113
122
  const schemaNames = ctx.schema.vars?.size ? new Set(ctx.schema.vars.keys()) : null
@@ -130,6 +139,7 @@ export default (ctx) => {
130
139
  ...(defaults && { defaults }),
131
140
  ...(boxedCaptures.length && { boxed: new Set(boxedCaptures) }),
132
141
  ...(captureIntConsts.size && { intConsts: captureIntConsts }),
142
+ ...(captureIntCertain.size && { intCertain: captureIntCertain }),
133
143
  ...(captureValTypes.size && { valTypes: captureValTypes }),
134
144
  ...(captureSchemaVars.size && { schemaVars: captureSchemaVars }),
135
145
  ...(captureTypedElems.size && { typedElems: captureTypedElems }),