jz 0.7.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 (53) hide show
  1. package/README.md +37 -33
  2. package/bench/README.md +176 -73
  3. package/bench/bench.svg +58 -71
  4. package/cli.js +12 -5
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +1156 -984
  7. package/index.js +40 -9
  8. package/interop.js +193 -138
  9. package/layout.js +29 -18
  10. package/module/array.js +29 -73
  11. package/module/collection.js +83 -25
  12. package/module/console.js +1 -1
  13. package/module/core.js +161 -15
  14. package/module/json.js +3 -3
  15. package/module/math.js +26 -3
  16. package/module/number.js +247 -13
  17. package/module/object.js +11 -5
  18. package/module/regex.js +8 -7
  19. package/module/string.js +162 -156
  20. package/module/typedarray.js +169 -105
  21. package/package.json +7 -3
  22. package/src/abi/string.js +29 -29
  23. package/src/ast.js +19 -2
  24. package/src/compile/analyze.js +64 -2
  25. package/src/compile/cse-load.js +200 -0
  26. package/src/compile/emit-assign.js +73 -14
  27. package/src/compile/emit.js +253 -23
  28. package/src/compile/index.js +198 -61
  29. package/src/compile/loop-divmod.js +12 -58
  30. package/src/compile/loop-model.js +91 -0
  31. package/src/compile/loop-square.js +102 -0
  32. package/src/compile/narrow.js +169 -32
  33. package/src/compile/peel-stencil.js +18 -64
  34. package/src/compile/plan/common.js +29 -0
  35. package/src/compile/plan/index.js +4 -1
  36. package/src/compile/plan/inline.js +176 -21
  37. package/src/compile/plan/literals.js +93 -19
  38. package/src/ctx.js +51 -12
  39. package/src/helper-counters.js +137 -0
  40. package/src/ir.js +102 -13
  41. package/src/kind-traits.js +7 -3
  42. package/src/kind.js +14 -1
  43. package/src/op-policy.js +5 -2
  44. package/src/ops.js +119 -0
  45. package/src/optimize/index.js +960 -136
  46. package/src/optimize/recurse.js +182 -0
  47. package/src/optimize/vectorize.js +1292 -144
  48. package/src/prepare/index.js +11 -7
  49. package/src/reps.js +4 -1
  50. package/src/type.js +53 -45
  51. package/src/wat/assemble.js +92 -9
  52. package/src/widen.js +21 -0
  53. package/src/wat/optimize.js +0 -3938
