jz 0.6.0 → 0.7.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 (49) hide show
  1. package/README.md +66 -43
  2. package/bench/README.md +128 -78
  3. package/bench/bench.svg +32 -42
  4. package/cli.js +73 -7
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +190 -34
  9. package/interop.js +71 -27
  10. package/layout.js +5 -0
  11. package/module/array.js +71 -39
  12. package/module/collection.js +41 -5
  13. package/module/core.js +2 -4
  14. package/module/math.js +138 -2
  15. package/module/number.js +21 -0
  16. package/module/object.js +26 -0
  17. package/module/simd.js +37 -5
  18. package/module/string.js +41 -12
  19. package/module/typedarray.js +415 -26
  20. package/package.json +21 -6
  21. package/src/autoload.js +3 -0
  22. package/src/compile/analyze-scans.js +174 -11
  23. package/src/compile/analyze.js +38 -3
  24. package/src/compile/emit-assign.js +9 -6
  25. package/src/compile/emit.js +347 -36
  26. package/src/compile/index.js +307 -29
  27. package/src/compile/infer.js +36 -1
  28. package/src/compile/loop-divmod.js +155 -0
  29. package/src/compile/narrow.js +108 -11
  30. package/src/compile/peel-stencil.js +261 -0
  31. package/src/compile/plan/advise.js +55 -1
  32. package/src/compile/plan/index.js +4 -0
  33. package/src/compile/plan/inline.js +5 -2
  34. package/src/compile/plan/literals.js +220 -5
  35. package/src/compile/plan/scope.js +115 -39
  36. package/src/compile/program-facts.js +21 -2
  37. package/src/ctx.js +45 -7
  38. package/src/ir.js +55 -7
  39. package/src/kind-traits.js +27 -0
  40. package/src/kind.js +65 -3
  41. package/src/optimize/index.js +344 -45
  42. package/src/optimize/vectorize.js +2968 -182
  43. package/src/prepare/index.js +64 -7
  44. package/src/prepare/lift-iife.js +149 -0
  45. package/src/reps.js +3 -2
  46. package/src/static.js +9 -0
  47. package/src/type.js +10 -6
  48. package/src/wat/assemble.js +195 -13
  49. package/src/wat/optimize.js +363 -185
@@ -25,8 +25,8 @@ import {
25
25
  commaList, T, isBlockBody, isReassigned, mutatesArrayLength, isConstLiteral, constLiteralHoistable,
26
26
  hasOwnContinue, hasLabeledContinueTo, hasOwnBreakOrContinue, extractParams, classifyParam, JZ_UNDEF, TYPEOF,
27
27
  } from '../ast.js'
28
- import { ctx, err, inc, PTR } from '../ctx.js'
29
- import { nonNegIntLiteral, staticPropertyKey } from '../static.js'
28
+ import { ctx, err, inc, warnDeopt, PTR } from '../ctx.js'
29
+ import { nonNegIntLiteral, intLiteralValue, staticPropertyKey } from '../static.js'
30
30
  import { findFreeVars } from './analyze.js'
