jz 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +99 -72
  2. package/bench/README.md +253 -100
  3. package/bench/bench.svg +58 -81
  4. package/cli.js +85 -12
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6196 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +222 -35
  9. package/interop.js +240 -141
  10. package/layout.js +34 -18
  11. package/module/array.js +99 -111
  12. package/module/collection.js +124 -30
  13. package/module/console.js +1 -1
  14. package/module/core.js +163 -19
  15. package/module/json.js +3 -3
  16. package/module/math.js +162 -3
  17. package/module/number.js +268 -13
  18. package/module/object.js +37 -5
  19. package/module/regex.js +8 -7
  20. package/module/simd.js +37 -5
  21. package/module/string.js +203 -168
  22. package/module/typedarray.js +565 -112
  23. package/package.json +26 -7
  24. package/src/abi/string.js +29 -29
  25. package/src/ast.js +19 -2
  26. package/src/autoload.js +3 -0
  27. package/src/compile/analyze-scans.js +174 -11
  28. package/src/compile/analyze.js +101 -4
  29. package/src/compile/cse-load.js +200 -0
  30. package/src/compile/emit-assign.js +82 -20
  31. package/src/compile/emit.js +592 -51
  32. package/src/compile/index.js +504 -89
  33. package/src/compile/infer.js +36 -1
  34. package/src/compile/loop-divmod.js +109 -0
  35. package/src/compile/loop-model.js +91 -0
  36. package/src/compile/loop-square.js +102 -0
  37. package/src/compile/narrow.js +275 -41
  38. package/src/compile/peel-stencil.js +215 -0
  39. package/src/compile/plan/advise.js +55 -1
  40. package/src/compile/plan/common.js +29 -0
  41. package/src/compile/plan/index.js +8 -1
  42. package/src/compile/plan/inline.js +180 -22
  43. package/src/compile/plan/literals.js +313 -24
  44. package/src/compile/plan/scope.js +115 -39
  45. package/src/compile/program-facts.js +21 -2
  46. package/src/ctx.js +96 -19
  47. package/src/helper-counters.js +137 -0
  48. package/src/ir.js +157 -20
  49. package/src/kind-traits.js +34 -3
  50. package/src/kind.js +79 -4
  51. package/src/op-policy.js +5 -2
  52. package/src/ops.js +119 -0
  53. package/src/optimize/index.js +1274 -151
  54. package/src/optimize/recurse.js +182 -0
  55. package/src/optimize/vectorize.js +4187 -253
  56. package/src/prepare/index.js +75 -14
  57. package/src/prepare/lift-iife.js +149 -0
  58. package/src/reps.js +6 -2
  59. package/src/static.js +9 -0
  60. package/src/type.js +63 -51
  61. package/src/wat/assemble.js +286 -21
  62. package/src/widen.js +21 -0
  63. package/src/wat/optimize.js +0 -3760
package/src/ir.js CHANGED
@@ -21,8 +21,8 @@
21
21
  */
22
22
 
23
23
  import { ctx, err, inc, PTR, LAYOUT } from './ctx.js'
24
- import { ptrBoxPrefixBigInt, atomNanHex, nanPrefixHex } from '../layout.js'
25
- import { I32_MIN, I32_MAX, isI32, isLiteralStr, isFuncRef } from './ast.js'
24
+ import { ptrBoxPrefixBigInt, ptrBits, i64Hex, atomNanHex, nanPrefixHex } from '../layout.js'
25
+ import { I32_MIN, I32_MAX, isI32, isLiteralStr, isFuncRef, isLeaf } from './ast.js'
26
26
  import { VAL, lookupValType, repOf, repOfGlobal } from './reps.js'
27
27
  import { valTypeOf } from './kind.js'
28
28
  import { T } from './ast.js'
