jz 0.6.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 (63) hide show
  1. package/README.md +99 -72
  2. package/bench/README.md +253 -100
  3. package/bench/bench.svg +58 -81
  4. package/cli.js +85 -12
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6196 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +222 -35
  9. package/interop.js +240 -141
  10. package/layout.js +34 -18
  11. package/module/array.js +99 -111
  12. package/module/collection.js +124 -30
  13. package/module/console.js +1 -1
  14. package/module/core.js +163 -19
  15. package/module/json.js +3 -3
  16. package/module/math.js +162 -3
  17. package/module/number.js +268 -13
  18. package/module/object.js +37 -5
  19. package/module/regex.js +8 -7
  20. package/module/simd.js +37 -5
  21. package/module/string.js +203 -168
  22. package/module/typedarray.js +565 -112
  23. package/package.json +26 -7
  24. package/src/abi/string.js +29 -29
  25. package/src/ast.js +19 -2
  26. package/src/autoload.js +3 -0
  27. package/src/compile/analyze-scans.js +174 -11
  28. package/src/compile/analyze.js +101 -4
  29. package/src/compile/cse-load.js +200 -0
  30. package/src/compile/emit-assign.js +82 -20
  31. package/src/compile/emit.js +592 -51
  32. package/src/compile/index.js +504 -89
  33. package/src/compile/infer.js +36 -1
  34. package/src/compile/loop-divmod.js +109 -0
  35. package/src/compile/loop-model.js +91 -0
  36. package/src/compile/loop-square.js +102 -0
  37. package/src/compile/narrow.js +275 -41
  38. package/src/compile/peel-stencil.js +215 -0
  39. package/src/compile/plan/advise.js +55 -1
  40. package/src/compile/plan/common.js +29 -0
  41. package/src/compile/plan/index.js +8 -1
  42. package/src/compile/plan/inline.js +180 -22
  43. package/src/compile/plan/literals.js +313 -24
  44. package/src/compile/plan/scope.js +115 -39
  45. package/src/compile/program-facts.js +21 -2
  46. package/src/ctx.js +96 -19
  47. package/src/helper-counters.js +137 -0
  48. package/src/ir.js +157 -20
  49. package/src/kind-traits.js +34 -3
  50. package/src/kind.js +79 -4
  51. package/src/op-policy.js +5 -2
  52. package/src/ops.js +119 -0
  53. package/src/optimize/index.js +1274 -151
  54. package/src/optimize/recurse.js +182 -0
  55. package/src/optimize/vectorize.js +4187 -253
  56. package/src/prepare/index.js +75 -14
  57. package/src/prepare/lift-iife.js +149 -0
  58. package/src/reps.js +6 -2
  59. package/src/static.js +9 -0
  60. package/src/type.js +63 -51
  61. package/src/wat/assemble.js +286 -21
  62. package/src/widen.js +21 -0
  63. package/src/wat/optimize.js +0 -3760
@@ -25,8 +25,10 @@ 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 { includeForStringOnly } from '../autoload.js'
30
+ import { FITS_I32_MAX } from '../widen.js'
31
+ import { nonNegIntLiteral, intLiteralValue, staticPropertyKey } from '../static.js'
30
32
  import { findFreeVars } from './analyze.js'
