jz 0.8.1 → 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 (72) 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 -6271
  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 +299 -44
  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 +892 -32
  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 +301 -84
  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 +71 -5
  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 +979 -54
  45. package/src/compile/index.js +271 -29
  46. package/src/compile/infer.js +34 -3
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/narrow.js +543 -15
  49. package/src/compile/peel-stencil.js +5 -0
  50. package/src/compile/plan/index.js +45 -3
  51. package/src/compile/plan/inline.js +8 -1
  52. package/src/compile/plan/literals.js +39 -0
  53. package/src/compile/plan/scope.js +430 -23
  54. package/src/compile/program-facts.js +732 -38
  55. package/src/ctx.js +64 -9
  56. package/src/helper-counters.js +7 -1
  57. package/src/ir.js +113 -5
  58. package/src/kind-traits.js +66 -3
  59. package/src/kind.js +84 -12
  60. package/src/op-policy.js +7 -4
  61. package/src/optimize/index.js +1060 -750
  62. package/src/optimize/recurse.js +2 -2
  63. package/src/optimize/vectorize.js +972 -67
  64. package/src/prepare/index.js +792 -63
  65. package/src/prepare/math-kernel.js +331 -0
  66. package/src/prepare/pre-eval.js +714 -0
  67. package/src/snapshot.js +194 -0
  68. package/src/type.js +1170 -56
  69. package/src/wat/assemble.js +403 -65
  70. package/src/wat/codegen.js +74 -14
  71. package/transform.js +113 -4
  72. package/wasi.js +3 -0
