jz 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6178
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +318 -43
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +1032 -145
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +428 -93
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +82 -11
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +1055 -70
  45. package/src/compile/index.js +277 -29
  46. package/src/compile/infer.js +42 -4
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/loop-recurrence.js +167 -0
  49. package/src/compile/narrow.js +555 -18
  50. package/src/compile/peel-stencil.js +5 -0
  51. package/src/compile/plan/index.js +45 -3
  52. package/src/compile/plan/inline.js +8 -1
  53. package/src/compile/plan/literals.js +39 -0
  54. package/src/compile/plan/scope.js +430 -23
  55. package/src/compile/program-facts.js +732 -38
  56. package/src/ctx.js +64 -9
  57. package/src/helper-counters.js +7 -1
  58. package/src/ir.js +113 -5
  59. package/src/kind-traits.js +66 -3
  60. package/src/kind.js +84 -12
  61. package/src/op-policy.js +7 -4
  62. package/src/optimize/index.js +1179 -704
  63. package/src/optimize/recurse.js +2 -2
  64. package/src/optimize/vectorize.js +982 -67
  65. package/src/prepare/index.js +809 -67
  66. package/src/prepare/math-kernel.js +331 -0
  67. package/src/prepare/pre-eval.js +714 -0
  68. package/src/snapshot.js +194 -0
  69. package/src/type.js +1170 -56
  70. package/src/wat/assemble.js +403 -65
  71. package/src/wat/codegen.js +74 -14
  72. package/transform.js +113 -4
  73. package/wasi.js +3 -0
@@ -24,8 +24,11 @@
24
24
  import {
25
25
  commaList, T, isBlockBody, isReassigned, mutatesArrayLength, isConstLiteral, constLiteralHoistable,
26
26
  hasOwnContinue, hasLabeledContinueTo, hasOwnBreakOrContinue, extractParams, classifyParam, JZ_UNDEF, TYPEOF,
27
+ ASSIGN_OPS,
27
28
  } from '../ast.js'
28
- import { ctx, err, inc, warnDeopt, PTR } from '../ctx.js'
29
+ import { ctx, err, inc, warnDeopt, PTR, ssoBitI64Hex, LAYOUT } from '../ctx.js'
30
+ import { i64Hex, encodePtrHi, STR_HCACHE_BIT, typedElemAux } from '../../layout.js'
31
+ import { bodyOnlyCharCodeAtCalls } from '../abi/string.js'
29
32
  import { includeForStringOnly } from '../autoload.js'
30
33
  import { FITS_I32_MAX } from '../widen.js'
31
34
  import { nonNegIntLiteral, intLiteralValue, staticPropertyKey } from '../static.js'
@@ -35,12 +38,13 @@ import {
35
38
  containsDeclOf, cloneWithSubst, containsKnownTypedArrayIndex,
36
39
  smallConstForTripCount, isTerminator, scanBoundedLoops, inBoundsCharCodeAt,
37
40
  exprType, MAX_SMALL_FOR_UNROLL, MAX_NESTED_FOR_UNROLL,
41
+ inBoundsArrIdx, typedIdxProven, versionableTypedNest, idxKey,
38
42
  } from '../type.js'
39
43
  import { valTypeOf, shapeOf } from '../kind.js'
40
44
  import { VAL, lookupValType, repOf, updateRep, repOfGlobal } from '../reps.js'