@@ -55,6 +55,15 @@ function boxPtrIR(i32node, ptrType, aux = 0) {
55
55
  * `(x >>> 0)` uint32 idiom converts to a positive f64 in [0, 2^32) instead of sign-flipping. */
56
56
  export const asF64 = n => {
57
57
  if (n == null) err(`compiler internal: expected emitted IR value in ${ctx.func.current?.name || '<module>'}, got empty value`)
58
+ // A v128 (SIMD) value can't be NaN-boxed into the uniform f64 closure ABI — there is no
59
+ // lossless f64 carrier for 128 bits. This is reached only at a closure boundary: a SIMD
60
+ // value captured by, passed to, returned from, or flowing through a `(…)=>…` used as a
61
+ // VALUE — most commonly an IIFE `(() => f32x4.…)()`, which jz lowers via the closure path.
62
+ // Without this guard the coercion emits `f64.convert_i32_s(<v128>)` and dies in the wasm
63
+ // validator with an opaque type error. Keep SIMD inside a NAMED top-level function called
64
+ // directly (`let sdf = (x) => f32x4.…; sdf(v)` lowers to a typed v128 `call`, no boxing),
65
+ // and extract scalars with `f64x2.lane` / `f32x4.lane` before crossing a closure boundary.
66
+ if (n.type === 'v128') err(`SIMD (v128) values can't cross a closure/IIFE boundary — closures use the uniform f64 ABI. Move the SIMD into a named top-level function called directly, or extract a lane (f64x2.lane / f32x4.lane) to an f64 first.`)
58
67
  if (n.ptrKind != null) return boxPtrIR(n, valKindToPtr(n.ptrKind), n.ptrAux || 0)
59
68
  if (n.type === 'f64') return n
60
69
  if (n.type === 'i64') {
@@ -97,6 +106,22 @@ export const asPtrOffset = (n, ptrKind) => {
97
106
  /** Coerce emitted IR to a target WASM param type ('i32' | 'i64' | 'f64'). */
98
107
  export const asParamType = (n, t) => t === 'i32' ? asI32(n) : t === 'i64' ? asI64(n) : t === 'v128' ? n : asF64(n)
99
108
 
109
+ // Sound upper bound on the value of a masking expr (`&` / `>>>`), so a product
110
+ // against it can be proven < 2^53 and narrow to i32.mul instead of the guarded f64
111
+ // path. `& m` with a non-negative mask m clamps the result to [0, m] (regardless of
112
+ // the other operand's sign); `>>> k` is logical, so it's ≤ 2^(32−k). Anything else
113
+ // (signed shift, plain locals, negative mask) stays the full i32 range.
114
+ export const maskBound = (x) => {
115
+ if (!Array.isArray(x)) return 2 ** 31
116
+ if (x[0] === 'i32.const') return x[1] >= 0 ? x[1] : 2 ** 31
117
+ if (x[0] === 'i32.and') return Math.min(maskBound(x[1]), maskBound(x[2]))
118
+ if (x[0] === 'i32.shr_u') {
119
+ const k = Array.isArray(x[2]) && x[2][0] === 'i32.const' ? (x[2][1] & 31) : 0
120
+ return k > 0 ? 2 ** (32 - k) : 2 ** 31
121
+ }
122
+ return 2 ** 31
123
+ }
124
+
100
125
  /**
101
126
  * Narrow an f64 arithmetic tree under ToInt32 — the general int-accumulator path.
102
127
  *
@@ -157,6 +182,82 @@ const narrowI32 = (x, isRoot) => {
157
182
  return null
158
183
  }
159
184
 
185
+ // Conservative VALUE-RANGE for a pure f64 expression tree: returns { lo, hi } bounding
186
+ // every value the node can take, or null when unknown. SOUND by construction — each rule
187
+ // over-approximates using the SAME f64 ops the runtime uses (f64 +/−/× are monotone in
188
+ // each argument, so combining endpoint-bounds with plain JS doubles yields bounds that
189
+ // contain the true value). A null/non-finite endpoint anywhere collapses to null, so a
190
+ // non-null result also PROVES the value is finite (never NaN, never ±∞). Used by toI32 to
191
+ // drop the +∞-guard `select` (and the i64 round-trip when the value fits i32) — the guard
192
+ // exists only to remap +∞→0, so a proof of finiteness retires it.
193
+ const fin = (lo, hi) => (Number.isFinite(lo) && Number.isFinite(hi) && lo <= hi) ? { lo, hi } : null
194
+ // Range of the i32 that feeds an `f64.convert_i32_*`, refined by a narrowing load width.
195
+ const convRange = (child, signed) => {
196
+ if (Array.isArray(child)) {
197
+ const o = child[0]
198
+ if (o === 'i32.load8_u') return { lo: 0, hi: 255 }
199
+ if (o === 'i32.load8_s') return { lo: -128, hi: 127 }
200
+ if (o === 'i32.load16_u') return { lo: 0, hi: 65535 }
201
+ if (o === 'i32.load16_s') return { lo: -32768, hi: 32767 }
202
+ if (o === 'i32.const' && typeof child[1] === 'number') return signed ? { lo: child[1] | 0, hi: child[1] | 0 } : { lo: child[1] >>> 0, hi: child[1] >>> 0 }
203
+ }
204
+ return signed ? { lo: I32_MIN, hi: I32_MAX } : { lo: 0, hi: 4294967295 }
205
+ }
206
+ // `get`, when supplied, resolves a `(local.get $V)` to $V's single defining value node
207
+ // (a `name → defExpr | null` map/function built from a one-def-per-local scan). This lets the
208
+ // range see through the temps that inlining introduces — e.g. `floor(mul(convert($px),0.03125))`
209
+ // stashed in `$xi` before truncation — so the i32-fit proof survives the indirection. SOUND
210
+ // without code motion: a single-textual-def local holds exactly the value its def computes, so
211
+ // the def's range bounds every value the local can take, even if the def's inputs vary across
212
+ // iterations. A self-referential (loop-carried) single def is caught by the `seen` cycle guard
213
+ // and yields null (unknown), which is conservative.
214
+ export const f64Range = (n, get) => {
215
+ const seen = get ? new Set() : null
216
+ const r = (n) => {
217
+ if (!Array.isArray(n)) return null
218
+ const op = n[0]
219
+ if (op === 'local.get' && get && typeof n[1] === 'string') {
220
+ if (seen.has(n[1])) return null // loop-carried / cyclic def → unknown
221
+ const def = typeof get === 'function' ? get(n[1]) : get.get(n[1])
222
+ if (!def) return null
223
+ seen.add(n[1]); const rng = r(def); seen.delete(n[1])
224
+ return rng
225
+ }
226
+ if (op === 'f64.const') return typeof n[1] === 'number' ? fin(n[1], n[1]) : null // `nan:…`/Inf literal strings → null
227
+ if (op === 'f64.convert_i32_s') return convRange(n[1], true)
228
+ if (op === 'f64.convert_i32_u') return convRange(n[1], false)
229
+ if (op === 'f64.neg') { const a = r(n[1]); return a && fin(-a.hi, -a.lo) }
230
+ if (op === 'f64.abs') { const a = r(n[1]); return a && fin(a.lo > 0 ? a.lo : a.hi < 0 ? -a.hi : 0, Math.max(-a.lo, a.hi)) }
231
+ if (op === 'f64.sqrt') { const a = r(n[1]); return a && a.lo >= 0 && fin(Math.sqrt(a.lo), Math.sqrt(a.hi)) }
232
+ // Rounding ops preserve finiteness and are monotonic, so the range maps elementwise. This lets
233
+ // `Math.floor(x)|0` over a bounded x (every grid/image/audio index: `px*scale`, perm[] lookups)
234
+ // drop the +∞-guard + i64 round-trip in toI32 down to a single i32.trunc_sat_f64_s. `nearest`
235
+ // (round-half-to-even) lands in {floor,ceil} so its bounds are floor(lo)..ceil(hi).
236
+ if (op === 'f64.floor') { const a = r(n[1]); return a && fin(Math.floor(a.lo), Math.floor(a.hi)) }
237
+ if (op === 'f64.ceil') { const a = r(n[1]); return a && fin(Math.ceil(a.lo), Math.ceil(a.hi)) }
238
+ if (op === 'f64.trunc') { const a = r(n[1]); return a && fin(Math.trunc(a.lo), Math.trunc(a.hi)) }
239
+ if (op === 'f64.nearest') { const a = r(n[1]); return a && fin(Math.floor(a.lo), Math.ceil(a.hi)) }
240
+ if (op === 'f64.add') { const a = r(n[1]), b = r(n[2]); return a && b && fin(a.lo + b.lo, a.hi + b.hi) }
241
+ if (op === 'f64.sub') { const a = r(n[1]), b = r(n[2]); return a && b && fin(a.lo - b.hi, a.hi - b.lo) }
242
+ if (op === 'f64.mul') {
243
+ const a = r(n[1]), b = r(n[2]); if (!a || !b) return null
244
+ const p = [a.lo * b.lo, a.lo * b.hi, a.hi * b.lo, a.hi * b.hi]
245
+ return fin(Math.min(...p), Math.max(...p))
246
+ }
247
+ if (op === 'f64.div') {
248
+ const c = Array.isArray(n[2]) && n[2][0] === 'f64.const' && typeof n[2][1] === 'number' ? n[2][1] : null
249
+ if (c == null || c === 0) return null // variable / zero divisor → may be ±∞
250
+ const a = r(n[1]); if (!a) return null
251
+ const p = [a.lo / c, a.hi / c]
252
+ return fin(Math.min(...p), Math.max(...p))
253
+ }
254
+ if (op === 'f64.min') { const a = r(n[1]), b = r(n[2]); return a && b && fin(Math.min(a.lo, b.lo), Math.min(a.hi, b.hi)) }
255
+ if (op === 'f64.max') { const a = r(n[1]), b = r(n[2]); return a && b && fin(Math.max(a.lo, b.lo), Math.max(a.hi, b.hi)) }
256
+ return null
257
+ }
258
+ return r(n)
259
+ }
260
+
160
261
  /** Coerce node to i32 with wrapping (JS `|0` semantics: values > 2^31 wrap to negative).
161
262
  * Per ECMAScript ToInt32, NaN and ±∞ map to 0. `i64.trunc_sat_f64_s` handles NaN
162
263
  * and -∞ correctly, but +∞ saturates to i64_max which wraps to -1 — guard +∞ via
@@ -179,15 +280,27 @@ export const toI32 = n => {
179
280
  // computes in i32 (mod-2^32 ring) — no trunc/guard at all.
180
281
  const nw = narrowI32(n, true)
181
282
  if (nw) return nw.node
283
+ // Value-range narrowing: a NON-integer f64 tree (e.g. `10 + 200·(u8[i]/255)`) the ring
284
+ // path rejects, but whose value is PROVABLY FINITE — so the +∞-guard `select` is dead.
285
+ // When the value also provably fits i32, a single `i32.trunc_sat_f64_s` IS exact ToInt32
286
+ // (no saturation can fire in-range, no NaN, no ±∞) — dropping the i64 round-trip AND the
287
+ // guard. Pervasive in pixel/colour packing: `(base + scale·v)|0`.
288
+ const rng = f64Range(n)
289
+ if (rng) {
290
+ if (rng.lo >= I32_MIN && rng.hi <= I32_MAX) return typed(['i32.trunc_sat_f64_s', n], 'i32')
291
+ // Finite and within (−2^63, 2^63): keep the mod-2^32 wrap, drop the (now-dead) +∞ guard.
292
+ // i64.trunc_sat does not saturate in this window, so wrap_i64 == ToInt32. Beyond ±2^63 we
293
+ // fall through to the guarded path (which already saturates there — the documented boundary).
294
+ if (rng.lo >= -9223372036854775808 && rng.hi < 9223372036854775808)
295
+ return typed(['i32.wrap_i64', ['i64.trunc_sat_f64_s', n]], 'i32')
296
+ }
182
297
  // Leaf nodes are cheap to duplicate; for everything else, evaluate once via local.tee.
183
- const isLeaf = Array.isArray(n) && n.length <= 2 &&
184
- (n[0] === 'f64.const' || n[0] === 'local.get' || n[0] === 'global.get')
185
298
  // `i32.wrap_i64(i64.trunc_sat_f64_s x)` is exact ToInt32 for |x| < 2^63 (the
186
299
  // overwhelming common range), maps NaN/−∞→0, and +∞ is guarded to 0 by the
187
300
  // select. For |x| ≥ 2^63 it saturates rather than wrapping mod 2^32 — a
188
301
  // deliberately-allowed asm.js-style boundary (no per-`|0` helper/guard cost).
189
302
  const wrap = x => typed(['i32.wrap_i64', ['i64.trunc_sat_f64_s', x]], 'i32')
190
- if (isLeaf) {
303
+ if (isLeaf(n)) {
191
304
  return typed(['select', wrap(n), ['i32.const', 0], ['f64.ne', n, ['f64.const', Infinity]]], 'i32')
192
305
  }
193
306
  const t = temp('inf')
@@ -292,13 +405,7 @@ export const BOXED_MUTATORS = new Set(['push', 'pop', 'shift', 'unshift', 'splic
292
405
  const litI32 = n => Array.isArray(n) && n[0] === 'i32.const' && typeof n[1] === 'number' ? n[1] : null
293
406
 
294
407
  /** Pack (type, aux, offset) into the f64 NaN-box bit pattern as a hex string. */
295
- function packPtrBits(type, aux, offset) {
296
- const bits = LAYOUT.NAN_PREFIX_BITS
297
- | ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
298
- | ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
299
- | (BigInt(offset >>> 0) & BigInt(LAYOUT.OFFSET_MASK))
300
- return '0x' + bits.toString(16).toUpperCase().padStart(16, '0')
301
- }
408
+ const packPtrBits = (type, aux, offset) => i64Hex(ptrBits(type, aux, offset))
302
409
 
303
410
  /** Build `__mkptr(type, aux, offset)` IR. Folds to `(f64.const nan:0x...)` — 9 bytes
304
411
  * vs 12 for `f64.reinterpret_i64 (i64.const ...)` — when all args are i32 literals.
@@ -359,7 +466,7 @@ export function ptrTypeIR(valIR, valType) {
359
466
  // op on it misdispatches; BigInt64Array/BigUint64Array views and
360
467
  // DataView.{get,set}BigUint64 are a legacy f64-value shim there. Strings are
361
468
  // tagged and survive every boundary; BigInt math happens only inside single
362
- // expressions. (Same contract as wat/optimize.js's i64 VALUE CONTRACT.)
469
+ // expressions. (Same contract as watr/optimize's i64 VALUE CONTRACT.)
363
470
  const _F64_BITS_BUF = new ArrayBuffer(8)
364
471
  const _F64_BITS_F = new Float64Array(_F64_BITS_BUF)
365
472
  const _F64_BITS_U32 = new Uint32Array(_F64_BITS_BUF) // LE halves: [0]=lo, [1]=hi
@@ -459,7 +566,7 @@ const PURE_F64_OPS = new Set([
459
566
  * is correctly NOT numeric, while `cond ? n*2 : n*3` is. Conservative: any shape
460
567
  * not provably numeric (property gets, user calls, local.get, f64.const nan:…)
461
568
  * returns false, so the caller keeps the __to_num coercion. */
462
- const isNumericIR = (r) => {
569
+ export const isNumericIR = (r) => {
463
570
  if (!Array.isArray(r)) return false
464
571
  const op = r[0]
465
572
  if (PURE_F64_OPS.has(op)) return true
@@ -550,19 +657,21 @@ export function buildRefcount(fn) {
550
657
  * existing ids in a single walk. Replaces the per-pass
551
658
  * `while (fn.some(... === $__prefixK)) k++` (O(K·N)) with one O(N) scan. */
552
659
  export function nextLocalId(fn, prefix) {
553
- const seen = new Set()
660
+ // HIGH-WATER mark (max existing + 1), NOT the first free id. Callers allocate sequentially
661
+ // (id++), so a first-gap start would walk straight into an existing higher local once watr's
662
+ // coalesce has left non-contiguous numbering (e.g. $__pe0,$__pe1,$__pe5 → start at 2, then
663
+ // mint 3,4,5 and collide on $__pe5 = "Duplicate local"). High-water is always collision-free.
554
664
  const needle = `$__${prefix}`
665
+ let id = 0
555
666
  const walk = (n) => {
556
667
  if (!Array.isArray(n)) return
557
668
  if (n[0] === 'local' && typeof n[1] === 'string' && n[1].startsWith(needle)) {
558
669
  const tail = n[1].slice(needle.length)
559
- if (/^\d+$/.test(tail)) seen.add(+tail)
670
+ if (/^\d+$/.test(tail)) { const k = +tail; if (k >= id) id = k + 1 }
560
671
  }
561
672
  for (let i = 0; i < n.length; i++) walk(n[i])
562
673
  }
563
674
  walk(fn)
564
- let id = 0
565
- while (seen.has(id)) id++
566
675
  return id
567
676
  }
568
677
 
@@ -731,10 +840,15 @@ export function toNumF64(node, v) {
731
840
  if (ctx.schema.slotIntCertainAt?.(node[1], node[2]) === true) return asF64(v)
732
841
  }
733
842
  // IR-level shapes that produce real f64 numbers (never NaN-boxed pointers):
734
- // i32→f64 conversions, stdlib clock helper. Skip the __to_num call wrapper.
843
+ // i32→f64 conversions, stdlib clock helper, length/ptr helpers.
844
+ // Skip the __to_num call wrapper for these — they always return plain f64.
735
845
  if (Array.isArray(v)) {
736
846
  if (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u') return v
737
847
  if (v[0] === 'call' && v[1] === '$__time_ms') return v
848
+ // __length, __str_len return f64.convert_i32_s of an i32 — never a boxed pointer.
849
+ if (v[0] === 'call' && (v[1] === '$__length' || v[1] === '$__len' || v[1] === '$__str_len')) return v
850
+ // __ptr_type returns i32 tag, __ptr_offset returns i32 offset — both numeric.
851
+ if (v[0] === 'call' && (v[1] === '$__ptr_type' || v[1] === '$__ptr_offset')) return v
738
852
  }
739
853
  // f64 arithmetic ops and math intrinsics never produce NaN-boxed pointers — the
740
854
  // result is always a plain f64 number. Skip __to_num for these, eliminating the
@@ -785,6 +899,14 @@ export function toStrI64(node, v) {
785
899
  return typed(['call', '$__to_str', prim], 'i64')
786
900
  }
787
901
  }
902
+ // Provably-integer operand → render with the i32-only formatter, bypassing __to_str's
903
+ // float machinery (__ftoa/__toExp/__pow10, ~2 KB). A raw i32 value (`n|0`, a bitwise
904
+ // result, a loop counter) carries no NaN-box, so its ToString is just digits + sign.
905
+ // ptrKind != null means it's an unboxed pointer (i32 offset), NOT a number — exclude.
906
+ if (v.type === 'i32' && v.ptrKind == null) {
907
+ inc('__i32_to_str')
908
+ return typed(['i64.reinterpret_f64', ['call', '$__i32_to_str', v]], 'i64')
909
+ }
788
910
  inc('__to_str')
789
911
  return typed(['call', '$__to_str', asI64(v)], 'i64')
790
912
  }
@@ -809,8 +931,16 @@ const numericTruthy = e => {
809
931
 
810
932
  // i32 ops whose result is already a 0/1 boolean (comparisons + eqz) — safe to use
811
933
  // directly as a truthiness without a redundant `!= 0`.
934
+ // Ops whose result is already a canonical i32 boolean (0 or 1) — a condition built
935
+ // from one needs no `i32.ne(_, 0)` normalization. Every wasm comparison returns 0/1,
936
+ // so the f64/f32/i64 relations belong here too (they were missing — a `a > b ? …`
937
+ // f64 compare was wrapped in a dead `i32.ne(f64.gt …, 0)` in every branch/select).
812
938
  const I32_BOOL_OPS = new Set(['i32.eq', 'i32.ne', 'i32.lt_s', 'i32.lt_u', 'i32.gt_s', 'i32.gt_u',
813
- 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u', 'i32.eqz'])
939
+ 'i32.le_s', 'i32.le_u', 'i32.ge_s', 'i32.ge_u', 'i32.eqz',
940
+ 'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
941
+ 'f32.eq', 'f32.ne', 'f32.lt', 'f32.gt', 'f32.le', 'f32.ge',
942
+ 'i64.eq', 'i64.ne', 'i64.lt_s', 'i64.lt_u', 'i64.gt_s', 'i64.gt_u',
943
+ 'i64.le_s', 'i64.le_u', 'i64.ge_s', 'i64.ge_u', 'i64.eqz'])
814
944
 
815
945
  export function truthyIR(e) {
816
946
  // An i32 *constant* is a concrete number, not a known 0/1 boolean — fold it to its
@@ -961,6 +1091,13 @@ export function readVar(name) {
961
1091
  return typed(['f64.load', boxedAddr(name)], 'f64')
962
1092
  }
963
1093
  if (isGlobal(name)) {
1094
+ // A module-level integer const (`const N = 16384`) is an immutable compile-time
1095
+ // value: emit i32.const directly (when it fits i32) so `x % N` / `x & N` / `x / N`
1096
+ // and counters bounded by N take the native integer path, instead of the global
1097
+ // folding to an f64 constant and routing through the f64 round-trip. Value-preserving
1098
+ // — an f64 consumer widens the i32.const via convert, which folds back to f64.const.
1099
+ const ci = ctx.scope.constInts?.get?.(name)
1100
+ if (ci != null && isI32(ci)) return typed(['i32.const', ci], 'i32')
964
1101
  const gt = ctx.scope.globalTypes.get(name) || 'f64'
965
1102
  const node = typed(['global.get', dollar(name)], gt)
966
1103
  const grep = repOfGlobal(name)
@@ -5,9 +5,13 @@
5
5
 
6
6
  import { VAL } from './reps.js'
7
7
 
8
- export const BOOL_OPS = new Set([
9
- '!', '<', '<=', '>', '>=', '==', '!=', '===', '!==', 'in', 'instanceof',
10
- ])
8
+ // Comparison / logical-not ops — result is a 0|1 boolean carried as i32. The one
9
+ // source of truth for "this operator yields a boolean": valTypeOf reads it as
10
+ // VAL.BOOL, exprType/isIntExpr read it as integer-certain. `in`/`instanceof` also
11
+ // yield a boolean but are membership ops, kept out of the integer-certainty set
12
+ // (they throw on BigInt operands and never reach numeric analysis).
13
+ export const CMP_OPS = new Set(['!', '<', '<=', '>', '>=', '==', '!=', '===', '!=='])
14
+ export const BOOL_OPS = new Set([...CMP_OPS, 'in', 'instanceof'])
11
15
 
12
16
  export const NUMERIC_BINARY_OPS = ['-', 'u-', '*', '/', '%', '&', '|', '^', '<<', '>>']
13
17
  export const NUMERIC_UNARY_OPS = new Set(['**', '++', '--', '~', '>>>', 'u+'])
@@ -94,9 +98,36 @@ export function methodValType(method, obj, objType, ctx) {
94
98
  if (objType === VAL.STRING || objType === VAL.ARRAY || objType === VAL.TYPED) return objType
95
99
  return null
96
100
  }
101
+ // .subarray / .toReversed / .toSorted / .with all return a typed array of the same kind.
102
+ if (method === 'subarray' || method === 'toReversed' || method === 'toSorted' || method === 'with')
103
+ return objType === VAL.TYPED ? VAL.TYPED : null
97
104
  return null
98
105
  }
99
106
 
107
+ // Built-in PROPERTY val-types — the property-read mirror of methodValType.
108
+ // These are language invariants: `.length` is always a number on the sized
109
+ // value kinds, `.size` on Set/Map, `.byteLength`/`.byteOffset` on typed arrays.
110
+ // Without this, `arr.length + x` sees `.length` as untyped and routes `+`
111
+ // through the __is_str_key string-concat dispatch — even though `.length` can
112
+ // never be a string on a known sized kind. (Object schema slots override this
113
+ // earlier in VT['.'], so `{length:'x'}.length` keeps its true slot type.)
114
+ //
115
+ // Gate on a known objType: an untyped receiver could be an object with a
116
+ // string-valued shadow of the same name, so leave it null there (conservative).
117
+ // null-proto: user code reads `.valueOf`, `.toString` etc. — a plain `{}` would
118
+ // expose inherited Object.prototype members as bogus "numeric props".
119
+ const NUMERIC_PROPS = Object.assign(Object.create(null), {
120
+ length: new Set([VAL.STRING, VAL.ARRAY, VAL.TYPED]),
121
+ byteLength: new Set([VAL.TYPED, VAL.BUFFER]),
122
+ byteOffset: new Set([VAL.TYPED]),
123
+ size: new Set([VAL.SET, VAL.MAP]),
124
+ })
125
+ export function propValType(prop, objType) {
126
+ if (objType == null) return null
127
+ const kinds = NUMERIC_PROPS[prop]
128
+ return kinds && kinds.has(objType) ? VAL.NUMBER : null
129
+ }
130
+
100
131
  export function typedCtorElemValType(ctor) {
101
132
  if (!ctor) return null
102
133
  const isView = ctor.endsWith('.view')
package/src/kind.js CHANGED
@@ -8,10 +8,10 @@
8
8
 
9
9
  import { ctx } from './ctx.js'
10
10
  import { VAL, lookupValType } from './reps.js'
11
- import { intLiteralValue } from './static.js'
11
+ import { intLiteralValue, staticIndexKey } from './static.js'
12
12
  import {
13
13
  BOOL_OPS, NUMERIC_BINARY_OPS, NUMERIC_UNARY_OPS, COMPOUND_NUMERIC_OPS,
14
- calleeValType, methodValType, typedCtorElemValType,
14
+ calleeValType, methodValType, propValType, typedCtorElemValType,
15
15
  } from './kind-traits.js'
16
16
 
17
17
  export { typedCtorElemValType } from './kind-traits.js'
@@ -137,7 +137,16 @@ VT['?:'] = (args) => {
137
137
  const truthy = literalTruthiness(args[0])
138
138
  if (truthy != null) return valTypeOf(truthy ? args[1] : args[2])
139
139
  const ta = valTypeOf(args[1]), tb = valTypeOf(args[2])
140
- return ta && ta === tb ? ta : null
140
+ if (ta && ta === tb) return ta
141
+ // A boolean branch coerces to 0/1 in numeric context (same rule as &&/||/?? below):
142
+ // when the other branch has a known non-boolean type, the conditional carries it.
143
+ // Without this, `num + (cond ? num : num>k)` sees a null-typed operand and emits the
144
+ // polymorphic string-concat dispatch on two pure-numeric subexprs — which pins the
145
+ // whole number→string formatter (__str_concat → __to_str → __static_str), a pure-int
146
+ // program ballooning 1 → ~19 funcs (see test/wat-invariants.js, .work/todo.md).
147
+ if (ta === VAL.BOOL && tb && tb !== VAL.BOOL) return tb
148
+ if (tb === VAL.BOOL && ta && ta !== VAL.BOOL) return ta
149
+ return null
141
150
  }
142
151
 
143
152
  // Value-preserving logical: `&&`/`||` return one of their operands.
@@ -145,7 +154,11 @@ VT['?:'] = (args) => {
145
154
  // (a condition/guard) and the other has a known non-boolean type,
146
155
  // return the non-boolean type — common in `condition && numericValue`
147
156
  // guard patterns where the falsey boolean is coerced to 0 in numeric context.
148
- VT['&&'] = VT['||'] = (args) => {
157
+ // `a && b` / `a || b` / `a ?? b` all yield one of the two operands, so the result
158
+ // type is their common type (else unknown). Giving `??` a type — not just ||/&& —
159
+ // lets `numA ?? numB` read NaN-safe (value-typed NUMBER → f64.eq) instead of routing
160
+ // through the bit-comparing __is_truthy, which mis-reads a non-canonical NaN.
161
+ VT['&&'] = VT['||'] = VT['??'] = (args) => {
149
162
  const ta = valTypeOf(args[0]), tb = valTypeOf(args[1])
150
163
  if (ta && ta === tb) return ta
151
164
  if (ta === VAL.BOOL && tb && tb !== VAL.BOOL) return tb
@@ -158,6 +171,24 @@ VT['&&'] = VT['||'] = (args) => {
158
171
  // Index access: `arr[i]` → ['[]', arr, i].
159
172
  VT['[]'] = (args) => {
160
173
  if (args.length < 2) return VAL.ARRAY
174
+ // A literal NEGATIVE index is always out of range → reads undefined, not the
175
+ // element type. Returning a numeric elem type here would let `a[-1] === undefined`
176
+ // fold to false (a NUMBER can't be undefined), silently dropping the guard.
177
+ { const li = intLiteralValue(args[1]); if (li != null && li < 0) return null }
178
+ // SRoA flat-array slot read: `a[k]` (static index) where `a` dissolved into
179
+ // scalar `a#i` locals (scanFlatObjects). A write-once slot's value-type is its
180
+ // element literal's — same numeric-binding as the `VT['.']` object case, so
181
+ // `a[0] * 2` stays a plain f64 op instead of the polymorphic ToNumber battery.
182
+ if (typeof args[0] === 'string') {
183
+ const flat = ctx.func.flatObjects?.get(args[0])
184
+ if (flat) {
185
+ const k = staticIndexKey(args[1])
186
+ if (k != null && !flat.written?.has(k)) {
187
+ const i = flat.names.indexOf(k)
188
+ if (i >= 0 && flat.values[i] !== undefined) return valTypeOf(flat.values[i])
189
+ }
190
+ }
191
+ }
161
192
  // Indexed read on a known typed-array receiver yields Number except for
162
193
  // BigInt64Array/BigUint64Array, whose i64 carriers must stay BigInt-typed.
163
194
  if (typeof args[0] === 'string' && lookupValType(args[0]) === VAL.TYPED)
@@ -170,6 +201,25 @@ VT['[]'] = (args) => {
170
201
  if (typeof args[0] === 'string') {
171
202
  const elemVt = ctx.func.localReps?.get(args[0])?.arrayElemValType
172
203
  if (elemVt) return elemVt
204
+ // Module-level const array (a numeric/uniform table): its element val-type was
205
+ // recorded on the global rep at decl time. Trust it only when no function element-
206
+ // writes the array — dynWriteVars holds every var written via a non-named-property
207
+ // index, so a `X[i]=str` anywhere disables this and falls back to the untyped read.
208
+ if (!ctx.func.localReps?.has(args[0])) {
209
+ const gElem = ctx.scope.globalReps?.get(args[0])?.arrayElemValType
210
+ if (gElem && !ctx.types?.dynWriteVars?.has(args[0])) return gElem
211
+ }
212
+ }
213
+ // Direct double-index on a module-level nested numeric table — `C[i][j]` where
214
+ // `C = [[…number…], …]`. The receiver is itself a single-index read of a global
215
+ // array whose nested element kind was recorded at decl time. Same dynWriteVars
216
+ // guard (now root-aware, so a `C[i][j]=…` write anywhere disables it).
217
+ if (Array.isArray(args[0]) && args[0][0] === '[]' && args[0].length === 3 && typeof args[0][1] === 'string') {
218
+ const base = args[0][1]
219
+ if (!ctx.func.localReps?.has(base)) {
220
+ const gNested = ctx.scope.globalReps?.get(base)?.arrayElemElemValType
221
+ if (gNested && !ctx.types?.dynWriteVars?.has(base)) return gNested
222
+ }
173
223
  }
174
224
  // Indexed read on an inline all-numeric array literal — `[2,4,2,9][i]` (floatbeat
175
225
  // chord/pattern tables; literal op is `[`, elements inline). Every element is a
@@ -183,6 +233,21 @@ VT['[]'] = (args) => {
183
233
 
184
234
  VT['.'] = (args) => {
185
235
  if (typeof args[1] !== 'string') return null
236
+ // SRoA flat-object slot read: `p.x` where `p` dissolved into scalar `p#i`
237
+ // locals (scanFlatObjects). A write-once slot's value-type IS its literal
238
+ // initializer's, so bind by it — exactly as a plain `let slot = value` local
239
+ // would. Without this `p.x * 2` looks like "could be anything" and pulls the
240
+ // ToNumber + string-format battery, though it can only be numeric. Computed
241
+ // on-demand (not cached at analyze time) because param val-types — `{x:n}`'s
242
+ // `n` is numeric-by-divergence — are only seeded at emit. A reassigned slot
243
+ // (`p.x = …`) stays untyped: its runtime value may differ from the literal.
244
+ if (typeof args[0] === 'string') {
245
+ const flat = ctx.func.flatObjects?.get(args[0])
246
+ if (flat && !flat.written?.has(args[1])) {
247
+ const i = flat.names.indexOf(args[1])
248
+ if (i >= 0 && flat.values[i] !== undefined) return valTypeOf(flat.values[i])
249
+ }
250
+ }
186
251
  // Schema slot read: when `varName` has a bound schemaId and `.prop` resolves
187
252
  // to a slot whose VAL kind is monomorphic across program-wide observations,
188
253
  // return that kind. Lets `+`, `===`, method dispatch skip runtime str-key
@@ -200,6 +265,14 @@ VT['.'] = (args) => {
200
265
  const child = sh.props[args[1]]
201
266
  if (child) return child.val
202
267
  }
268
+ // Built-in property on a known sized kind — `.length` on STRING/ARRAY/TYPED,
269
+ // `.size` on SET/MAP, `.byteLength`/`.byteOffset` on TYPED/BUFFER. These are
270
+ // language invariants (the property is always a number on that kind), so typing
271
+ // them NUMBER lets `+` skip the string-concat dispatch. Object schema slots
272
+ // resolved above override this, keeping user-defined same-name slots sound.
273
+ const objType = typeof args[0] === 'string' ? lookupValType(args[0]) : valTypeOf(args[0])
274
+ const pvt = propValType(args[1], objType)
275
+ if (pvt) return pvt
203
276
  return null
204
277
  }
205
278
 
@@ -248,6 +321,8 @@ VT['()'] = (args) => {
248
321
  const t = valTypeOf(args[1])
249
322
  return t === VAL.SET || t === VAL.MAP ? VAL.ARRAY : t
250
323
  }
324
+ // for-in's read-only key list (src/prepare) — always an Array of key strings.
325
+ if (callee === '__keys_ro') return VAL.ARRAY
251
326
  // Ternary is parsed as call to '?' operator: ['()', ['?', cond, a, b]]
252
327
  if (Array.isArray(callee) && callee[0] === '?') {
253
328
  const truthy = literalTruthiness(callee[1])
package/src/op-policy.js CHANGED
@@ -22,7 +22,10 @@ export const REJECT_OPS = {
22
22
  }
23
23
 
24
24
  /** Bare identifiers prepare rejects (no jzify lowering). */
25
- export const REJECT_IDENTS = {
25
+ // Prototype-less (Object.create(null)): a plain `{}` inherits Object.prototype in V8, so
26
+ // `REJECT_IDENTS['valueOf']` would return the inherited method (truthy) and wrongly reject
27
+ // a user identifier named like an Object method. Kernel objects are already prototype-less.
28
+ export const REJECT_IDENTS = Object.assign(Object.create(null), {
26
29
  with: '`with` not supported',
27
30
  class: '`class` not supported',
28
31
  yield: '`yield` not supported',
@@ -36,7 +39,7 @@ export const REJECT_IDENTS = {
36
39
  // identifier in sloppy JS (`var let = 5`), so rejecting it would refuse valid JS
37
40
  // (test262 language/expressions/object/let-non-strict-*).
38
41
  const: '`const` is a reserved word, not a valid name',
39
- }
42
+ })
40
43
 
41
44
  /** jzify-only errors for class lowering (no prepare counterpart). */
42
45
  export const JZIFY_CLASS_ERRORS = {
package/src/ops.js ADDED
@@ -0,0 +1,119 @@
1
+ // === AST op tags — integer-tagged-union representation ===
2
+ // internOps (prepare->compile boundary) converts array node[0] from op STRING to its
3
+ // integer tag; the compile half then dispatches via integer-keyed (eventually array)
4
+ // tables, removing the per-node string-hash lookup in the self-host kernel.
5
+ // OP: string -> int (1-based; 0 reserved => a missing tag is falsy). OPS: int -> string.
6
+ export const OP = {
7
+ "!": 1,
8
+ "!=": 2,
9
+ "!==": 3,
10
+ "%": 4,
11
+ "%=": 5,
12
+ "&": 6,
13
+ "&&": 7,
14
+ "&&=": 8,
15
+ "&=": 9,
16
+ "(": 10,
17
+ "()": 11,
18
+ "*": 12,
19
+ "**": 13,
20
+ "*=": 14,
21
+ "+": 15,
22
+ "++": 16,
23
+ "+=": 17,
24
+ ",": 18,
25
+ "-": 19,
26
+ "--": 20,
27
+ "-=": 21,
28
+ ".": 22,
29
+ "...": 23,
30
+ "/": 24,
31
+ "//": 25,
32
+ "/=": 26,
33
+ ";": 27,
34
+ "<": 28,
35
+ "<<": 29,
36
+ "<<=": 30,
37
+ "<=": 31,
38
+ "=": 32,
39
+ "==": 33,
40
+ "===": 34,
41
+ "=>": 35,
42
+ ">": 36,
43
+ ">=": 37,
44
+ ">>": 38,
45
+ ">>=": 39,
46
+ ">>>": 40,
47
+ ">>>=": 41,
48
+ "?": 42,
49
+ "?:": 43,
50
+ "??": 44,
51
+ "??=": 45,
52
+ "[": 46,
53
+ "[]": 47,
54
+ "^": 48,
55
+ "^=": 49,
56
+ "async": 50,
57
+ "await": 51,
58
+ "bigint": 52,
59
+ "block": 53,
60
+ "bool": 54,
61
+ "break": 55,
62
+ "call": 56,
63
+ "catch": 57,
64
+ "const": 58,
65
+ "continue": 59,
66
+ "default": 60,
67
+ "delete": 61,
68
+ "export": 62,
69
+ "finally": 63,
70
+ "for": 64,
71
+ "if": 65,
72
+ "import": 66,
73
+ "in": 67,
74
+ "instanceof": 68,
75
+ "label": 69,
76
+ "let": 70,
77
+ "nan": 71,
78
+ "new": 72,
79
+ "return": 73,
80
+ "spread": 74,
81
+ "str": 75,
82
+ "strcat": 76,
83
+ "switch": 77,
84
+ "throw": 78,
85
+ "typeof": 79,
86
+ "u+": 80,
87
+ "u-": 81,
88
+ "void": 82,
89
+ "while": 83,
90
+ "yield": 84,
91
+ "{": 85,
92
+ "{}": 86,
93
+ "|": 87,
94
+ "|=": 88,
95
+ "||": 89,
96
+ "||=": 90,
97
+ "~": 91,
98
+ }
99
+ export const OPS = [null, "!", "!=", "!==", "%", "%=", "&", "&&", "&&=", "&=", "(", "()", "*", "**", "*=", "+", "++", "+=", ",", "-", "--", "-=", ".", "...", "/", "//", "/=", ";", "<", "<<", "<<=", "<=", "=", "==", "===", "=>", ">", ">=", ">>", ">>=", ">>>", ">>>=", "?", "?:", "??", "??=", "[", "[]", "^", "^=", "async", "await", "bigint", "block", "bool", "break", "call", "catch", "const", "continue", "default", "delete", "export", "finally", "for", "if", "import", "in", "instanceof", "label", "let", "nan", "new", "return", "spread", "str", "strcat", "switch", "throw", "typeof", "u+", "u-", "void", "while", "yield", "{", "{}", "|", "|=", "||", "||=", "~"]
100
+ export const OP_COUNT = 92
101
+
102
+ // Normalize an op tag to its string form for op-Set membership / switch checks,
103
+ // so `SET.has(opStr(node[0]))` and `switch (opStr(op))` work whether node[0] is
104
+ // still a string (intern off / non-interned op like ':') or an integer (intern
105
+ // on). Self-host-safe: the typeof guard avoids indexing the OPS array by a string
106
+ // (jz arrays trap on non-integer indices), and it never grows a Set.
107
+ export const opStr = (op) => typeof op === "number" ? OPS[op] : op
108
+
109
+ // Convert array node[0] from op-STRING to integer tag, recursively. Once per node at
110
+ // the prepare boundary. Unknown op-strings stay strings (dual-keyed tables handle them
111
+ // until the int-only phase). node[0]===null (numeric literal) stays null; identifiers
112
+ // (bare strings at n[1+]) untouched.
113
+ export const internOps = (n) => {
114
+ if (!Array.isArray(n)) return n
115
+ const t = n[0]
116
+ if (typeof t === "string") { const id = OP[t]; if (id !== undefined) n[0] = id }
117
+ for (let i = 1; i < n.length; i++) if (Array.isArray(n[i])) internOps(n[i])
118
+ return n
119
+ }