@@ -0,0 +1,714 @@
1
+ /**
2
+ * preEval — unified compile-time constant-folding pass over the PREPARED AST
3
+ * (runs once, right after `prepare()`, before `compile()`).
4
+ *
5
+ * Subsumes/extends the narrow const-folders already scattered through prepare
6
+ * (staticValue, staticStringExpr, constNum in prepare/index.js) with one pass
7
+ * that also folds: numeric arithmetic chains (with optional rational/extended
8
+ * precision — see Rational below), comparisons/equality, `%`/bitwise ops,
9
+ * ASCII string methods, pure `Math.*` calls (bit-exact vs jz's own kernel via
10
+ * math-kernel.js, NOT host Math — see that module), dead `if`/`while(false)`
11
+ * branches, and zero-arg pure function calls (which subsumes IIFE collapse:
12
+ * lift-iife.js already turns `(() => EXPR)()` into a 0-param top-level
13
+ * function + a 0-arg call before prepare ever runs, so "IIFE collapse" here
14
+ * is just the general case of a 0-arg call whose target's body reduces to a
15
+ * constant — same code path for a literal IIFE and an ordinary user-authored
16
+ * zero-arg helper).
17
+ *
18
+ * # Architecture
19
+ * Two cooperating passes, sharing one `env` (Map<name, EvalResult>) and one
20
+ * `state` ({ rationalOn, funcByName, evaluating }):
21
+ *
22
+ * evalConst(node, env, state) -> EvalResult | null
23
+ * Tries to reduce a whole expression subtree to a SINGLE constant value,
24
+ * recursing entirely in EvalResult space (never rebuilds AST nodes
25
+ * mid-chain) — this is what lets a numeric chain carry an exact Rational
26
+ * all the way to the final `+`/`-`/`*`/`/` and round only once. Also
27
+ * resolves Math.* calls, ASCII string methods, and — recursively, with a
28
+ * cycle guard — zero-arg calls to other functions.
29
+ *
30
+ * foldNode(node, env, state) -> node
31
+ * The tree REWRITER. At every node it first asks evalConst for a full
32
+ * reduction (turning that subtree into ONE literal node — the only place
33
+ * an exact Rational gets rounded to f64). When evalConst can't fully
34
+ * reduce (e.g. one operand is a runtime value), it falls back to
35
+ * structural per-child folding, plus statement-level dead-`if`/`while`-
36
+ * branch elimination (foldStmts).
37
+ *
38
+ * `env` (Map<name, EvalResult>) is the scaffolding for ONE narrowly-scoped use:
39
+ * evalFunctionBodyConst's zero-arg-call evaluation threads its OWN, freshly-
40
+ * empty env through a callee's `let`/`const`-then-`return` chain (so `helper`'s
41
+ * internal `const a = 1+2; return a*3` resolves) — see evalStmtsConst. The
42
+ * general `foldStmts`/`foldNode` walk never POPULATES it (a bare identifier
43
+ * reference is therefore never rewritten): several existing passes downstream
44
+ * pattern-match a NAMED loop bound/index/property-key expression structurally
45
+ * rather than by value (clamp-peel + the multi-pixel SIMD blur match,
46
+ * unrollSmallConstFor's trip-count shape, watr LICM's post-inline invariant
47
+ * recognition, static.js's schema/SRoA static-vs-dynamic key classification —
48
+ * discovered the hard way, by regressing each of them once). Rewriting
49
+ * `row = y*ww` to `row = y*64` is value-identical but silently swaps which of
50
+ * those shape-sensitive passes fires. Tier 1 stays inside the proven-safe
51
+ * boundary: fold every expression tree, never rewrite a bare-name reference.
52
+ *
53
+ * A single top-to-bottom pass over (every ctx.func.list body + the module
54
+ * body) is a full fixpoint: evalConst re-derives everything it needs from the
55
+ * RAW callee body on demand (via state.funcByName), so it never depends on
56
+ * another function having been folded first, regardless of declaration order.
57
+ *
58
+ * Identity preservation matters here beyond the usual "avoid needless
59
+ * allocation": prepare() forward-seeds compile-stage fact stores (program-
60
+ * facts.js's WeakMap caches, compile/infer.js's recordGlobalRep, ...) keyed by
61
+ * the SPECIFIC node objects it walked. `foldStmts`/`foldBlockLike` return the
62
+ * exact input array/node whenever nothing in it changed, all the way up, so a
63
+ * subtree preEval didn't touch keeps the object identity those caches rely on.
64
+ *
65
+ * # Purity / precision guards
66
+ * - Zero-arg call folding evaluates the callee's OWN body in a FRESH empty
67
+ * env (no outer capture) and bails on anything but a `let`/`const` chain
68
+ * ending in one `return` — any other statement shape (if/for/throw/...)
69
+ * is conservatively left unfolded.
70
+ * - String folding is ASCII-only (jz strings are UTF-8 internally; a
71
+ * non-ASCII `.length`/`.slice` could disagree with host JS's UTF-16
72
+ * view — see README divergences) and mixed string+number `+` is
73
+ * deliberately NOT folded (self-host's __ftoa is a 9-significant-digit
74
+ * dtoa, host `String(number)` is shortest-round-trip — folding could
75
+ * bake a MORE precise string than the unfolded kernel would produce).
76
+ * - `Math.pow`/`**` folds via the exact 3-way split emit.js's own
77
+ * constant-arg fast path already uses (math-kernel.js `pow`) — zero new
78
+ * divergence from today's compiled output.
79
+ * - `optimize.rationalConst !== false` (default ON) gates the rational
80
+ * carry; off, numeric folding still happens (still shrinks WAT) via
81
+ * plain sequential per-op f64 rounding — bit-exact vs naive JS
82
+ * evaluation, for callers who want that instead.
83
+ *
84
+ * @module prepare/pre-eval
85
+ */
86
+
87
+ import { extractParams, classifyParam } from '../ast.js'
88
+ import { ctx } from '../ctx.js'
89
+ import { MATH_KERNEL, powFold } from './math-kernel.js'
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Rational — exact value = n/d, n: signed BigInt, d: positive BigInt. Every
93
+ // finite f64 IS an exact rational (double = mantissa * 2^exponent), so a
94
+ // literal seeds an EXACT starting point; +,-,*,/ stay exact through a whole
95
+ // formula; the f64 result is materialized via correctly-rounded decimal
96
+ // string -> Number() ONCE, at the point the chain stops (crosses into a
97
+ // non-arithmetic consumer, or reaches the top of a foldable subtree).
98
+ // ---------------------------------------------------------------------------
99
+ const _buf = new ArrayBuffer(8)
100
+ const _dv = new DataView(_buf)
101
+ function f64Bits(x) { _dv.setFloat64(0, x, false); return _dv.getBigUint64(0, false) }
102
+
103
+ function ratGcd(a, b) { a = a < 0n ? -a : a; b = b < 0n ? -b : b; while (b) { [a, b] = [b, a % b] } return a || 1n }
104
+ function ratMake(n, d) {
105
+ if (d < 0n) { n = -n; d = -d }
106
+ const g = ratGcd(n, d)
107
+ return { n: n / g, d: d / g }
108
+ }
109
+ /** Exact rational for a finite f64 (null for NaN/±Infinity — those bail the rational chain). */
110
+ function f64ToRational(x) {
111
+ if (!Number.isFinite(x)) return null
112
+ if (x === 0) return { n: 0n, d: 1n }
113
+ const bits = f64Bits(x)
114
+ const sign = (bits >> 63n) & 1n
115
+ let exp = Number((bits >> 52n) & 0x7ffn)
116
+ let mant = bits & 0xfffffffffffffn
117
+ if (exp === 0) exp = 1
118
+ else mant |= 0x10000000000000n
119
+ const e = exp - 1075
120
+ let n = mant, d = 1n
121
+ if (e >= 0) n <<= BigInt(e)
122
+ else d <<= BigInt(-e)
123
+ if (sign) n = -n
124
+ return ratMake(n, d)
125
+ }
126
+ const ratAdd = (a, b) => ratMake(a.n * b.d + b.n * a.d, a.d * b.d)
127
+ const ratSub = (a, b) => ratMake(a.n * b.d - b.n * a.d, a.d * b.d)
128
+ const ratMul = (a, b) => ratMake(a.n * b.n, a.d * b.d)
129
+ const ratDiv = (a, b) => b.n === 0n ? null : ratMake(a.n * b.d, a.d * b.n)
130
+ /** Correctly-rounded rational -> f64: exact decimal expansion (generous digit budget,
131
+ * far beyond the 17 significant digits that suffice to round-trip any double) fed
132
+ * through the host's spec-mandated (round-to-nearest) string-to-Number parser. */
133
+ function ratToF64(r) {
134
+ if (r.n === 0n) return 0
135
+ let n = r.n, neg = n < 0n
136
+ if (neg) n = -n
137
+ const d = r.d
138
+ const intPart = n / d
139
+ let rem = n % d
140
+ let s = intPart.toString()
141
+ if (rem !== 0n) {
142
+ s += '.'
143
+ for (let i = 0; i < 60 && rem !== 0n; i++) { rem *= 10n; const dig = rem / d; s += dig.toString(); rem %= d }
144
+ }
145
+ return Number(neg ? '-' + s : s)
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // EvalResult: { t: 'num'|'str'|'bool'|'null'|'undef', v?, r? }
150
+ // ---------------------------------------------------------------------------
151
+ const numResult = (v) => ({ t: 'num', v, r: Number.isFinite(v) ? f64ToRational(v) : null })
152
+ const strResult = (v) => ({ t: 'str', v })
153
+ const boolResult = (v) => ({ t: 'bool', v: !!v })
154
+ const NULL_RESULT = { t: 'null' }
155
+ const UNDEF_RESULT = { t: 'undef' }
156
+
157
+ const isAsciiSafe = (s) => /^[\x00-\x7F]*$/.test(s)
158
+
159
+ function isLiteralNode(node) {
160
+ if (!Array.isArray(node)) return false
161
+ const op = node[0]
162
+ return op == null || op === 'str' || op === 'bool'
163
+ }
164
+ /** Read an already-literal AST node into an EvalResult (no evaluation, just recognition). */
165
+ function literalOf(node) {
166
+ if (!Array.isArray(node)) return null
167
+ const op = node[0]
168
+ if (op == null) {
169
+ const v = node[1]
170
+ if (typeof v === 'number') return numResult(v)
171
+ if (v === null) return NULL_RESULT
172
+ if (v === undefined) return UNDEF_RESULT
173
+ if (typeof v === 'boolean') return boolResult(v)
174
+ return null
175
+ }
176
+ if (op === 'str' && typeof node[1] === 'string') return strResult(node[1])
177
+ if (op === 'bool') return boolResult(node[1])
178
+ return null
179
+ }
180
+ /** EvalResult -> literal AST node. The ONE place a Rational's exact value is rounded
181
+ * and forgotten — callers only reach here once a chain truly terminates. */
182
+ function nodeOf(r) {
183
+ switch (r.t) {
184
+ case 'num': return [null, r.v]
185
+ case 'str': return ['str', r.v]
186
+ case 'bool': return ['bool', r.v ? 1 : 0]
187
+ case 'null': return [null, null]
188
+ default: return [null, undefined]
189
+ }
190
+ }
191
+
192
+ const toJSValue = (r) => r.t === 'null' ? null : r.t === 'undef' ? undefined : r.v
193
+ function toNumResult(r) {
194
+ if (r.t === 'num') return r
195
+ if (r.t === 'bool') return numResult(r.v ? 1 : 0)
196
+ if (r.t === 'null') return numResult(0)
197
+ if (r.t === 'undef') return numResult(NaN)
198
+ return null // strings: deliberately NOT ToNumber-coerced (see module doc)
199
+ }
200
+ function toBoolean(r) {
201
+ if (r.t === 'bool') return r.v
202
+ if (r.t === 'num') return r.v !== 0 && !Number.isNaN(r.v)
203
+ if (r.t === 'str') return r.v.length !== 0
204
+ return false // null/undefined
205
+ }
206
+
207
+ function plainNumOp(op, a, b) {
208
+ switch (op) {
209
+ case '-': return a - b
210
+ case '*': return a * b
211
+ case '/': return a / b
212
+ case '%': return a % b
213
+ case '&': return a & b
214
+ case '|': return a | b
215
+ case '^': return a ^ b
216
+ case '<<': return a << b
217
+ case '>>': return a >> b
218
+ case '>>>': return a >>> b
219
+ }
220
+ }
221
+ const NUM_ONLY_OPS = new Set(['-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>'])
222
+ const RATIONAL_OPS = new Set(['-', '*', '/'])
223
+ const CMP_OPS = new Set(['<', '>', '<=', '>=', '==', '!=', '===', '!=='])
224
+ const BINARY_OPS = new Set([...NUM_ONLY_OPS, ...CMP_OPS])
225
+
226
+ /** Numeric binary fold. Carries the exact Rational through +,-,*,/ when both operands
227
+ * still have one (state.rationalOn); falls back to plain per-op f64 otherwise. A ZERO
228
+ * rational result recomputes via plain f64 arithmetic instead — signed zero (`x+(-x)`
229
+ * -> +0, `0*-1` -> -0) has no faithful rational encoding, and plain JS +,-,*,/ already
230
+ * implement IEEE754 signed-zero correctly, so falling back for that one case is exact,
231
+ * not approximate. A non-finite rational result (true overflow of the exact formula,
232
+ * e.g. `1e300*1e300/1e300`) is KEPT — that's the accuracy win rational carry promises,
233
+ * not a divergence to guard against. */
234
+ function foldNumBinary(op, L, R, rationalOn) {
235
+ const plain = plainNumOp(op, L.v, R.v)
236
+ if (!rationalOn || !RATIONAL_OPS.has(op) || !L.r || !R.r) return numResult(plain)
237
+ const rr = op === '-' ? ratSub(L.r, R.r) : op === '*' ? ratMul(L.r, R.r) : ratDiv(L.r, R.r)
238
+ if (!rr) return numResult(plain)
239
+ if (rr.n === 0n) return numResult(plain)
240
+ return { t: 'num', v: ratToF64(rr), r: rr }
241
+ }
242
+ function foldNumAdd(L, R, rationalOn) {
243
+ if (!rationalOn || !L.r || !R.r) return numResult(L.v + R.v)
244
+ const rr = ratAdd(L.r, R.r)
245
+ if (rr.n === 0n) return numResult(L.v + R.v)
246
+ return { t: 'num', v: ratToF64(rr), r: rr }
247
+ }
248
+ function foldNumUnaryNeg(a) {
249
+ if (a.v === 0) return numResult(-a.v) // exact sign flip incl. ±0; BigInt has no signed zero
250
+ return a.r ? { t: 'num', v: -a.v, r: { n: -a.r.n, d: a.r.d } } : numResult(-a.v)
251
+ }
252
+
253
+ function foldUnary(op, a) {
254
+ if (op === 'u-') { const L = toNumResult(a); return L && foldNumUnaryNeg(L) }
255
+ if (op === 'u+') return toNumResult(a)
256
+ if (op === '!') return boolResult(!toBoolean(a))
257
+ if (op === '~') return a.t === 'str' ? null : numResult(~toJSValue(a))
258
+ return null
259
+ }
260
+
261
+ function foldBinary(op, a, b, rationalOn) {
262
+ if (op === '+') {
263
+ if (a.t === 'str' && b.t === 'str')
264
+ return (isAsciiSafe(a.v) && isAsciiSafe(b.v)) ? strResult('' + a.v + b.v) : null
265
+ if (a.t === 'str' || b.t === 'str') return null // mixed string+number: see module doc
266
+ const L = toNumResult(a), R = toNumResult(b)
267
+ return (L && R) ? foldNumAdd(L, R, rationalOn) : null
268
+ }
269
+ if (op === '**') {
270
+ const L = toNumResult(a), R = toNumResult(b)
271
+ return (L && R) ? numResult(powFold(L.v, R.v)) : null
272
+ }
273
+ if (NUM_ONLY_OPS.has(op)) {
274
+ const L = toNumResult(a), R = toNumResult(b)
275
+ if (!L || !R) return null
276
+ return op === '-' || op === '*' || op === '/' ? foldNumBinary(op, L, R, rationalOn) : numResult(plainNumOp(op, L.v, R.v))
277
+ }
278
+ if (CMP_OPS.has(op)) {
279
+ if ((a.t === 'str' && !isAsciiSafe(a.v)) || (b.t === 'str' && !isAsciiSafe(b.v))) return null
280
+ const x = toJSValue(a), y = toJSValue(b)
281
+ switch (op) {
282
+ case '<': return boolResult(x < y)
283
+ case '>': return boolResult(x > y)
284
+ case '<=': return boolResult(x <= y)
285
+ case '>=': return boolResult(x >= y)
286
+ case '==': return boolResult(x == y)
287
+ case '!=': return boolResult(x != y)
288
+ case '===': return boolResult(x === y)
289
+ case '!==': return boolResult(x !== y)
290
+ }
291
+ }
292
+ return null
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Math.* / string-method call evaluation
297
+ // ---------------------------------------------------------------------------
298
+ const MATH_CONST = new Set(['PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2'])
299
+ // prepare resolves `Math.X` to a bare `'math.X'` STRING (both a niladic-call target
300
+ // `['()','math.sqrt',args]` and, for the no-arg constants, a plain value reference
301
+ // `'math.PI'` with no wrapping `()` at all) whenever it runs through the full host
302
+ // pipeline's jzify/autoload service wiring — `['.', 'Math', 'X']` only survives on a
303
+ // bare prepare() call with no services injected. Recognize both shapes.
304
+ function mathCalleeName(callee) {
305
+ if (Array.isArray(callee) && callee[0] === '.' && callee[1] === 'Math' && typeof callee[2] === 'string') return callee[2]
306
+ if (typeof callee === 'string' && callee.startsWith('math.')) return callee.slice(5)
307
+ return null
308
+ }
309
+ // sqrt/abs/floor/ceil/trunc: IEEE754-mandated correctly-rounded in both JS and wasm.
310
+ // round/sign/fround: jz's WAT is deliberately engineered to reproduce these exact host
311
+ // JS semantics (see module/math.js). All bit-exact vs the compiled kernel by construction.
312
+ const HOST_EXACT_UNARY = new Set(['sqrt', 'abs', 'floor', 'ceil', 'trunc', 'round', 'fround', 'sign'])
313
+
314
+ function evalMathCall(name, vs) {
315
+ if (name === 'pow') return vs.length === 2 ? numResult(powFold(vs[0], vs[1])) : null
316
+ if (name === 'min') return numResult(vs.length ? Math.min(...vs) : Infinity)
317
+ if (name === 'max') return numResult(vs.length ? Math.max(...vs) : -Infinity)
318
+ if (name === 'imul') return vs.length === 2 ? numResult(Math.imul(vs[0], vs[1])) : null
319
+ if (name === 'clz32') return vs.length === 1 ? numResult(Math.clz32(vs[0])) : null
320
+ if (HOST_EXACT_UNARY.has(name)) return vs.length === 1 ? numResult(Math[name](vs[0])) : null
321
+ const kfn = MATH_KERNEL['math.' + name]
322
+ return kfn ? numResult(kfn(...vs)) : null
323
+ }
324
+
325
+ /** args: EvalResult[] (some entries may be null — the method itself validates types/arity). */
326
+ function evalStringMethod(name, s, args) {
327
+ const isNumOrAbsent = (a) => a == null || a.t === 'num'
328
+ if (name === 'toUpperCase' && args.length === 0) return strResult(s.toUpperCase())
329
+ if (name === 'toLowerCase' && args.length === 0) return strResult(s.toLowerCase())
330
+ if (name === 'trim' && args.length === 0) return strResult(s.trim())
331
+ if (name === 'slice' && args.length <= 2 && args.every(isNumOrAbsent)) {
332
+ const r = s.slice(args[0]?.v, args[1]?.v)
333
+ return isAsciiSafe(r) ? strResult(r) : null
334
+ }
335
+ if (name === 'charAt' && args.length <= 1 && isNumOrAbsent(args[0])) return strResult(s.charAt(args[0]?.v ?? 0))
336
+ if (name === 'indexOf' && args.length >= 1 && args[0]?.t === 'str' && isAsciiSafe(args[0].v))
337
+ return numResult(s.indexOf(args[0].v, args[1]?.v))
338
+ return null
339
+ }
340
+
341
+ function collectArgs(argsNode) {
342
+ if (argsNode == null) return []
343
+ if (Array.isArray(argsNode) && argsNode[0] === ',') return argsNode.slice(1)
344
+ return [argsNode]
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // evalConst — full-subtree constant evaluation (EvalResult space, no AST
349
+ // round-trips mid-chain — see module doc).
350
+ // ---------------------------------------------------------------------------
351
+ function evalConst(node, env, state) {
352
+ if (typeof node === 'string') {
353
+ const b = env.get(node)
354
+ if (b !== undefined) return b
355
+ if (node.startsWith('math.') && MATH_CONST.has(node.slice(5))) return numResult(Math[node.slice(5)])
356
+ return null
357
+ }
358
+ if (!Array.isArray(node)) return null
359
+ const op = node[0]
360
+
361
+ if (op == null) {
362
+ const v = node[1]
363
+ if (typeof v === 'number') return numResult(v)
364
+ if (v === null) return NULL_RESULT
365
+ if (v === undefined) return UNDEF_RESULT
366
+ if (typeof v === 'boolean') return boolResult(v)
367
+ return null
368
+ }
369
+ if (op === 'str') return typeof node[1] === 'string' ? strResult(node[1]) : null
370
+ if (op === 'bool') return boolResult(node[1])
371
+
372
+ if (op === 'u-' || op === 'u+' || op === '!' || op === '~') {
373
+ const a = evalConst(node[1], env, state)
374
+ return a && foldUnary(op, a)
375
+ }
376
+ if (op === '+' || op === '**' || BINARY_OPS.has(op)) {
377
+ if (node.length !== 3) return null
378
+ const a = evalConst(node[1], env, state)
379
+ if (!a) return null
380
+ const b = evalConst(node[2], env, state)
381
+ return b && foldBinary(op, a, b, state.rationalOn)
382
+ }
383
+ if (op === '&&' || op === '||' || op === '??') {
384
+ const a = evalConst(node[1], env, state)
385
+ if (!a) return null
386
+ const takeLeft = op === '&&' ? !toBoolean(a) : op === '||' ? toBoolean(a) : !(a.t === 'null' || a.t === 'undef')
387
+ const picked = takeLeft ? a : evalConst(node[2], env, state)
388
+ // `&&`/`||`/`??` is value-preserving at RUNTIME (it returns whichever raw operand
389
+ // won, untyped) — jz deliberately does NOT re-narrow that to the picked operand's
390
+ // own type when the two operands' types differ (e.g. `5 && true` crosses the
391
+ // boundary as the numeric carrier 1, not JS `true` — a documented gap, see
392
+ // test/booleans.js). Folding to a literal of the picked operand's OWN type would be
393
+ // MORE precise than that runtime behavior, i.e. a real divergence — so only fold
394
+ // when the operand that would have been dropped has the SAME type as the one kept
395
+ // (never fires here means never risks it; still folds the far more common
396
+ // same-type case, e.g. `x ?? 0` chains, `a && b` boolean chains).
397
+ if (!picked) return null
398
+ // Both branches must independently prove out to a value (and agree in type) —
399
+ // an un-evaluable other branch means its type is UNKNOWN here, which is exactly
400
+ // the unsafe case (see comment above): stay conservative, don't fold.
401
+ const other = takeLeft ? evalConst(node[2], env, state) : a
402
+ return (other && other.t === picked.t) ? picked : null
403
+ }
404
+ if ((op === '?:' || op === '?') && node.length === 4) {
405
+ const c = evalConst(node[1], env, state)
406
+ if (!c) return null
407
+ const cond = toBoolean(c)
408
+ const picked = cond ? evalConst(node[2], env, state) : evalConst(node[3], env, state)
409
+ if (!picked) return null
410
+ const other = cond ? evalConst(node[3], env, state) : evalConst(node[2], env, state)
411
+ return (other && other.t === picked.t) ? picked : null
412
+ }
413
+ if (op === '.' || op === '?.') {
414
+ if (node[1] === 'Math' && typeof node[2] === 'string' && MATH_CONST.has(node[2])) return numResult(Math[node[2]])
415
+ const recv = evalConst(node[1], env, state)
416
+ if (recv && recv.t === 'str' && node[2] === 'length' && isAsciiSafe(recv.v)) return numResult(recv.v.length)
417
+ return null
418
+ }
419
+ if (op === '()') return evalCallConst(node, env, state)
420
+ if (op === ',') {
421
+ let last = null
422
+ for (let i = 1; i < node.length; i++) { last = evalConst(node[i], env, state); if (!last) return null }
423
+ return last
424
+ }
425
+ return null
426
+ }
427
+
428
+ function evalCallConst(node, env, state) {
429
+ const callee = node[1]
430
+ const args = collectArgs(node.length > 2 ? node[2] : null)
431
+
432
+ const mathName = mathCalleeName(callee)
433
+ if (mathName != null) {
434
+ const vs = []
435
+ for (const a of args) { const r = evalConst(a, env, state); if (!r || r.t !== 'num') return null; vs.push(r.v) }
436
+ return evalMathCall(mathName, vs)
437
+ }
438
+ if (Array.isArray(callee) && callee[0] === '.' && typeof callee[2] === 'string') {
439
+ const recv = evalConst(callee[1], env, state)
440
+ if (!recv || recv.t !== 'str' || !isAsciiSafe(recv.v)) return null
441
+ return evalStringMethod(callee[2], recv.v, args.map(a => evalConst(a, env, state)))
442
+ }
443
+ if (typeof callee === 'string' && (node.length < 3 || node[2] == null)) {
444
+ const f = state.funcByName.get(callee)
445
+ if (f && f.sig?.params?.length === 0 && !f.rest && !f.defaults) return evalFunctionBodyConst(f, state)
446
+ }
447
+ return null
448
+ }
449
+
450
+ /** Zero-arg pure call collapse — subsumes IIFE collapse (see module doc). Evaluates the
451
+ * callee's OWN body in a fresh, empty env (no outer capture: purity stays trivially
452
+ * provable). Bails (returns null) on anything but a `let`/`const` chain of constants
453
+ * ending in exactly one `return`. Cycle-guarded for (mutual) self-recursive 0-arg calls. */
454
+ function evalFunctionBodyConst(f, state) {
455
+ if (state.evaluating.has(f.name)) return null
456
+ state.evaluating.add(f.name)
457
+ try { return evalBodyConst(f.body, new Map(), state) }
458
+ finally { state.evaluating.delete(f.name) }
459
+ }
460
+ function evalBodyConst(body, env, state) {
461
+ if (!Array.isArray(body)) return null
462
+ if (body[0] !== '{}' && body[0] !== ';') return evalConst(body, env, state)
463
+ return evalStmtsConst(blockToStmtArray(body), env, state)
464
+ }
465
+ function evalStmtsConst(stmts, env, state) {
466
+ const local = new Map(env)
467
+ for (const s of stmts) {
468
+ if (!Array.isArray(s)) return null
469
+ if (s[0] === 'let' || s[0] === 'const') {
470
+ for (let i = 1; i < s.length; i++) {
471
+ const d = s[i]
472
+ if (!Array.isArray(d) || d[0] !== '=' || typeof d[1] !== 'string') return null
473
+ const v = evalConst(d[2], local, state)
474
+ if (!v) return null
475
+ local.set(d[1], v)
476
+ }
477
+ continue
478
+ }
479
+ if (s[0] === 'return') return s.length < 2 ? UNDEF_RESULT : evalConst(s[1], local, state)
480
+ return null // if/for/while/throw/expr-stmt/... — not fully constant, bail
481
+ }
482
+ return null // fell off the end without a return
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // foldNode — the tree rewriter. Tries evalConst first (full reduction); falls
487
+ // back to structural per-child folding otherwise.
488
+ // ---------------------------------------------------------------------------
489
+ function collectParamNamesShallow(paramsNode) {
490
+ const names = []
491
+ for (const p of extractParams(paramsNode)) {
492
+ const c = classifyParam(p)
493
+ if (typeof c.name === 'string') names.push(c.name)
494
+ }
495
+ return names
496
+ }
497
+
498
+ function foldNode(node, env, state) {
499
+ if (typeof node === 'string') {
500
+ const b = env.get(node)
501
+ return b !== undefined ? nodeOf(b) : node
502
+ }
503
+ if (!Array.isArray(node)) return node
504
+ const op = node[0]
505
+ if (op == null || op === 'str' || op === 'bool') return node
506
+
507
+ const full = evalConst(node, env, state)
508
+ if (full) return nodeOf(full)
509
+
510
+ if (op === '=>') {
511
+ const childEnv = new Map(env)
512
+ for (const p of collectParamNamesShallow(node[1])) childEnv.delete(p)
513
+ const newBody = foldNode(node[2], childEnv, state)
514
+ return newBody === node[2] ? node : [node[0], node[1], newBody]
515
+ }
516
+ if (op === 'for' && node.length === 5) {
517
+ // Leave the loop HEAD (init/cond/step — incl. for-in/for-of, already desugared to
518
+ // this shape by prepare) completely untouched: several downstream passes pattern-
519
+ // match a loop's bound/index expressions structurally (auto-vectorization's
520
+ // clamp-peel + 4-pixel SIMD blur match, unrollSmallConstFor's trip-count shape,
521
+ // watr LICM's post-inline invariant recognition, ...). Replacing a symbolic bound
522
+ // (`k <= rr`) with its folded literal (`k <= 4`) is VALUE-preserving but changes
523
+ // which of those shape-sensitive passes fires — e.g. it can silently swap a SIMD
524
+ // lane-vectorized loop for a fully unrolled one. The loop BODY has no such
525
+ // structural sensitivity and still folds normally.
526
+ const body = foldNode(node[4], new Map(env), state)
527
+ return body === node[4] ? node : [node[0], node[1], node[2], node[3], body]
528
+ }
529
+ if (op === 'return') {
530
+ if (node.length < 2) return node
531
+ const v = foldNode(node[1], env, state)
532
+ return v === node[1] ? node : ['return', v]
533
+ }
534
+ if (op === '&&' || op === '||' || op === '??') {
535
+ const a = foldNode(node[1], env, state)
536
+ const b = foldNode(node[2], env, state)
537
+ return (a === node[1] && b === node[2]) ? node : [op, a, b]
538
+ }
539
+ if ((op === '?:' || op === '?') && node.length === 4) {
540
+ const c = foldNode(node[1], env, state)
541
+ const t = foldNode(node[2], env, state)
542
+ const e = foldNode(node[3], env, state)
543
+ return (c === node[1] && t === node[2] && e === node[3]) ? node : [node[0], c, t, e]
544
+ }
545
+ if (op === '.' || op === '?.') {
546
+ const recv = foldNode(node[1], env, state)
547
+ return recv === node[1] ? node : [node[0], recv, node[2]]
548
+ }
549
+ if (op === ':' && node.length === 3) {
550
+ // Object-literal property `[':', key, value]`. A SHORTHAND property `{a}` desugars
551
+ // to `[':', 'a', 'a']` — the same bare identifier in BOTH the key slot and the value
552
+ // slot. Only the value is a real expression to fold/inline; the key slot is always a
553
+ // property NAME (or, for `{[k]: v}`, a computed-key expression) — inlining an
554
+ // identifier there would rewrite the property's NAME itself. Never touch it.
555
+ const key = node[1]
556
+ const value = foldNode(node[2], env, state)
557
+ return value === node[2] ? node : [op, key, value]
558
+ }
559
+ if (op === '[]' && node.length === 3) {
560
+ const base = foldNode(node[1], env, state)
561
+ // Never inline an identifier KEY to a literal here: prepare() already ran its
562
+ // static-vs-dynamic property/index classification (staticPropertyKey/staticIndexKey,
563
+ // static.js — schema dynProps tracking, SRoA flat-array slots) against the ORIGINAL
564
+ // `o[k]` shape and committed codegen decisions (e.g. for-in's dynamic-key bookkeeping)
565
+ // to that. Rewriting `k` to a literal post-hoc would make `o[k]` LOOK static to
566
+ // anything reading the AST downstream while every fact prepare recorded still says
567
+ // "dynamic" — a stale-vs-fresh mismatch, not a value-preserving fold. A non-identifier
568
+ // key (`o[i+1]`, `o[f()]`) was never eligible for that static fast path anyway, so it
569
+ // folds normally.
570
+ const key = typeof node[2] === 'string' ? node[2] : foldNode(node[2], env, state)
571
+ return (base === node[1] && key === node[2]) ? node : [op, base, key]
572
+ }
573
+ if (op === '()') return foldCallPartial(node, env, state)
574
+ if (op === ',') {
575
+ const parts = node.slice(1).map(n => foldNode(n, env, state))
576
+ return parts.some((p, i) => p !== node[i + 1]) ? [',', ...parts] : node
577
+ }
578
+
579
+ // Generic fallback: recurse into every child, preserving node shape. Covers
580
+ // for/array-literal/object-literal/call-args-of-non-foldable-callee/etc.
581
+ let changed = false
582
+ const out = node.map((c, i) => {
583
+ if (i === 0) return c
584
+ const v = foldNode(c, env, state)
585
+ if (v !== c) changed = true
586
+ return v
587
+ })
588
+ return changed ? out : node
589
+ }
590
+
591
+ function foldCallPartial(node, env, state) {
592
+ const callee = Array.isArray(node[1]) ? foldNode(node[1], env, state) : node[1]
593
+ if (node.length < 3) return callee === node[1] ? node : [node[0], callee]
594
+ const rawArgs = collectArgs(node[2])
595
+ const args = rawArgs.map(a => foldNode(a, env, state))
596
+ if (callee === node[1] && args.every((a, i) => a === rawArgs[i])) return node
597
+ const newArgsNode = args.length === 0 ? null : args.length === 1 ? args[0] : [',', ...args]
598
+ return [node[0], callee, newArgsNode]
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // Statement-list folding: env threading (constant `let`/`const` -> inlined at
603
+ // every later reference), `if`/`while(false)` dead-branch splicing.
604
+ // ---------------------------------------------------------------------------
605
+ function blockToStmtArray(node) {
606
+ if (node == null) return []
607
+ if (Array.isArray(node) && node[0] === '{}') {
608
+ const inner = node.length > 1 ? node[1] : null
609
+ if (inner == null) return []
610
+ return Array.isArray(inner) && inner[0] === ';' ? inner.slice(1) : [inner]
611
+ }
612
+ if (Array.isArray(node) && node[0] === ';') return node.slice(1)
613
+ return [node]
614
+ }
615
+ function wrapBlockLike(stmts, wasBraced) {
616
+ if (!stmts.length) return wasBraced ? ['{}'] : [';']
617
+ if (stmts.length === 1) return wasBraced ? ['{}', stmts[0]] : stmts[0]
618
+ return wasBraced ? ['{}', [';', ...stmts]] : [';', ...stmts]
619
+ }
620
+ const sameStmts = (a, b) => a.length === b.length && a.every((s, i) => s === b[i])
621
+
622
+ // IMPORTANT — identity preservation: prepare() forward-seeds compile-stage fact stores
623
+ // (program-facts.js's WeakMap caches, compile/infer.js recordGlobalRep, ...) keyed by
624
+ // the SPECIFIC node objects it walked. Rebuilding a statement/block node whose content
625
+ // didn't actually change would silently orphan any per-node fact recorded against the
626
+ // original object. `foldStmts` therefore returns the exact input array (and
627
+ // `foldBlockLike` the exact input node) whenever nothing in it changed, all the way up.
628
+ function foldBlockLike(node, env, state) {
629
+ const wasBraced = Array.isArray(node) && node[0] === '{}'
630
+ const original = blockToStmtArray(node)
631
+ const folded = foldStmts(original, env, state)
632
+ return folded === original ? node : wrapBlockLike(folded, wasBraced)
633
+ }
634
+
635
+ function foldStmts(stmts, env, state) {
636
+ const out = []
637
+ for (const s0 of stmts) {
638
+ const op = Array.isArray(s0) ? s0[0] : null
639
+
640
+ if (op === 'let' || op === 'const') {
641
+ // Fold each initializer's OWN expression (pure literal arithmetic/string/bool/
642
+ // Math chains need no outside binding to reduce). Deliberately NOT propagated any
643
+ // further: `env` here is never populated from a declaration, so a LATER reference
644
+ // to `name` is never rewritten to its value. Earlier revisions did that (and it's
645
+ // sound in isolation — see evalFunctionBodyConst's OWN, separately-scoped env,
646
+ // which still does this safely for a zero-arg call's self-contained body) but
647
+ // several existing passes downstream pattern-match a NAMED loop bound/index
648
+ // expression structurally rather than by value — clamp-peel's & the multi-pixel
649
+ // SIMD blur's loop-shape match, unrollSmallConstFor's trip-count shape, watr
650
+ // LICM's post-inline invariant recognition all fire (or don't) off the SYMBOLIC
651
+ // shape. Replacing `row = y*ww` with `row = y*64` is value-identical but silently
652
+ // swaps which of those passes engages. Tier 1 stays inside the proven-safe
653
+ // boundary: fold the expression tree, never rewrite a bare-name reference.
654
+ const decls = []
655
+ for (let i = 1; i < s0.length; i++) {
656
+ const d = s0[i]
657
+ if (!Array.isArray(d) || d[0] !== '=') { decls.push(d); continue }
658
+ const name = d[1], init = d[2]
659
+ const foldedInit = init !== undefined ? foldNode(init, env, state) : init
660
+ decls.push(foldedInit === init ? d : ['=', name, foldedInit])
661
+ }
662
+ const declsChanged = decls.some((d, i) => d !== s0[i + 1])
663
+ out.push(declsChanged ? [s0[0], ...decls] : s0)
664
+ continue
665
+ }
666
+
667
+ if (op === 'if') {
668
+ const condVal = evalConst(s0[1], env, state)
669
+ if (condVal) {
670
+ const takeThen = toBoolean(condVal)
671
+ const branch = takeThen ? s0[2] : s0[3]
672
+ if (branch != null) out.push(...foldStmts(blockToStmtArray(branch), new Map(env), state))
673
+ continue
674
+ }
675
+ const cond = foldNode(s0[1], env, state)
676
+ const thenF = s0[2] != null ? foldBlockLike(s0[2], new Map(env), state) : s0[2]
677
+ const hasElse = s0.length > 3
678
+ const elseF = hasElse ? (s0[3] != null ? foldBlockLike(s0[3], new Map(env), state) : s0[3]) : undefined
679
+ const changed = cond !== s0[1] || thenF !== s0[2] || (hasElse && elseF !== s0[3])
680
+ out.push(changed ? (hasElse ? ['if', cond, thenF, elseF] : ['if', cond, thenF]) : s0)
681
+ continue
682
+ }
683
+
684
+ if (op === 'while') {
685
+ const condVal = evalConst(s0[1], env, state)
686
+ if (condVal && !toBoolean(condVal)) continue
687
+ const cond = foldNode(s0[1], env, state)
688
+ const bodyF = s0[2] != null ? foldBlockLike(s0[2], new Map(env), state) : s0[2]
689
+ out.push(cond === s0[1] && bodyF === s0[2] ? s0 : ['while', cond, bodyF])
690
+ continue
691
+ }
692
+
693
+ out.push(foldNode(s0, env, state))
694
+ }
695
+ return sameStmts(out, stmts) ? stmts : out
696
+ }
697
+
698
+ function foldFunctionBody(body, state) {
699
+ if (body == null || isLiteralNode(body)) return body
700
+ if (!Array.isArray(body) || (body[0] !== '{}' && body[0] !== ';')) return foldNode(body, new Map(), state)
701
+ return foldBlockLike(body, new Map(), state)
702
+ }
703
+
704
+ /** Run preEval over the prepared module AST + every ctx.func.list body (mutated in place —
705
+ * the same funcInfo objects compile() reads). Single top-to-bottom pass; see module doc for
706
+ * why that's already a full fixpoint. */
707
+ export function preEval(ast) {
708
+ const rationalOn = ctx.transform.optimize?.rationalConst !== false
709
+ const funcByName = new Map(ctx.func.list.map(f => [f.name, f]))
710
+ const state = { rationalOn, funcByName, evaluating: new Set() }
711
+ for (const f of ctx.func.list) f.body = foldFunctionBody(f.body, state)
712
+ if (ast == null) return ast
713
+ return foldBlockLike(ast, new Map(), state)
714
+ }