41
45
  import {
42
46
  typed, asF64, asI32, asI64, asPtrOffset, asParamType, toI32, fromI64,
43
- NULL_IR, nullExpr, undefExpr, MAX_CLOSURE_ARITY,
47
+ NULL_IR, nullExpr, undefExpr, MAX_CLOSURE_ARITY, TRUE_NAN, FALSE_NAN, NULL_NAN,
44
48
  WASM_OPS, SPREAD_MUTATORS, BOXED_MUTATORS,
45
49
  mkPtrIR, ptrOffsetIR, ptrTypeIR, ptrTypeEq, dispatchByPtrType, sidecarOverride, valKindToPtr,
46
50
  isLit, litVal, isNullishLit, isPureIR, emitNum, f64rem, toNumF64, toStrI64, maskBound,
@@ -49,10 +53,11 @@ import {
49
53
  temp, tempI32, tempI64, allocPtr,
50
54
  block64, withTemp,
51
55
  boxedAddr, readVar, writeVar, isNullish, isNull, isUndef, isBoolAtom,
52
- boolBoxIR,
56
+ boolBoxIR, carrierF64,
53
57
  isLiteralStr, resolveValType, isFuncRef,
54
58
  multiCount, loopTop, flat,
55
59
  reconstructArgsWithSpreads, tcoTailRewrite,
60
+ extractF64Bits,
56
61
  } from '../ir.js'
57
62
  import { isBoundName } from '../ir.js'
58
63
  import { extractRefinements, withRefinements } from './flow-types.js'
@@ -191,16 +196,29 @@ const FIRST_CLASS_UNARY_MATH = {
191
196
  'math.trunc': 'f64.trunc',
192
197
  }
193
198
 
199
+ // Builtins with a hand-written uniform-ABI body (beyond the single-op math set).
200
+ // Array.isArray: NaN-boxed AND tag==ARRAY → 1/0 — the same f64.convert_i32 form
201
+ // an arrow returning a comparison produces, so callback semantics match
202
+ // `xs.filter(x => Array.isArray(x))` exactly (watr's optimizer passes the bare
203
+ // builtin to .filter; the self-host kernel must compile it).
204
+ const FIRST_CLASS_BUILTIN_BODY = {
205
+ 'Array.isArray': () =>
206
+ `(if (result f64) (i32.and (f64.ne (local.get $__a0) (local.get $__a0)) ` +
207
+ `(i32.eq (i32.and (i32.wrap_i64 (i64.shr_u (i64.reinterpret_f64 (local.get $__a0)) (i64.const ${LAYOUT.TAG_SHIFT}))) (i32.const ${LAYOUT.TAG_MASK})) (i32.const ${PTR.ARRAY}))) ` +
208
+ `(then (f64.const 1)) (else (f64.const 0)))`,
209
+ }
210
+
194
211
  function builtinFunctionValue(name) {
195
212
  const op = FIRST_CLASS_UNARY_MATH[name]
196
- if (!op) err(`Builtin function '${name}' cannot be used as a first-class value`)
213
+ const bodyGen = FIRST_CLASS_BUILTIN_BODY[name]
214
+ if (!op && !bodyGen) err(`Builtin function '${name}' cannot be used as a first-class value`)
197
215
  if (!ctx.closure.table) err(`Builtin function '${name}' used as value requires closure support`)
198
216
  const fn = `${T}builtin_${name.replace(/\W/g, '_')}`
199
217
  if (!ctx.core.stdlib[fn]) {
200
218
  const width = ctx.closure.width ?? MAX_CLOSURE_ARITY
201
219
  const params = ['(param $__env f64)', '(param $__argc i32)']
202
220
  for (let i = 0; i < width; i++) params.push(`(param $__a${i} f64)`)
203
- ctx.core.stdlib[fn] = `(func $${fn} ${params.join(' ')} (result f64) (${op} (local.get $__a0)))`
221
+ ctx.core.stdlib[fn] = `(func $${fn} ${params.join(' ')} (result f64) ${op ? `(${op} (local.get $__a0))` : bodyGen()})`
204
222
  inc(fn)
205
223
  }
206
224
  let idx = ctx.closure.table.indexOf(fn)
@@ -717,6 +735,10 @@ function unrollForIn(init, cond, step, body) {
717
735
  if (lookupValType(src) !== VAL.OBJECT) return null
718
736
  const keys = ctx.schema.resolve(src)
719
737
  if (!keys || !keys.length || keys.length > FORIN_UNROLL_MAX) return null
738
+ // A literal-key write OUTSIDE the schema also adds an enumerable key (it
739
+ // lands in the dyn sidecar) — same proof obligation as computed writes.
740
+ const lw = ctx.types.literalWriteKeys?.get(src)
741
+ if (lw) for (const k of lw) if (!keys.includes(k)) return null
720
742
 
721
743
  const rest = body.slice(2)
722
744
  const realBody = rest.length === 1 ? rest[0] : [';', ...rest]
@@ -880,9 +902,17 @@ function tryIntDivTrunc(aNode, bNode) {
880
902
  }
881
903
 
882
904
  /** Coerce an emitted arg IR to match a callee param. Param may carry ptrKind (pointer-ABI
883
- * i32 offset), else falls back to numeric WASM type coercion. */
884
- function coerceArg(ir, param) {
905
+ * i32 offset), else falls back to numeric WASM type coercion.
906
+ * `node` (the arg's AST, when the caller has it): a statically-BOOL arg headed
907
+ * into an UNTYPED f64 param crosses as its TRUE/FALSE atom box — the callee
908
+ * treats that slot as an opaque value, so identity (typeof/String/strict-eq)
909
+ * must survive. A val-known param (narrow stamped `p.val`) keeps the raw 0/1
910
+ * ABI its body assumes; i32/pointer params are numeric positions. */
911
+ function coerceArg(ir, param, node) {
885
912
  if (param?.ptrKind != null) return ptrOffsetIR(ir, param.ptrKind)
913
+ if (node !== undefined && (param == null || (param.type !== 'i32' && param.val == null)) &&
914
+ valTypeOf(node) === VAL.BOOL)
915
+ return carrierF64(node, ir)
886
916
  return asParamType(ir, param?.type)
887
917
  }
888
918
 
@@ -898,7 +928,174 @@ function padArgs(args, params) {
898
928
  /** Emit a node list as call arguments for the given param list: per-param
899
929
  * coercion then arity padding. Used at every direct-call site. */
900
930
  function emitCallArgs(argNodes, params) {
901
- return padArgs(argNodes.map((a, k) => coerceArg(emit(a), params[k])), params)
931
+ return padArgs(argNodes.map((a, k) => coerceArg(emit(a), params[k], a)), params)
932
+ }
933
+
934
+ /** Fuse `a + b` when it tops a string-concat chain of ≥3 leaves: evaluate
935
+ * each leaf ONCE to an i64 string box (left-to-right — JS ToString order),
936
+ * measure each with __str_byteLen, allocate the [hash=0][len][bytes]
937
+ * HCACHE header once, and __str_copy each leaf at its cumulative offset.
938
+ * Replaces the pairwise lowering's per-`+` alloc + triangular prefix
939
+ * re-copy. Self-accumulation (`line = line + …`) keeps the head pairwise:
940
+ * the TAIL fuses to one fresh string and the head takes the existing
941
+ * bump-extend concatRaw. A total ≤ 6 yields a short HEAP string where
942
+ * pairwise gave SSO — value-equal (SSO is representation, not semantics). */
943
+ function tryConcatChain(a, b, selfAccum) {
944
+ // A `+` NODE is a string concat iff a side is statically STRING — the exact
945
+ // gate the pairwise lowering uses. (BOOL/OBJECT must NOT qualify a node:
946
+ // `(x===y) + (u===v)` is NUMERIC bool addition; they only stringify as
947
+ // LEAVES once the node qualifies through a genuine STRING side.)
948
+ const isStr = (n) => valTypeOf(n) === VAL.STRING
949
+ if (!(isStr(a) || isStr(b))) return null
950
+ const leaves = []
951
+ const walk = (n) => {
952
+ if (Array.isArray(n) && n[0] === '+' && n.length === 3 && (isStr(n[1]) || isStr(n[2]))) {
953
+ walk(n[1]); walk(n[2])
954
+ } else leaves.push(n)
955
+ }
956
+ walk(a); walk(b)
957
+ // Self-accumulating head: fuse only the tail, join with bump-extend after.
958
+ const headAccum = selfAccum && leaves[0] === a && typeof a === 'string' ? leaves.shift() : null
959
+ if (leaves.length < 3) return null
960
+ // Every leaf must stringify deterministically at this site: known kinds
961
+ // (STRING/OBJECT/BOOL/NUMBER) or unknown-through-__to_str. BIGINT joins
962
+ // numerically elsewhere — bail so the existing lowering keeps its path.
963
+ for (const l of leaves) if (valTypeOf(l) === VAL.BIGINT) return null
964
+ inc('__alloc', '__mkptr', '__sso_norm')
965
+ // LITERAL ASCII leaves (the serializer separators — ',', '\n', 'k=' …) carry
966
+ // their bytes and length at compile time: no box/len temps, no __str_byteLen,
967
+ // no __str_copy — the length const-folds into the total and the bytes store
968
+ // directly at the cursor (grouped 4/2/1-wide; watr folds the const totals).
969
+ // Profiled on strbuild: copy+len calls on 1-6 byte parts were 38.7% of a row.
970
+ const litOf = (n) => {
971
+ if (!Array.isArray(n) || n[0] !== 'str' || typeof n[1] !== 'string' || n[1].length === 0) return null
972
+ for (let i = 0; i < n[1].length; i++) if (n[1].charCodeAt(i) > 0x7f) return null
973
+ return n[1]
974
+ }
975
+ const lits = leaves.map(litOf)
976
+ const bT = [], nT = [], lT = leaves.map((_, k) => lits[k] != null ? null : tempI32('cl'))
977
+ const offT = tempI32('co'), curT = tempI32('cu')
978
+ const seq = []
979
+ let litTotal = 0
980
+ leaves.forEach((n, k) => {
981
+ if (lits[k] != null) { litTotal += lits[k].length; return }
982
+ const vt = valTypeOf(n)
983
+ // BOOL renders through emitBoolStr(node); every other leaf emits its value once here.
984
+ const v = vt === VAL.BOOL ? null : emit(n)
985
+ // i32-PROVEN leaf (exactly toStrI64's __i32_to_str class): keep the raw value,
986
+ // not a temp string — __ilen joins the total and __itoa_s renders the digits
987
+ // directly at the cursor. Drops the per-number __i32_to_str (alloc+itoa+mkstr),
988
+ // __str_byteLen and __str_copy — the whole temp-string round trip.
989
+ if ((vt === VAL.NUMBER || vt == null) && v.type === 'i32' && v.ptrKind == null) {
990
+ inc('__ilen', '__itoa_s')
991
+ nT[k] = tempI32('cn')
992
+ seq.push(['local.set', `$${nT[k]}`, v])
993
+ seq.push(['local.set', `$${lT[k]}`, ['call', '$__ilen', ['local.get', `$${nT[k]}`]]])
994
+ return
995
+ }
996
+ inc('__str_byteLen', '__str_copy')
997
+ bT[k] = tempI64('cc')
998
+ seq.push(['local.set', `$${bT[k]}`,
999
+ vt === VAL.STRING ? ['i64.reinterpret_f64', asF64(v)] :
1000
+ vt === VAL.BOOL ? ['i64.reinterpret_f64', emitBoolStr(n)] :
1001
+ toStrI64(n, v)]) // OBJECT (compile-time ToPrimitive), NUMBER, unknown
1002
+ seq.push(['local.set', `$${lT[k]}`, ['call', '$__str_byteLen', ['local.get', `$${bT[k]}`]]])
1003
+ })
1004
+ const totalIR = () => {
1005
+ let t = ['i32.const', litTotal]
1006
+ for (let k = 0; k < leaves.length; k++) if (lT[k] != null) t = ['i32.add', t, ['local.get', `$${lT[k]}`]]
1007
+ return t
1008
+ }
1009
+ seq.push(['local.set', `$${offT}`, ['call', '$__alloc', ['i32.add', ['i32.const', 8], totalIR()]]])
1010
+ seq.push(['i32.store', ['local.get', `$${offT}`], ['i32.const', 0]]) // lazy hash cell
1011
+ seq.push(['i32.store', 'offset=4', ['local.get', `$${offT}`], totalIR()]) // len
1012
+ seq.push(['local.set', `$${offT}`, ['i32.add', ['local.get', `$${offT}`], ['i32.const', 8]]])
1013
+ seq.push(['local.set', `$${curT}`, ['local.get', `$${offT}`]])
1014
+ leaves.forEach((n, k) => {
1015
+ if (lits[k] != null) {
1016
+ const s = lits[k]
1017
+ let j = 0 // grouped little-endian stores: 4-byte words, 2-byte tail, then 1
1018
+ const at = (o) => o ? [`offset=${o}`, ['local.get', `$${curT}`]] : [['local.get', `$${curT}`]]
1019
+ for (; j + 4 <= s.length; j += 4)
1020
+ seq.push(['i32.store', ...at(j), ['i32.const',
1021
+ (s.charCodeAt(j) | (s.charCodeAt(j + 1) << 8) | (s.charCodeAt(j + 2) << 16) | (s.charCodeAt(j + 3) << 24)) | 0]])
1022
+ if (j + 2 <= s.length) {
1023
+ seq.push(['i32.store16', ...at(j), ['i32.const', s.charCodeAt(j) | (s.charCodeAt(j + 1) << 8)]])
1024
+ j += 2
1025
+ }
1026
+ if (j < s.length)
1027
+ seq.push(['i32.store8', ...at(j), ['i32.const', s.charCodeAt(j)]])
1028
+ if (k < leaves.length - 1)
1029
+ seq.push(['local.set', `$${curT}`, ['i32.add', ['local.get', `$${curT}`], ['i32.const', s.length]]])
1030
+ return
1031
+ }
1032
+ if (nT[k] != null) {
1033
+ // digits render at the cursor; the returned byte count (== $lT) advances it
1034
+ seq.push(k < leaves.length - 1
1035
+ ? ['local.set', `$${curT}`, ['i32.add',
1036
+ ['call', '$__itoa_s', ['local.get', `$${nT[k]}`], ['local.get', `$${curT}`]], ['local.get', `$${curT}`]]]
1037
+ : ['drop', ['call', '$__itoa_s', ['local.get', `$${nT[k]}`], ['local.get', `$${curT}`]]])
1038
+ return
1039
+ }
1040
+ seq.push(['call', '$__str_copy', ['local.get', `$${bT[k]}`], ['local.get', `$${curT}`], ['local.get', `$${lT[k]}`]])
1041
+ if (k < leaves.length - 1)
1042
+ seq.push(['local.set', `$${curT}`, ['i32.add', ['local.get', `$${curT}`], ['local.get', `$${lT[k]}`]]])
1043
+ })
1044
+ // __sso_norm epilogue: every producer that hand-writes heap bytes must
1045
+ // re-canonicalize — a ≤6-ASCII result MUST be SSO or its hash diverges
1046
+ // from a literal/SSO-built equal string (representation-keyed fast paths:
1047
+ // the SSO arithmetic mix vs the byte-FNV walk) and keyed lookups miss.
1048
+ const fresh = typed(['block', ['result', 'f64'],
1049
+ ...seq,
1050
+ ['call', '$__sso_norm', mkPtrIR(PTR.STRING, STR_HCACHE_BIT, ['local.get', `$${offT}`])]], 'f64')
1051
+ if (headAccum != null)
1052
+ return typed(ctx.abi.string.ops.concatRaw(asF64(emit(headAccum)), fresh, ctx, true), 'f64')
1053
+ return fresh
1054
+ }
1055
+
1056
+ /** Guarded dispatch to a speculative typed clone (narrow's speculateTypedParams).
1057
+ * Args evaluate once, in order, into temps; a single masked NaN-box compare per
1058
+ * speculated position proves tag==TYPED && aux==elem-kind (owned — a view or any
1059
+ * other value falls to the original call unchanged, bit-exact). TYPED headers
1060
+ * never relocate (FORWARDING_MASK), so the proven offset is a bare mask — the
1061
+ * same inlining emitSchemaSlotGuarded does for OBJECT. */
1062
+ const TYPED_HI_MASK = '0xFFFFFFFF00000000'
1063
+ function emitSpeculativeCall(callee, spec, argNodes, func) {
1064
+ const params = func.sig.params
1065
+ const specAt = new Map(spec.guards.map(g => [g.k, g.aux]))
1066
+ const rt = func.sig.results[0] || 'f64'
1067
+ const seq = [], slots = []
1068
+ for (let k = 0; k < params.length; k++) {
1069
+ if (k < argNodes.length) {
1070
+ const ir = coerceArg(emit(argNodes[k]), params[k], argNodes[k])
1071
+ // Temp width follows the PARAM's ABI (coerceArg's contract), not the IR
1072
+ // tag — pointer-ABI coercions (`__ptr_offset`) come back untagged i32.
1073
+ const pt = params[k].ptrKind != null || params[k].type === 'i32' ? 'i32' : 'f64'
1074
+ const t = pt === 'i32' ? tempI32('sa') : temp('sa')
1075
+ seq.push(['local.set', `$${t}`, ir])
1076
+ slots.push({ local: t, type: pt })
1077
+ } else {
1078
+ slots.push(null) // arity pad — fresh per use below
1079
+ }
1080
+ }
1081
+ const get = (k) => slots[k]
1082
+ ? typed(['local.get', `$${slots[k].local}`], slots[k].type)
1083
+ : params[k].type === 'i32' ? typed(['i32.const', 0], 'i32') : undefExpr()
1084
+ let cond = null
1085
+ for (const [k, aux] of specAt) {
1086
+ const c = ['i64.eq',
1087
+ ['i64.and', ['i64.reinterpret_f64', get(k)], ['i64.const', TYPED_HI_MASK]],
1088
+ ['i64.const', i64Hex(BigInt(encodePtrHi(PTR.TYPED, aux)) << 32n)]]
1089
+ cond = cond ? ['i32.and', cond, c] : c
1090
+ }
1091
+ const thenArgs = params.map((p, k) => specAt.has(k)
1092
+ ? ['i32.wrap_i64', ['i64.and', ['i64.reinterpret_f64', get(k)], ['i64.const', LAYOUT.OFFSET_MASK]]]
1093
+ : get(k))
1094
+ const elseArgs = params.map((p, k) => get(k))
1095
+ const ifIR = ['if', ['result', rt], cond,
1096
+ ['then', ['call', `$${spec.clone}`, ...thenArgs]],
1097
+ ['else', ['call', `$${callee}`, ...elseArgs]]]
1098
+ return attachSigMeta(typed(['block', ['result', rt], ...seq, ifIR], rt), func.sig)
902
1099
  }
903
1100
 
904
1101
  /** Stamp a `call` IR with the pointer-ABI / sign metadata its signature carries.
@@ -1079,6 +1276,17 @@ export function emitDecl(...inits) {
1079
1276
  if (isObjLit) ctx.schema.targetStack.push({ name, active: true })
1080
1277
  const val = viewInit || emit(init)
1081
1278
  if (isObjLit) ctx.schema.targetStack.pop()
1279
+ // Record the declared name's valTypeOf(init) into the flow overlay right after
1280
+ // emitting init — not just for sibling `let`s in the same block (emitBlockBody used
1281
+ // to do this itself, one statement late), but for decls that live INSIDE a `for`
1282
+ // node's init clause, which emitBlockBody's per-statement loop never sees directly
1283
+ // (e.g. src/prepare/index.js's for-of/for-in desugar: `let arrVar = __iter_arr(node),
1284
+ // idx = 0, len = arrVar.length`). valTypeOf consults ctx.func.refinements first, so
1285
+ // an early-return `Array.isArray` guard on `node` now correctly flows into `arrVar`
1286
+ // (and therefore into `len`'s own init two decls later in the same `let`) — every
1287
+ // downstream `arrVar[i]`/`.length` in the loop then takes the ARRAY-known fast path
1288
+ // instead of falling to the generic __typed_idx/__length dispatch.
1289
+ setFlowVal(name, valTypeOf(init))
1082
1290
  // Direct-call dispatch for const-bound, non-escaping local closures: skip call_indirect.
1083
1291
  // Gate: not boxed (no mutable cross-fn capture), not global, not reassigned in this body.
1084
1292
  // isReassigned is conservative across nested arrow shadows — we miss the optimization
@@ -1113,6 +1321,26 @@ export function emitDecl(...inits) {
1113
1321
  continue
1114
1322
  }
1115
1323
  if (isGlobal(name)) {
1324
+ // Module-const array of capture-free closures: record the candidate set for
1325
+ // indexed-call devirt (tryConstFnArrayDispatch). Const-only — a reassignable
1326
+ // binding could point at a different array whose elements we never saw.
1327
+ // NOTE: a dispatch-site arg lattice (argc/numeric row merged into the element
1328
+ // bodies' paramTypes/minArgc, killing their boxed-arg guards) was BUILT and
1329
+ // REVERTED here: prepare-time folds erase element reads (`let p = ops[1]`
1330
+ // pre-evals to the closure ref before program facts see the '[]' shape), so
1331
+ // no AST-level gate can prove the tagged sites are the only callers — the
1332
+ // trusted body then truncates raw box bits on a string arg through the alias
1333
+ // (see test/closures.js "element-as-value alias and arity variance stay
1334
+ // exact", which records the pre-existing string-coercion gap). Bodies keep
1335
+ // their guards; the arm-inline + watr trunc∘convert identities still
1336
+ // collapse the provably-int side.
1337
+ if (val.fnElements && ctx.scope.consts?.has(name))
1338
+ (ctx.scope.constFnArrays ||= new Map()).set(name, val.fnElements)
1339
+ // Const binding of a STATIC array literal: record base/len (+ the box bits as
1340
+ // identity) for optimize's foldStaticConstArrayReads. Same const-only logic.
1341
+ if (val.staticOff != null && ctx.scope.consts?.has(name))
1342
+ (ctx.scope.staticArrs ||= new Map()).set(name,
1343
+ { off: val.staticOff, len: val.staticLen, bits: extractF64Bits(val) })
1116
1344
  // Unboxed pointer const globals carry the raw i32 offset; init coerces via asPtrOffset.
1117
1345
  // Only an i32-STORED global is a raw pointer carrier — an f64 global holds a
1118
1346
  // NaN-boxed value, so coercing its init to an i32 offset (asPtrOffset → i32.wrap)
@@ -1412,6 +1640,67 @@ export function emitVoid(node) {
1412
1640
  return items
1413
1641
  }
1414
1642
 
1643
+ // Record a name's valTypeOf(rhs) fact into the live localValTypesOverlay layer (tier #2
1644
+ // in reps.js's lookup priority — see lookupValType). `let`/`const` decls record this
1645
+ // themselves at their emit site (emitDecl, right after each `emit(init)`); this helper
1646
+ // covers the remaining case emitBlockBody drives directly: a bare `name = rhs`
1647
+ // reassignment statement.
1648
+ function setFlowVal(name, vt) {
1649
+ if (!ctx.func.localValTypesOverlay || !isBoundName(name)) return
1650
+ // A name reassigned at any NESTED position of the current block (inside an
1651
+ // if/loop/closure body, a for's step, …) carries NO overlay fact: the recording
1652
+ // site doesn't dominate the reassignment, so the fact can go stale while the
1653
+ // binding is live — `let x = [7,8]; if (c) x = 5; x.length` read the number 5
1654
+ // through the ARRAY fast path (OOB): a latent pre-existing miscompile, widened
1655
+ // when decl recording moved into emitDecl and began covering for-init decls
1656
+ // (`for (let x = […]; x.length; x = 0)`). Top-level `=` statements stay
1657
+ // recordable — the block driver re-records at each, so the fact always
1658
+ // reflects the latest dominating write.
1659
+ if (ctx.func.flowValBlocked?.has(name)) return
1660
+ if (vt) ctx.func.localValTypesOverlay.set(name, vt)
1661
+ else ctx.func.localValTypesOverlay.delete(name)
1662
+ }
1663
+
1664
+ // Names assigned at a NESTED position within this block's statements: anything
1665
+ // except top-level `name = rhs` statement heads and top-level decl heads (both
1666
+ // re-recorded by the emit drivers as they pass). Walks into closures too — a
1667
+ // closure assigning an outer name can run between the recording and any later
1668
+ // read. ++/-- count as assignments (conservative: their result is numeric, but
1669
+ // blocking keeps the rule uniform).
1670
+ function collectNestedAssigns(stmts) {
1671
+ const blocked = new Set()
1672
+ const walk = (n) => {
1673
+ if (!Array.isArray(n)) return
1674
+ const op = n[0]
1675
+ // A decl's `['=', name, init]` pairs are DECLARATIONS, not reassignments
1676
+ // (same as isReassigned's let/const handling) — a nested `for (let x = …)`
1677
+ // init must not block x; only a true write in cond/step/body does.
1678
+ if (op === 'let' || op === 'const') {
1679
+ for (let i = 1; i < n.length; i++) {
1680
+ const d = n[i]
1681
+ if (Array.isArray(d) && d[0] === '=' && d[2] != null) walk(d[2])
1682
+ }
1683
+ return
1684
+ }
1685
+ if ((ASSIGN_OPS.has(op) || op === '++' || op === '--') && typeof n[1] === 'string') blocked.add(n[1])
1686
+ for (let i = 1; i < n.length; i++) walk(n[i])
1687
+ }
1688
+ for (const s of stmts) {
1689
+ if (!Array.isArray(s)) continue
1690
+ const op = s[0]
1691
+ if (op === '=' && typeof s[1] === 'string') { walk(s[2]); continue } // top-level target re-records
1692
+ if (op === 'let' || op === 'const') {
1693
+ for (let i = 1; i < s.length; i++) {
1694
+ const d = s[i]
1695
+ if (Array.isArray(d) && d[0] === '=' && d[2] != null) walk(d[2]) // decl head re-records; walk init
1696
+ }
1697
+ continue
1698
+ }
1699
+ walk(s)
1700
+ }
1701
+ return blocked
1702
+ }
1703
+
1415
1704
  /** Emit block body as flat list of WASM instructions. Unwraps {} and delegates to emitVoid per statement.
1416
1705
  * Also drives early-return refinement: `if (!guard) return/throw` narrows `guard` for the
1417
1706
  * rest of the enclosing block. Refinements added here are rolled back on block exit. */
@@ -1422,32 +1711,21 @@ export function emitBlockBody(node) {
1422
1711
  const accumulated = []
1423
1712
  const prevValOverlay = ctx.func.localValTypesOverlay
1424
1713
  ctx.func.localValTypesOverlay = new Map(prevValOverlay || [])
1425
- const setFlowVal = (name, vt) => {
1426
- if (!isBoundName(name)) return
1427
- if (vt) ctx.func.localValTypesOverlay.set(name, vt)
1428
- else ctx.func.localValTypesOverlay.delete(name)
1429
- }
1430
- const updateFlowVal = (stmt) => {
1431
- if (!Array.isArray(stmt)) return
1432
- const op = stmt[0]
1433
- if (op === '=' && typeof stmt[1] === 'string') {
1434
- setFlowVal(stmt[1], valTypeOf(stmt[2]))
1435
- return
1436
- }
1437
- if (op === 'let' || op === 'const') {
1438
- for (let i = 1; i < stmt.length; i++) {
1439
- const d = stmt[i]
1440
- if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string')
1441
- setFlowVal(d[1], valTypeOf(d[2]))
1442
- }
1443
- }
1444
- }
1714
+ // Nested-assignment blocklist for this block. Per-block own-scan is sufficient:
1715
+ // an outer name whose fact was blocked in the outer block never entered the
1716
+ // outer overlay (which this block's overlay copies), and a name reassigned at
1717
+ // THIS block's top level re-records right after the assignment (dominating the
1718
+ // rest of this block) — the scan blocks exactly the recordings that don't
1719
+ // dominate their possible staleness point.
1720
+ const prevFlowBlocked = ctx.func.flowValBlocked
1721
+ ctx.func.flowValBlocked = collectNestedAssigns(stmts)
1445
1722
  try {
1446
1723
  for (let i = 0; i < stmts.length; i++) {
1447
1724
  const s = stmts[i]
1448
1725
  if (s == null || typeof s === 'number') continue
1449
1726
  out.push(...emitVoid(s))
1450
- updateFlowVal(s)
1727
+ // `let`/`const` decls self-record via emitDecl; only a bare reassignment needs it here.
1728
+ if (Array.isArray(s) && s[0] === '=' && typeof s[1] === 'string') setFlowVal(s[1], valTypeOf(s[2]))
1451
1729
  // After an `if (cond) terminator` (no else), narrow types from !cond for subsequent statements.
1452
1730
  // Skip names that are reassigned later — refinement would be unsound past the assignment.
1453
1731
  if (Array.isArray(s) && s[0] === 'if' && s[3] == null && isTerminator(s[2])) {
@@ -1468,6 +1746,7 @@ export function emitBlockBody(node) {
1468
1746
  }
1469
1747
  } finally {
1470
1748
  ctx.func.localValTypesOverlay = prevValOverlay
1749
+ ctx.func.flowValBlocked = prevFlowBlocked
1471
1750
  // Restore prior refinements on block exit.
1472
1751
  for (let i = accumulated.length - 1; i >= 0; i--) {
1473
1752
  const [name, prev] = accumulated[i]
@@ -1508,8 +1787,17 @@ const STRICT_PRIM = new Set([VAL.NUMBER, VAL.BOOL, VAL.STRING, VAL.BIGINT])
1508
1787
  // nullish literal) can hold null/undefined at runtime, so `x === null` / `x == null`
1509
1788
  // must NOT fold to a constant even when `val` is a definite non-null kind. Only bare
1510
1789
  // variable reads carry the flag; literals/fresh allocations are inherently non-null.
1511
- const nullableOperand = (n) =>
1512
- typeof n === 'string' && !!(repOf(n)?.nullable || repOfGlobal(n)?.nullable)
1790
+ // An UNPROVEN typed-index read joins the set: `ta[i]` reads `undefined` past the end
1791
+ // (the checked .typed:[] form), while its VT stays NUMBER for numeric dispatch — the
1792
+ // undef box IS a NaN through arithmetic; only these identity folds must stay live.
1793
+ // `ta[i] === undefined` is the idiomatic bounds probe, so folding it kills real code.
1794
+ const nullableOperand = (n) => {
1795
+ if (typeof n === 'string') return !!(repOf(n)?.nullable || repOfGlobal(n)?.nullable)
1796
+ if (Array.isArray(n) && n[0] === '[]' && n.length === 3
1797
+ && typeof n[1] === 'string' && lookupValType(n[1]) === VAL.TYPED)
1798
+ return !typedIdxProven(n[1], n[2])
1799
+ return false
1800
+ }
1513
1801
 
1514
1802
  // An emitted value whose bit pattern is an i32, paired with how it widens to f64: a
1515
1803
  // `f64.convert_i32_s/u(x)` peels to its i32 source `x`; a bare i32 widens signed. Used to compare
@@ -1553,6 +1841,41 @@ const isCheapPureVal = (n) => {
1553
1841
  return false
1554
1842
  }
1555
1843
 
1844
+ // Side-effect-free: no writes (assignment / ++ / --), no calls, no closures, no throw. UNLIKE
1845
+ // `isCheapPureVal` this ALLOWS loads, member reads, and `/` `%` — a side-effect-free expr may read
1846
+ // memory or trap. It is the right gate for an `if` CONDITION promoted to a `select` condition: the
1847
+ // condition is evaluated exactly once whether the lowering branches or selects (any trap fires the
1848
+ // same in both, the read order vs the pure value arm is immaterial), so it need only avoid MUTATING
1849
+ // state the value arm could read — i.e. be side-effect-free, not unconditionally-evaluable.
1850
+ const SIDE_EFFECT_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '**=', '&=', '|=', '^=', '>>=', '<<=',
1851
+ '>>>=', '||=', '&&=', '??=', '++', '--', '()', '=>', 'throw', 'new', 'await', 'yield'])
1852
+ const isSideEffectFree = (n) => {
1853
+ if (!Array.isArray(n)) return true
1854
+ if (typeof n[0] === 'string' && SIDE_EFFECT_OPS.has(n[0])) return false
1855
+ for (let i = 1; i < n.length; i++) if (!isSideEffectFree(n[i])) return false
1856
+ return true
1857
+ }
1858
+
1859
+ const isLit1 = (n) => Array.isArray(n) && n[0] == null && n[1] === 1
1860
+ // A void statement whose whole effect is `x = <cheap pure value>` for a simple local `x` — the
1861
+ // shape if→select can lower to `x = cond ? value : x`. Recognizes the plain assignment plus the
1862
+ // increment forms `++x`/`--x` and their postfix lowerings `(++x) - 1` / `(--x) + 1` (prepare turns
1863
+ // `x++` in statement position into the latter; the discarded ∓1 is dead in void context, so the
1864
+ // net effect is the increment). Returns `{ lhs, val }` or null.
1865
+ function matchVoidLocalStore(s) {
1866
+ if (!Array.isArray(s)) return null
1867
+ if (s[0] === '=' && typeof s[1] === 'string' && isCheapPureVal(s[2])) return { lhs: s[1], val: s[2] }
1868
+ if ((s[0] === '++' || s[0] === '--') && typeof s[1] === 'string')
1869
+ return { lhs: s[1], val: [s[0] === '++' ? '+' : '-', s[1], [, 1]] }
1870
+ // postfix: `x++` → `(++x) - 1`, `x--` → `(--x) + 1`
1871
+ if ((s[0] === '-' || s[0] === '+') && isLit1(s[2]) && Array.isArray(s[1])
1872
+ && (s[1][0] === '++' || s[1][0] === '--') && typeof s[1][1] === 'string') {
1873
+ const inc = s[1][0] === '++'
1874
+ if ((inc && s[0] === '-') || (!inc && s[0] === '+')) return { lhs: s[1][1], val: [inc ? '+' : '-', s[1][1], [, 1]] }
1875
+ }
1876
+ return null
1877
+ }
1878
+
1556
1879
  function emitLooseEq(a, b, negate) {
1557
1880
  const eqOp = negate ? 'ne' : 'eq'
1558
1881
  const sentinel = emitNum(negate ? 1 : 0)
@@ -1617,6 +1940,16 @@ function emitLooseEq(a, b, negate) {
1617
1940
  // spec-on/spec-off differential (zero divergence at optimize 0 and 2).
1618
1941
  const strEqResult = (r) => negate ? typed(['i32.eqz', r], 'i32') : r
1619
1942
  const aStr = rawA === VAL.STRING, bStr = rawB === VAL.STRING
1943
+ // SSO literal (≤6 ASCII — its NaN-box IS its content, see module/string.js codec):
1944
+ // under the ≤6-ASCII⇒SSO producer invariant, content equality ⟺ bit equality
1945
+ // against ANY operand — an equal string must be the same SSO pattern, a heap
1946
+ // string can't hold ≤6-ASCII content, and a non-string never equals a string
1947
+ // (bit-aliasing NaNs behave identically to the pre-existing bit-eq fast path).
1948
+ // So the whole compare collapses to ONE i64.eq/ne — no call, no fallback.
1949
+ const ssoLit = (n) => ctx.features.sso && isLiteralStr(n) && n[1].length <= 6 && /^[\x00-\x7f]*$/.test(n[1])
1950
+ if ((aStr || bStr) && (rawA == null || aStr) && (rawB == null || bStr) && (ssoLit(a) || ssoLit(b))) {
1951
+ return typed([`i64.${negate ? 'ne' : 'eq'}`, asI64(va), asI64(vb)], 'i32')
1952
+ }
1620
1953
  if (aStr && bStr) {
1621
1954
  inc('__str_eq')
1622
1955
  return strEqResult(typed(['call', '$__str_eq', asI64(va), asI64(vb)], 'i32'))
@@ -1625,14 +1958,26 @@ function emitLooseEq(a, b, negate) {
1625
1958
  const uVal = bStr ? va : vb, lVal = bStr ? vb : va // u: unknown side, l: known string
1626
1959
  inc('__is_str_key', '__str_eq')
1627
1960
  const u = tempI64('seq'), l = tempI64('seq'), uG = ['local.get', `$${u}`], lG = ['local.get', `$${l}`]
1961
+ // On bit-mismatch, an SSO operand can't content-match anything (invariant
1962
+ // above) — one inline bit test skips the __is_str_key/__str_eq tail. Sound
1963
+ // for a non-string u too: the test only ever short-circuits to "not equal",
1964
+ // and a non-string never equals a string.
1965
+ const tail = ctx.features.sso
1966
+ ? ['if', ['result', 'i32'],
1967
+ ['i64.ne', ['i64.and', ['i64.or', uG, lG], ['i64.const', ssoBitI64Hex()]], ['i64.const', 0]],
1968
+ ['then', ['i32.const', 0]],
1969
+ ['else', ['if', ['result', 'i32'], ['call', '$__is_str_key', uG],
1970
+ ['then', ['call', '$__str_eq', uG, lG]],
1971
+ ['else', ['i32.const', 0]]]]]
1972
+ : ['if', ['result', 'i32'], ['call', '$__is_str_key', uG],
1973
+ ['then', ['call', '$__str_eq', uG, lG]],
1974
+ ['else', ['i32.const', 0]]]
1628
1975
  return strEqResult(typed(['block', ['result', 'i32'],
1629
1976
  ['local.set', `$${u}`, asI64(uVal)],
1630
1977
  ['local.set', `$${l}`, asI64(lVal)],
1631
1978
  ['if', ['result', 'i32'], ['i64.eq', uG, lG],
1632
1979
  ['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'))
1980
+ ['else', tail]]], 'i32'))
1636
1981
  }
1637
1982
  inc('__eq')
1638
1983
  const call = typed(['call', '$__eq', asI64(va), asI64(vb)], 'i32')
@@ -1669,6 +2014,24 @@ function emitStrictEq(a, b, negate) {
1669
2014
  const strictB = resolveValType(b, valTypeOf, lookupValType)
1670
2015
  if (strictA && strictB && strictA !== strictB && (STRICT_PRIM.has(strictA) || STRICT_PRIM.has(strictB)))
1671
2016
  return emitNum(negate ? 1 : 0)
2017
+ // Both sides statically BOOL: compare TRUTH VALUES, not raw bits — a boolean's
2018
+ // carrier varies by source (raw 0/1 from locals/comparisons, TRUE/FALSE atom out
2019
+ // of slots/hashes/JSON) and truthyIR normalizes both representations.
2020
+ if (strictA === VAL.BOOL && strictB === VAL.BOOL) {
2021
+ const cmp = typed(['i32.eq', truthyIR(emit(a)), truthyIR(emit(b))], 'i32')
2022
+ return negate ? typed(['i32.eqz', cmp], 'i32') : cmp
2023
+ }
2024
+ // One side statically BOOL, other side dynamic-unknown: strict equality is
2025
+ // IDENTITY. An unknown operand carries booleans as their TRUE/FALSE atom
2026
+ // (carrierF64 ingress) while numbers are raw — so `1 === true` must be false
2027
+ // even though the loose lowering's ToNumber would equate them. Compare bits:
2028
+ // the BOOL side boxes to its atom, the unknown side is compared verbatim.
2029
+ if ((strictA === VAL.BOOL) !== (strictB === VAL.BOOL) && (strictA == null || strictB == null)) {
2030
+ const va = strictA === VAL.BOOL ? carrierF64(a, emit(a)) : asF64(emit(a))
2031
+ const vb = strictB === VAL.BOOL ? carrierF64(b, emit(b)) : asF64(emit(b))
2032
+ const cmp = typed(['i64.eq', ['i64.reinterpret_f64', va], ['i64.reinterpret_f64', vb]], 'i32')
2033
+ return negate ? typed(['i32.eqz', cmp], 'i32') : cmp
2034
+ }
1672
2035
  // Same type (or dynamic-unknown): identical bits to loose `==`/`!=`.
1673
2036
  return emitter[negate ? '!=' : '=='](a, b)
1674
2037
  }
@@ -1687,6 +2050,16 @@ const cmpOp = (i32op, f64op, fn) => (a, b) => {
1687
2050
  const vta = numericVal(resolveValType(a, valTypeOf, lookupValType))
1688
2051
  const vtb = numericVal(resolveValType(b, valTypeOf, lookupValType))
1689
2052
  if (vta === VAL.BIGINT || vtb === VAL.BIGINT) {
2053
+ // Literal-mixed compare is MATHEMATICAL per spec (BigInt vs Number) — 5n > 3
2054
+ // must not compare raw NaN-box bits. Coerce through f64 (exact for literal
2055
+ // magnitudes); an unknown counterpart keeps the same-rep i64 contract
2056
+ // (kernel carriers' NUMBER is a kind-default, not a proof).
2057
+ if ((vta === VAL.BIGINT) !== (vtb === VAL.BIGINT) && numLiteralNode(vta === VAL.BIGINT ? b : a)) {
2058
+ const conv = (node, v, isBig) => isBig
2059
+ ? typed([bigintUnsignedBound(node) ? 'f64.convert_i64_u' : 'f64.convert_i64_s', asI64(v)], 'f64')
2060
+ : toNumF64(node, asF64(v))
2061
+ return typed([`f64.${f64op}`, conv(a, va, vta === VAL.BIGINT), conv(b, vb, vtb === VAL.BIGINT)], 'i32')
2062
+ }
1690
2063
  const op = bigintUnsignedBound(a) || bigintUnsignedBound(b) ? i32op.replace('_s', '_u') : i32op
1691
2064
  return typed([`i64.${op}`, asI64(va), asI64(vb)], 'i32')
1692
2065
  }
@@ -1919,6 +2292,27 @@ function emitSingleSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1919
2292
  const acc = `${T}acc${ctx.func.uniq++}`
1920
2293
  ctx.func.locals.set(acc, 'f64')
1921
2294
  const ir = [['local.set', `$${acc}`, asF64(emit(objArg))]]
2295
+ if (reverse) {
2296
+ // unshift(a, b, ...s): ES yields [a, b, ...s, ...existing]. Per-element
2297
+ // PREPENDS must run right-to-left over the WHOLE argument list — spread
2298
+ // elements first (end→start), the normal args last — or the spread lands
2299
+ // in front of the normals ([...s, a, b, ...] — the order bug that broke
2300
+ // the kernel's own `inject.unshift(setBase, ...stores)`). Argument
2301
+ // EVALUATION order stays left-to-right: normals spill to temps first.
2302
+ const temps = parsed.normal.map((a) => {
2303
+ const t = `${T}usv${ctx.func.uniq++}`
2304
+ ctx.func.locals.set(t, 'f64')
2305
+ ir.push(['local.set', `$${t}`, asF64(emitAsValue(() => emit(a)))])
2306
+ return t
2307
+ })
2308
+ ir.push(...emitSpreadElementLoop(parsed.spreads[0].expr, (arr, idx) => {
2309
+ const body = asF64(emitAsValue(() => methodEmitter(objArg, ['[]', arr, idx])))
2310
+ return [['drop', body]]
2311
+ }, { reverse: true }))
2312
+ if (temps.length) ir.push(['drop', asF64(emitAsValue(() => methodEmitter(objArg, ...temps)))])
2313
+ ir.push(asF64(emit(objArg)))
2314
+ return block64(...ir)
2315
+ }
1922
2316
  if (parsed.normal.length > 0) {
1923
2317
  const r = asF64(emitAsValue(() => methodEmitter(objArg, ...parsed.normal)))
1924
2318
  ir.push(inPlace ? ['drop', r] : ['local.set', `$${acc}`, r])
@@ -1943,6 +2337,42 @@ function emitMultiSpreadMethodCall(objArg, parsed, method, methodEmitter) {
1943
2337
  if (acc) ctx.func.locals.set(acc, 'f64')
1944
2338
  const recv = inPlace ? objArg : acc
1945
2339
  const ir = inPlace ? [] : [['local.set', `$${acc}`, asF64(emit(objArg))]]
2340
+ if (method === 'unshift') {
2341
+ // Prepends compose right-to-left (see emitSingleSpreadMethodCall's reverse
2342
+ // arm). Evaluation order stays left-to-right: spill every segment first —
2343
+ // normal args to value temps, each spread's source array to a temp — then
2344
+ // walk the segments END→START, spreads iterating end→start, each normal
2345
+ // batch prepended through the multi-arg emitter (which lands its own args
2346
+ // in argument order).
2347
+ const segs = []
2348
+ for (const item of combined) {
2349
+ if (Array.isArray(item) && item[0] === '__spread') {
2350
+ const t = `${T}ussp${ctx.func.uniq++}`
2351
+ ctx.func.locals.set(t, 'f64')
2352
+ ir.push(['local.set', `$${t}`, asF64(emitAsValue(() => emit(item[1])))])
2353
+ segs.push(['spread', t])
2354
+ } else {
2355
+ const t = `${T}usv${ctx.func.uniq++}`
2356
+ ctx.func.locals.set(t, 'f64')
2357
+ ir.push(['local.set', `$${t}`, asF64(emitAsValue(() => emit(item)))])
2358
+ if (segs.length && segs[segs.length - 1][0] === 'batch') segs[segs.length - 1].push(t)
2359
+ else segs.push(['batch', t])
2360
+ }
2361
+ }
2362
+ for (let i = segs.length - 1; i >= 0; i--) {
2363
+ const [kind, ...temps] = segs[i]
2364
+ if (kind === 'spread') {
2365
+ ir.push(...emitSpreadElementLoop(temps[0], (arr, idx) => {
2366
+ const body = asF64(emitAsValue(() => methodEmitter(objArg, ['[]', arr, idx])))
2367
+ return [['drop', body]]
2368
+ }, { reverse: true }))
2369
+ } else {
2370
+ ir.push(['drop', asF64(emitAsValue(() => methodEmitter(objArg, ...temps)))])
2371
+ }
2372
+ }
2373
+ ir.push(asF64(emit(objArg)))
2374
+ return block64(...ir)
2375
+ }
1946
2376
  let batch = []
1947
2377
  const flushBatch = () => {
1948
2378
  if (!batch.length) return
@@ -1983,11 +2413,14 @@ function emitMethodCallSpread(objArg, methodEmitter, parsed, method) {
1983
2413
  * the nullish-guard scaffold stays in one place. */
1984
2414
  function withNullGuard(headExpr, body, tag = 'ng') {
1985
2415
  const t = temp(tag)
2416
+ // asF64 on the taken arm: the continuation may come back i32-narrowed (an
2417
+ // int-certain slot read at O0 kept its raw i32), and the f64-typed if would
2418
+ // fail validation ("type error in fallthru: expected f64, got i32").
1986
2419
  return block64(
1987
2420
  ['local.set', `$${t}`, headExpr],
1988
2421
  ['if', ['result', 'f64'],
1989
2422
  ['i32.eqz', isNullish(['local.get', `$${t}`])],
1990
- ['then', body(t)],
2423
+ ['then', asF64(body(t))],
1991
2424
  ['else', undefExpr()]])
1992
2425
  }
1993
2426
 
@@ -2068,6 +2501,11 @@ function tryFnPropCall(callee, obj, method, parsed) {
2068
2501
  if (ctx.func.names.has(fname)) {
2069
2502
  const func = ctx.func.map.get(fname)
2070
2503
  const emittedArgs = emitCallArgs(parsed.normal, func.sig.params)
2504
+ // Drop extras like the plain-call path (emit.js regular-call arm): the dyn
2505
+ // closure ABI absorbed over-arity (`parse.enter?.(p, end)` on a 0-param
2506
+ // hook), but a devirtualized direct call pushes exactly sig arity — extras
2507
+ // would be stack leftovers (asi.js's parse.enter broke the self-host here).
2508
+ if (emittedArgs.length > func.sig.params.length) emittedArgs.length = func.sig.params.length
2071
2509
  return attachSigMeta(typed(['call', `$${fname}`, ...emittedArgs], func.sig.results[0]), func.sig)
2072
2510
  }
2073
2511
  }
@@ -2235,6 +2673,49 @@ function tryGenericEmitter({ obj, method, parsed, vt, callMethod }) {
2235
2673
  // correctly instead of being hijacked by `Array.prototype.{find,map,…}`.
2236
2674
  const objectShadow = vt === VAL.OBJECT || vt === VAL.HASH
2237
2675
  if (ctx.core.emit[`.${method}`] && !collectionMisfit && !strIndexMisfit && !objectShadow) {
2676
+ // Statically-UNKNOWN receiver: an OWN property named like the builtin shadows it
2677
+ // (ES prototype semantics) — the runtime analogue of `objectShadow` above. Without
2678
+ // this fork, subscript's `d.map(a)` descriptor mapper (or any user method colliding
2679
+ // with Array.prototype names) is hijacked by the builtin and reads array layout off
2680
+ // an object. Probe the dyn-prop sidecar: own closure wins, else the builtin runs —
2681
+ // emitted ONCE (the builtin bodies are large inline emitters; a dual-arm emission
2682
+ // doubled closure-heavy golden sizes). __dyn_get_expr guards real-number receivers
2683
+ // itself, so no f===f pre-fork is needed. Gated on the string module (the probe key
2684
+ // is a string literal): a string-less program has no user string props to shadow.
2685
+ if (vt == null && ctx.closure.call && !parsed.hasSpread && ctx.core.emit.str) {
2686
+ // HOISTED override probe: for a stable module-global receiver (the same
2687
+ // proof as charCodeAt shape-1b — never assigned in this function, and the
2688
+ // body's only calls are .charCodeAt, so nothing that runs here can change
2689
+ // the receiver or its props), the probe's answer is loop-invariant.
2690
+ // Register a per-(receiver, method) entry-prologue probe (drained by
2691
+ // collectParamInits) and reduce the per-site cost to one predictable
2692
+ // branch on the cached i32 + the lean builtin arm. jessie's space paid
2693
+ // the full 3-frame probe per CHARACTER without this.
2694
+ if (typeof obj === 'string' && ctx.func.charDecompGlobals && isGlobal(obj)
2695
+ && ctx.func.body && !isReassigned(ctx.func.body, obj) && bodyOnlyCharCodeAtCalls(ctx.func.body)) {
2696
+ const key = `${obj}#${method}`
2697
+ let ph = (ctx.func.probeHoist ??= new Map()).get(key)
2698
+ if (!ph) {
2699
+ const ovr = `${obj}$ovr$${method}`, is = `${obj}$ovrIs$${method}`
2700
+ ctx.func.locals.set(ovr, 'f64')
2701
+ ctx.func.locals.set(is, 'i32')
2702
+ inc('__dyn_get_expr', '__ptr_type')
2703
+ ph = { ovr, is, recvIR: () => asF64(emit(obj)), keyIR: () => asI64(emit(['str', method])) }
2704
+ ctx.func.probeHoist.set(key, ph)
2705
+ }
2706
+ return typed(['if', ['result', 'f64'], ['local.get', `$${ph.is}`],
2707
+ ['then', ctx.closure.call(typed(['local.get', `$${ph.ovr}`], 'f64'), parsed.normal)],
2708
+ ['else', asF64(callMethod(obj, ctx.core.emit[`.${method}`]))]], 'f64')
2709
+ }
2710
+ // Fallback arm: a bare-name receiver re-references the ORIGINAL binding
2711
+ // (variable reads are pure) instead of the probe's spilled temp — so a
2712
+ // module-global string receiver reaches the ABI op as `global.get` and
2713
+ // the charCodeAt shape-1b entry decomposition can fire (the layered-
2714
+ // parser `cur.charCodeAt(idx)` hot shape; a local temp would hide it).
2715
+ return sidecarOverride(emit(obj), asI64(emit(['str', method])),
2716
+ (p) => ctx.closure.call(typed(['local.get', `$${p}`], 'f64'), parsed.normal),
2717
+ (o) => asF64(callMethod(typeof obj === 'string' ? obj : o, ctx.core.emit[`.${method}`])))
2718
+ }
2238
2719
  return callMethod(obj, ctx.core.emit[`.${method}`])
2239
2720
  }
2240
2721
  }
@@ -2251,7 +2732,15 @@ function tryDynamicPropCall({ obj, method, parsed, vt }) {
2251
2732
  const propTmp = temp('mprop')
2252
2733
  const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2253
2734
  const arrayIR = buildArrayWithSpreads(combined)
2254
- const propRead = typed(['f64.reinterpret_i64', ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], asI64(emit(['str', method]))]], 'f64')
2735
+ // Primitive receivers skip the override probe see sidecarOverride (ir.js).
2736
+ const propRead = typed(['if', ['result', 'f64'],
2737
+ ['i32.and',
2738
+ ['f64.ne', ['local.get', `$${objTmp}`], ['local.get', `$${objTmp}`]],
2739
+ ['i64.ne',
2740
+ ['i64.and', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], ['i64.const', i64Hex(BigInt(LAYOUT.TAG_MASK) << BigInt(LAYOUT.TAG_SHIFT))]],
2741
+ ['i64.const', i64Hex(BigInt(PTR.STRING) << BigInt(LAYOUT.TAG_SHIFT))]]],
2742
+ ['then', ['f64.reinterpret_i64', ['call', '$__dyn_get_expr', ['i64.reinterpret_f64', ['local.get', `$${objTmp}`]], asI64(emit(['str', method]))]]],
2743
+ ['else', undefExpr()]], 'f64')
2255
2744
  const closureOnly = usesDynProps(vt) || ctx.transform.host === 'wasi'
2256
2745
  inc('__dyn_get_expr', '__ptr_type')
2257
2746
  if (!closureOnly) { inc('__ext_call'); ctx.features.external = true }
@@ -2275,6 +2764,17 @@ function tryDynamicPropCall({ obj, method, parsed, vt }) {
2275
2764
 
2276
2765
  // 12. Unknown callee — assume external method. Total: always returns.
2277
2766
  function externalMethodFallback({ obj, method, parsed }) {
2767
+ // A receiver with a KNOWN jz-native kind (linear-memory value) has no host
2768
+ // prototype behind it — every native strategy above declined, so the method
2769
+ // is simply missing and __ext_call could only marshal garbage / return
2770
+ // undefined at runtime. Fail at compile in every mode, like strict does.
2771
+ // OBJECT/HASH are exempt: their property sets are user data, not a closed
2772
+ // builtin table — `o.x()` may resolve to a closure slot at runtime (and when
2773
+ // it doesn't, the documented lowering is undefined, host's TypeError shape).
2774
+ // (Host values carry no static kind, so a null kind keeps the fallback.)
2775
+ const vt = typeof obj === 'string' ? (lookupValType(obj) ?? valTypeOf(obj)) : valTypeOf(obj)
2776
+ if (vt != null && vt !== VAL.OBJECT && vt !== VAL.HASH)
2777
+ err(`\`${typeof obj === 'string' ? obj : '<expr>'}.${method}(...)\` — '${method}' is not implemented for a ${vt} receiver, and jz-native values have no host fallthrough (the call could only yield undefined). Check the method name; if it's a real JS API, it's a missing jz builtin.`)
2278
2778
  if (ctx.transform.strict)
2279
2779
  err(`strict mode: method call \`${typeof obj === 'string' ? obj : '<expr>'}.${method}(...)\` on a value of unknown type falls through to host \`__ext_call\`. Annotate the receiver type or pass { strict: false }.`)
2280
2780
  // Under wasi there is no host `__ext_call` — the call lowers to a
@@ -2424,6 +2924,14 @@ function emitDirectFunctionCall(callee, parsed, callArgs) {
2424
2924
 
2425
2925
  // Regular function call without rest params
2426
2926
  if (parsed.hasSpread) err(`Spread not supported in calls to non-variadic function ${callee}`)
2927
+ // Speculative typed dispatch (narrow's speculateTypedParams): route the call
2928
+ // through a per-arg tag guard to the typed clone; a miss takes the original
2929
+ // call unchanged. Guard positions must be covered by real args — a site
2930
+ // relying on arity-padding at a speculated position would guard `undefined`
2931
+ // every call, pure loss.
2932
+ const spec = func && ctx.types.specFns?.get(callee)
2933
+ if (spec && func.sig.results.length === 1 && spec.guards.every(g => g.k < parsed.normal.length))
2934
+ return emitSpeculativeCall(callee, spec, parsed.normal, func)
2427
2935
  // Pad missing args with `undefined` so default-param init triggers per spec
2428
2936
  // (only undefined, not null, should trigger defaults). Drop extras to match
2429
2937
  // JS calling convention — emitting them anyway produces an invalid call
@@ -2432,7 +2940,7 @@ function emitDirectFunctionCall(callee, parsed, callArgs) {
2432
2940
  // legitimate 0-arity callee isn't bypassed.
2433
2941
  const params = func?.sig.params ?? []
2434
2942
  const args = func ? emitCallArgs(parsed.normal, params)
2435
- : parsed.normal.map(a => coerceArg(emit(a), undefined))
2943
+ : parsed.normal.map(a => coerceArg(emit(a), undefined, a))
2436
2944
  if (func && args.length > params.length) args.length = params.length
2437
2945
  // Multi-value return: materialize as heap array (caller expects single pointer).
2438
2946
  // Reuse the canonical comma-wrapped arg slot — materializeMulti re-reads args
@@ -2484,7 +2992,10 @@ function tryDirectClosureCall(callee, parsed) {
2484
2992
  mn.set(bodyName, prev === undefined ? n : (n < prev ? n : prev))
2485
2993
  // Body signature is uniform $ftN: (env f64, argc i32, a0..a{W-1} f64) → f64.
2486
2994
  // We pass the closure NaN-box itself as env (body extracts captures via __ptr_offset(__env)).
2487
- const slots = parsed.normal.map(a => asF64(emit(a)))
2995
+ // Slots are untyped boxed-value positions: a BOOL arg crosses as its atom box
2996
+ // (the paramTypes numeric lattice above already poisons on non-NUMBER args, so
2997
+ // the body never assumes raw numerics for these slots).
2998
+ const slots = parsed.normal.map(a => carrierF64(a, emit(a)))
2488
2999
  while (slots.length < W) slots.push(undefExpr())
2489
3000
  return typed(['call', `$${bodyName}`,
2490
3001
  asF64(emit(callee)),
@@ -2492,16 +3003,37 @@ function tryDirectClosureCall(callee, parsed) {
2492
3003
  ...slots], 'f64')
2493
3004
  }
2494
3005
 
3006
+ /** Tag the generic call_indirect of `constFnArr[idx](args)` for the optimizer's
3007
+ * devirtConstFnArrayCalls pass (optimize/index.js). The candidate set — a
3008
+ * module-const array of capture-free arrows — is recorded when the DECL emits,
3009
+ * which happens in buildStartFn AFTER function bodies emit; so emit only marks
3010
+ * the site (receiver name), and the rewrite runs in optimizeFunc where the
3011
+ * facts are complete. */
3012
+ const tagFnArrayDispatch = (ir, arrName) => {
3013
+ const findCI = (n) => {
3014
+ if (!Array.isArray(n)) return null
3015
+ if (n[0] === 'call_indirect') return n
3016
+ for (let i = 1; i < n.length; i++) { const f = findCI(n[i]); if (f) return f }
3017
+ return null
3018
+ }
3019
+ const ci = findCI(ir)
3020
+ if (ci) ci.dvArr = arrName
3021
+ return ir
3022
+ }
3023
+
2495
3024
  /** Generic closure call: callee is a value holding a NaN-boxed closure pointer.
2496
3025
  * Uniform convention: fn.call packs all args into an array and trampolines. */
2497
3026
  function emitGenericClosureCall(callee, parsed) {
3027
+ const dvName = ctx.transform.optimize && !parsed.hasSpread &&
3028
+ Array.isArray(callee) && callee[0] === '[]' && typeof callee[1] === 'string' ? callee[1] : null
2498
3029
  if (parsed.hasSpread) {
2499
3030
  const combined = reconstructArgsWithSpreads(parsed.normal, parsed.spreads)
2500
3031
  const arrayIR = buildArrayWithSpreads(combined)
2501
3032
  // Pass pre-built array as single already-emitted arg
2502
3033
  return ctx.closure.call(emit(callee), [arrayIR], true)
2503
3034
  }
2504
- return ctx.closure.call(emit(callee), parsed.normal)
3035
+ const ir = ctx.closure.call(emit(callee), parsed.normal)
3036
+ return dvName ? tagFnArrayDispatch(ir, dvName) : ir
2505
3037
  }
2506
3038
 
2507
3039
  /** Last-resort fallback: assume `(call $callee args)` against an import / unknown
@@ -2547,6 +3079,21 @@ function compoundAssign(name, val, f64op, i32op) {
2547
3079
  return writeVar(name, f64op(asF64(va), asF64(vb)), void_)
2548
3080
  }
2549
3081
 
3082
+ // Ring 0.3 (re-landed after the dispatch rework dropped the uncommitted original):
3083
+ // JS makes BigInt⊕Number arithmetic a TypeError. Enforce it exactly where the mix
3084
+ // is PROVABLE from source — one side proven BIGINT, the other a NUMERIC LITERAL —
3085
+ // and stay permissive otherwise: kernel carriers read NUMBER as a kind-DEFAULT
3086
+ // (not a proof), so rejecting proven-BIGINT × default-NUMBER breaks sound kernels.
3087
+ const numLiteralNode = (n) =>
3088
+ typeof n === 'number' || (Array.isArray(n) && n[0] == null && typeof n[1] === 'number')
3089
+ function bigintMixReject(op, a, b) {
3090
+ if (b === undefined) return
3091
+ const aBig = valTypeOf(a) === VAL.BIGINT, bBig = valTypeOf(b) === VAL.BIGINT
3092
+ if (aBig === bBig) return
3093
+ if (numLiteralNode(aBig ? b : a))
3094
+ err(`Cannot mix BigInt and other types in \`${op}\` (TypeError in JS) — convert explicitly with BigInt() or Number()`)
3095
+ }
3096
+
2550
3097
  // === Core emitter dispatch table ===
2551
3098
  // ctx.core.emit is seeded with a flat copy of this object on reset;
2552
3099
  // language modules add or override ops on ctx.core.emit directly.
@@ -2584,8 +3131,15 @@ export const emitter = {
2584
3131
  ...results.flatMap(dropSpread),
2585
3132
  ['f64.const', 0])
2586
3133
  }
2587
- return typed(['block', ['result', last.type],
3134
+ const seq = typed(['block', ['result', last.type],
2588
3135
  ...results.slice(0, -1).flatMap(dropSpread), last], last.type)
3136
+ // The sequence's VALUE is `last` — carry its value metadata, or downstream
3137
+ // coercions misread the carrier: an i32 OBJECT/CLOSURE pointer without its
3138
+ // ptrKind gets f64.convert_i32_s'd (`return (fn.a = 1, fn)` returned the raw
3139
+ // heap offset as a number). Same bug-class as the ternary's tagPtr (below).
3140
+ if (last.ptrKind != null) { seq.ptrKind = last.ptrKind; if (last.ptrAux != null) seq.ptrAux = last.ptrAux }
3141
+ if (last.unsigned) seq.unsigned = last.unsigned
3142
+ return seq
2589
3143
  },
2590
3144
  'let': emitDecl,
2591
3145
  'const': emitDecl,
@@ -2683,7 +3237,25 @@ export const emitter = {
2683
3237
  if (expr == null) return [...finalizers, typed(['return', undefExpr()], 'void')]
2684
3238
  const rt = ctx.func.current?.results[0] || 'f64'
2685
3239
  const pk = ctx.func.current?.ptrKind
2686
- const ir = pk != null ? asPtrOffset(emit(expr), pk) : asParamType(emit(expr), rt)
3240
+ // Emit ONCE, before branching on pk self-host miscompile: the equivalent inline
3241
+ // form `pk != null ? asPtrOffset(emit(expr), pk) : asParamType(emit(expr), rt)`
3242
+ // (emit(expr) repeated once per ternary arm, only one ever executing) is behaviorally
3243
+ // identical in JS but the self-hosted kernel drops the f64.convert_i32_s/u rebox on
3244
+ // the taken arm's result — an i32-typed return tail comes back bare (unconverted) in
3245
+ // a non-narrowed (f64-result) function, so the wasm validator sees "expected f64, got
3246
+ // i32" at every return site shaped like `return (expr)|0` inside a function whose
3247
+ // result the narrower left at f64 (e.g. blocked by an unrelated same-name shadow
3248
+ // elsewhere — narrowI32Results itself is unaffected either way). compile/index.js's
3249
+ // sibling call site (`const ir = emit(body); … ptrKind != null ? asPtrOffset(ir, …) :
3250
+ // asParamType(ir, …)`) already used this materialize-then-branch shape and was never
3251
+ // affected — mirroring it here is both the fix and the more idiomatic form (DRY: one
3252
+ // emit call instead of a copy per arm). Root cause not fully localized beyond "the
3253
+ // self-hosted kernel, at every optimize level 0-2, treats a value produced by a call
3254
+ // repeated textually across both arms of a ternary differently from one materialized
3255
+ // to a local first" — pinned in test/parser-bugs.js rather than chased further into
3256
+ // the kernel's own call/branch codegen. See .work/todo.md (groundtruth archive).
3257
+ const emitted = emit(expr)
3258
+ const ir = pk != null ? asPtrOffset(emitted, pk) : asParamType(emitted, rt)
2687
3259
  const ty = pk != null ? 'i32' : rt
2688
3260
  const tcoed = tcoTailRewrite(ir, ty)
2689
3261
  if (Array.isArray(tcoed) && tcoed[0] === 'return_call' && finalizers.length === 0) {
@@ -2713,7 +3285,15 @@ export const emitter = {
2713
3285
  inc('__to_num')
2714
3286
  return writeVar(name, typed(['call', '$__to_num', asI64(emit(name))], 'f64'), void_)
2715
3287
  }
2716
- return writeVar(name, emit(val), void_)
3288
+ // Self-accumulation `x = x + …` (incl. desugared `x += …`): the new value REPLACES x, so x's
3289
+ // old buffer is dead — the one context where a string concat may bump-EXTEND it in place. The
3290
+ // `+` handler reads this flag for its immediate concat; nested operands clear it (not the target).
3291
+ const selfAccum = Array.isArray(val) && val[0] === '+' && val[1] === name
3292
+ const prevSA = ctx.func._selfAccumConcat
3293
+ ctx.func._selfAccumConcat = selfAccum ? name : null
3294
+ const ev = emit(val)
3295
+ ctx.func._selfAccumConcat = prevSA
3296
+ return writeVar(name, ev, void_)
2717
3297
  },
2718
3298
 
2719
3299
  // Compound assignments: read-modify-write with type coercion
@@ -2743,6 +3323,8 @@ export const emitter = {
2743
3323
  if (typeof name !== 'string') return emit(['=', name, ['%', name, val]])
2744
3324
  return compoundAssign(name, val, f64rem, (a, b) => typed(['i32.rem_s', a, b], 'i32'))
2745
3325
  },
3326
+ // `**` is always f64 (and has its own const-exponent lowering) — full desugar.
3327
+ '**=': (name, val) => emit(['=', name, ['**', name, val]]),
2746
3328
 
2747
3329
  // Bitwise compound assignments: i32 normally, i64 when either operand is BigInt
2748
3330
  ...Object.fromEntries([
@@ -2803,15 +3385,31 @@ export const emitter = {
2803
3385
  // Postfix in void: (++i)-1 / (--i)+1 → just ++i / --i
2804
3386
  '+': (a, b) => {
2805
3387
  if (ctx.func._expect === 'void' && isPostfix(a, '--', b)) return emit(a, 'void')
3388
+ // A self-accumulation `a = a + …` lets the concat bump-EXTEND `a` in place (a is dead-after).
3389
+ // Read it for THIS concat, then clear so nested operands (not the accumulation target) stay fresh.
3390
+ const selfAccum = typeof a === 'string' && a === ctx.func._selfAccumConcat
3391
+ ctx.func._selfAccumConcat = null
3392
+ // String concat-CHAIN fusion: `i + ',' + name + ',' + v + '\n'` is
3393
+ // left-associated pairwise `+`, and pairwise lowering re-copies the whole
3394
+ // growing prefix at every step (triangular bytes moved, one fresh heap
3395
+ // buffer per `+`). Flatten the chain and emit ONE measure→alloc→copy pass
3396
+ // instead. Fusion crosses a nested `+` only when a side is statically
3397
+ // string-ish (so a numeric `1 + 2 + s` keeps its numeric ADD as a single
3398
+ // leaf); ToString order stays left-to-right (leaves evaluate in order).
3399
+ // A self-accumulating head (`line = line + a + b`) keeps leaf 0 pairwise
3400
+ // so the O(1) bump-extend accumulator survives — only the tail fuses.
3401
+ {
3402
+ const fused = tryConcatChain(a, b, selfAccum)
3403
+ if (fused) return fused
3404
+ }
2806
3405
  // String concatenation: pure string operands skip generic ToString coercion.
2807
3406
  const vtA = valTypeOf(a)
2808
3407
  const vtB = valTypeOf(b)
2809
3408
  if (vtA === VAL.STRING && vtB === VAL.STRING) {
2810
- // Fused append-byte: `buf += s[i]` skips 1-char SSO construction +
2811
- // generic concat dispatch when rhs is a string-index. The byte flows
2812
- // straight from __char_at into memory, and the bump-extend path elides
2813
- // the alloc+copy when lhs is the heap-top STRING.
2814
- if (Array.isArray(b) && b[0] === '[]' && ctx.core.stdlib['__str_append_byte'] && ctx.core.stdlib['__char_at']) {
3409
+ // Fused append-byte: `buf += s[i]` skips 1-char SSO construction + generic concat dispatch
3410
+ // when rhs is a string-index. The byte flows straight from __char_at into memory and bump-
3411
+ // EXTENDS the heap-top lhs so only when proven self-accumulating (else it mutates a live s).
3412
+ if (selfAccum && Array.isArray(b) && b[0] === '[]' && ctx.core.stdlib['__str_append_byte'] && ctx.core.stdlib['__char_at']) {
2815
3413
  if (valTypeOf(b[1]) === VAL.STRING) {
2816
3414
  inc('__str_append_byte', '__char_at')
2817
3415
  return typed(['call', '$__str_append_byte',
@@ -2820,7 +3418,7 @@ export const emitter = {
2820
3418
  ], 'f64')
2821
3419
  }
2822
3420
  }
2823
- return typed(ctx.abi.string.ops.concatRaw(asF64(emit(a)), asF64(emit(b)), ctx), 'f64')
3421
+ return typed(ctx.abi.string.ops.concatRaw(asF64(emit(a)), asF64(emit(b)), ctx, selfAccum), 'f64')
2824
3422
  }
2825
3423
  if (vtA === VAL.STRING || vtB === VAL.STRING) {
2826
3424
  // An OBJECT operand coerces via ToPrimitive(string) at compile time —
@@ -2840,24 +3438,58 @@ export const emitter = {
2840
3438
  const coercionFree = (vt) => vt === VAL.STRING || vt === VAL.OBJECT || vt === VAL.BOOL
2841
3439
  const cfA = coercionFree(vtA), cfB = coercionFree(vtB)
2842
3440
  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')
3441
+ if (cfA && cfB) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strOperand(vtB, b), ctx, selfAccum), 'f64')
3442
+ if (cfA) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strI64(b), ctx, selfAccum), 'f64')
3443
+ if (cfB) return typed(ctx.abi.string.ops.concatRaw(strI64(a), strOperand(vtB, b), ctx, selfAccum), 'f64')
3444
+ return typed(ctx.abi.string.ops.cat(strOperand(vtA, a), strOperand(vtB, b), ctx, selfAccum), 'f64')
2847
3445
  }
2848
- if (vtA === VAL.BIGINT || vtB === VAL.BIGINT)
3446
+ if (vtA === VAL.BIGINT || vtB === VAL.BIGINT) {
3447
+ bigintMixReject('+', a, b)
2849
3448
  return fromI64(['i64.add', asI64(emit(a)), asI64(emit(b))])
3449
+ }
2850
3450
  // Runtime string dispatch when at least one side could be a string. When one side has
2851
3451
  // a known non-STRING vtype, skip its `__is_str_key` (statically false). Common in
2852
3452
  // chained additions `s + a*b + c.d` — left grows as `+` (=NUMBER), only the new right
2853
3453
  // operand needs the runtime check.
2854
3454
  if ((vtA == null || vtB == null) && ctx.core.stdlib['__str_concat']) {
2855
3455
  const tA = temp('add'), tB = temp('add')
3456
+ // Fully-untyped `+`: the string arm is a runtime-guarded cold path that the engine reaches
3457
+ // only if BOTH operands are strings at runtime, so it keeps the bump-extend `__str_concat`
3458
+ // (its body stays out-of-line — folding it to the smaller _fresh twin would inline this
3459
+ // never-numeric branch into every hot integer loop). The demonstrated `t = s + "lit"` mutation
3460
+ // is a TYPED concat (handled by concatRaw above); a both-untyped self-mutation stays the
3461
+ // documented rare-aliasing tradeoff. Self-accumulation is still safe to extend.
2856
3462
  inc('__str_concat', '__is_str_key')
2857
- const checkA = vtA == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tA}`, asF64(emit(a))]]] : null
2858
- const checkB = vtB == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tB}`, asF64(emit(b))]]] : null
3463
+ const eA = vtA == null ? asF64(emit(a)) : null
3464
+ const eB = vtB == null ? asF64(emit(b)) : null
3465
+ const checkA = eA ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tA}`, eA]]] : null
3466
+ const checkB = eB ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tB}`, eB]]] : null
2859
3467
  const concat = ['call', '$__str_concat', ['i64.reinterpret_f64', ['local.get', `$${tA}`]], ['i64.reinterpret_f64', ['local.get', `$${tB}`]]]
2860
- const add = ['f64.add', ['local.get', `$${tA}`], ['local.get', `$${tB}`]]
3468
+ // Numeric arm: an UNKNOWN operand may still be a non-string NaN-box (bool
3469
+ // atom, null) whose ToNumber is not its raw bits — `true + 1` is 2,
3470
+ // `null + 1` is 1. Guard with the self-compare (every non-NaN f64 IS its
3471
+ // own ToNumber; two inline ops on the hot path); the cold arm is the
3472
+ // inline ATOM ladder, not __to_num — strings can't reach here (the
3473
+ // __is_str_key fork above took them) and objects stay jz-permissive NaN
3474
+ // either way, so the full ToNumber (and the number↔string formatter tree
3475
+ // it pins — the dyn-object golden) buys nothing. Skipped when the side is
3476
+ // known-vt (raw carrier by design) or IR-shape numeric (isNumArm — keeps
3477
+ // floatbeat kernels at their box-free ratchet counts).
3478
+ const numSide = (t, e, node) => {
3479
+ if (!e || isNumArm(e, node)) return ['local.get', `$${t}`]
3480
+ const bits = ['i64.reinterpret_f64', ['local.get', `$${t}`]]
3481
+ return ['if', ['result', 'f64'],
3482
+ ['f64.eq', ['local.get', `$${t}`], ['local.get', `$${t}`]],
3483
+ ['then', ['local.get', `$${t}`]],
3484
+ ['else', ['select',
3485
+ ['f64.const', 1],
3486
+ ['select',
3487
+ ['f64.const', 0],
3488
+ ['f64.const', 'nan'],
3489
+ ['i32.or', ['i64.eq', bits, ['i64.const', FALSE_NAN]], ['i64.eq', bits, ['i64.const', NULL_NAN]]]],
3490
+ ['i64.eq', bits, ['i64.const', TRUE_NAN]]]]]
3491
+ }
3492
+ const add = ['f64.add', numSide(tA, eA, a), numSide(tB, eB, b)]
2861
3493
  if (checkA && checkB) {
2862
3494
  return typed(['if', ['result', 'f64'], ['i32.or', checkA, checkB], ['then', concat], ['else', add]], 'f64')
2863
3495
  }
@@ -2882,10 +3514,12 @@ export const emitter = {
2882
3514
  },
2883
3515
  '-': (a, b) => {
2884
3516
  if (ctx.func._expect === 'void' && isPostfix(a, '++', b)) return emit(a, 'void')
2885
- if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
3517
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT) {
3518
+ bigintMixReject('-', a, b)
2886
3519
  return b === undefined
2887
3520
  ? fromI64(['i64.sub', ['i64.const', 0], asI64(emit(a))])
2888
3521
  : fromI64(['i64.sub', asI64(emit(a)), asI64(emit(b))])
3522
+ }
2889
3523
  if (b === undefined) return emitNeg(a)
2890
3524
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a - b)
2891
3525
  if (_f) return _f
@@ -2898,7 +3532,7 @@ export const emitter = {
2898
3532
  },
2899
3533
  'u+': a => {
2900
3534
  if (valTypeOf(a) === VAL.BIGINT)
2901
- return typed(['f64.convert_i64_s', asI64(emit(a))], 'f64')
3535
+ return err('unary `+` on a BigInt is a TypeError in JS — use Number(x)')
2902
3536
  const v = emit(a)
2903
3537
  if (v.type === 'i32') return asF64(v)
2904
3538
  if (valTypeOf(a) === VAL.NUMBER) return toNumF64(a, v)
@@ -2907,8 +3541,10 @@ export const emitter = {
2907
3541
  },
2908
3542
  'u-': a => emitNeg(a),
2909
3543
  '*': (a, b) => {
2910
- if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
3544
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT) {
3545
+ bigintMixReject('*', a, b)
2911
3546
  return fromI64(['i64.mul', asI64(emit(a)), asI64(emit(b))])
3547
+ }
2912
3548
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a * b)
2913
3549
  if (_f) return _f
2914
3550
  if (isLit(vb) && litVal(vb) === 1) return toNumF64(a, va)
@@ -2924,20 +3560,37 @@ export const emitter = {
2924
3560
  // `.unsigned` operand is a uint32 ([0, 2^32)); its product can exceed i32, so
2925
3561
  // `i32.mul` would wrap ((2^32-1)*2 → -2). Widen to f64 — see `+` above.
2926
3562
  if (isI32Num(va) && isI32Num(vb) && !widensUnsigned(va) && !widensUnsigned(vb) && (mulFitsI32(va, vb) || mulBoundedFaithful(va, vb))) return typed(['i32.mul', va, vb], 'i32')
3563
+ // Typed-element reads arrive PRE-converted (`.typed:[]` returns
3564
+ // f64.convert_i32_{s,u}(loadN)), so the faithful-product gate above never
3565
+ // sees them. Peel the convert to expose the bounded integer source: when
3566
+ // |a|·|b| ≤ 2^31−1 the exact product fits signed i32, so
3567
+ // f64.mul(convert(x), convert(y)) == convert_s(i32.mul(x, y)) in every
3568
+ // consumer context — one int op instead of two converts + f64.mul, and the
3569
+ // i32 product chain is lane-vectorizable. Unsigned converts are safe here
3570
+ // for the same reason: a magnitude-bounded (< 2^31) uint reads the same
3571
+ // signed or unsigned, and the bounded product needs the signed convert.
3572
+ const peeled = (v) => Array.isArray(v) && (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u') && v.length === 2 ? v[1]
3573
+ : isI32Num(v) && !widensUnsigned(v) ? v : null
3574
+ const pa = peeled(va), pb = peeled(vb)
3575
+ if (pa && pb && i32Mag(pa) * i32Mag(pb) <= 0x7fffffff) return typed(['i32.mul', pa, pb], 'i32')
2927
3576
  const i32mul = tryI32Arith('i32.mul', '*', a, b, va, vb); if (i32mul) return i32mul
2928
3577
  return typed(['f64.mul', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2929
3578
  },
2930
3579
  '/': (a, b) => {
2931
- if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
3580
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT) {
3581
+ bigintMixReject('/', a, b)
2932
3582
  return fromI64(['i64.div_s', asI64(emit(a)), asI64(emit(b))])
3583
+ }
2933
3584
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a / b, b => b !== 0)
2934
3585
  if (_f) return _f
2935
3586
  if (isLit(vb) && litVal(vb) === 1) return toNumF64(a, va)
2936
3587
  return typed(['f64.div', stripCanon(toNumF64(a, va)), stripCanon(toNumF64(b, vb))], 'f64')
2937
3588
  },
2938
3589
  '%': (a, b) => {
2939
- if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
3590
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT) {
3591
+ bigintMixReject('%', a, b)
2940
3592
  return fromI64(['i64.rem_s', asI64(emit(a)), asI64(emit(b))])
3593
+ }
2941
3594
  const va = emit(a), vb = emit(b), _f = foldConst(va, vb, (a, b) => a % b, b => b !== 0)
2942
3595
  if (_f) return _f
2943
3596
  // ES remainder by zero is NaN; only the f64 path yields that (a - trunc(a/0)*0).
@@ -3010,6 +3663,27 @@ export const emitter = {
3010
3663
  const elseRefs = extractRefinements(a, new Map(), false)
3011
3664
  const vb = withRefinements(thenRefs, b, () => emit(b))
3012
3665
  const vc = withRefinements(elseRefs, c, () => emit(c))
3666
+ // A BOOL arm beside a non-BOOL, non-NUMBER arm: the merge kills the static
3667
+ // type, so the boolean's identity is observable only through its atom box —
3668
+ // materialize it per-arm here, BEFORE the raw-bit collapses below erase it
3669
+ // (`i ? true : [from, len]` — watr's rec-type marker — must yield TRUE_NAN,
3670
+ // not 1.0). BOOL∪NUMBER stays raw: VT['?:'] carries NUMBER there (the raw
3671
+ // 0/1 IS the bool's ToNumber image — the benign numeric-context lie), and
3672
+ // both-BOOL arms keep vt BOOL and stay raw 0/1 by design.
3673
+ {
3674
+ const vtbM = resolveValType(b, valTypeOf, lookupValType)
3675
+ const vtcM = resolveValType(c, valTypeOf, lookupValType)
3676
+ if ((vtbM === VAL.BOOL) !== (vtcM === VAL.BOOL) &&
3677
+ (vtbM === VAL.BOOL ? vtcM : vtbM) !== VAL.NUMBER) {
3678
+ const fb = vtbM === VAL.BOOL ? boolBoxIR(vb) : asF64(vb)
3679
+ const fc = vtcM === VAL.BOOL ? boolBoxIR(vc) : asF64(vc)
3680
+ const ib = ['i64.reinterpret_f64', fb], ic = ['i64.reinterpret_f64', fc]
3681
+ const bits = isPureIR(fb) && isPureIR(fc)
3682
+ ? ['select', ib, ic, cond]
3683
+ : ['if', ['result', 'i64'], cond, ['then', ib], ['else', ic]]
3684
+ return typed(['f64.reinterpret_i64', bits], 'f64')
3685
+ }
3686
+ }
3013
3687
  // `cond ? 1 : 0` is the condition bit itself; `cond ? 0 : 1` its negation. `cond`
3014
3688
  // (truthyIR) is already canonical 0/1, so the select + two const arms collapse to
3015
3689
  // the bit. (Both arms are literals here, so dropping their emitted IR is side-effect
@@ -3114,7 +3788,39 @@ export const emitter = {
3114
3788
  // a is truthy in the right-arm — narrow b accordingly. Matches `?:`'s then-arm threading
3115
3789
  // (`Array.isArray(x) && x[0]` → x[0] sees x as ARRAY, eliding union-rep fallbacks).
3116
3790
  const rightRefs = extractRefinements(a, new Map(), true)
3117
- const emitRight = () => withRefinements(rightRefs, b, () => emit(b))
3791
+ // Guarded indexed-closure dispatch: `(name = V[idx]) && name(args)` —
3792
+ // subscript's parse.step idiom for "look up a handler, call it if present."
3793
+ // `a`'s assignment (emitted normally, above) already set `name` from
3794
+ // `V[idx]`; `b`'s callee, though syntactically a bare identifier, is
3795
+ // PROVABLY that same read — tag the resulting call_indirect the same way
3796
+ // a direct `V[idx](args)` gets tagged (emitGenericClosureCall below), so
3797
+ // devirtConstFnArrayCalls can rewrite it once dyn-closure-tables.js proves
3798
+ // V monomorphic. An unresolved/untagged V just leaves the tag inert.
3799
+ const dvArrName = ctx.transform.optimize && Array.isArray(a) && a[0] === '=' &&
3800
+ typeof a[1] === 'string' && Array.isArray(a[2]) && a[2][0] === '[]' &&
3801
+ typeof a[2][1] === 'string' && ctx.scope.dynFnTableCandidates?.has(a[2][1]) &&
3802
+ Array.isArray(b) && b[0] === '()' && b[1] === a[1] ? a[2][1] : null
3803
+ const emitRight = () => {
3804
+ const vr = withRefinements(rightRefs, b, () => emit(b))
3805
+ return dvArrName ? tagFnArrayDispatch(vr, dvArrName) : vr
3806
+ }
3807
+ // Mixed BOOL/non-NUMBER sides: the merge kills the static type (VT['&&']
3808
+ // returns null), so a surfacing bool must carry its atom box — same rule as
3809
+ // the `?:` arm materialization above. Both-BOOL and BOOL∪NUMBER stay raw.
3810
+ {
3811
+ const vtA = resolveValType(a, valTypeOf, lookupValType)
3812
+ const vtB = resolveValType(b, valTypeOf, lookupValType)
3813
+ if ((vtA === VAL.BOOL) !== (vtB === VAL.BOOL) && (vtA === VAL.BOOL ? vtB : vtA) !== VAL.NUMBER) {
3814
+ const t = temp()
3815
+ const fa = vtA === VAL.BOOL ? boolBoxIR(va) : asF64(va)
3816
+ const fb0 = emitRight()
3817
+ const fb = vtB === VAL.BOOL ? boolBoxIR(fb0) : asF64(fb0)
3818
+ return typed(['if', ['result', 'f64'],
3819
+ toBoolFromEmitted(typed(['local.tee', `$${t}`, fa], 'f64')),
3820
+ ['then', fb],
3821
+ ['else', ['local.get', `$${t}`]]], 'f64')
3822
+ }
3823
+ }
3118
3824
  // i32 fast path: use i32 tee as cond directly (nonzero=truthy in wasm `if`),
3119
3825
  // skip f64 round-trip and __is_truthy call entirely.
3120
3826
  if (va.type === 'i32') {
@@ -3162,6 +3868,21 @@ export const emitter = {
3162
3868
  // De Morgan'd via the sense=false branch of extractRefinements (mirrors the ?: else-arm).
3163
3869
  const rightRefs = extractRefinements(a, new Map(), false)
3164
3870
  const emitRight = () => withRefinements(rightRefs, b, () => emit(b))
3871
+ // Mixed BOOL/non-NUMBER sides — see `&&`: a surfacing bool carries its atom box.
3872
+ {
3873
+ const vtA = resolveValType(a, valTypeOf, lookupValType)
3874
+ const vtB = resolveValType(b, valTypeOf, lookupValType)
3875
+ if ((vtA === VAL.BOOL) !== (vtB === VAL.BOOL) && (vtA === VAL.BOOL ? vtB : vtA) !== VAL.NUMBER) {
3876
+ const t = temp()
3877
+ const fa = vtA === VAL.BOOL ? boolBoxIR(va) : asF64(va)
3878
+ const fb0 = emitRight()
3879
+ const fb = vtB === VAL.BOOL ? boolBoxIR(fb0) : asF64(fb0)
3880
+ return typed(['if', ['result', 'f64'],
3881
+ toBoolFromEmitted(typed(['local.tee', `$${t}`, fa], 'f64')),
3882
+ ['then', ['local.get', `$${t}`]],
3883
+ ['else', fb]], 'f64')
3884
+ }
3885
+ }
3165
3886
  if (va.type === 'i32') {
3166
3887
  const vb = emitRight()
3167
3888
  const t = tempI32()
@@ -3193,6 +3914,19 @@ export const emitter = {
3193
3914
  '??': (a, b) => {
3194
3915
  const va = emit(a), vb = emit(b)
3195
3916
  const t = temp()
3917
+ // Mixed BOOL/non-NUMBER sides — see `&&`: a surfacing bool carries its atom box.
3918
+ {
3919
+ const vtA = resolveValType(a, valTypeOf, lookupValType)
3920
+ const vtB = resolveValType(b, valTypeOf, lookupValType)
3921
+ if ((vtA === VAL.BOOL) !== (vtB === VAL.BOOL) && (vtA === VAL.BOOL ? vtB : vtA) !== VAL.NUMBER) {
3922
+ const fa = vtA === VAL.BOOL ? boolBoxIR(va) : asF64(va)
3923
+ const fb = vtB === VAL.BOOL ? boolBoxIR(vb) : asF64(vb)
3924
+ return typed(['if', ['result', 'f64'],
3925
+ ['i32.eqz', isNullish(['local.tee', `$${t}`, fa])],
3926
+ ['then', ['local.get', `$${t}`]],
3927
+ ['else', fb]], 'f64')
3928
+ }
3929
+ }
3196
3930
  const numA = isNumArm(va, a), numB = isNumArm(vb, b)
3197
3931
  // Both arms can surface as the (untyped) result — `a` when non-nullish (a NaN is not
3198
3932
  // nullish, so it IS returned), `b` otherwise. Canon a lone-numeric arm; `a` before the
@@ -3243,8 +3977,10 @@ export const emitter = {
3243
3977
  ...Object.fromEntries([
3244
3978
  ['&', 'and'], ['|', 'or'], ['^', 'xor'], ['<<', 'shl'], ['>>', 'shr_s'],
3245
3979
  ].map(([op, fn]) => [op, (a, b) => {
3246
- if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT)
3980
+ if (valTypeOf(a) === VAL.BIGINT || valTypeOf(b) === VAL.BIGINT) {
3981
+ bigintMixReject(op, a, b)
3247
3982
  return fromI64([`i64.${fn}`, asI64(emit(a)), asI64(emit(b))])
3983
+ }
3248
3984
  if (op === '|') { // `(x / y) | 0` integer-division idiom → i32.div_s
3249
3985
  const divN = intLiteralValue(b) === 0 ? a : intLiteralValue(a) === 0 ? b : null
3250
3986
  if (Array.isArray(divN) && divN[0] === '/') { const r = tryIntDivTrunc(divN[1], divN[2]); if (r) return r }
@@ -3297,13 +4033,17 @@ export const emitter = {
3297
4033
  // If-conversion (speed tier): `if (cond) x = <cheap pure value>` (no else) → `x = cond ? value
3298
4034
  // : x`, which lowers to a branchless `select`. Removes the data-dependent branch (and its
3299
4035
  // 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)) {
4036
+ // ~27% faster and from heapsort's child pick `if (a[c] < a[c+1]) c++`, the canonical
4037
+ // unpredictable compare that costs jz on x86 (Cranelift/V8-x64 keep the branch; Binaryen, which
4038
+ // AS uses, selects it). The condition is evaluated exactly once whether we branch or select, so
4039
+ // it need only be SIDE-EFFECT-FREE (loads allowed sort's `a[c] < a[c+1]`); only the assigned
4040
+ // VALUE is evaluated unconditionally, hence must be a cheap, trap-free pure expr. `x++`/`x--`
4041
+ // are admitted as `x = x ± 1`. The already-emitted condition `ce` is reused (`__emitted`), so a
4042
+ // load-bearing condition is not emitted twice.
4043
+ if (els == null && ctx.transform.optimize?.boolConvertToSelect && isSideEffectFree(cond)) {
3304
4044
  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
4045
+ const sel = matchVoidLocalStore(asg)
4046
+ if (sel) return emitVoid(['=', sel.lhs, ['?:', ['__emitted', ce], sel.val, sel.lhs]])
3307
4047
  }
3308
4048
  const c = ce.type === 'i32' ? ce : toBoolFromEmitted(ce)
3309
4049
  // Flow-sensitive type refinement: narrow types within each branch based on the guard.
@@ -3322,6 +4062,7 @@ export const emitter = {
3322
4062
  // An enclosing labeled statement (`outer: for …`) hands its label down so `continue outer`
3323
4063
  // can target this loop's continue point. The immediately-enclosed loop consumes it.
3324
4064
  const myLabel = ctx.func.pendingLabel; ctx.func.pendingLabel = null
4065
+ const bodyNode0 = body // identity for assumption owners — survives the hoist rebind below
3325
4066
  const labeledContinue = myLabel != null && hasLabeledContinueTo(body, myLabel)
3326
4067
  // Don't unroll a loop that is the target of a `continue <label>` — unrolling would lose the
3327
4068
  // continue edge. (Plain loops with no labeled-continue still unroll.)
@@ -3335,6 +4076,246 @@ export const emitter = {
3335
4076
  const fu = unrollForIn(init, cond, step, body)
3336
4077
  if (fu) return fu
3337
4078
  }
4079
+ // Typed-bounds loop VERSIONING (Root F): a countable loop whose body indexes typed
4080
+ // receivers with iv-affine indices no static class proves gets a ONCE-per-entry
4081
+ // runtime extent guard. The fast arm re-emits with those (recv, idx) pairs assumed
4082
+ // in-bounds — bare loads/stores, i.e. the vectorizer's shapes — while the else arm
4083
+ // keeps the checked forms verbatim (also the correct semantics for a failing guard:
4084
+ // OOB reads yield undefined, OOB writes are ignored). Guard arithmetic runs in i64:
4085
+ // a*(B-1)+b overflows i32 near the edge, and a wrapped guard that passes is heap
4086
+ // corruption. `_tbVersioned` brakes the arms' re-entry into this same intercept —
4087
+ // keyed by ctx.func identity so a REUSED AST (same source compiled twice, the
4088
+ // self-host warm path) versions afresh in the next compile instead of silently
4089
+ // skipping.
4090
+ if (!labeledContinue && body._tbVersioned !== ctx.func
4091
+ && (!ctx.transform.optimize || ctx.transform.optimize.versionTypedBounds !== false)) {
4092
+ const levels = versionableTypedNest(init, cond, step, body, ctx.func.locals)
4093
+ if (levels) {
4094
+ body._tbVersioned = ctx.func
4095
+ // every LIFTED level is proven by THIS guard — brake their own intercepts
4096
+ // (re-versioning per level compounds 2^depth checked twins)
4097
+ for (const vs of levels) if (vs.bodyNode && !vs.partial) vs.bodyNode._tbVersioned = ctx.func
4098
+ const result = []
4099
+ if (init != null) result.push(...emitVoid(init))
4100
+ const i64c = (n) => ['i64.const', n]
4101
+ const ext = (ir) => ['i64.extend_i32_s', ir]
4102
+ const conjs = []
4103
+ // one evaluation per symbolic-offset slot (a stable name or an invariant pure
4104
+ // expr like `y*w`); an 'f64' slot adds `v integral ∧ |v| ≤ 2^31` conjuncts —
4105
+ // the int model of `a*iv + v` is exact only for integral v (trunc does NOT
4106
+ // distribute over f64 sums)
4107
+ const slotKey = (s) => typeof s === 'string' ? s : JSON.stringify(s)
4108
+ const slots = new Map()
4109
+ const slotI64 = (slot, kind) => {
4110
+ const key = slotKey(slot)
4111
+ let s = slots.get(key)
4112
+ if (s) return s
4113
+ if (kind === 'i32') {
4114
+ const nT = tempI64('tvm')
4115
+ result.push(['local.set', `$${nT}`, ext(asI32(emit(slot)))])
4116
+ s = ['local.get', `$${nT}`]
4117
+ } else {
4118
+ const nF = temp('tvn')
4119
+ result.push(['local.set', `$${nF}`, asF64(emit(slot))])
4120
+ conjs.push(['f64.eq', ['local.get', `$${nF}`], ['f64.floor', ['local.get', `$${nF}`]]])
4121
+ conjs.push(['f64.le', ['f64.abs', ['local.get', `$${nF}`]], ['f64.const', 2147483648]])
4122
+ const nT = tempI64('tvm')
4123
+ result.push(['local.set', `$${nT}`, ['i64.trunc_sat_f64_s', ['local.get', `$${nF}`]]])
4124
+ s = ['local.get', `$${nT}`]
4125
+ }
4126
+ slots.set(key, s)
4127
+ return s
4128
+ }
4129
+ const slotSum = (base, list, lo = false) => {
4130
+ let r = base
4131
+ for (const t of list) {
4132
+ // a WRAP atom (toroidal iv ternary ∈ [0, B-1]) is one-sided: B-1 into
4133
+ // the hi extent, nothing into the lo
4134
+ if (t.wrap) {
4135
+ if (!lo) r = ['i64.add', r,
4136
+ ['i64.mul', i64c(t.k), ['i64.sub', slotI64(t.e, t.kind), i64c(1)]]]
4137
+ continue
4138
+ }
4139
+ const s = slotI64(t.e, t.kind)
4140
+ r = ['i64.add', r, t.k === 1 ? s : ['i64.mul', i64c(t.k), s]]
4141
+ }
4142
+ return r
4143
+ }
4144
+ // len as ONE inline header load for a RESOLVED elem type (owned byteLen at
4145
+ // base-8, view at descriptor[0]; elemCount = byteLen >> shift) — a call in
4146
+ // the guard costs per LOOP ENTRY on re-entered inner nests (fft measured
4147
+ // 1.35x with calls, parity without); unresolved receivers keep $__len.
4148
+ const len64Of = (recv) => {
4149
+ const aux = typedElemAux(ctx.types.typedElem?.get(recv))
4150
+ if (aux == null) {
4151
+ inc('__len')
4152
+ return ['i64.extend_i32_u', ['call', '$__len', ['i64.reinterpret_f64', asF64(emit(recv))]]]
4153
+ }
4154
+ const et = aux & 7, isView = (aux & 8) !== 0
4155
+ const shift = (aux & 16) ? 3 : et <= 1 ? 0 : et <= 3 ? 1 : et <= 6 ? 2 : 3
4156
+ const base = ['i32.wrap_i64', ['i64.and', ['i64.reinterpret_f64', asF64(emit(recv))], ['i64.const', LAYOUT.OFFSET_MASK]]]
4157
+ return ['i64.extend_i32_u', ['i32.shr_u',
4158
+ ['i32.load', isView ? base : ['i32.sub', base, ['i32.const', 8]]], ['i32.const', shift]]]
4159
+ }
4160
+ // one guard covers the whole NEST — each level contributes its own max-iv
4161
+ // and extent conjuncts (nested recognizers need the BARE nest in the fast
4162
+ // arm, and one guard per nest beats one per row)
4163
+ const levelInfo = new Map()
4164
+ for (const vs of levels) {
4165
+ // max iv as i64. An 'f64' bound (untyped param, unknown box) converts via
4166
+ // ceil (`<`: the max int iv under B) / floor (`<=`) + trunc_sat — never
4167
+ // traps — with a `|B| ≤ 2^31` conjunct making the conversion exact: NaN and
4168
+ // box bit patterns fail the abs-compare and fall to the checked arm;
4169
+ // saturated garbage past the limit is conjunct-dead. i64 extents then never
4170
+ // overflow (|terms| ≤ 2^31, a is an i32 literal → |hi| < 2^63).
4171
+ // a RANGE-ONLY level guards hull conjuncts alone — no iv, no max-iv
4172
+ if (vs.rangeOnly) {
4173
+ for (const c of vs.cands) {
4174
+ if (c.range.hiName != null) {
4175
+ const cS = slotI64(c.range.hiName, exprType(c.range.hiName, ctx.func.locals) === 'i32' ? 'i32' : 'f64')
4176
+ conjs.push(['i64.ge_s', cS, i64c(c.range.entryHi + 1)])
4177
+ conjs.push(['i64.lt_s', ['i64.add', cS, i64c(c.range.hiBias)], len64Of(c.recv)])
4178
+ } else conjs.push(['i64.lt_s', i64c(c.range[1]), len64Of(c.recv)])
4179
+ }
4180
+ continue
4181
+ }
4182
+ const maxIv = tempI64('tvq')
4183
+ if (vs.bKind === 'f64') {
4184
+ const bF = temp('tvf')
4185
+ result.push(['local.set', `$${bF}`, asF64(emit(vs.bound))])
4186
+ conjs.push(['f64.le', ['f64.abs', ['local.get', `$${bF}`]], ['f64.const', 2147483648]])
4187
+ result.push(['local.set', `$${maxIv}`,
4188
+ ['i64.trunc_sat_f64_s', [vs.incl ? 'f64.floor' : 'f64.ceil', ['local.get', `$${bF}`]]]])
4189
+ if (vs.bump - (vs.incl ? 0 : 1)) result.push(['local.set', `$${maxIv}`,
4190
+ ['i64.add', ['local.get', `$${maxIv}`], i64c(vs.bump - (vs.incl ? 0 : 1))]])
4191
+ } else {
4192
+ const adj = vs.bump - (vs.incl ? 0 : 1)
4193
+ result.push(['local.set', `$${maxIv}`,
4194
+ adj ? ['i64.add', ext(asI32(emit(vs.bound))), i64c(adj)] : ext(asI32(emit(vs.bound)))])
4195
+ }
4196
+ levelInfo.set(vs, { maxIv, entryIR: () => vs.startC != null ? i64c(vs.startC) : slotI64(vs.iv, vs.ivKind) })
4197
+ // non-unit monotone stride: positivity is the soundness condition
4198
+ if (vs.stepBy?.name != null)
4199
+ conjs.push(['i64.ge_s', slotI64(vs.stepBy.name, vs.stepBy.kind), i64c(1)])
4200
+ // one extent conjunct pair per (recv, a, slots) group: hi = a*maxIv+Σkᵢ·slotᵢ
4201
+ // +maxC < len, plus lo = a*entry+Σkᵢ·slotᵢ+minC ≥ 0 — folded when the static
4202
+ // start proves it, read from the live iv local otherwise (top level only)
4203
+ const groups = new Map(), indGroups = new Map()
4204
+ for (const c of vs.cands) {
4205
+ if (c.range != null) {
4206
+ // interval-hulled idx against a dynamic length (the affine fallback).
4207
+ // Numeric hull: one `hi < len` conjunct. Symbolic hull (wrap cursor vs
4208
+ // a MUTABLE bound C): cursor ∈ [0, C-1] relative to C's runtime value —
4209
+ // `C ≥ entryHi+1` (the entry fits) ∧ `C+bias < len` close it.
4210
+ if (c.range.hiName != null) {
4211
+ const cS = slotI64(c.range.hiName, exprType(c.range.hiName, ctx.func.locals) === 'i32' ? 'i32' : 'f64')
4212
+ conjs.push(['i64.ge_s', cS, i64c(c.range.entryHi + 1)])
4213
+ conjs.push(['i64.lt_s', ['i64.add', cS, i64c(c.range.hiBias)], len64Of(c.recv)])
4214
+ } else conjs.push(['i64.lt_s', i64c(c.range[1]), len64Of(c.recv)])
4215
+ continue
4216
+ }
4217
+ if (c.ind != null) {
4218
+ const gk = c.recv + '\x00' + c.ind
4219
+ if (!indGroups.has(gk)) indGroups.set(gk, c)
4220
+ continue
4221
+ }
4222
+ const gk = c.recv + '\x00' + c.a + '\x00' + c.slots.map(t => t.k + '*' + slotKey(t.e)).join('+')
4223
+ const g = groups.get(gk)
4224
+ if (!g) groups.set(gk, { recv: c.recv, a: c.a, slots: c.slots, maxC: c.bConst, minC: c.bConst })
4225
+ else { g.maxC = Math.max(g.maxC, c.bConst); g.minC = Math.min(g.minC, c.bConst) }
4226
+ }
4227
+ for (const g of groups.values()) {
4228
+ let hi = slotSum(['i64.mul', i64c(g.a), ['local.get', `$${maxIv}`]], g.slots)
4229
+ if (g.maxC) hi = ['i64.add', hi, i64c(g.maxC)]
4230
+ conjs.push(['i64.lt_s', hi, len64Of(g.recv)])
4231
+ if (vs.startC != null && !g.slots.length) continue
4232
+ let lo = slotSum(vs.startC != null ? i64c(g.a * vs.startC)
4233
+ : ['i64.mul', i64c(g.a), slotI64(vs.iv, vs.ivKind)], g.slots, true)
4234
+ if (g.minC) lo = ['i64.add', lo, i64c(g.minC)]
4235
+ conjs.push(['i64.ge_s', lo, i64c(0)])
4236
+ }
4237
+ // induction cursors (`k += step` in a comma step): value at iteration t is
4238
+ // entry + slope*t, t ∈ [0, maxIv - ivEntry] — monotone either direction, so
4239
+ // BOTH endpoints guard in [0, len) and every intermediate value is covered
4240
+ for (const c of indGroups.values()) {
4241
+ const kE = c.entryC != null ? i64c(c.entryC)
4242
+ : slotI64(c.ind, exprType(c.ind, ctx.func.locals) === 'i32' ? 'i32' : 'f64')
4243
+ const slopeLit = intLiteralValue(c.slope)
4244
+ const slope64 = slopeLit != null ? i64c(slopeLit)
4245
+ : slotI64(c.slope, exprType(c.slope, ctx.func.locals) === 'i32' ? 'i32' : 'f64')
4246
+ const ivE = vs.startC != null ? i64c(vs.startC) : slotI64(vs.iv, vs.ivKind)
4247
+ const endT = tempI64('tvi')
4248
+ result.push(['local.set', `$${endT}`, ['i64.add', kE,
4249
+ ['i64.mul', slope64, ['i64.sub', ['local.get', `$${maxIv}`], ivE]]]])
4250
+ const len64 = len64Of(c.recv)
4251
+ conjs.push(['i64.ge_s', kE, i64c(0)])
4252
+ conjs.push(['i64.lt_s', kE, len64])
4253
+ conjs.push(['i64.ge_s', ['local.get', `$${endT}`], i64c(0)])
4254
+ conjs.push(['i64.lt_s', ['local.get', `$${endT}`], len64Of(c.recv)])
4255
+ }
4256
+ }
4257
+ // FLAT-CURSOR endpoint guards: `j++` once per pixel across the nest —
4258
+ // value spans [j0, j0 + slope·(Π trips − (pre ? 1 : 0))]; the steps cap
4259
+ // keeps the slope product overflow-free, a negative trip (empty level)
4260
+ // fails its conjunct into the checked arm
4261
+ for (const cur of levels.cursors ?? []) {
4262
+ const j0 = slotI64(cur.name, cur.kind)
4263
+ let steps = null
4264
+ for (const L of cur.chain) {
4265
+ const info = levelInfo.get(L)
4266
+ if (!info) { steps = null; break }
4267
+ const trip = tempI64('tvt')
4268
+ result.push(['local.set', `$${trip}`,
4269
+ ['i64.add', ['i64.sub', ['local.get', `$${info.maxIv}`], info.entryIR()], i64c(1)]])
4270
+ conjs.push(['i64.ge_s', ['local.get', `$${trip}`], i64c(0)])
4271
+ steps = steps ? ['i64.mul', steps, ['local.get', `$${trip}`]] : ['local.get', `$${trip}`]
4272
+ }
4273
+ if (!steps) { cur.dead = true; continue }
4274
+ const stepsT = tempI64('tvs')
4275
+ result.push(['local.set', `$${stepsT}`, steps])
4276
+ conjs.push(['i64.le_s', ['local.get', `$${stepsT}`], i64c(2147483648)])
4277
+ const seen = new Set()
4278
+ for (const c of cur.cands) {
4279
+ const gk = c.recv + '' + c.post
4280
+ if (seen.has(gk)) continue
4281
+ seen.add(gk)
4282
+ const endT = tempI64('tvz')
4283
+ result.push(['local.set', `$${endT}`, ['i64.add', j0,
4284
+ ['i64.mul', i64c(cur.slope), c.post ? ['local.get', `$${stepsT}`]
4285
+ : ['i64.sub', ['local.get', `$${stepsT}`], i64c(1)]]]])
4286
+ conjs.push(['i64.ge_s', j0, i64c(0)])
4287
+ conjs.push(['i64.lt_s', ['local.get', `$${endT}`], len64Of(c.recv)])
4288
+ }
4289
+ }
4290
+ let guard = conjs[0]
4291
+ for (let k = 1; k < conjs.length; k++) guard = ['i32.and', guard, conjs[k]]
4292
+ // arm-scoped assumption MAP key → OWNING loop body: an assumption is honored
4293
+ // only while its loop's frame is on the emission stack (typedIdxProven checks
4294
+ // frame.bodyNode) — a textual twin of an inner access OUTSIDE that loop (the
4295
+ // cursor past its bound) must NOT inherit the proof. Snapshot/RESTORE (not
4296
+ // add/delete): unrolls inside the fast arm stamp clone keys that must not
4297
+ // survive into the checked arm, which runs exactly when the guard failed.
4298
+ const saved = ctx.types.assumedBounds
4299
+ ctx.types.assumedBounds = new Map(saved ?? [])
4300
+ for (const vs of levels)
4301
+ for (const c of vs.cands)
4302
+ // hull cands are position-independent (the hull joins EVERY sighting of
4303
+ // the key) — owned by the TOP arm so unrolled inner loops (no frame of
4304
+ // their own) still validate; affine/induction extents stay level-owned
4305
+ ctx.types.assumedBounds.set(idxKey(c.recv, c.idx), c.range != null ? body : vs.bodyNode ?? body)
4306
+ // cursor claims hold across the WHOLE nest (entry → end) — owned by the top
4307
+ for (const cur of levels.cursors ?? [])
4308
+ if (!cur.dead) for (const c of cur.cands) ctx.types.assumedBounds.set(idxKey(c.recv, c.idx), body)
4309
+ const fast = emitter['for'](null, cond, step, body)
4310
+ ctx.types.assumedBounds = saved
4311
+ const checked = emitter['for'](null, cond, step, body)
4312
+ const stmts = (r) => Array.isArray(r[0]) ? r : [r]
4313
+ result.push(['if', typed(guard, 'i32'),
4314
+ ['then', ...stmts(fast)],
4315
+ ['else', ...stmts(checked)]])
4316
+ return result
4317
+ }
4318
+ }
3338
4319
  // Lift constant array/object literals out of the loop (allocate once, not per
3339
4320
  // iteration) when they are read-only + non-escaping inside it. Strip them from the
3340
4321
  // body up front so freshBoxed / continue analysis see the reduced body.
@@ -3350,7 +4331,7 @@ export const emitter = {
3350
4331
  // can target the loop label directly, saving a redundant `block`.
3351
4332
  const needsCont = step && (hasOwnContinue(body) || labeledContinue)
3352
4333
  const cont = needsCont ? `$cont${id}` : loop
3353
- ctx.func.stack.push({ brk, loop: cont })
4334
+ ctx.func.stack.push({ brk, loop: cont, bodyNode: bodyNode0 })
3354
4335
  const frame = ctx.func.stack[ctx.func.stack.length - 1]
3355
4336
  if (myLabel != null) frame.contLabel = myLabel // so `continue <myLabel>` targets this loop's step/test
3356
4337
  // Per-iteration fresh cells for boxed locals declared in the body — allocated
@@ -3566,6 +4547,10 @@ export function emit(node, expect) {
3566
4547
  if (node.loc != null) ctx.error.loc = node.loc
3567
4548
  }
3568
4549
  if (node == null) return null
4550
+ // Pre-emitted IR passthrough: `['__emitted', ir]` returns `ir` untouched. Lets a caller that
4551
+ // already emitted a subtree (e.g. the `if` handler's condition) splice it into an AST-shaped
4552
+ // re-emit (a `?:` for if→select conversion) without emitting it a second time.
4553
+ if (Array.isArray(node) && node[0] === '__emitted') return node[1]
3569
4554
  // Boolean literals carry VAL.BOOL for type observation (valTypeOf reads the
3570
4555
  // AST), but their working representation is the plain number 0/1 — identical
3571
4556
  // codegen to the pre-carrier `[, 1]`/`[, 0]` folding, so no perf is paid.