31
33
  import {
32
34
  containsNestedClosure, containsNestedLoop, nestedSmallLoopBudget,
@@ -41,7 +43,7 @@ import {
41
43
  NULL_IR, nullExpr, undefExpr, MAX_CLOSURE_ARITY,
42
44
  WASM_OPS, SPREAD_MUTATORS, BOXED_MUTATORS,
43
45
  mkPtrIR, ptrOffsetIR, ptrTypeIR, ptrTypeEq, dispatchByPtrType, sidecarOverride, valKindToPtr,
44
- isLit, litVal, isNullishLit, isPureIR, emitNum, f64rem, toNumF64, toStrI64,
46
+ isLit, litVal, isNullishLit, isPureIR, emitNum, f64rem, toNumF64, toStrI64, maskBound,
45
47
  truthyIR, toBoolFromEmitted, isPostfix,
46
48
  isGlobal, isConst, usesDynProps, needsDynShadow,
47
49
  temp, tempI32, tempI64, allocPtr,
@@ -76,7 +78,45 @@ const stringOps = (node) => {
76
78
  // instead route through ToNumber (`toNumF64`), which performs ToPrimitive.
77
79
  const isI32Num = (v) => v.type === 'i32' && v.ptrKind == null
78
80
 
81
+ // Peel an emitted operand back to its raw i32 value when it carries one: a value already
82
+ // typed i32 (integer literals included — they emit as i32.const), or an integer read wrapped
83
+ // in f64.convert_i32_s/u (typed-array / i32-global reads default to the f64 rep). Else null.
84
+ const peelI32 = (v) =>
85
+ isI32Num(v) ? v
86
+ : (Array.isArray(v) && (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u'))
87
+ ? (Array.isArray(v[1]) ? typed(v[1], 'i32') : v[1])
88
+ : null
89
+
90
+ // Native wrapping i32 arithmetic for `+`/`-`/`*` whose result is consumed as i32. Peels the
91
+ // f64.convert_i32_s/u that integer reads (`DX[i]`, a global Int32Array) wrap their load in, so
92
+ // `ax = ax + DX[i]` (ax and DX[i] both i32) lowers to one i32.add instead of the
93
+ // convert → f64.add → trunc_sat round-trip that doubled hot integer loops (ulam's spiral walk,
94
+ // ring-buffer indexing). Bit-identical for an i32 result: ToInt32(exact) ≡ two's-complement wrap.
95
+ // Gated on exprType(whole expr)==='i32' so an f64-consumed sum — or an unsigned-wide (uint32)
96
+ // operand, which exprType already reports as f64 — still widens. Returns null when inapplicable.
97
+ const tryI32Arith = (wasmOp, astOp, a, b, va, vb) => {
98
+ const pa = peelI32(va); if (pa == null) return null
99
+ const pb = peelI32(vb); if (pb == null) return null
100
+ if (exprType([astOp, a, b], ctx.func.locals) !== 'i32') return null
101
+ return typed([wasmOp, pa, pb], 'i32')
102
+ }
103
+
104
+ // f64 arithmetic that can MINT a sign-nondeterministic NaN (0/0, ∞−∞, 0·∞, x%0): on x86
105
+ // these are 0xFFF8…, on arm 0x7FF8…. sqrt/min/max/neg are NOT here — they canon at their
106
+ // own emit (math.js / unary `-`), so they reach canonNum already canonical.
107
+ const NAN_MINTING = new Set(['f64.div', 'f64.add', 'f64.sub', 'f64.mul'])
108
+
79
109
  const canonNum = (node) => {
110
+ // Fold a possibly-non-canonical NaN to the canonical number-NaN before it reaches a
111
+ // bit-comparing consumer (__is_truthy / untyped === / typeof), which match the canonical
112
+ // NaN by bits and so misread x86's 0xFFF8 as truthy. ONLY an un-canon'd NaN-minting
113
+ // arithmetic op can carry such a value — literals, i32-conversions, opaque locals/calls
114
+ // (canonical by the canon-at-source invariant) and already-canon'd shapes don't — so
115
+ // skipping everything else keeps the size win. (The broken middle ground was
116
+ // `02873d0`'s `isNumericIR` skip, which dropped canon for f64.div too → x86 miscompile.)
117
+ const arith = Array.isArray(node) &&
118
+ (NAN_MINTING.has(node[0]) || (node[0] === 'call' && node[1] === '$__rem'))
119
+ if (!arith) return node
80
120
  const t = temp('cn')
81
121
  return typed(['block', ['result', 'f64'],
82
122
  ['local.set', `$${t}`, node],
@@ -86,6 +126,21 @@ const canonNum = (node) => {
86
126
  ['f64.ne', ['local.get', `$${t}`], ['local.get', `$${t}`]]]], 'f64')
87
127
  }
88
128
 
129
+ // Is an emitted arm `v` (AST `node`) a plain NUMBER? The predicate the two-arm merges
130
+ // (?:, ??) share to decide canon: an i32 number, NUMBER-tagged IR, or a NUMBER
131
+ // value-type qualifies; a pointer/opaque arm does not. `vt` is the node's resolved
132
+ // value-type — pass it when already computed to avoid the re-resolve.
133
+ const isNumArm = (v, node, vt = resolveValType(node, valTypeOf, lookupValType)) =>
134
+ isI32Num(v) || v.valKind === VAL.NUMBER || vt === VAL.NUMBER
135
+
136
+ // One arm of a two-arm f64 merge (?:, ??, ||, &&) whose result may be bit-tested while
137
+ // untyped. Canon (canonNum, a no-op unless the arm is NaN-minting arithmetic) ONLY a
138
+ // LONE numeric arm: when both arms are numeric the merge is value-typed NUMBER and read
139
+ // NaN-by-value (no canon); when the other arm is opaque the result is untyped, so a
140
+ // non-canonical NaN here would be misread by __is_truthy — fold it. A pointer arm
141
+ // (isNum=false) is never touched (canon would destroy its NaN-box).
142
+ const canonArm = (f, isNum, otherNum) => isNum && !otherNum ? canonNum(f) : f
143
+
89
144
  // Host globals auto-imported as `(import "env" "name" (global … i64))` when
90
145
  // referenced as a value. Drained from ctx.core.hostGlobals at assembly.
91
146
  const HOST_GLOBALS = new Set(['WebAssembly', 'globalThis', 'self', 'window', 'global', 'process'])
@@ -98,6 +153,36 @@ const HOST_GLOBALS = new Set(['WebAssembly', 'globalThis', 'self', 'window', 'gl
98
153
  // use — wrapping is exactly its intended semantics, so it stays on the i32 path.
99
154
  const widensUnsigned = (v) => v.unsigned && !v.wrapSafe
100
155
 
156
+ // Strip a redundant NaN-canon wrapper (math.js `canon`) from an operand that
157
+ // feeds a NaN-propagating f64 op. `f64.sqrt`/`min`/`max` mint a sign-nondeterministic
158
+ // NaN that math.js canon-izes so it can't be bit-confused with a NaN-boxed pointer in
159
+ // `===`/`typeof`. But when the result flows straight into `f64.add`/`sub`/`mul`/`div`,
160
+ // the consumer propagates that NaN identically and is itself canon-ized if IT escapes —
161
+ // so the inner per-op canon (local.set + select + f64.ne, ~3 ops) is dead on the
162
+ // critical path. This is THE gap that put sqrt-heavy kernels ~23% behind V8
163
+ // (julia/raymarcher/boids); stripping it makes them match native JS.
164
+ const stripCanon = (v) => {
165
+ if (!v) return v
166
+ if (v.canonOf != null) return typed(v.canonOf, 'f64')
167
+ // A NaN-canon nested in the VALUE arm of a `select` / `(if result f64)` is equally
168
+ // dead: the consumer that called stripCanon (f64.add/sub/mul/div, or a math call)
169
+ // propagates the NaN identically and the outermost escape re-canon-izes. Recurse into
170
+ // the arms so `(cond ? x : -x) + v` (the Perlin-gradient sign-select, and every other
171
+ // conditional negation) drops the per-neg select+f64.ne, same as a bare `x + -y`.
172
+ if (Array.isArray(v)) {
173
+ if (v[0] === 'select' && v.length === 4) {
174
+ const a = stripCanon(v[1]), b = stripCanon(v[2])
175
+ if (a !== v[1] || b !== v[2]) return typed(['select', a, b, v[3]], 'f64')
176
+ } else if (v[0] === 'if' && Array.isArray(v[1]) && v[1][0] === 'result' && v[1][1] === 'f64'
177
+ && Array.isArray(v[3]) && v[3][0] === 'then' && v[3].length === 2
178
+ && Array.isArray(v[4]) && v[4][0] === 'else' && v[4].length === 2) {
179
+ const t = stripCanon(v[3][1]), e = stripCanon(v[4][1])
180
+ if (t !== v[3][1] || e !== v[4][1]) return typed(['if', v[1], v[2], ['then', t], ['else', e]], 'f64')
181
+ }
182
+ }
183
+ return v
184
+ }
185
+
101
186
  const FIRST_CLASS_UNARY_MATH = {
102
187
  'math.abs': 'f64.abs',
103
188
  'math.sqrt': 'f64.sqrt',
@@ -137,10 +222,17 @@ const emitNeg = (a) => {
137
222
  // flipped NaN reads as a tagged value (truthy / not-NaN). Fold any NaN result back
138
223
  // to canonical — the same invariant math.sqrt/min/max keep via `canon` (module/math.js).
139
224
  const t = temp('ng')
140
- return typed(['block', ['result', 'f64'],
141
- ['local.set', `$${t}`, ['f64.neg', toNumF64(a, v)]],
225
+ const raw = ['f64.neg', toNumF64(a, v)]
226
+ const ir = typed(['block', ['result', 'f64'],
227
+ ['local.set', `$${t}`, raw],
142
228
  ['select', ['f64.const', 'nan'], ['local.get', `$${t}`],
143
229
  ['f64.ne', ['local.get', `$${t}`], ['local.get', `$${t}`]]]], 'f64')
230
+ // Tag the un-canon'd `f64.neg` so a NaN-propagating consumer (f64.add/sub/mul/div, which
231
+ // canon-ize on their OWN escape) strips this redundant inner canon — same contract as the
232
+ // sqrt/min/max canons in math.js. A bare `x * -y` / `a - -b` then drops the per-neg
233
+ // select + f64.ne instead of carrying it into the multiply/add.
234
+ ir.canonOf = raw
235
+ return ir
144
236
  }
145
237
 
146
238
  /** Try constant-folding binary arith: returns emitNum(result) or null. */
@@ -154,14 +246,37 @@ const foldConst = (va, vb, fn, guard) =>
154
246
 
155
247
  // JS `*` is an f64 multiply; `i32.mul` yields only the exact product mod 2^32.
156
248
  // Those agree under a ToInt32/ToUint32 sink (and as plain numbers) while the
157
- // 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`.
249
+ // exact product stays f64-exact. A literal qualifies directly; so does a masked
250
+ // operand (`x & 63`, `x >>> k`) whose value is provably bounded. Keeps index
251
+ // arithmetic (`i*4`) and bitwise-masked scales (bytebeat's `t*(m&63)`) on
252
+ // `i32.mul` while routing hash-mix-scale products to `f64.mul`. The FITS_I32_MAX
253
+ // threshold (and the soundness contract with type.js exprType) lives in widen.js.
162
254
  const mulFitsI32 = (va, vb) =>
163
- (isLit(va) && Math.abs(litVal(va)) <= 0x400000) ||
164
- (isLit(vb) && Math.abs(litVal(vb)) <= 0x400000)
255
+ (isLit(va) && Math.abs(litVal(va)) <= FITS_I32_MAX) ||
256
+ (isLit(vb) && Math.abs(litVal(vb)) <= FITS_I32_MAX) ||
257
+ (!isLit(va) && maskBound(va) <= FITS_I32_MAX) ||
258
+ (!isLit(vb) && maskBound(vb) <= FITS_I32_MAX)
259
+
260
+ // Max |value| of an i32-typed operand from a narrowing typed-array load width — the
261
+ // element-read twin of maskBound's `x & 0xff` case (load8_u and `x & 0xff` carry the
262
+ // SAME [0,255] range). Infinity when the magnitude is unbounded. Signed loads reach
263
+ // −2^(w−1), so the magnitude bound is 2^(w−1).
264
+ const I32_LOAD_MAG = { 'i32.load8_s': 128, 'i32.load8_u': 255, 'i32.load16_s': 32768, 'i32.load16_u': 65535 }
265
+ const i32Mag = (v) =>
266
+ !Array.isArray(v) ? Infinity :
267
+ v[0] in I32_LOAD_MAG ? I32_LOAD_MAG[v[0]] :
268
+ (v[0] === 'i32.const' && typeof v[1] === 'number') ? Math.abs(v[1]) :
269
+ (v[0] === 'i32.and' || v[0] === 'i32.shr_u') ? maskBound(v) :
270
+ Infinity
271
+ // `int8[i]*int8[j]` and friends: a product of two range-bounded integer typed-array
272
+ // elements whose magnitudes multiply to ≤ 2^31−1 is FAITHFUL as i32.mul — the exact
273
+ // product fits signed i32, so i32.mul == the true value in EVERY consumer context
274
+ // (i32 sink AND f64 value), independent of the widen pass. Covers i8/u8/i16 pairs and
275
+ // i16×u16 (32768·65535 < 2^31); correctly EXCLUDES u16×u16 (65535² > 2^31). JS `*` of
276
+ // two such reads — the int-conv / correlation / quantised-MAC kernel shape — then rides
277
+ // the i32 ABI (one op, no f64 round-trip) on V8 / JSC / wasmtime alike, and the i32
278
+ // product is lane-vectorizable where the f64 form was not.
279
+ const mulBoundedFaithful = (va, vb) => i32Mag(va) * i32Mag(vb) <= 0x7fffffff
165
280
 
166
281
  /** Emit typeof comparison: typeof x == typeCode → type-aware check. */
167
282
  export function emitTypeofCmp(a, b, cmpOp) {
@@ -307,6 +422,13 @@ function intIndexIR(key) {
307
422
  */
308
423
  const I32_INDEX_OP = { '+': 'i32.add', '-': 'i32.sub', '*': 'i32.mul' }
309
424
  function tryI32Index(e) {
425
+ // Integer literal first — a prepare-wrapped literal `[null, k]` (and a const-int
426
+ // name) is itself an Array, so the operator dispatch below would reject it and
427
+ // bail the WHOLE index to the f64 round-trip. The classic victim is the `+ 1` /
428
+ // `(j + 1)` of a bilinear/stencil gather (`a[(j+1)*W + i + 1]`): one literal leaf
429
+ // forced `convert_i32 … f64.mul/add … trunc_sat_f64_s` across every term.
430
+ const lit = nonNegIntLiteral(e)
431
+ if (lit != null) return typed(['i32.const', lit], 'i32')
310
432
  if (Array.isArray(e)) {
311
433
  const inner = I32_INDEX_OP[e[0]]
312
434
  if (inner && e[2] != null) {
@@ -316,12 +438,31 @@ function tryI32Index(e) {
316
438
  }
317
439
  return null
318
440
  }
319
- const lit = nonNegIntLiteral(e)
320
- if (lit != null) return ['i32.const', lit]
321
441
  return exprType(e, ctx.func.locals) === 'i32' ? asI32(emit(e)) : null
322
442
  }
323
443
  export const emitIndex = (idx) => tryI32Index(idx) ?? asI32(emit(idx))
324
444
 
445
+ /**
446
+ * True when `e` is a pure integer `+`/`-`/`*` tree whose leaves are all i32-typed
447
+ * names/globals or integer literals — no calls, member reads, or indexed reads, so
448
+ * emitting it twice (or in a different rep) is side-effect-free. Used to recognise
449
+ * an i32-local initializer that `tryI32Index` can lower to native wrapping i32
450
+ * arithmetic instead of the f64 round-trip (`convert … f64.mul/add … trunc_sat`).
451
+ * The same residue-mod-2^32 argument as `tryI32Index`: ToInt32 of the exact integer
452
+ * value equals two's-complement wrapping i32, so for an i32 destination the two are
453
+ * bit-identical — even when an intermediate product overflows.
454
+ */
455
+ function isI32ArithTree(e) {
456
+ if (typeof e === 'number') return Number.isInteger(e)
457
+ if (typeof e === 'string') return exprType(e, ctx.func.locals) === 'i32'
458
+ if (!Array.isArray(e)) return false
459
+ const op = e[0]
460
+ if (op == null) return isI32ArithTree(e[1]) // literal wrapper [, v]
461
+ if ((op === '+' || op === '-' || op === '*') && e[2] != null)
462
+ return isI32ArithTree(e[1]) && isI32ArithTree(e[2])
463
+ return false
464
+ }
465
+
325
466
  function emitSingleCharIndexCmp(a, b, negate = false) {
326
467
  const leftLit = stringLiteral(a)
327
468
  const rightLit = stringLiteral(b)
@@ -448,6 +589,55 @@ function emitSubstringEqCmp(a, b, negate = false) {
448
589
  ['i64.reinterpret_f64', ['local.get', `$${o}`]]])], 'i32')
449
590
  }
450
591
 
592
+ // One half of a two-sided range test against a compile-time constant, normalized to
593
+ // an inclusive bound on a *local* `x`: `{ x, lo }` (x ≥ lo) or `{ x, hi }` (x ≤ hi).
594
+ // `>`/`<` fold to the inclusive neighbor; a const on either side is accepted. Returns
595
+ // null for anything else (so the caller leaves the expression untouched).
596
+ function rangeBound(n) {
597
+ if (!Array.isArray(n) || n.length !== 3) return null
598
+ const lc = intLiteralValue(n[1]), rc = intLiteralValue(n[2])
599
+ if (rc != null && typeof n[1] === 'string') { // x op CONST
600
+ if (n[0] === '>=') return { x: n[1], lo: rc }
601
+ if (n[0] === '>') return { x: n[1], lo: rc + 1 }
602
+ if (n[0] === '<=') return { x: n[1], hi: rc }
603
+ if (n[0] === '<') return { x: n[1], hi: rc - 1 }
604
+ }
605
+ if (lc != null && typeof n[2] === 'string') { // CONST op x
606
+ if (n[0] === '<=') return { x: n[2], lo: lc }
607
+ if (n[0] === '<') return { x: n[2], lo: lc + 1 }
608
+ if (n[0] === '>=') return { x: n[2], hi: lc }
609
+ if (n[0] === '>') return { x: n[2], hi: lc - 1 }
610
+ }
611
+ return null
612
+ }
613
+
614
+ // `x >= LO && x <= HI` (x a pure i32 local, LO ≤ HI constants) → `(x - LO) <=u (HI - LO)`.
615
+ // One subtract + one unsigned compare replaces two signed compares, an AND, and the
616
+ // short-circuit branch — the classic range-check trick (valid for any integers via
617
+ // wrapping subtraction). Returns the fused IR, or null to leave `&&` lowering unchanged.
618
+ function fuseRangeCheck(a, b) {
619
+ const ba = rangeBound(a), bb = rangeBound(b)
620
+ if (!ba || !bb || ba.x !== bb.x || (ba.lo != null) === (bb.lo != null)) return null
621
+ const lo = ba.lo ?? bb.lo, hi = ba.hi ?? bb.hi
622
+ if (lo > hi) return null
623
+ const xv = emit(ba.x)
624
+ if (xv.type !== 'i32') return null // f64 (fractional) would mis-fuse
625
+ return typed(['i32.le_u', ['i32.sub', xv, ['i32.const', lo]], ['i32.const', hi - lo]], 'i32')
626
+ }
627
+
628
+ // The complement: `x < LO || x > HI` (the two outside half-checks — one upper-bounded,
629
+ // one lower-bounded, with a gap between) → `(x - LO) >u (HI - LO)`, where [LO, HI] is the
630
+ // inside range. Same trick, negated; returns null to leave `||` lowering unchanged.
631
+ function fuseRangeCheckOr(a, b) {
632
+ const ba = rangeBound(a), bb = rangeBound(b)
633
+ if (!ba || !bb || ba.x !== bb.x || (ba.lo != null) === (bb.lo != null)) return null
634
+ const insideLo = (ba.hi ?? bb.hi) + 1, insideHi = (ba.lo ?? bb.lo) - 1
635
+ if (insideLo > insideHi) return null
636
+ const xv = emit(ba.x)
637
+ if (xv.type !== 'i32') return null
638
+ return typed(['i32.gt_u', ['i32.sub', xv, ['i32.const', insideLo]], ['i32.const', insideHi - insideLo]], 'i32')
639
+ }
640
+
451
641
  // Flow-sensitive type refinement moved to ./flow-types.js (extractRefinements,
452
642
  // predicateRefinement, mergeRefinement, withRefinements). emit.js imports them
453
643
  // from there — see the import block at the top of this file.
@@ -469,6 +659,80 @@ function unrollSmallConstFor(init, cond, step, body) {
469
659
  return out
470
660
  }
471
661
 
662
+ // Max distinct keys a for-in unrolls over (bounds code size; larger key sets keep
663
+ // the pooled-keys loop, which is already allocation-free via __keys_ro).
664
+ const FORIN_UNROLL_MAX = 16
665
+ // Total-expansion ceiling: unroll emits one body copy per key, so the size cost is
666
+ // keys × body, not keys alone. A large body over many keys (e.g. watr's 15-key
667
+ // schema loop) blows up code size for no deopt win — the pooled fallback is already
668
+ // allocation-free. Cap keys × nodeSize(body); past it, keep the loop. (Tuned above
669
+ // every unroll the corpus actually wants — the 16-key cap test lands at 80.)
670
+ const FORIN_UNROLL_BUDGET = 128
671
+ const forInBodyCost = (node) => {
672
+ if (!Array.isArray(node)) return 1
673
+ let n = 1
674
+ for (let i = 1; i < node.length; i++) n += forInBodyCost(node[i])
675
+ return n
676
+ }
677
+
678
+ // Pull the for-in source out of prepare's keys expression: either a bare
679
+ // `__keys_ro(src)` call or the nullish-guarded `cond ? [] : __keys_ro(src)`.
680
+ function keysRoSrc(node) {
681
+ if (!Array.isArray(node)) return null
682
+ if (node[0] === '()' && node[1] === '__keys_ro') return node[2]
683
+ if (node[0] === '?:' || node[0] === '?') {
684
+ const last = node[node.length - 1]
685
+ if (Array.isArray(last) && last[0] === '()' && last[1] === '__keys_ro') return last[2]
686
+ }
687
+ return null
688
+ }
689
+
690
+ // Unroll `for (k in o)` over a static schema. Prepare lowers for-in to a plain
691
+ // for-loop whose key array comes from the for-in-exclusive `__keys_ro` intrinsic,
692
+ // so a loop carrying it IS a for-in. When `o` is a bare OBJECT var with a complete
693
+ // static schema (no computed-key writes — same gate as __keys_ro pooling), replace
694
+ // the loop with one substituted copy of the body per key: the loop variable becomes
695
+ // a string literal, so `o[k]` folds to a static schema slot — no keys array, no
696
+ // per-element dynamic get. Falls back (returns null) to the pooled loop otherwise.
697
+ function unrollForIn(init, cond, step, body) {
698
+ if (!Array.isArray(init) || init[0] !== 'let' || !Array.isArray(init[1]) || init[1][0] !== '=') return null
699
+ const ksVar = init[1][1]
700
+ const src = keysRoSrc(init[1][2])
701
+ if (typeof src !== 'string') return null
702
+ if (!Array.isArray(cond) || cond[0] !== '<') return null
703
+ const ixVar = cond[1]
704
+ if (!Array.isArray(step) || step[0] !== '++' || step[1] !== ixVar) return null
705
+ // body = [';', ['let', ['=', target, ['[]', ksVar, ixVar]]], ...realBody]
706
+ if (!Array.isArray(body) || body[0] !== ';') return null
707
+ const bind = body[1]
708
+ if (!Array.isArray(bind) || bind[0] !== 'let' || !Array.isArray(bind[1]) || bind[1][0] !== '=') return null
709
+ const target = bind[1][1]
710
+ const acc = bind[1][2]
711
+ if (!Array.isArray(acc) || acc[0] !== '[]' || acc[1] !== ksVar || acc[2] !== ixVar) return null
712
+
713
+ // Unroll only with PROOF the schema is complete: a computed-key write adds
714
+ // enumerable keys, so bail if `src` takes one — or if the fact is unavailable
715
+ // (no proof ⇒ no unroll; unrolling drops the dynamic path, so erring safe matters).
716
+ if (!ctx.types.dynWriteVars || ctx.types.dynWriteVars.has(src)) return null
717
+ if (lookupValType(src) !== VAL.OBJECT) return null
718
+ const keys = ctx.schema.resolve(src)
719
+ if (!keys || !keys.length || keys.length > FORIN_UNROLL_MAX) return null
720
+
721
+ const rest = body.slice(2)
722
+ const realBody = rest.length === 1 ? rest[0] : [';', ...rest]
723
+ // Keep the pooled loop when unrolling would multiply a heavy body across many keys.
724
+ if (keys.length * forInBodyCost(realBody) > FORIN_UNROLL_BUDGET) return null
725
+ // Substitution safety, mirroring unrollSmallConstFor: no reassignment/redeclare
726
+ // of the loop var, no nested closure capturing it (cloneWithSubst skips `=>`),
727
+ // and no break/continue targeting this loop.
728
+ if (hasOwnBreakOrContinue(realBody) || containsNestedClosure(realBody) || containsDeclOf(realBody, target)) return null
729
+ if (isReassigned(realBody, target)) return null
730
+
731
+ const out = []
732
+ for (const key of keys) out.push(...emitVoid(cloneWithSubst(realBody, new Map([[target, ['str', key]]]))))
733
+ return out.length ? out : ['nop']
734
+ }
735
+
472
736
  function canThrow(body, seen = new Set()) {
473
737
  if (!Array.isArray(body)) return false
474
738
  const op = body[0]
@@ -581,6 +845,40 @@ export function toBool(node) {
581
845
  return toBoolFromEmitted(emit(node))
582
846
  }
583
847
 
848
+ // `(a / b) | 0` (the JS integer-division idiom) → i32.div_s. jz otherwise lowers `/`
849
+ // to f64.div + ToInt32, paying two i32→f64 converts and the trunc; i32.div_s is
850
+ // direct and lets the wasm backend magic-multiply a constant divisor. Bit-exact for
851
+ // all i32 a,b: |a|<2³³≪2⁵³ so the f64 quotient never rounds across the truncation
852
+ // boundary — EXCEPT b=0 (`(a/0)|0` is ToInt32(±Inf)=0, but i32.div_s traps) and
853
+ // INT_MIN/-1 (ToInt32 wraps to INT_MIN, i32.div_s traps); both guarded. A constant
854
+ // divisor folds the guards away. `exprType==='i32'` excludes unsigned operands
855
+ // (those return 'f64'), where div_s would misread the sign. Returns IR or null.
856
+ const INT_MIN_I32 = -2147483648
857
+ function tryIntDivTrunc(aNode, bNode) {
858
+ const o = ctx.transform.optimize
859
+ if (!o || o.intDivLower === false) return null
860
+ const L = ctx.func.locals
861
+ if (exprType(aNode, L) !== 'i32' || exprType(bNode, L) !== 'i32') return null
862
+ const dv = intLiteralValue(bNode)
863
+ if (dv != null) { // constant divisor — no runtime guard
864
+ const va = asI32(emit(aNode))
865
+ if (dv === 0) return typed(['block', ['result', 'i32'], ['drop', va], ['i32.const', 0]], 'i32')
866
+ if (dv === -1) return typed(['i32.sub', ['i32.const', 0], va], 'i32') // -a, wraps at INT_MIN
867
+ return typed(['i32.div_s', va, ['i32.const', dv | 0]], 'i32')
868
+ }
869
+ // Runtime divisor needs a,b repeated across the guard; only intercept when both are
870
+ // simple re-emittable operands (var / literal) so re-emit is pure and side-effect-free.
871
+ const simple = (n) => typeof n === 'string' || intLiteralValue(n) != null
872
+ if (!simple(aNode) || !simple(bNode)) return null
873
+ const A = () => asI32(emit(aNode)), B = () => asI32(emit(bNode))
874
+ return typed(['if', ['result', 'i32'], ['i32.eqz', B()],
875
+ ['then', ['i32.const', 0]],
876
+ ['else', ['if', ['result', 'i32'],
877
+ ['i32.and', ['i32.eq', A(), ['i32.const', INT_MIN_I32]], ['i32.eq', B(), ['i32.const', -1]]],
878
+ ['then', A()],
879
+ ['else', ['i32.div_s', A(), B()]]]]], 'i32')
880
+ }
881
+
584
882
  /** Coerce an emitted arg IR to match a callee param. Param may carry ptrKind (pointer-ABI
585
883
  * i32 offset), else falls back to numeric WASM type coercion. */
586
884
  function coerceArg(ir, param) {
@@ -721,7 +1019,7 @@ export function emitDecl(...inits) {
721
1019
  // Monotonic-extension fields (`o.newProp = …`) carry no literal value —
722
1020
  // they init to undefined so a read before the write matches JS.
723
1021
  const flatDecl = ctx.func.flatObjects?.get(name)
724
- if (flatDecl && Array.isArray(init) && init[0] === '{}') {
1022
+ if (flatDecl && Array.isArray(init) && (init[0] === '{}' || init[0] === '[' || init[0] === '[]')) {
725
1023
  for (let j = 0; j < flatDecl.names.length; j++)
726
1024
  result.push(['local.set', `$${name}#${j}`,
727
1025
  flatDecl.values[j] === undefined ? undefExpr() : asF64(emit(flatDecl.values[j]))])
@@ -790,6 +1088,17 @@ export function emitDecl(...inits) {
790
1088
  if (!ctx.func.directClosures) ctx.func.directClosures = new Map()
791
1089
  ctx.func.directClosures.set(name, val.closureBodyName)
792
1090
  }
1091
+ // Copy propagation of a direct closure: `let g = add`, where `add` is a non-escaping
1092
+ // directly-callable closure, makes `g` directly callable too — `g` holds the same
1093
+ // closure value, so `g(…)` calls add's body with g's value as env. This is what
1094
+ // devirtualizes `let arr = [add]; arr[0](…)`: array scalarization rewrites it to
1095
+ // `let g = add; g(…)` before emit (D3), and also covers the explicit `let g = arr[0]`.
1096
+ // Same soundness gate as the direct-closure case: stable binding (not reassigned),
1097
+ // not boxed, not global.
1098
+ if (typeof init === 'string' && ctx.func.directClosures?.has(init) && !ctx.func.boxed.has(name)
1099
+ && !isGlobal(name) && ctx.func.body && !isReassigned(ctx.func.body, name)) {
1100
+ ctx.func.directClosures.set(name, ctx.func.directClosures.get(init))
1101
+ }
793
1102
  if (ctx.func.boxed.has(name)) {
794
1103
  const cell = ctx.func.boxed.get(name)
795
1104
  ctx.func.locals.set(cell, 'i32')
@@ -862,11 +1171,23 @@ export function emitDecl(...inits) {
862
1171
  updateRep(name, { ptrAux: val.closureFuncIdx })
863
1172
  coerced = val.ptrKind === ptrKind ? val
864
1173
  : typed(['i32.wrap_i64', ['i64.reinterpret_f64', asF64(val)]], 'i32')
1174
+ } else if (localType === 'i32' && val.type !== 'i32' && isI32ArithTree(init)) {
1175
+ // Integer index feeder (`let idx = py*W + qx`) bound to an i32 local: compute
1176
+ // it in native wrapping i32 instead of the f64 round-trip + trunc_sat. Bit-
1177
+ // identical for an i32 destination (ToInt32 ≡ two's-complement wrap), and the
1178
+ // i32.mul is hoistable when loop-invariant. Falls back to toI32 defensively.
1179
+ coerced = tryI32Index(init) ?? toI32(val)
865
1180
  } else {
866
1181
  coerced = localType === 'v128' ? val : localType === 'f64' ? asF64(val) : val.type === 'i32' ? val : toI32(val)
867
1182
  }
868
- if (!(isLit(coerced) && coerced[1] === 0 && !Object.is(coerced[1], -0) && !ctx.func.stack.length))
1183
+ // `let x = 0` at function scope is normally elided — WASM zero-inits locals. But loop
1184
+ // unrolling flattens iteration bodies into one scope, so the 2nd+ `let x = 0` are
1185
+ // genuine RE-inits between iterations (e.g. a nested reduce's accumulator). Elide only
1186
+ // the FIRST per name; emit the rest as resets. (Names are preserved — no renaming.)
1187
+ const zeroInit = isLit(coerced) && coerced[1] === 0 && !Object.is(coerced[1], -0) && !ctx.func.stack.length
1188
+ if (!zeroInit || ctx.func.zeroInitSeen?.has(name))
869
1189
  result.push(['local.set', `$${name}`, coerced])
1190
+ else (ctx.func.zeroInitSeen ??= new Set()).add(name)
870
1191
 
871
1192
  const schemaId = ctx.schema.idOf?.(name)
872
1193
  if (ctx.func.localProps?.has(name) && schemaId != null) {
@@ -912,12 +1233,22 @@ function emitSpreadCopy(dest, posLocal, srcLocal, srcLenLocal, staticVT) {
912
1233
  const sidx = `${T}sidx${ctx.func.uniq++}`
913
1234
  ctx.func.locals.set(sidx, 'i32')
914
1235
  const loopId = ctx.func.uniq++
915
- const elem = ctx.module.modules['string']
916
- ? ['if', ['result', 'f64'],
1236
+ // When the source is statically known to be a typed array, __typed_idx suffices.
1237
+ // Otherwise (STRING, or unknown type whose runtime value may be a string) dispatch on
1238
+ // ptr_type: STRING→__str_idx, else→__typed_idx.
1239
+ // The old gate (ctx.module.modules['string']) was wrong: for `[...s]` with an untyped
1240
+ // param the string module is never loaded, so __typed_idx was used for strings —
1241
+ // __typed_idx calls __len which returns 0 for strings, making i>=len always true and
1242
+ // storing UNDEF into every element slot. Pull in the string module here so __str_idx
1243
+ // is registered before inc() adds it to the dependency set.
1244
+ const elem = staticVT === VAL.TYPED
1245
+ ? (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])
1246
+ : (includeForStringOnly(),
1247
+ ['if', ['result', 'f64'],
917
1248
  ['i32.eq', ['call', '$__ptr_type', srcI64()], ['i32.const', PTR.STRING]],
918
1249
  ['then', (inc('__str_idx'), ['call', '$__str_idx', srcI64(), ['local.get', `$${sidx}`]])],
919
- ['else', (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])]]
920
- : (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])
1250
+ ['else', (inc('__typed_idx'), ['call', '$__typed_idx', srcI64(), ['local.get', `$${sidx}`]])]
1251
+ ])
921
1252
  // Reset the counter on each entry — WASM zeroes locals once at function
922
1253
  // entry, but this loop re-executes when the spread sits inside a JS loop;
923
1254
  // a stale `sidx` (= prior srcLen) would skip the copy entirely.
@@ -1019,8 +1350,25 @@ export function buildArrayWithSpreads(items) {
1019
1350
  // emitSpreadCopy resolve its kind at runtime via its one-time __ptr_type branch.
1020
1351
  sec.val = n ? undefined : valTypeOf(srcExpr)
1021
1352
  ir.push(['local.set', `$${sec.local}`, n ? materializeMulti(sec.expr) : asF64(emit(srcExpr))])
1022
- // Cache __len once per spread; reused below for total-len sum and the copy.
1023
- ir.push(['local.set', `$${sec.lenLocal}`, ['call', '$__len', ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]]])
1353
+ // Cache the source length once per spread (reused for the total-len sum and the
1354
+ // copy). `__len` is ARRAY/typed length — WRONG for a STRING (returns 0, so `[...str]`
1355
+ // spreads an empty array). Pick the length to MATCH emitSpreadCopy's element decode:
1356
+ // a known string counts chars (__str_len, paired with the __str_idx per-char copy); a
1357
+ // statically-unknown source — `[...x]` / `[...fnParam]`, the compiler's own
1358
+ // `[...key]` — dispatches once at runtime (STRING→__str_len, else→__len), mirroring
1359
+ // emitSpreadCopy's ARRAY-vs-scalar branch. (Not __length: its `off>=8` guard returns
1360
+ // undefined for host/static typed arrays.) Known array/typed/multi keep plain __len.
1361
+ const srcI64 = () => ['i64.reinterpret_f64', ['local.get', `$${sec.local}`]]
1362
+ const lenIR = sec.val === VAL.STRING
1363
+ ? (inc('__str_len'), ['call', '$__str_len', srcI64()])
1364
+ : (sec.val === VAL.ARRAY || sec.val === VAL.TYPED || n)
1365
+ ? (inc('__len'), ['call', '$__len', srcI64()])
1366
+ : (inc('__str_len', '__len', '__ptr_type'),
1367
+ ['if', ['result', 'i32'],
1368
+ ['i32.eq', ['call', '$__ptr_type', srcI64()], ['i32.const', PTR.STRING]],
1369
+ ['then', ['call', '$__str_len', srcI64()]],
1370
+ ['else', ['call', '$__len', srcI64()]]])
1371
+ ir.push(['local.set', `$${sec.lenLocal}`, lenIR])
1024
1372
  }
1025
1373
  }
1026
1374
 
@@ -1163,6 +1511,48 @@ const STRICT_PRIM = new Set([VAL.NUMBER, VAL.BOOL, VAL.STRING, VAL.BIGINT])
1163
1511
  const nullableOperand = (n) =>
1164
1512
  typeof n === 'string' && !!(repOf(n)?.nullable || repOfGlobal(n)?.nullable)
1165
1513
 
1514
+ // An emitted value whose bit pattern is an i32, paired with how it widens to f64: a
1515
+ // `f64.convert_i32_s/u(x)` peels to its i32 source `x`; a bare i32 widens signed. Used to compare
1516
+ // two integer-backed operands directly in i32 instead of widening both to f64.
1517
+ const peelIntCmp = (v) => {
1518
+ if (Array.isArray(v) && (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u'))
1519
+ return { src: Array.isArray(v[1]) ? typed(v[1], 'i32') : v[1], sign: v[0] === 'f64.convert_i32_u' ? 'u' : 's' }
1520
+ if (v && v.type === 'i32') return { src: v, sign: 's' }
1521
+ return null
1522
+ }
1523
+ // The value's top bit is provably 0 (so its signed and unsigned readings agree): a u8/u16 load,
1524
+ // `>>>` (always clears the sign bit), `& m` with m a non-negative small const, or a small const.
1525
+ const i32TopBitClear = (n) => {
1526
+ if (typeof n === 'number') return n >= 0 && n < 0x80000000
1527
+ if (!Array.isArray(n)) return false
1528
+ if (n[0] == null) return typeof n[1] === 'number' && n[1] >= 0 && n[1] < 0x80000000
1529
+ if (n[0] === 'i32.load8_u' || n[0] === 'i32.load16_u') return true
1530
+ if (n[0] === 'i32.const') return typeof n[1] === 'number' ? (n[1] >= 0 && n[1] < 0x80000000) : false
1531
+ if (n[0] === 'i32.shr_u' || n[0] === '>>>') return true
1532
+ if (n[0] === 'i32.and' || n[0] === '&') return i32TopBitClear(n[1]) || i32TopBitClear(n[2])
1533
+ return false
1534
+ }
1535
+ // i32.eq/ne over the peeled sources equals the f64-widened compare when the signs match, or — for
1536
+ // a mixed signed/unsigned pair — when the unsigned-read source is top-bit-clear (then both readings
1537
+ // of equal bits agree, and unequal bits stay unequal under both).
1538
+ const i32EqSound = (pa, pb) => pa.sign === pb.sign ||
1539
+ i32TopBitClear((pa.sign === 'u' ? pa : pb).src)
1540
+
1541
+ // A memory-free, trap-free, side-effect-free expression — safe to evaluate UNCONDITIONALLY (as a
1542
+ // `select` arm does) and cheap enough that doing so never loses to a branch. Locals/consts and
1543
+ // arithmetic/bitwise/compare/logical over them. Excludes loads (`[]`, may read OOB when the guard
1544
+ // was protecting the access), calls, `.`/`?.` (dispatch), `/` `%` (int trap on 0), assignments.
1545
+ const CHEAP_PURE_OPS = new Set(['+', '-', '*', 'u-', 'u+', '&', '|', '^', '<<', '>>', '>>>', '~',
1546
+ '<', '<=', '>', '>=', '==', '!=', '===', '!==', '&&', '||', '!', '?:'])
1547
+ const isCheapPureVal = (n) => {
1548
+ if (typeof n === 'string' || typeof n === 'number') return true
1549
+ if (!Array.isArray(n)) return false
1550
+ if (n[0] == null) return true // boxed literal [, v]
1551
+ if (n[0] === 'local.get') return true
1552
+ if (CHEAP_PURE_OPS.has(n[0])) { for (let i = 1; i < n.length; i++) if (!isCheapPureVal(n[i])) return false; return true }
1553
+ return false
1554
+ }
1555
+
1166
1556
  function emitLooseEq(a, b, negate) {
1167
1557
  const eqOp = negate ? 'ne' : 'eq'
1168
1558
  const sentinel = emitNum(negate ? 1 : 0)
@@ -1181,6 +1571,12 @@ function emitLooseEq(a, b, negate) {
1181
1571
  const tc = emitTypeofCmp(a, b, eqOp); if (tc) return tc
1182
1572
  const va = emit(a), vb = emit(b)
1183
1573
  if (va.type === 'i32' && vb.type === 'i32') return typed([`i32.${eqOp}`, va, vb], 'i32')
1574
+ // Both operands integer-backed (e.g. an i32 local vs a `b[j]` u8 read materialized as f64):
1575
+ // compare the i32 sources directly, skipping the per-op widen to f64. Recovers `intElem ===
1576
+ // intElem` in hot loops (levenshtein's DP cell, where `a[i-1] === b[j-1]` was an f64.eq + 2
1577
+ // converts every iteration). Sound only when the widen can't change the answer (see i32EqSound).
1578
+ const pa = peelIntCmp(va), pb = peelIntCmp(vb)
1579
+ if (pa && pb && i32EqSound(pa, pb)) return typed([`i32.${eqOp}`, pa.src, pb.src], 'i32')
1184
1580
  // Either side known-pure NUMBER (literal or typed) → f64.eq/ne is correct regardless
1185
1581
  // of the other side: jz's `==` is strict (prepare.js:868), and every NaN-boxed pointer
1186
1582
  // reinterprets to a quiet NaN (0x7FF8… prefix) so f64.eq with any normal float is false.
@@ -1201,6 +1597,43 @@ function emitLooseEq(a, b, negate) {
1201
1597
  if (vta && vta === vtb && REF_EQ_KINDS.has(vta)) {
1202
1598
  return typed([`i64.${eqOp}`, ['i64.reinterpret_f64', asF64(va)], ['i64.reinterpret_f64', asF64(vb)]], 'i32')
1203
1599
  }
1600
+ // String-equality specialization — the hot `node[0] === 'literal'` AST-tag dispatch,
1601
+ // the compiler's single most-emitted comparison (5579 of its 6487 __eq sites). When one
1602
+ // side is statically a STRING, skip the generic __eq NaN-box dispatch (the #1 self-host
1603
+ // hot helper). jz's ==/=== never coerce (number-vs-string is false in __eq), so this is
1604
+ // sound for both. Two shapes by what the OTHER side is known to be:
1605
+ // both STRING → __str_eq directly (no number/NaN/tag test needed at all).
1606
+ // STRING vs unknown → i64.eq fast ? equal : (__is_str_key(u) ? __str_eq : not-equal).
1607
+ // Soundness of the fast path: the known string is a non-NaN STRING NaN-box, so a bit
1608
+ // match can ONLY be that same string (a normal f64 can't alias those bits). On bit
1609
+ // MISMATCH the unknown can still content-match — a heap string from `'i'+'f'` shares
1610
+ // content but not bits — so the fallback __str_eq stays (pure i64.eq is unsound here).
1611
+ // __is_str_key rejects the number-whose-bits-alias-the-STRING-tag case that a bare
1612
+ // __ptr_type would misroute into a wild __str_eq deref (see __eq's own guard).
1613
+ // INLINED (not a helper call): a single $__str_eq_lit helper measured 2.4% slower on
1614
+ // the corpus — V8 keeps the call at the hot miss path; inlining lets the optimizer fold
1615
+ // __is_str_key/__str_eq's prefix in, which is where the tag dispatch spends its time.
1616
+ // Behaviorally identical to __eq when one side is a string — proven by a 4584-case
1617
+ // spec-on/spec-off differential (zero divergence at optimize 0 and 2).
1618
+ const strEqResult = (r) => negate ? typed(['i32.eqz', r], 'i32') : r
1619
+ const aStr = rawA === VAL.STRING, bStr = rawB === VAL.STRING
1620
+ if (aStr && bStr) {
1621
+ inc('__str_eq')
1622
+ return strEqResult(typed(['call', '$__str_eq', asI64(va), asI64(vb)], 'i32'))
1623
+ }
1624
+ if ((bStr && rawA == null) || (aStr && rawB == null)) {
1625
+ const uVal = bStr ? va : vb, lVal = bStr ? vb : va // u: unknown side, l: known string
1626
+ inc('__is_str_key', '__str_eq')
1627
+ const u = tempI64('seq'), l = tempI64('seq'), uG = ['local.get', `$${u}`], lG = ['local.get', `$${l}`]
1628
+ return strEqResult(typed(['block', ['result', 'i32'],
1629
+ ['local.set', `$${u}`, asI64(uVal)],
1630
+ ['local.set', `$${l}`, asI64(lVal)],
1631
+ ['if', ['result', 'i32'], ['i64.eq', uG, lG],
1632
+ ['then', ['i32.const', 1]],
1633
+ ['else', ['if', ['result', 'i32'], ['call', '$__is_str_key', uG],
1634
+ ['then', ['call', '$__str_eq', uG, lG]],
1635
+ ['else', ['i32.const', 0]]]]]], 'i32'))
1636
+ }
1204
1637
  inc('__eq')
1205
1638
  const call = typed(['call', '$__eq', asI64(va), asI64(vb)], 'i32')
1206
1639
  return negate ? typed(['i32.eqz', call], 'i32') : call
@@ -1472,6 +1905,13 @@ function emitSpreadElementLoop(spreadExpr, bodyFn, { reverse = false } = {}) {
1472
1905
  ]
1473
1906
  }
1474
1907
 
1908
+ function emitAsValue(fn) {
1909
+ const prev = ctx.func._expect
1910
+ ctx.func._expect = null
1911
+ try { return fn() }
1912
+ finally { ctx.func._expect = prev }
1913
+ }
1914
+
1475
1915
  function emitSingleSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1476
1916
  const inPlace = SPREAD_MUTATORS.has(method)
1477
1917
  // unshift prepends each arg to the front — forward iteration reverses intent.
@@ -1480,11 +1920,11 @@ function emitSingleSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1480
1920
  ctx.func.locals.set(acc, 'f64')
1481
1921
  const ir = [['local.set', `$${acc}`, asF64(emit(objArg))]]
1482
1922
  if (parsed.normal.length > 0) {
1483
- const r = asF64(methodEmitter(objArg, ...parsed.normal))
1923
+ const r = asF64(emitAsValue(() => methodEmitter(objArg, ...parsed.normal)))
1484
1924
  ir.push(inPlace ? ['drop', r] : ['local.set', `$${acc}`, r])
1485
1925
  }
1486
1926
  ir.push(...emitSpreadElementLoop(parsed.spreads[0].expr, (arr, idx) => {
1487
- const body = asF64(methodEmitter(inPlace ? objArg : acc, ['[]', arr, idx]))
1927
+ const body = asF64(emitAsValue(() => methodEmitter(inPlace ? objArg : acc, ['[]', arr, idx])))
1488
1928
  return [inPlace ? ['drop', body] : ['local.set', `$${acc}`, body]]
1489
1929
  }, { reverse }))
1490
1930
  ir.push(inPlace ? asF64(emit(objArg)) : ['local.get', `$${acc}`])
@@ -1506,7 +1946,7 @@ function emitMultiSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1506
1946
  let batch = []
1507
1947
  const flushBatch = () => {
1508
1948
  if (!batch.length) return
1509
- const r = asF64(methodEmitter(recv, ...batch))
1949
+ const r = asF64(emitAsValue(() => methodEmitter(recv, ...batch)))
1510
1950
  ir.push(inPlace ? ['drop', r] : ['local.set', `$${acc}`, r])
1511
1951
  batch = []
1512
1952
  }
@@ -1514,7 +1954,7 @@ function emitMultiSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1514
1954
  if (Array.isArray(item) && item[0] === '__spread') {
1515
1955
  flushBatch()
1516
1956
  ir.push(...emitSpreadElementLoop(item[1], (arr, idx) => {
1517
- const body = asF64(methodEmitter(recv, ['[]', arr, idx]))
1957
+ const body = asF64(emitAsValue(() => methodEmitter(recv, ['[]', arr, idx])))
1518
1958
  return [inPlace ? ['drop', body] : ['local.set', `$${acc}`, body]]
1519
1959
  }))
1520
1960
  } else {
@@ -1591,7 +2031,7 @@ function tryCharCodeAtFast(callee, obj, method, parsed) {
1591
2031
  return typed(['call', '$__jss_charCodeAt', recv, asI32(emit(parsed.normal[0]))], 'i32')
1592
2032
  }
1593
2033
  return typed(stringOps(obj).charCodeAt(
1594
- asF64(recv), asI32(emit(parsed.normal[0])), ctx, false), 'i32')
2034
+ asF64(recv), asI32(emit(parsed.normal[0])), ctx, false, true), 'i32')
1595
2035
  }
1596
2036
  }
1597
2037
 
@@ -1705,7 +2145,9 @@ function tryStaticDispatch({ obj, method, vt, callMethod }) {
1705
2145
  // emitter which already knows how to handle them.
1706
2146
  function tryRuntimeStringFork({ obj, method, vt, callMethod }) {
1707
2147
  const strKey = `.string:${method}`, genKey = `.${method}`
1708
- if ((!vt || vt === VAL.ARRAY) && ctx.core.emit[strKey] && ctx.core.emit[genKey]) {
2148
+ // VAL.ARRAY is structurally incompatible with PTR.STRING no fork needed.
2149
+ // Only fork when vt is truly unknown (!vt), not for proven types.
2150
+ if (!vt && ctx.core.emit[strKey] && ctx.core.emit[genKey]) {
1709
2151
  const t = `${T}rt${ctx.func.uniq++}`, tt = `${T}rtt${ctx.func.uniq++}`
1710
2152
  ctx.func.locals.set(t, 'f64'); ctx.func.locals.set(tt, 'i32')
1711
2153
  const strEmitter = ctx.core.emit[strKey]
@@ -1840,6 +2282,7 @@ function externalMethodFallback({ obj, method, parsed }) {
1840
2282
  // can target js and wasi from one source; users who want fail-fast
1841
2283
  // pass `strict: true` (handled above).
1842
2284
  if (ctx.transform.host === 'wasi') return undefExpr()
2285
+ 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
2286
  inc('__ext_call')
1844
2287
  ctx.features.external = true
1845
2288
  const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
@@ -2018,9 +2461,21 @@ function tryDirectClosureCall(callee, parsed) {
2018
2461
  // export-param path gives. An arg we can't prove numeric poisons the slot to false.
2019
2462
  const pt = (ctx.closure.paramTypes ||= new Map())
2020
2463
  let row = pt.get(bodyName); if (!row) pt.set(bodyName, row = [])
2464
+ // Parallel typed-array ctor lattice: a param passed the SAME typed-array ctor at
2465
+ // every direct call site is a TYPED param, so its body reads (`buf[i]`) take the
2466
+ // typed fast-path instead of the dynamic `__typed_idx`/`__len` route that drags in
2467
+ // the string runtime. `null` (sticky) once two sites disagree or an arg isn't a
2468
+ // known typed array — the same monotone meet as the numeric row. Mirrors the named-fn
2469
+ // applyTypedPointerParamAbi, restricted to non-escaping (directly-called) closures.
2470
+ const tc = (ctx.closure.paramTypedCtors ||= new Map())
2471
+ let tcRow = tc.get(bodyName); if (!tcRow) tc.set(bodyName, tcRow = [])
2021
2472
  for (let i = 0; i < n; i++) {
2022
2473
  const numeric = valTypeOf(parsed.normal[i]) === VAL.NUMBER
2023
2474
  row[i] = row[i] === undefined ? numeric : (row[i] && numeric)
2475
+ const arg = parsed.normal[i]
2476
+ const ctor = typeof arg === 'string' && valTypeOf(arg) === VAL.TYPED ? (ctx.types.typedElem?.get(arg) ?? null) : null
2477
+ if (tcRow[i] === undefined) tcRow[i] = ctor
2478
+ else if (tcRow[i] !== ctor) tcRow[i] = null
2024
2479
  }
2025
2480
  // Track the fewest args any call passed: a slot at index ≥ minArgc is omitted by some call
2026
2481
  // site (padded with UNDEF_NAN), so it may be undefined — emitClosureBody flags it nullable.
@@ -2373,9 +2828,22 @@ export const emitter = {
2373
2828
  // A BOOL operand renders "true"/"false" rather than its 0/1 carrier.
2374
2829
  const strOperand = (vt, n) => vt === VAL.OBJECT ? typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
2375
2830
  : 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')
2831
+ // Coercion-free sides are already strings: a known STRING is raw; OBJECT/BOOL
2832
+ // were stringified by `strOperand`. An unknown side still needs ToString, but
2833
+ // we can apply it *once* (explicit `__to_str` via `strI64`) and join with
2834
+ // concatRaw — equivalent to `__str_concat`'s internal `__to_str` on that side,
2835
+ // while NOT re-coercing the already-string side. This drops the redundant
2836
+ // per-append `__to_str` on the accumulator in `s += part` (s proven STRING):
2837
+ // - both coercion-free → concatRaw(ea, eb)
2838
+ // - one unknown → concatRaw(known, __to_str(unknown))
2839
+ // - both unknown → cat (unchanged; its runtime __to_str covers both)
2840
+ const coercionFree = (vt) => vt === VAL.STRING || vt === VAL.OBJECT || vt === VAL.BOOL
2841
+ const cfA = coercionFree(vtA), cfB = coercionFree(vtB)
2842
+ const strI64 = (n) => typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
2843
+ if (cfA && cfB) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2844
+ if (cfA) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strI64(b), ctx), 'f64')
2845
+ if (cfB) return typed(ctx.abi.string.ops.concatRaw(strI64(a), strOperand(vtB, b), ctx), 'f64')
2846
+ return typed(ctx.abi.string.ops.cat(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2379
2847
  }
2380
2848
  if (vtA === VAL.BIGINT || vtB === VAL.BIGINT)
2381
2849
  return fromI64(['i64.add', asI64(emit(a)), asI64(emit(b))])
@@ -2409,7 +2877,8 @@ export const emitter = {
2409
2877
  // op whose result can exceed i32, so `i32.add` would wrap (4294967295+1→0).
2410
2878
  // Widen to f64 — never wrap — matching spec. Only `>>>0`/`|0`/imul wrap.
2411
2879
  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')
2880
+ const i32add = tryI32Arith('i32.add', '+', a, b, va, vb); if (i32add) return i32add
2881
+ return typed(['f64.add', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2413
2882
  },
2414
2883
  '-': (a, b) => {
2415
2884
  if (ctx.func._expect === 'void' && isPostfix(a, '++', b)) return emit(a, 'void')
@@ -2424,7 +2893,8 @@ export const emitter = {
2424
2893
  // Unsigned uint32 operand: JS `-` is float (can go negative / exceed i32),
2425
2894
  // so avoid the wrapping i32.sub fast-path. See `+` above.
2426
2895
  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')
2896
+ const i32sub = tryI32Arith('i32.sub', '-', a, b, va, vb); if (i32sub) return i32sub
2897
+ return typed(['f64.sub', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2428
2898
  },
2429
2899
  'u+': a => {
2430
2900
  if (valTypeOf(a) === VAL.BIGINT)
@@ -2453,8 +2923,9 @@ export const emitter = {
2453
2923
  if (isLit(va) && litVal(va) === 0 && finiteFactor(vb)) return isLit(vb) ? va : typed(['block', ['result', va.type], vb, 'drop', va], va.type)
2454
2924
  // `.unsigned` operand is a uint32 ([0, 2^32)); its product can exceed i32, so
2455
2925
  // `i32.mul` would wrap ((2^32-1)*2 → -2). Widen to f64 — see `+` above.
2456
- 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')
2926
+ if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb) && (mulFitsI32(va, vb) || mulBoundedFaithful(va, vb))) return typed(['i32.mul', va, vb], 'i32')
2927
+ const i32mul = tryI32Arith('i32.mul', '*', a, b, va, vb); if (i32mul) return i32mul
2928
+ return typed(['f64.mul', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2458
2929
  },
2459
2930
  '/': (a, b) => {
2460
2931
  if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
@@ -2462,7 +2933,7 @@ export const emitter = {
2462
2933
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a / b, b => b !== 0)
2463
2934
  if (_f) return _f
2464
2935
  if (isLit(vb) && litVal(vb) === 1) return toNumF64(a, va)
2465
- return typed(['f64.div', toNumF64(a, va), toNumF64(b, vb)], 'f64')
2936
+ return typed(['f64.div', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2466
2937
  },
2467
2938
  '%': (a, b) => {
2468
2939
  if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
@@ -2539,6 +3010,15 @@ export const emitter = {
2539
3010
  const elseRefs = extractRefinements(a, new Map(), false)
2540
3011
  const vb = withRefinements(thenRefs, b, () => emit(b))
2541
3012
  const vc = withRefinements(elseRefs, c, () => emit(c))
3013
+ // `cond ? 1 : 0` is the condition bit itself; `cond ? 0 : 1` its negation. `cond`
3014
+ // (truthyIR) is already canonical 0/1, so the select + two const arms collapse to
3015
+ // the bit. (Both arms are literals here, so dropping their emitted IR is side-effect
3016
+ // free.) Mirrors what `+(x > 0)` already produces.
3017
+ if (isLit(vb) && isLit(vc)) {
3018
+ const lb = litVal(vb), lc = litVal(vc)
3019
+ if (lb === 1 && lc === 0) return typed(cond, 'i32')
3020
+ if (lb === 0 && lc === 1) return typed(['i32.eqz', cond], 'i32')
3021
+ }
2542
3022
  // L: Use WASM select for pure ternaries — branchless, smaller bytecode
2543
3023
  if (vb.type === 'i32' && vc.type === 'i32') {
2544
3024
  // A single i32 select is only sound when BOTH arms' i32 carriers mean the same
@@ -2577,10 +3057,24 @@ export const emitter = {
2577
3057
  const refPayload = (vtb && vtb === vtc && REF_EQ_KINDS.has(vtb))
2578
3058
  || vb.closureFuncIdx != null || vc.closureFuncIdx != null
2579
3059
  || 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
3060
+ const numericB = isNumArm(vb, b, vtb)
3061
+ const numericC = isNumArm(vc, c, vtc)
3062
+ // Peephole: `cond ? 1 : 0` (or `cond ? 0 : 1`) is just `f64.convert_i32_s(cond)`
3063
+ // the select collapses because cond is already 0/1. Saves 5 instructions.
3064
+ const isOneZero = (one, zero) => {
3065
+ const o = one, z = zero
3066
+ return o.type === 'i32' && Array.isArray(o) && o[0] === 'i32.const' && o[1] === 1 &&
3067
+ z.type === 'i32' && Array.isArray(z) && z[0] === 'i32.const' && z[1] === 0
3068
+ }
3069
+ if ((isOneZero(vb, vc) || isOneZero(vc, vb)) && !numericB && !numericC) {
3070
+ const condBool = truthyIR(emit(a))
3071
+ const n = isOneZero(vb, vc)
3072
+ ? typed(['f64.convert_i32_s', condBool], 'f64')
3073
+ : typed(['f64.convert_i32_s', ['i32.eqz', condBool]], 'f64')
3074
+ n.valKind = VAL.NUMBER
3075
+ return n
3076
+ }
3077
+ const branchB = canonArm(fb, numericB, numericC), branchC = canonArm(fc, numericC, numericB)
2584
3078
  const markNumeric = (n) => {
2585
3079
  if (numericB && numericC) n.valKind = VAL.NUMBER
2586
3080
  return n
@@ -2599,6 +3093,14 @@ export const emitter = {
2599
3093
  },
2600
3094
 
2601
3095
  '&&': (a, b) => {
3096
+ // Range-check fusion: `x >= LO && x <= HI` (x a pure i32 local, LO ≤ HI compile-time
3097
+ // constants) collapses to one unsigned compare `(x - LO) <=u (HI - LO)` — a subtract
3098
+ // plus a branch instead of two compares, an AND, and a short-circuit branch. This is
3099
+ // the per-char cost in scanners/parsers (digit/alpha classification) and in any
3100
+ // two-sided bounds check. Restricted to a local `x` so evaluating it once (the fused
3101
+ // form) matches the original's twice-read, side-effect-free semantics.
3102
+ const fused = fuseRangeCheck(a, b)
3103
+ if (fused) return fused
2602
3104
  const va = emit(a)
2603
3105
  // Constant-folded literal: pre-bind under truthy refinements (b runs only when a was truthy).
2604
3106
  if (isLit(va)) {
@@ -2630,14 +3132,24 @@ export const emitter = {
2630
3132
  ['else', typed(['f64.convert_i32_s', ['local.get', `$${t}`]], 'f64')]], 'f64')
2631
3133
  }
2632
3134
  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())],
3135
+ const numA = isNumArm(va, a)
3136
+ const vb = emitRight(), numB = isNumArm(vb, b)
3137
+ // `a` is the else-arm result (returned when falsy — incl NaN), so canon a lone-numeric
3138
+ // `a` before the tee: `$t` then feeds both the result and the cond canonically.
3139
+ const teed = typed(['local.tee', `$${t}`, canonArm(asF64(va), numA, numB)], 'f64')
3140
+ // A numeric left arm tests truthiness NaN-by-value (not __is_truthy, which mis-reads
3141
+ // x86's sign-set NaN as truthy) — tag it so truthyIR takes that path.
3142
+ if (numA) teed.valKind = VAL.NUMBER
3143
+ return typed(['if', ['result', 'f64'], toBoolFromEmitted(teed),
3144
+ ['then', canonArm(asF64(vb), numB, numA)],
2637
3145
  ['else', ['local.get', `$${t}`]]], 'f64')
2638
3146
  },
2639
3147
 
2640
3148
  '||': (a, b) => {
3149
+ // Outside-range fusion (the complement of `&&`): `x < LO || x > HI` → one unsigned
3150
+ // compare `(x - LO) >u (HI - LO)`. Common in validation (`if (c < 'a' || c > 'z') …`).
3151
+ const fusedOr = fuseRangeCheckOr(a, b)
3152
+ if (fusedOr) return fusedOr
2641
3153
  const va = emit(a)
2642
3154
  // Constant-folded literal: pre-bind under falsy refinements (b runs only when a was falsy).
2643
3155
  if (isLit(va)) {
@@ -2665,22 +3177,30 @@ export const emitter = {
2665
3177
  ['else', asF64(vb)]], 'f64')
2666
3178
  }
2667
3179
  const t = temp()
3180
+ const numA = isNumArm(va, a)
3181
+ const vb = emitRight(), numB = isNumArm(vb, b)
3182
+ // `a` (then-arm) is returned only when truthy — hence never NaN — so it needs no canon;
3183
+ // the cond's NaN-safety comes from the valKind tag. Only the else (b) arm can surface
3184
+ // as a numeric NaN.
2668
3185
  const teed = typed(['local.tee', `$${t}`, asF64(va)], 'f64')
2669
- return typed(['if', ['result', 'f64'],
2670
- toBoolFromEmitted(teed),
3186
+ if (numA) teed.valKind = VAL.NUMBER // numeric left arm: NaN-safe truthiness (see `&&`)
3187
+ return typed(['if', ['result', 'f64'], toBoolFromEmitted(teed),
2671
3188
  ['then', ['local.get', `$${t}`]],
2672
- ['else', asF64(emitRight())]], 'f64')
3189
+ ['else', canonArm(asF64(vb), numB, numA)]], 'f64')
2673
3190
  },
2674
3191
 
2675
3192
  // a ?? b: returns b only if a is nullish
2676
3193
  '??': (a, b) => {
2677
- const va = emit(a)
3194
+ const va = emit(a), vb = emit(b)
2678
3195
  const t = temp()
3196
+ const numA = isNumArm(va, a), numB = isNumArm(vb, b)
3197
+ // Both arms can surface as the (untyped) result — `a` when non-nullish (a NaN is not
3198
+ // nullish, so it IS returned), `b` otherwise. Canon a lone-numeric arm; `a` before the
3199
+ // tee so `local.get $t` is canonical. The cond is isNullish, robust to non-canon NaN.
2679
3200
  return typed(['if', ['result', 'f64'],
2680
- // Check: is a NOT nullish?
2681
- ['i32.eqz', isNullish(['local.tee', `$${t}`, asF64(va)])],
3201
+ ['i32.eqz', isNullish(['local.tee', `$${t}`, canonArm(asF64(va), numA, numB)])],
2682
3202
  ['then', ['local.get', `$${t}`]],
2683
- ['else', asF64(emit(b))]], 'f64')
3203
+ ['else', canonArm(asF64(vb), numB, numA)]], 'f64')
2684
3204
  },
2685
3205
 
2686
3206
  'void': a => {
@@ -2725,6 +3245,10 @@ export const emitter = {
2725
3245
  ].map(([op, fn]) => [op, (a, b) => {
2726
3246
  if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
2727
3247
  return fromI64([`i64.${fn}`, asI64(emit(a)), asI64(emit(b))])
3248
+ if (op === '|') { // `(x / y) | 0` integer-division idiom → i32.div_s
3249
+ const divN = intLiteralValue(b) === 0 ? a : intLiteralValue(a) === 0 ? b : null
3250
+ if (Array.isArray(divN) && divN[0] === '/') { const r = tryIntDivTrunc(divN[1], divN[2]); if (r) return r }
3251
+ }
2728
3252
  const va = emit(a), vb = emit(b)
2729
3253
  if (isLit(va) && isLit(vb)) {
2730
3254
  const la = litVal(va), lb = litVal(vb)
@@ -2770,6 +3294,17 @@ export const emitter = {
2770
3294
  if (els != null) return emitVoid(els)
2771
3295
  return null
2772
3296
  }
3297
+ // If-conversion (speed tier): `if (cond) x = <cheap pure value>` (no else) → `x = cond ? value
3298
+ // : x`, which lowers to a branchless `select`. Removes the data-dependent branch (and its
3299
+ // misprediction) from min/max/clamp reductions — e.g. levenshtein's `if (ins < m) m = ins`,
3300
+ // ~27% faster. Gated to the same speed tier as boolConvertToSelect (the select latency/size
3301
+ // trade). Restricted to a plain assignment of a memory-/trap-free expr to a simple local, so
3302
+ // the unconditional false-case eval is free and identical in effect.
3303
+ if (els == null && ctx.transform.optimize?.boolConvertToSelect && isCheapPureVal(cond)) {
3304
+ const asg = Array.isArray(then) && then[0] === ';' && then.length === 2 ? then[1] : then
3305
+ if (Array.isArray(asg) && asg[0] === '=' && typeof asg[1] === 'string' && isCheapPureVal(asg[2]))
3306
+ return emitVoid(['=', asg[1], ['?:', cond, asg[2], asg[1]]]) // cond cheap-pure → re-emit is free
3307
+ }
2773
3308
  const c = ce.type === 'i32' ? ce : toBoolFromEmitted(ce)
2774
3309
  // Flow-sensitive type refinement: narrow types within each branch based on the guard.
2775
3310
  const thenRefs = extractRefinements(cond, new Map(), true)
@@ -2794,6 +3329,12 @@ export const emitter = {
2794
3329
  const unrolled = unrollSmallConstFor(init, cond, step, body)
2795
3330
  if (unrolled) return unrolled
2796
3331
  }
3332
+ // for-in over a static schema → unroll with key-literal substitution (folds
3333
+ // o[k] to schema slots). Recognized via the for-in-exclusive __keys_ro intrinsic.
3334
+ if (!labeledContinue && (!ctx.transform.optimize || ctx.transform.optimize.forInUnroll !== false)) {
3335
+ const fu = unrollForIn(init, cond, step, body)
3336
+ if (fu) return fu
3337
+ }
2797
3338
  // Lift constant array/object literals out of the loop (allocate once, not per
2798
3339
  // iteration) when they are read-only + non-escaping inside it. Strip them from the
2799
3340
  // body up front so freshBoxed / continue analysis see the reduced body.