@@ -0,0 +1,137 @@
1
+ import { ctx, declGlobal, inc } from './ctx.js'
2
+ import { findBodyStart } from './ir.js'
3
+
4
+ export const HELPER_COUNTERS = [
5
+ ['__eq', 'eq'],
6
+ ['__same_value_zero', 'same_value_zero'],
7
+ ['__is_truthy', 'is_truthy'],
8
+ ['__ptr_type', 'ptr_type'],
9
+ ['__ptr_offset', 'ptr_offset'],
10
+ ['__len', 'len'],
11
+ ['__length', 'length'],
12
+ ['__typed_idx', 'typed_idx'],
13
+ ['__str_len', 'str_len'],
14
+ ['__str_eq', 'str_eq'],
15
+ ['__str_eq_cold', 'str_eq_cold'],
16
+ ['__str_hash', 'str_hash'],
17
+ ['__map_hash', 'map_hash'],
18
+ ['__hash_get_local', 'hash_get_local'],
19
+ ['__hash_set_local', 'hash_set_local'],
20
+ ['__ihash_get_local', 'ihash_get_local'],
21
+ ['__ihash_set_local', 'ihash_set_local'],
22
+ ['__dyn_get', 'dyn_get'],
23
+ ['__dyn_get_t', 'dyn_get_t'],
24
+ ['__dyn_get_t_h', 'dyn_get_t_h'],
25
+ ['__dyn_set', 'dyn_set'],
26
+ ['__arr_grow', 'arr_grow'],
27
+ ['__arr_grow_known', 'arr_grow_known'],
28
+ ['__arr_push1', 'arr_push1'],
29
+ ['__arr_shift', 'arr_shift'],
30
+ ['__alloc', 'alloc'],
31
+ ['__alloc_hdr', 'alloc_hdr'],
32
+ ['__alloc_hdr_n', 'alloc_hdr_n'],
33
+ ['__memgrow', 'memgrow'],
34
+ ]
35
+
36
+ const COUNTER_BY_HELPER = new Map(HELPER_COUNTERS.map(([helper, label]) => [helper, `__hc_${label}`]))
37
+ const LABEL_BY_WAT_HELPER = new Map(HELPER_COUNTERS.map(([helper, label]) => [`$${helper}`, label]))
38
+
39
+ export const HELPER_SITE_PREFIX = '__hcs_'
40
+
41
+ export const helperCounterName = helper => COUNTER_BY_HELPER.get(helper)
42
+
43
+ export function installHelperCounters() {
44
+ if (!ctx.transform.helperCounters) return
45
+ for (const counter of COUNTER_BY_HELPER.values()) {
46
+ if (!ctx.scope.globals.has(counter)) declGlobal(counter, 'i64', 0, { export: counter })
47
+ }
48
+ ctx.core.stdlib.__helper_counts_reset = `(func $__helper_counts_reset (export "__helper_counts_reset")
49
+ ${[...COUNTER_BY_HELPER.values()].map(counter => ` (global.set $${counter} (i64.const 0))`).join('\n')})`
50
+ inc('__helper_counts_reset')
51
+ }
52
+
53
+ // Bump the helper's counter once on entry. NOTE the semantics: this counts FUNCTION
54
+ // ENTRIES at runtime — a call site that jz inlined or specialized away (fusedRewrite,
55
+ // specializeMkptr/specializePtrBase, …) never enters the function and is NOT counted. So
56
+ // the numbers are a relative ranking / lower bound for picking hot helpers, not exact
57
+ // operation counts. Good enough to choose targets; don't read them as call totals.
58
+ export function instrumentHelperCounter(helper, fn) {
59
+ const counter = ctx.transform.helperCounters && helperCounterName(helper)
60
+ if (!counter || !Array.isArray(fn) || fn[0] !== 'func') return fn
61
+ fn.splice(findBodyStart(fn), 0,
62
+ ['global.set', `$${counter}`, ['i64.add', ['global.get', `$${counter}`], ['i64.const', 1]]])
63
+ return fn
64
+ }
65
+
66
+ const safeExportPart = name => String(name || 'anon')
67
+ .replace(/^\$/, '')
68
+ .replace(/[^A-Za-z0-9_.-]+/g, '_')
69
+ .slice(0, 80) || 'anon'
70
+
71
+ const helperSiteFilter = () => {
72
+ const opt = ctx.transform.helperCallsites
73
+ if (opt === true) return null
74
+ const raw = Array.isArray(opt) ? opt : String(opt || '').split(',')
75
+ const labels = raw.map(s => String(s).trim()).filter(Boolean).map(s => s.replace(/^\$?__/, ''))
76
+ return labels.length ? new Set(labels) : null
77
+ }
78
+
79
+ const funcResults = fn => {
80
+ const out = []
81
+ if (!Array.isArray(fn) || fn[0] !== 'func') return out
82
+ for (let i = 2; i < fn.length; i++) {
83
+ const n = fn[i]
84
+ if (Array.isArray(n) && n[0] === 'result') out.push(...n.slice(1))
85
+ }
86
+ return out
87
+ }
88
+
89
+ const bumpCounter = counter =>
90
+ ['global.set', `$${counter}`, ['i64.add', ['global.get', `$${counter}`], ['i64.const', 1]]]
91
+
92
+ // Profiling-only helper-callsite counters. Unlike instrumentHelperCounter(), this
93
+ // answers "which compiled function executed the helper call?" by wrapping each
94
+ // final `(call $__helper ...)` with a tiny counter block:
95
+ // (block (result T) (global.set $__hcs_N ...) (call $__helper ...))
96
+ //
97
+ // This intentionally runs after whole-module optimization. The profile should
98
+ // observe final codegen, while production output remains byte-identical because
99
+ // ctx.transform.helperCallsites is build-time opt-in.
100
+ export function instrumentHelperCallsites(funcs) {
101
+ if (!ctx.transform.helperCallsites) return 0
102
+ const only = helperSiteFilter()
103
+
104
+ const resultsByName = new Map()
105
+ for (const fn of funcs) {
106
+ if (Array.isArray(fn) && fn[0] === 'func' && typeof fn[1] === 'string')
107
+ resultsByName.set(fn[1], funcResults(fn))
108
+ }
109
+
110
+ let id = 0
111
+ const wrap = (node, owner) => {
112
+ if (!Array.isArray(node)) return node
113
+ for (let i = 1; i < node.length; i++) node[i] = wrap(node[i], owner)
114
+
115
+ if (node[0] !== 'call' || typeof node[1] !== 'string') return node
116
+ const label = LABEL_BY_WAT_HELPER.get(node[1])
117
+ if (!label) return node
118
+ if (only && !only.has(label) && !only.has(node[1].replace(/^\$?__/, ''))) return node
119
+ const results = resultsByName.get(node[1])
120
+ if (!results) return node
121
+
122
+ const counter = `${HELPER_SITE_PREFIX}${id++}`
123
+ const ownerPart = safeExportPart(owner)
124
+ declGlobal(counter, 'i64', 0, { export: `${counter}:${label}:${ownerPart}` })
125
+ const block = ['block']
126
+ for (const type of results) block.push(['result', type])
127
+ block.push(bumpCounter(counter), node)
128
+ return block
129
+ }
130
+
131
+ for (const fn of funcs) {
132
+ if (!Array.isArray(fn) || fn[0] !== 'func' || typeof fn[1] !== 'string') continue
133
+ const bodyStart = findBodyStart(fn)
134
+ for (let i = bodyStart; i < fn.length; i++) fn[i] = wrap(fn[i], fn[1])
135
+ }
136
+ return id
137
+ }
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'
@@ -182,6 +182,82 @@ const narrowI32 = (x, isRoot) => {
182
182
  return null
183
183
  }