31
31
  import {
32
32
  containsNestedClosure, containsNestedLoop, nestedSmallLoopBudget,
@@ -41,7 +41,7 @@ import {
41
41
  NULL_IR, nullExpr, undefExpr, MAX_CLOSURE_ARITY,
42
42
  WASM_OPS, SPREAD_MUTATORS, BOXED_MUTATORS,
43
43
  mkPtrIR, ptrOffsetIR, ptrTypeIR, ptrTypeEq, dispatchByPtrType, sidecarOverride, valKindToPtr,
44
- isLit, litVal, isNullishLit, isPureIR, emitNum, f64rem, toNumF64, toStrI64,
44
+ isLit, litVal, isNullishLit, isPureIR, emitNum, f64rem, toNumF64, toStrI64, maskBound,
45
45
  truthyIR, toBoolFromEmitted, isPostfix,
46
46
  isGlobal, isConst, usesDynProps, needsDynShadow,
47
47
  temp, tempI32, tempI64, allocPtr,
@@ -76,7 +76,22 @@ const stringOps = (node) => {
76
76
  // instead route through ToNumber (`toNumF64`), which performs ToPrimitive.
77
77
  const isI32Num = (v) => v.type === 'i32' && v.ptrKind == null
78
78
 
79
+ // f64 arithmetic that can MINT a sign-nondeterministic NaN (0/0, ∞−∞, 0·∞, x%0): on x86
80
+ // these are 0xFFF8…, on arm 0x7FF8…. sqrt/min/max/neg are NOT here — they canon at their
81
+ // own emit (math.js / unary `-`), so they reach canonNum already canonical.
82
+ const NAN_MINTING = new Set(['f64.div', 'f64.add', 'f64.sub', 'f64.mul'])
83
+
79
84
  const canonNum = (node) => {
85
+ // Fold a possibly-non-canonical NaN to the canonical number-NaN before it reaches a
86
+ // bit-comparing consumer (__is_truthy / untyped === / typeof), which match the canonical
87
+ // NaN by bits and so misread x86's 0xFFF8 as truthy. ONLY an un-canon'd NaN-minting
88
+ // arithmetic op can carry such a value — literals, i32-conversions, opaque locals/calls
89
+ // (canonical by the canon-at-source invariant) and already-canon'd shapes don't — so
90
+ // skipping everything else keeps the size win. (The broken middle ground was
91
+ // `02873d0`'s `isNumericIR` skip, which dropped canon for f64.div too → x86 miscompile.)
92
+ const arith = Array.isArray(node) &&
93
+ (NAN_MINTING.has(node[0]) || (node[0] === 'call' && node[1] === '$__rem'))
94
+ if (!arith) return node
80
95
  const t = temp('cn')
81
96
  return typed(['block', ['result', 'f64'],
82
97
  ['local.set', `$${t}`, node],
@@ -86,6 +101,21 @@ const canonNum = (node) => {
86
101
  ['f64.ne', ['local.get', `$${t}`], ['local.get', `$${t}`]]]], 'f64')
87
102
  }
88
103
 
104
+ // Is an emitted arm `v` (AST `node`) a plain NUMBER? The predicate the two-arm merges
105
+ // (?:, ??) share to decide canon: an i32 number, NUMBER-tagged IR, or a NUMBER
106
+ // value-type qualifies; a pointer/opaque arm does not. `vt` is the node's resolved
107
+ // value-type — pass it when already computed to avoid the re-resolve.
108
+ const isNumArm = (v, node, vt = resolveValType(node, valTypeOf, lookupValType)) =>
109
+ isI32Num(v) || v.valKind === VAL.NUMBER || vt === VAL.NUMBER
110
+
111
+ // One arm of a two-arm f64 merge (?:, ??, ||, &&) whose result may be bit-tested while
112
+ // untyped. Canon (canonNum, a no-op unless the arm is NaN-minting arithmetic) ONLY a
113
+ // LONE numeric arm: when both arms are numeric the merge is value-typed NUMBER and read
114
+ // NaN-by-value (no canon); when the other arm is opaque the result is untyped, so a
115
+ // non-canonical NaN here would be misread by __is_truthy — fold it. A pointer arm
116
+ // (isNum=false) is never touched (canon would destroy its NaN-box).
117
+ const canonArm = (f, isNum, otherNum) => isNum && !otherNum ? canonNum(f) : f
118
+
89
119
  // Host globals auto-imported as `(import "env" "name" (global … i64))` when
90
120
  // referenced as a value. Drained from ctx.core.hostGlobals at assembly.
91
121
  const HOST_GLOBALS = new Set(['WebAssembly', 'globalThis', 'self', 'window', 'global', 'process'])
@@ -98,6 +128,16 @@ const HOST_GLOBALS = new Set(['WebAssembly', 'globalThis', 'self', 'window', 'gl
98
128
  // use — wrapping is exactly its intended semantics, so it stays on the i32 path.
99
129
  const widensUnsigned = (v) => v.unsigned && !v.wrapSafe
100
130
 
131
+ // Strip a redundant NaN-canon wrapper (math.js `canon`) from an operand that
132
+ // feeds a NaN-propagating f64 op. `f64.sqrt`/`min`/`max` mint a sign-nondeterministic
133
+ // NaN that math.js canon-izes so it can't be bit-confused with a NaN-boxed pointer in
134
+ // `===`/`typeof`. But when the result flows straight into `f64.add`/`sub`/`mul`/`div`,
135
+ // the consumer propagates that NaN identically and is itself canon-ized if IT escapes —
136
+ // so the inner per-op canon (local.set + select + f64.ne, ~3 ops) is dead on the
137
+ // critical path. This is THE gap that put sqrt-heavy kernels ~23% behind V8
138
+ // (julia/raymarcher/boids); stripping it makes them match native JS.
139
+ const stripCanon = (v) => (v && v.canonOf != null) ? typed(v.canonOf, 'f64') : v
140
+
101
141
  const FIRST_CLASS_UNARY_MATH = {
102
142
  'math.abs': 'f64.abs',
103
143
  'math.sqrt': 'f64.sqrt',
@@ -155,13 +195,18 @@ const foldConst = (va, vb, fn, guard) =>
155
195
  // JS `*` is an f64 multiply; `i32.mul` yields only the exact product mod 2^32.
156
196
  // Those agree under a ToInt32/ToUint32 sink (and as plain numbers) while the
157
197
  // exact product stays f64-exact, i.e. |product| <= 2^53. Two i32 operands can
158
- // reach 2^62, so `i32.mul` is sound only when one side is a literal small
159
- // enough that, against the full i32 range (2^31) of the other, the product
160
- // holds within 2^53 — i.e. |literal| <= 2^22. Keeps index arithmetic (`i*4`,
161
- // `row*16`) on `i32.mul` while routing hash-mix-scale products to `f64.mul`.
198
+ // reach 2^62, so `i32.mul` is sound only when one side is bounded small enough
199
+ // that, against the full i32 range (2^31) of the other, the product holds within
200
+ // 2^53 — i.e. its magnitude <= 2^22. A literal qualifies directly; so does a
201
+ // masked operand (`x & 63`, `x >>> k`) whose value is provably bounded. Keeps
202
+ // index arithmetic (`i*4`) and bitwise-masked scales (bytebeat's `t*(m&63)`) on
203
+ // `i32.mul` while routing hash-mix-scale products to `f64.mul`.
204
+ const FITS_I32_MAX = 0x400000 // 2^22 — see derivation above
162
205
  const mulFitsI32 = (va, vb) =>
163
- (isLit(va) && Math.abs(litVal(va)) <= 0x400000) ||
164
- (isLit(vb) && Math.abs(litVal(vb)) <= 0x400000)
206
+ (isLit(va) && Math.abs(litVal(va)) <= FITS_I32_MAX) ||
207
+ (isLit(vb) && Math.abs(litVal(vb)) <= FITS_I32_MAX) ||
208
+ (!isLit(va) && maskBound(va) <= FITS_I32_MAX) ||
209
+ (!isLit(vb) && maskBound(vb) <= FITS_I32_MAX)
165
210
 
166
211
  /** Emit typeof comparison: typeof x == typeCode → type-aware check. */
167
212
  export function emitTypeofCmp(a, b, cmpOp) {
@@ -307,6 +352,13 @@ function intIndexIR(key) {
307
352
  */
308
353
  const I32_INDEX_OP = { '+': 'i32.add', '-': 'i32.sub', '*': 'i32.mul' }
309
354
  function tryI32Index(e) {
355
+ // Integer literal first — a prepare-wrapped literal `[null, k]` (and a const-int
356
+ // name) is itself an Array, so the operator dispatch below would reject it and
357
+ // bail the WHOLE index to the f64 round-trip. The classic victim is the `+ 1` /
358
+ // `(j + 1)` of a bilinear/stencil gather (`a[(j+1)*W + i + 1]`): one literal leaf
359
+ // forced `convert_i32 … f64.mul/add … trunc_sat_f64_s` across every term.
360
+ const lit = nonNegIntLiteral(e)
361
+ if (lit != null) return typed(['i32.const', lit], 'i32')
310
362
  if (Array.isArray(e)) {
311
363
  const inner = I32_INDEX_OP[e[0]]
312
364
  if (inner && e[2] != null) {
@@ -316,8 +368,6 @@ function tryI32Index(e) {
316
368
  }
317
369
  return null
318
370
  }
319
- const lit = nonNegIntLiteral(e)
320
- if (lit != null) return ['i32.const', lit]
321
371
  return exprType(e, ctx.func.locals) === 'i32' ? asI32(emit(e)) : null
322
372
  }
323
373
  export const emitIndex = (idx) => tryI32Index(idx) ?? asI32(emit(idx))
@@ -448,6 +498,55 @@ function emitSubstringEqCmp(a, b, negate = false) {
448
498
  ['i64.reinterpret_f64', ['local.get', `$${o}`]]])], 'i32')
449
499
  }
450
500
 
501
+ // One half of a two-sided range test against a compile-time constant, normalized to
502
+ // an inclusive bound on a *local* `x`: `{ x, lo }` (x ≥ lo) or `{ x, hi }` (x ≤ hi).
503
+ // `>`/`<` fold to the inclusive neighbor; a const on either side is accepted. Returns
504
+ // null for anything else (so the caller leaves the expression untouched).
505
+ function rangeBound(n) {
506
+ if (!Array.isArray(n) || n.length !== 3) return null
507
+ const lc = intLiteralValue(n[1]), rc = intLiteralValue(n[2])
508
+ if (rc != null && typeof n[1] === 'string') { // x op CONST
509
+ if (n[0] === '>=') return { x: n[1], lo: rc }
510
+ if (n[0] === '>') return { x: n[1], lo: rc + 1 }
511
+ if (n[0] === '<=') return { x: n[1], hi: rc }
512
+ if (n[0] === '<') return { x: n[1], hi: rc - 1 }
513
+ }
514
+ if (lc != null && typeof n[2] === 'string') { // CONST op x
515
+ if (n[0] === '<=') return { x: n[2], lo: lc }
516
+ if (n[0] === '<') return { x: n[2], lo: lc + 1 }
517
+ if (n[0] === '>=') return { x: n[2], hi: lc }
518
+ if (n[0] === '>') return { x: n[2], hi: lc - 1 }
519
+ }
520
+ return null
521
+ }
522
+
523
+ // `x >= LO && x <= HI` (x a pure i32 local, LO ≤ HI constants) → `(x - LO) <=u (HI - LO)`.
524
+ // One subtract + one unsigned compare replaces two signed compares, an AND, and the
525
+ // short-circuit branch — the classic range-check trick (valid for any integers via
526
+ // wrapping subtraction). Returns the fused IR, or null to leave `&&` lowering unchanged.
527
+ function fuseRangeCheck(a, b) {
528
+ const ba = rangeBound(a), bb = rangeBound(b)
529
+ if (!ba || !bb || ba.x !== bb.x || (ba.lo != null) === (bb.lo != null)) return null
530
+ const lo = ba.lo ?? bb.lo, hi = ba.hi ?? bb.hi
531
+ if (lo > hi) return null
532
+ const xv = emit(ba.x)
533
+ if (xv.type !== 'i32') return null // f64 (fractional) would mis-fuse
534
+ return typed(['i32.le_u', ['i32.sub', xv, ['i32.const', lo]], ['i32.const', hi - lo]], 'i32')
535
+ }
536
+
537
+ // The complement: `x < LO || x > HI` (the two outside half-checks — one upper-bounded,
538
+ // one lower-bounded, with a gap between) → `(x - LO) >u (HI - LO)`, where [LO, HI] is the
539
+ // inside range. Same trick, negated; returns null to leave `||` lowering unchanged.
540
+ function fuseRangeCheckOr(a, b) {
541
+ const ba = rangeBound(a), bb = rangeBound(b)
542
+ if (!ba || !bb || ba.x !== bb.x || (ba.lo != null) === (bb.lo != null)) return null
543
+ const insideLo = (ba.hi ?? bb.hi) + 1, insideHi = (ba.lo ?? bb.lo) - 1
544
+ if (insideLo > insideHi) return null
545
+ const xv = emit(ba.x)
546
+ if (xv.type !== 'i32') return null
547
+ return typed(['i32.gt_u', ['i32.sub', xv, ['i32.const', insideLo]], ['i32.const', insideHi - insideLo]], 'i32')
548
+ }
549
+
451
550
  // Flow-sensitive type refinement moved to ./flow-types.js (extractRefinements,
452
551
  // predicateRefinement, mergeRefinement, withRefinements). emit.js imports them
453
552
  // from there — see the import block at the top of this file.
@@ -469,6 +568,80 @@ function unrollSmallConstFor(init, cond, step, body) {
469
568
  return out
470
569
  }
471
570
 
571
+ // Max distinct keys a for-in unrolls over (bounds code size; larger key sets keep
572
+ // the pooled-keys loop, which is already allocation-free via __keys_ro).
573
+ const FORIN_UNROLL_MAX = 16
574
+ // Total-expansion ceiling: unroll emits one body copy per key, so the size cost is
575
+ // keys × body, not keys alone. A large body over many keys (e.g. watr's 15-key
576
+ // schema loop) blows up code size for no deopt win — the pooled fallback is already
577
+ // allocation-free. Cap keys × nodeSize(body); past it, keep the loop. (Tuned above
578
+ // every unroll the corpus actually wants — the 16-key cap test lands at 80.)
579
+ const FORIN_UNROLL_BUDGET = 128
580
+ const forInBodyCost = (node) => {
581
+ if (!Array.isArray(node)) return 1
582
+ let n = 1
583
+ for (let i = 1; i < node.length; i++) n += forInBodyCost(node[i])
584
+ return n
585
+ }
586
+
587
+ // Pull the for-in source out of prepare's keys expression: either a bare
588
+ // `__keys_ro(src)` call or the nullish-guarded `cond ? [] : __keys_ro(src)`.
589
+ function keysRoSrc(node) {
590
+ if (!Array.isArray(node)) return null
591
+ if (node[0] === '()' && node[1] === '__keys_ro') return node[2]
592
+ if (node[0] === '?:' || node[0] === '?') {
593
+ const last = node[node.length - 1]
594
+ if (Array.isArray(last) && last[0] === '()' && last[1] === '__keys_ro') return last[2]
595
+ }
596
+ return null
597
+ }
598
+
599
+ // Unroll `for (k in o)` over a static schema. Prepare lowers for-in to a plain
600
+ // for-loop whose key array comes from the for-in-exclusive `__keys_ro` intrinsic,
601
+ // so a loop carrying it IS a for-in. When `o` is a bare OBJECT var with a complete
602
+ // static schema (no computed-key writes — same gate as __keys_ro pooling), replace
603
+ // the loop with one substituted copy of the body per key: the loop variable becomes
604
+ // a string literal, so `o[k]` folds to a static schema slot — no keys array, no
605
+ // per-element dynamic get. Falls back (returns null) to the pooled loop otherwise.
606
+ function unrollForIn(init, cond, step, body) {
607
+ if (!Array.isArray(init) || init[0] !== 'let' || !Array.isArray(init[1]) || init[1][0] !== '=') return null
608
+ const ksVar = init[1][1]
609
+ const src = keysRoSrc(init[1][2])
610
+ if (typeof src !== 'string') return null
611
+ if (!Array.isArray(cond) || cond[0] !== '<') return null
612
+ const ixVar = cond[1]
613
+ if (!Array.isArray(step) || step[0] !== '++' || step[1] !== ixVar) return null
614
+ // body = [';', ['let', ['=', target, ['[]', ksVar, ixVar]]], ...realBody]
615
+ if (!Array.isArray(body) || body[0] !== ';') return null
616
+ const bind = body[1]
617
+ if (!Array.isArray(bind) || bind[0] !== 'let' || !Array.isArray(bind[1]) || bind[1][0] !== '=') return null
618
+ const target = bind[1][1]
619
+ const acc = bind[1][2]
620
+ if (!Array.isArray(acc) || acc[0] !== '[]' || acc[1] !== ksVar || acc[2] !== ixVar) return null
621
+
622
+ // Unroll only with PROOF the schema is complete: a computed-key write adds
623
+ // enumerable keys, so bail if `src` takes one — or if the fact is unavailable
624
+ // (no proof ⇒ no unroll; unrolling drops the dynamic path, so erring safe matters).
625
+ if (!ctx.types.dynWriteVars || ctx.types.dynWriteVars.has(src)) return null
626
+ if (lookupValType(src) !== VAL.OBJECT) return null
627
+ const keys = ctx.schema.resolve(src)
628
+ if (!keys || !keys.length || keys.length > FORIN_UNROLL_MAX) return null
629
+
630
+ const rest = body.slice(2)
631
+ const realBody = rest.length === 1 ? rest[0] : [';', ...rest]
632
+ // Keep the pooled loop when unrolling would multiply a heavy body across many keys.
633
+ if (keys.length * forInBodyCost(realBody) > FORIN_UNROLL_BUDGET) return null
634
+ // Substitution safety, mirroring unrollSmallConstFor: no reassignment/redeclare
635
+ // of the loop var, no nested closure capturing it (cloneWithSubst skips `=>`),
636
+ // and no break/continue targeting this loop.
637
+ if (hasOwnBreakOrContinue(realBody) || containsNestedClosure(realBody) || containsDeclOf(realBody, target)) return null
638
+ if (isReassigned(realBody, target)) return null
639
+
640
+ const out = []
641
+ for (const key of keys) out.push(...emitVoid(cloneWithSubst(realBody, new Map([[target, ['str', key]]]))))
642
+ return out.length ? out : ['nop']
643
+ }
644
+
472
645
  function canThrow(body, seen = new Set()) {
473
646
  if (!Array.isArray(body)) return false
474
647
  const op = body[0]
@@ -581,6 +754,40 @@ export function toBool(node) {
581
754
  return toBoolFromEmitted(emit(node))
582
755
  }
583
756
 
757
+ // `(a / b) | 0` (the JS integer-division idiom) → i32.div_s. jz otherwise lowers `/`
758
+ // to f64.div + ToInt32, paying two i32→f64 converts and the trunc; i32.div_s is
759
+ // direct and lets the wasm backend magic-multiply a constant divisor. Bit-exact for
760
+ // all i32 a,b: |a|<2³³≪2⁵³ so the f64 quotient never rounds across the truncation
761
+ // boundary — EXCEPT b=0 (`(a/0)|0` is ToInt32(±Inf)=0, but i32.div_s traps) and
762
+ // INT_MIN/-1 (ToInt32 wraps to INT_MIN, i32.div_s traps); both guarded. A constant
763
+ // divisor folds the guards away. `exprType==='i32'` excludes unsigned operands
764
+ // (those return 'f64'), where div_s would misread the sign. Returns IR or null.
765
+ const INT_MIN_I32 = -2147483648
766
+ function tryIntDivTrunc(aNode, bNode) {
767
+ const o = ctx.transform.optimize
768
+ if (!o || o.intDivLower === false) return null
769
+ const L = ctx.func.locals
770
+ if (exprType(aNode, L) !== 'i32' || exprType(bNode, L) !== 'i32') return null
771
+ const dv = intLiteralValue(bNode)
772
+ if (dv != null) { // constant divisor — no runtime guard
773
+ const va = asI32(emit(aNode))
774
+ if (dv === 0) return typed(['block', ['result', 'i32'], ['drop', va], ['i32.const', 0]], 'i32')
775
+ if (dv === -1) return typed(['i32.sub', ['i32.const', 0], va], 'i32') // -a, wraps at INT_MIN
776
+ return typed(['i32.div_s', va, ['i32.const', dv | 0]], 'i32')
777
+ }
778
+ // Runtime divisor needs a,b repeated across the guard; only intercept when both are
779
+ // simple re-emittable operands (var / literal) so re-emit is pure and side-effect-free.
780
+ const simple = (n) => typeof n === 'string' || intLiteralValue(n) != null
781
+ if (!simple(aNode) || !simple(bNode)) return null
782
+ const A = () => asI32(emit(aNode)), B = () => asI32(emit(bNode))
783
+ return typed(['if', ['result', 'i32'], ['i32.eqz', B()],
784
+ ['then', ['i32.const', 0]],
785
+ ['else', ['if', ['result', 'i32'],
786
+ ['i32.and', ['i32.eq', A(), ['i32.const', INT_MIN_I32]], ['i32.eq', B(), ['i32.const', -1]]],
787
+ ['then', A()],
788
+ ['else', ['i32.div_s', A(), B()]]]]], 'i32')
789
+ }
790
+
584
791
  /** Coerce an emitted arg IR to match a callee param. Param may carry ptrKind (pointer-ABI
585
792
  * i32 offset), else falls back to numeric WASM type coercion. */
586
793
  function coerceArg(ir, param) {
@@ -721,7 +928,7 @@ export function emitDecl(...inits) {
721
928
  // Monotonic-extension fields (`o.newProp = …`) carry no literal value —
722
929
  // they init to undefined so a read before the write matches JS.
723
930
  const flatDecl = ctx.func.flatObjects?.get(name)
724
- if (flatDecl && Array.isArray(init) && init[0] === '{}') {
931
+ if (flatDecl && Array.isArray(init) && (init[0] === '{}' || init[0] === '[' || init[0] === '[]')) {
725
932
  for (let j = 0; j < flatDecl.names.length; j++)
726
933
  result.push(['local.set', `$${name}#${j}`,
727
934
  flatDecl.values[j] === undefined ? undefExpr() : asF64(emit(flatDecl.values[j]))])
@@ -790,6 +997,17 @@ export function emitDecl(...inits) {
790
997
  if (!ctx.func.directClosures) ctx.func.directClosures = new Map()
791
998
  ctx.func.directClosures.set(name, val.closureBodyName)
792
999
  }
1000
+ // Copy propagation of a direct closure: `let g = add`, where `add` is a non-escaping
1001
+ // directly-callable closure, makes `g` directly callable too — `g` holds the same
1002
+ // closure value, so `g(…)` calls add's body with g's value as env. This is what
1003
+ // devirtualizes `let arr = [add]; arr[0](…)`: array scalarization rewrites it to
1004
+ // `let g = add; g(…)` before emit (D3), and also covers the explicit `let g = arr[0]`.
1005
+ // Same soundness gate as the direct-closure case: stable binding (not reassigned),
1006
+ // not boxed, not global.
1007
+ if (typeof init === 'string' && ctx.func.directClosures?.has(init) && !ctx.func.boxed.has(name)
1008
+ && !isGlobal(name) && ctx.func.body && !isReassigned(ctx.func.body, name)) {
1009
+ ctx.func.directClosures.set(name, ctx.func.directClosures.get(init))
1010
+ }
793
1011
  if (ctx.func.boxed.has(name)) {
794
1012
  const cell = ctx.func.boxed.get(name)
795
1013
  ctx.func.locals.set(cell, 'i32')
@@ -865,8 +1083,14 @@ export function emitDecl(...inits) {
865
1083
  } else {
866
1084
  coerced = localType === 'v128' ? val : localType === 'f64' ? asF64(val) : val.type === 'i32' ? val : toI32(val)
867
1085
  }
868
- if (!(isLit(coerced) && coerced[1] === 0 && !Object.is(coerced[1], -0) && !ctx.func.stack.length))
1086
+ // `let x = 0` at function scope is normally elided — WASM zero-inits locals. But loop
1087
+ // unrolling flattens iteration bodies into one scope, so the 2nd+ `let x = 0` are
1088
+ // genuine RE-inits between iterations (e.g. a nested reduce's accumulator). Elide only
1089
+ // the FIRST per name; emit the rest as resets. (Names are preserved — no renaming.)
1090
+ const zeroInit = isLit(coerced) && coerced[1] === 0 && !Object.is(coerced[1], -0) && !ctx.func.stack.length
1091
+ if (!zeroInit || ctx.func.zeroInitSeen?.has(name))
869
1092
  result.push(['local.set', `$${name}`, coerced])
1093
+ else (ctx.func.zeroInitSeen ??= new Set()).add(name)
870
1094
 
871
1095
  const schemaId = ctx.schema.idOf?.(name)
872
1096
  if (ctx.func.localProps?.has(name) && schemaId != null) {
@@ -1705,7 +1929,9 @@ function tryStaticDispatch({ obj, method, vt, callMethod }) {
1705
1929
  // emitter which already knows how to handle them.
1706
1930
  function tryRuntimeStringFork({ obj, method, vt, callMethod }) {
1707
1931
  const strKey = `.string:${method}`, genKey = `.${method}`
1708
- if ((!vt || vt === VAL.ARRAY) && ctx.core.emit[strKey] && ctx.core.emit[genKey]) {
1932
+ // VAL.ARRAY is structurally incompatible with PTR.STRING no fork needed.
1933
+ // Only fork when vt is truly unknown (!vt), not for proven types.
1934
+ if (!vt && ctx.core.emit[strKey] && ctx.core.emit[genKey]) {
1709
1935
  const t = `${T}rt${ctx.func.uniq++}`, tt = `${T}rtt${ctx.func.uniq++}`
1710
1936
  ctx.func.locals.set(t, 'f64'); ctx.func.locals.set(tt, 'i32')
1711
1937
  const strEmitter = ctx.core.emit[strKey]
@@ -1840,6 +2066,7 @@ function externalMethodFallback({ obj, method, parsed }) {
1840
2066
  // can target js and wasi from one source; users who want fail-fast
1841
2067
  // pass `strict: true` (handled above).
1842
2068
  if (ctx.transform.host === 'wasi') return undefExpr()
2069
+ warnDeopt('deopt-method', `method call \`${typeof obj === 'string' ? obj : '<expr>'}.${method}(…)\` on a value whose type couldn't be resolved dispatches through the JS host (\`__ext_call\`) — a wasm→JS round-trip per call, orders of magnitude slower than a direct call. Restructure so the receiver's type is provable, or keep it off the hot path.`)
1843
2070
  inc('__ext_call')
1844
2071
  ctx.features.external = true
1845
2072
  const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
@@ -2018,9 +2245,21 @@ function tryDirectClosureCall(callee, parsed) {
2018
2245
  // export-param path gives. An arg we can't prove numeric poisons the slot to false.
2019
2246
  const pt = (ctx.closure.paramTypes ||= new Map())
2020
2247
  let row = pt.get(bodyName); if (!row) pt.set(bodyName, row = [])
2248
+ // Parallel typed-array ctor lattice: a param passed the SAME typed-array ctor at
2249
+ // every direct call site is a TYPED param, so its body reads (`buf[i]`) take the
2250
+ // typed fast-path instead of the dynamic `__typed_idx`/`__len` route that drags in
2251
+ // the string runtime. `null` (sticky) once two sites disagree or an arg isn't a
2252
+ // known typed array — the same monotone meet as the numeric row. Mirrors the named-fn
2253
+ // applyTypedPointerParamAbi, restricted to non-escaping (directly-called) closures.
2254
+ const tc = (ctx.closure.paramTypedCtors ||= new Map())
2255
+ let tcRow = tc.get(bodyName); if (!tcRow) tc.set(bodyName, tcRow = [])
2021
2256
  for (let i = 0; i < n; i++) {
2022
2257
  const numeric = valTypeOf(parsed.normal[i]) === VAL.NUMBER
2023
2258
  row[i] = row[i] === undefined ? numeric : (row[i] && numeric)
2259
+ const arg = parsed.normal[i]
2260
+ const ctor = typeof arg === 'string' && valTypeOf(arg) === VAL.TYPED ? (ctx.types.typedElem?.get(arg) ?? null) : null
2261
+ if (tcRow[i] === undefined) tcRow[i] = ctor
2262
+ else if (tcRow[i] !== ctor) tcRow[i] = null
2024
2263
  }
2025
2264
  // Track the fewest args any call passed: a slot at index ≥ minArgc is omitted by some call
2026
2265
  // site (padded with UNDEF_NAN), so it may be undefined — emitClosureBody flags it nullable.
@@ -2373,9 +2612,22 @@ export const emitter = {
2373
2612
  // A BOOL operand renders "true"/"false" rather than its 0/1 carrier.
2374
2613
  const strOperand = (vt, n) => vt === VAL.OBJECT ? typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
2375
2614
  : vt === VAL.BOOL ? emitBoolStr(n) : asF64(emit(n))
2376
- const ea = strOperand(vtA, a)
2377
- const eb = strOperand(vtB, b)
2378
- return typed(ctx.abi.string.ops.cat(ea, eb, ctx), 'f64')
2615
+ // Coercion-free sides are already strings: a known STRING is raw; OBJECT/BOOL
2616
+ // were stringified by `strOperand`. An unknown side still needs ToString, but
2617
+ // we can apply it *once* (explicit `__to_str` via `strI64`) and join with
2618
+ // concatRaw — equivalent to `__str_concat`'s internal `__to_str` on that side,
2619
+ // while NOT re-coercing the already-string side. This drops the redundant
2620
+ // per-append `__to_str` on the accumulator in `s += part` (s proven STRING):
2621
+ // - both coercion-free → concatRaw(ea, eb)
2622
+ // - one unknown → concatRaw(known, __to_str(unknown))
2623
+ // - both unknown → cat (unchanged; its runtime __to_str covers both)
2624
+ const coercionFree = (vt) => vt === VAL.STRING || vt === VAL.OBJECT || vt === VAL.BOOL
2625
+ const cfA = coercionFree(vtA), cfB = coercionFree(vtB)
2626
+ const strI64 = (n) => typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
2627
+ if (cfA && cfB) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2628
+ if (cfA) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strI64(b), ctx), 'f64')
2629
+ if (cfB) return typed(ctx.abi.string.ops.concatRaw(strI64(a), strOperand(vtB, b), ctx), 'f64')
2630
+ return typed(ctx.abi.string.ops.cat(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2379
2631
  }
2380
2632
  if (vtA === VAL.BIGINT || vtB === VAL.BIGINT)
2381
2633
  return fromI64(['i64.add', asI64(emit(a)), asI64(emit(b))])
@@ -2409,7 +2661,7 @@ export const emitter = {
2409
2661
  // op whose result can exceed i32, so `i32.add` would wrap (4294967295+1→0).
2410
2662
  // Widen to f64 — never wrap — matching spec. Only `>>>0`/`|0`/imul wrap.
2411
2663
  if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.add', va, vb], 'i32')
2412
- return typed(['f64.add', toNumF64(a, va), toNumF64(b, vb)], 'f64')
2664
+ return typed(['f64.add', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2413
2665
  },
2414
2666
  '-': (a, b) => {
2415
2667
  if (ctx.func._expect === 'void' && isPostfix(a, '++', b)) return emit(a, 'void')
@@ -2424,7 +2676,7 @@ export const emitter = {
2424
2676
  // Unsigned uint32 operand: JS `-` is float (can go negative / exceed i32),
2425
2677
  // so avoid the wrapping i32.sub fast-path. See `+` above.
2426
2678
  if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb)) return typed(['i32.sub', va, vb], 'i32')
2427
- return typed(['f64.sub', toNumF64(a, va), toNumF64(b, vb)], 'f64')
2679
+ return typed(['f64.sub', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2428
2680
  },
2429
2681
  'u+': a => {
2430
2682
  if (valTypeOf(a) === VAL.BIGINT)
@@ -2454,7 +2706,7 @@ export const emitter = {
2454
2706
  // `.unsigned` operand is a uint32 ([0, 2^32)); its product can exceed i32, so
2455
2707
  // `i32.mul` would wrap ((2^32-1)*2 → -2). Widen to f64 — see `+` above.
2456
2708
  if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb) && mulFitsI32(va, vb)) return typed(['i32.mul', va, vb], 'i32')
2457
- return typed(['f64.mul', toNumF64(a, va), toNumF64(b, vb)], 'f64')
2709
+ return typed(['f64.mul', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2458
2710
  },
2459
2711
  '/': (a, b) => {
2460
2712
  if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
@@ -2462,7 +2714,7 @@ export const emitter = {
2462
2714
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a / b, b => b !== 0)
2463
2715
  if (_f) return _f
2464
2716
  if (isLit(vb) && litVal(vb) === 1) return toNumF64(a, va)
2465
- return typed(['f64.div', toNumF64(a, va), toNumF64(b, vb)], 'f64')
2717
+ return typed(['f64.div', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2466
2718
  },
2467
2719
  '%': (a, b) => {
2468
2720
  if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
@@ -2539,6 +2791,15 @@ export const emitter = {
2539
2791
  const elseRefs = extractRefinements(a, new Map(), false)
2540
2792
  const vb = withRefinements(thenRefs, b, () => emit(b))
2541
2793
  const vc = withRefinements(elseRefs, c, () => emit(c))
2794
+ // `cond ? 1 : 0` is the condition bit itself; `cond ? 0 : 1` its negation. `cond`
2795
+ // (truthyIR) is already canonical 0/1, so the select + two const arms collapse to
2796
+ // the bit. (Both arms are literals here, so dropping their emitted IR is side-effect
2797
+ // free.) Mirrors what `+(x > 0)` already produces.
2798
+ if (isLit(vb) && isLit(vc)) {
2799
+ const lb = litVal(vb), lc = litVal(vc)
2800
+ if (lb === 1 && lc === 0) return typed(cond, 'i32')
2801
+ if (lb === 0 && lc === 1) return typed(['i32.eqz', cond], 'i32')
2802
+ }
2542
2803
  // L: Use WASM select for pure ternaries — branchless, smaller bytecode
2543
2804
  if (vb.type === 'i32' && vc.type === 'i32') {
2544
2805
  // A single i32 select is only sound when BOTH arms' i32 carriers mean the same
@@ -2577,10 +2838,24 @@ export const emitter = {
2577
2838
  const refPayload = (vtb && vtb === vtc && REF_EQ_KINDS.has(vtb))
2578
2839
  || vb.closureFuncIdx != null || vc.closureFuncIdx != null
2579
2840
  || isNaNBoxLit(fb) || isNaNBoxLit(fc)
2580
- const numericB = isI32Num(vb) || vb.valKind === VAL.NUMBER || vtb === VAL.NUMBER
2581
- const numericC = isI32Num(vc) || vc.valKind === VAL.NUMBER || vtc === VAL.NUMBER
2582
- const branchB = numericB && !numericC ? canonNum(fb) : fb
2583
- const branchC = numericC && !numericB ? canonNum(fc) : fc
2841
+ const numericB = isNumArm(vb, b, vtb)
2842
+ const numericC = isNumArm(vc, c, vtc)
2843
+ // Peephole: `cond ? 1 : 0` (or `cond ? 0 : 1`) is just `f64.convert_i32_s(cond)`
2844
+ // the select collapses because cond is already 0/1. Saves 5 instructions.
2845
+ const isOneZero = (one, zero) => {
2846
+ const o = one, z = zero
2847
+ return o.type === 'i32' && Array.isArray(o) && o[0] === 'i32.const' && o[1] === 1 &&
2848
+ z.type === 'i32' && Array.isArray(z) && z[0] === 'i32.const' && z[1] === 0
2849
+ }
2850
+ if ((isOneZero(vb, vc) || isOneZero(vc, vb)) && !numericB && !numericC) {
2851
+ const condBool = truthyIR(emit(a))
2852
+ const n = isOneZero(vb, vc)
2853
+ ? typed(['f64.convert_i32_s', condBool], 'f64')
2854
+ : typed(['f64.convert_i32_s', ['i32.eqz', condBool]], 'f64')
2855
+ n.valKind = VAL.NUMBER
2856
+ return n
2857
+ }
2858
+ const branchB = canonArm(fb, numericB, numericC), branchC = canonArm(fc, numericC, numericB)
2584
2859
  const markNumeric = (n) => {
2585
2860
  if (numericB && numericC) n.valKind = VAL.NUMBER
2586
2861
  return n
@@ -2599,6 +2874,14 @@ export const emitter = {
2599
2874
  },
2600
2875
 
2601
2876
  '&&': (a, b) => {
2877
+ // Range-check fusion: `x >= LO && x <= HI` (x a pure i32 local, LO ≤ HI compile-time
2878
+ // constants) collapses to one unsigned compare `(x - LO) <=u (HI - LO)` — a subtract
2879
+ // plus a branch instead of two compares, an AND, and a short-circuit branch. This is
2880
+ // the per-char cost in scanners/parsers (digit/alpha classification) and in any
2881
+ // two-sided bounds check. Restricted to a local `x` so evaluating it once (the fused
2882
+ // form) matches the original's twice-read, side-effect-free semantics.
2883
+ const fused = fuseRangeCheck(a, b)
2884
+ if (fused) return fused
2602
2885
  const va = emit(a)
2603
2886
  // Constant-folded literal: pre-bind under truthy refinements (b runs only when a was truthy).
2604
2887
  if (isLit(va)) {
@@ -2630,14 +2913,24 @@ export const emitter = {
2630
2913
  ['else', typed(['f64.convert_i32_s', ['local.get', `$${t}`]], 'f64')]], 'f64')
2631
2914
  }
2632
2915
  const t = temp()
2633
- const teed = typed(['local.tee', `$${t}`, asF64(va)], 'f64')
2634
- return typed(['if', ['result', 'f64'],
2635
- toBoolFromEmitted(teed),
2636
- ['then', asF64(emitRight())],
2916
+ const numA = isNumArm(va, a)
2917
+ const vb = emitRight(), numB = isNumArm(vb, b)
2918
+ // `a` is the else-arm result (returned when falsy — incl NaN), so canon a lone-numeric
2919
+ // `a` before the tee: `$t` then feeds both the result and the cond canonically.
2920
+ const teed = typed(['local.tee', `$${t}`, canonArm(asF64(va), numA, numB)], 'f64')
2921
+ // A numeric left arm tests truthiness NaN-by-value (not __is_truthy, which mis-reads
2922
+ // x86's sign-set NaN as truthy) — tag it so truthyIR takes that path.
2923
+ if (numA) teed.valKind = VAL.NUMBER
2924
+ return typed(['if', ['result', 'f64'], toBoolFromEmitted(teed),
2925
+ ['then', canonArm(asF64(vb), numB, numA)],
2637
2926
  ['else', ['local.get', `$${t}`]]], 'f64')
2638
2927
  },
2639
2928
 
2640
2929
  '||': (a, b) => {
2930
+ // Outside-range fusion (the complement of `&&`): `x < LO || x > HI` → one unsigned
2931
+ // compare `(x - LO) >u (HI - LO)`. Common in validation (`if (c < 'a' || c > 'z') …`).
2932
+ const fusedOr = fuseRangeCheckOr(a, b)
2933
+ if (fusedOr) return fusedOr
2641
2934
  const va = emit(a)
2642
2935
  // Constant-folded literal: pre-bind under falsy refinements (b runs only when a was falsy).
2643
2936
  if (isLit(va)) {
@@ -2665,22 +2958,30 @@ export const emitter = {
2665
2958
  ['else', asF64(vb)]], 'f64')
2666
2959
  }
2667
2960
  const t = temp()
2961
+ const numA = isNumArm(va, a)
2962
+ const vb = emitRight(), numB = isNumArm(vb, b)
2963
+ // `a` (then-arm) is returned only when truthy — hence never NaN — so it needs no canon;
2964
+ // the cond's NaN-safety comes from the valKind tag. Only the else (b) arm can surface
2965
+ // as a numeric NaN.
2668
2966
  const teed = typed(['local.tee', `$${t}`, asF64(va)], 'f64')
2669
- return typed(['if', ['result', 'f64'],
2670
- toBoolFromEmitted(teed),
2967
+ if (numA) teed.valKind = VAL.NUMBER // numeric left arm: NaN-safe truthiness (see `&&`)
2968
+ return typed(['if', ['result', 'f64'], toBoolFromEmitted(teed),
2671
2969
  ['then', ['local.get', `$${t}`]],
2672
- ['else', asF64(emitRight())]], 'f64')
2970
+ ['else', canonArm(asF64(vb), numB, numA)]], 'f64')
2673
2971
  },
2674
2972
 
2675
2973
  // a ?? b: returns b only if a is nullish
2676
2974
  '??': (a, b) => {
2677
- const va = emit(a)
2975
+ const va = emit(a), vb = emit(b)
2678
2976
  const t = temp()
2977
+ const numA = isNumArm(va, a), numB = isNumArm(vb, b)
2978
+ // Both arms can surface as the (untyped) result — `a` when non-nullish (a NaN is not
2979
+ // nullish, so it IS returned), `b` otherwise. Canon a lone-numeric arm; `a` before the
2980
+ // tee so `local.get $t` is canonical. The cond is isNullish, robust to non-canon NaN.
2679
2981
  return typed(['if', ['result', 'f64'],
2680
- // Check: is a NOT nullish?
2681
- ['i32.eqz', isNullish(['local.tee', `$${t}`, asF64(va)])],
2982
+ ['i32.eqz', isNullish(['local.tee', `$${t}`, canonArm(asF64(va), numA, numB)])],
2682
2983
  ['then', ['local.get', `$${t}`]],
2683
- ['else', asF64(emit(b))]], 'f64')
2984
+ ['else', canonArm(asF64(vb), numB, numA)]], 'f64')
2684
2985
  },
2685
2986
 
2686
2987
  'void': a => {
@@ -2725,6 +3026,10 @@ export const emitter = {
2725
3026
  ].map(([op, fn]) => [op, (a, b) => {
2726
3027
  if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
2727
3028
  return fromI64([`i64.${fn}`, asI64(emit(a)), asI64(emit(b))])
3029
+ if (op === '|') { // `(x / y) | 0` integer-division idiom → i32.div_s
3030
+ const divN = intLiteralValue(b) === 0 ? a : intLiteralValue(a) === 0 ? b : null
3031
+ if (Array.isArray(divN) && divN[0] === '/') { const r = tryIntDivTrunc(divN[1], divN[2]); if (r) return r }
3032
+ }
2728
3033
  const va = emit(a), vb = emit(b)
2729
3034
  if (isLit(va) && isLit(vb)) {
2730
3035
  const la = litVal(va), lb = litVal(vb)
@@ -2794,6 +3099,12 @@ export const emitter = {
2794
3099
  const unrolled = unrollSmallConstFor(init, cond, step, body)
2795
3100
  if (unrolled) return unrolled
2796
3101
  }
3102
+ // for-in over a static schema → unroll with key-literal substitution (folds
3103
+ // o[k] to schema slots). Recognized via the for-in-exclusive __keys_ro intrinsic.
3104
+ if (!labeledContinue && (!ctx.transform.optimize || ctx.transform.optimize.forInUnroll !== false)) {
3105
+ const fu = unrollForIn(init, cond, step, body)
3106
+ if (fu) return fu
3107
+ }
2797
3108
  // Lift constant array/object literals out of the loop (allocate once, not per
2798
3109
  // iteration) when they are read-only + non-escaping inside it. Strip them from the
2799
3110
  // body up front so freshBoxed / continue analysis see the reduced body.