184
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
+
185
261
  /** Coerce node to i32 with wrapping (JS `|0` semantics: values > 2^31 wrap to negative).
186
262
  * Per ECMAScript ToInt32, NaN and ±∞ map to 0. `i64.trunc_sat_f64_s` handles NaN
187
263
  * and -∞ correctly, but +∞ saturates to i64_max which wraps to -1 — guard +∞ via
@@ -204,15 +280,27 @@ export const toI32 = n => {
204
280
  // computes in i32 (mod-2^32 ring) — no trunc/guard at all.
205
281
  const nw = narrowI32(n, true)
206
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
+ }
207
297
  // Leaf nodes are cheap to duplicate; for everything else, evaluate once via local.tee.
208
- const isLeaf = Array.isArray(n) && n.length <= 2 &&
209
- (n[0] === 'f64.const' || n[0] === 'local.get' || n[0] === 'global.get')
210
298
  // `i32.wrap_i64(i64.trunc_sat_f64_s x)` is exact ToInt32 for |x| < 2^63 (the
211
299
  // overwhelming common range), maps NaN/−∞→0, and +∞ is guarded to 0 by the
212
300
  // select. For |x| ≥ 2^63 it saturates rather than wrapping mod 2^32 — a
213
301
  // deliberately-allowed asm.js-style boundary (no per-`|0` helper/guard cost).
214
302
  const wrap = x => typed(['i32.wrap_i64', ['i64.trunc_sat_f64_s', x]], 'i32')
215
- if (isLeaf) {
303
+ if (isLeaf(n)) {
216
304
  return typed(['select', wrap(n), ['i32.const', 0], ['f64.ne', n, ['f64.const', Infinity]]], 'i32')
217
305
  }
218
306
  const t = temp('inf')
@@ -317,13 +405,7 @@ export const BOXED_MUTATORS = new Set(['push', 'pop', 'shift', 'unshift', 'splic
317
405
  const litI32 = n => Array.isArray(n) && n[0] === 'i32.const' && typeof n[1] === 'number' ? n[1] : null
318
406
 
319
407
  /** Pack (type, aux, offset) into the f64 NaN-box bit pattern as a hex string. */
320
- function packPtrBits(type, aux, offset) {
321
- const bits = LAYOUT.NAN_PREFIX_BITS
322
- | ((BigInt(type) & BigInt(LAYOUT.TAG_MASK)) << BigInt(LAYOUT.TAG_SHIFT))
323
- | ((BigInt(aux) & BigInt(LAYOUT.AUX_MASK)) << BigInt(LAYOUT.AUX_SHIFT))
324
- | (BigInt(offset >>> 0) & BigInt(LAYOUT.OFFSET_MASK))
325
- return '0x' + bits.toString(16).toUpperCase().padStart(16, '0')
326
- }
408
+ const packPtrBits = (type, aux, offset) => i64Hex(ptrBits(type, aux, offset))
327
409
 
328
410
  /** Build `__mkptr(type, aux, offset)` IR. Folds to `(f64.const nan:0x...)` — 9 bytes
329
411
  * vs 12 for `f64.reinterpret_i64 (i64.const ...)` — when all args are i32 literals.
@@ -384,7 +466,7 @@ export function ptrTypeIR(valIR, valType) {
384
466
  // op on it misdispatches; BigInt64Array/BigUint64Array views and
385
467
  // DataView.{get,set}BigUint64 are a legacy f64-value shim there. Strings are
386
468
  // tagged and survive every boundary; BigInt math happens only inside single
387
- // expressions. (Same contract as wat/optimize.js's i64 VALUE CONTRACT.)
469
+ // expressions. (Same contract as watr/optimize's i64 VALUE CONTRACT.)
388
470
  const _F64_BITS_BUF = new ArrayBuffer(8)
389
471
  const _F64_BITS_F = new Float64Array(_F64_BITS_BUF)
390
472
  const _F64_BITS_U32 = new Uint32Array(_F64_BITS_BUF) // LE halves: [0]=lo, [1]=hi
@@ -1009,6 +1091,13 @@ export function readVar(name) {
1009
1091
  return typed(['f64.load', boxedAddr(name)], 'f64')
1010
1092
  }
1011
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')
1012
1101
  const gt = ctx.scope.globalTypes.get(name) || 'f64'
1013
1102
  const node = typed(['global.get', dollar(name)], gt)
1014
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+'])
package/src/kind.js CHANGED
@@ -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.
@@ -162,6 +171,10 @@ VT['&&'] = VT['||'] = VT['??'] = (args) => {
162
171
  // Index access: `arr[i]` → ['[]', arr, i].
163
172
  VT['[]'] = (args) => {
164
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 }
165
178
  // SRoA flat-array slot read: `a[k]` (static index) where `a` dissolved into
166
179
  // scalar `a#i` locals (scanFlatObjects). A write-once slot's value-type is its
167
180
  // element literal's — same numeric-binding as the `VT['.']` object case, so
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
+ }