jz 0.5.1 → 0.7.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 (86) hide show
  1. package/README.md +315 -318
  2. package/bench/README.md +369 -0
  3. package/bench/bench.svg +102 -0
  4. package/cli.js +104 -30
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +362 -84
  9. package/interop.js +159 -186
  10. package/jz.svg +5 -0
  11. package/jzify/arguments.js +97 -0
  12. package/jzify/bundler.js +382 -0
  13. package/jzify/classes.js +328 -0
  14. package/jzify/hoist-vars.js +177 -0
  15. package/jzify/index.js +51 -0
  16. package/jzify/names.js +37 -0
  17. package/jzify/switch.js +106 -0
  18. package/jzify/transform.js +349 -0
  19. package/layout.js +184 -0
  20. package/module/array.js +377 -176
  21. package/module/collection.js +639 -145
  22. package/module/console.js +55 -43
  23. package/module/core.js +264 -153
  24. package/module/date.js +15 -3
  25. package/module/function.js +73 -6
  26. package/module/index.js +2 -1
  27. package/module/json.js +226 -61
  28. package/module/math.js +551 -187
  29. package/module/number.js +327 -60
  30. package/module/object.js +474 -184
  31. package/module/regex.js +255 -25
  32. package/module/schema.js +24 -6
  33. package/module/simd.js +117 -0
  34. package/module/string.js +621 -226
  35. package/module/symbol.js +1 -1
  36. package/module/timer.js +9 -14
  37. package/module/typedarray.js +459 -73
  38. package/package.json +58 -14
  39. package/src/abi/index.js +39 -23
  40. package/src/abi/string.js +38 -41
  41. package/src/ast.js +460 -0
  42. package/src/autoload.js +29 -24
  43. package/src/bridge.js +111 -0
  44. package/src/compile/analyze-scans.js +824 -0
  45. package/src/compile/analyze.js +1600 -0
  46. package/src/compile/emit-assign.js +411 -0
  47. package/src/compile/emit.js +3512 -0
  48. package/src/compile/flow-types.js +103 -0
  49. package/src/{compile.js → compile/index.js} +778 -128
  50. package/src/{infer.js → compile/infer.js} +62 -98
  51. package/src/compile/loop-divmod.js +155 -0
  52. package/src/{narrow.js → compile/narrow.js} +409 -106
  53. package/src/compile/peel-stencil.js +261 -0
  54. package/src/compile/plan/advise.js +370 -0
  55. package/src/compile/plan/common.js +150 -0
  56. package/src/compile/plan/index.js +122 -0
  57. package/src/compile/plan/inline.js +682 -0
  58. package/src/compile/plan/literals.js +1199 -0
  59. package/src/compile/plan/loops.js +472 -0
  60. package/src/compile/plan/scope.js +649 -0
  61. package/src/compile/program-facts.js +423 -0
  62. package/src/ctx.js +220 -64
  63. package/src/ir.js +589 -172
  64. package/src/kind-traits.js +132 -0
  65. package/src/kind.js +524 -0
  66. package/src/op-policy.js +57 -0
  67. package/src/{optimize.js → optimize/index.js} +1432 -473
  68. package/src/optimize/vectorize.js +4660 -0
  69. package/src/param-reps.js +65 -0
  70. package/src/parse.js +44 -0
  71. package/src/{prepare.js → prepare/index.js} +649 -205
  72. package/src/prepare/lift-iife.js +149 -0
  73. package/src/reps.js +116 -0
  74. package/src/resolve.js +12 -3
  75. package/src/static.js +208 -0
  76. package/src/type.js +651 -0
  77. package/src/{assemble.js → wat/assemble.js} +275 -55
  78. package/src/wat/optimize.js +3938 -0
  79. package/transform.js +21 -0
  80. package/wasi.js +47 -5
  81. package/src/analyze.js +0 -3818
  82. package/src/emit.js +0 -3040
  83. package/src/jzify.js +0 -1580
  84. package/src/plan.js +0 -2132
  85. package/src/vectorize.js +0 -1088
  86. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Module-scope planning — type-narrowing and structural rewrites of
3
+ * module-level bindings. Runs once per program (post fact-collection,
4
+ * pre whole-program narrowing) under `plan()`.
5
+ *
6
+ * Concerns owned here (each operates on `ctx.scope` / `ctx.func` / `ctx.schema`,
7
+ * not on AST shape — except `flattenFuncNamespaces` and `devirtGlobalCalls`,
8
+ * which mutate the AST as their final step):
9
+ *
10
+ * - `inferModuleLetTypes` — module-level `let` typed-array union
11
+ * - `unboxConstTypedGlobals` — const typed-array → unboxed i32 offset
12
+ * - `inferModuleIntGlobals` — purpose-focused f64→i32 numeric demotion
13
+ * - `flattenFuncNamespaces` — `f.prop` slot SROA + dead-write drop
14
+ * - `devirtGlobalCalls` — `call_indirect $global` → direct `call`
15
+ * - `materializeAutoBoxSchemas` — schema registration for object propMap
16
+ * - `resolveClosureWidth` — uniform closure ABI width
17
+ * - `canSkipWholeProgramNarrowing` — fast-path gate for monomorphic programs
18
+ *
19
+ * @module compile/plan/scope
20
+ */
21
+
22
+ import { ctx, warn, declGlobal } from '../../ctx.js'
23
+ import { ASSIGN_OPS, T, refsAny } from '../../ast.js'
24
+ import { VAL, updateGlobalRep } from '../../reps.js'
25
+ import { typedElemCtor, ternaryCtorOfRhs, MIXED_CTORS } from '../../type.js'
26
+ import { typedElemAux } from '../../../layout.js'
27
+ import { MAX_CLOSURE_ARITY, UNDEF_NAN } from '../../ir.js'
28
+ import { analyzeFuncNamespaces } from '../analyze.js'
29
+ import { invalidateProgramFactsCache } from '../program-facts.js'
30
+
31
+ // `scanGlobalValueFacts` was deleted — prepare's depth-0 catch (calling
32
+ // `recordGlobalRep` from src/infer.js) is the authoritative pass and a
33
+ // strict superset of what this top-level walker observed.
34
+
35
+ // Flow-insensitive type inference for module-level `let` bindings whose
36
+ // initial RHS doesn't pin a type (most often `let mem;` followed later by
37
+ // `mem = new TypedArray(...)` inside an init function). Without this the
38
+ // read site has to runtime-check the NaN-box tag on every access — game-of-life's
39
+ // inner step does that 9× per cell, blowing up the hot loop. We union RHS types
40
+ // across every assignment (initial decl + every `name = …` in any function);
41
+ // if every observed RHS is either a typed-array ctor of the same kind, a known
42
+ // VAL.TYPED binding of the same ctor, or null/undefined, the binding is
43
+ // monomorphically VAL.TYPED. Anything else (literal number, non-typed call,
44
+ // mixed ctors) clears the candidacy, keeping the read site polymorphic.
45
+ export const inferModuleLetTypes = (ast) => {
46
+ if (!ctx.scope.userGlobals) return
47
+ // Build an assignment/alias graph over EVERY `=`/`let`/`const` binding in the
48
+ // program (globals and locals alike), then resolve each global's typed-array
49
+ // ctor by least-fixed-point. A single forward pass can't see the double-buffer
50
+ // swap idiom — `let tmp = a; a = b; b = tmp` assigns `a` from `b` and `b` from
51
+ // a local `tmp` that aliases `a`, so neither ref resolves until its sibling is
52
+ // already known. The fixpoint closes that cycle: `a`/`b` each anchor on their
53
+ // `new Float64Array(...)` decl, the alias edges carry the ctor around the loop,
54
+ // and they promote to VAL.TYPED. Without it the swap poisoned both globals and
55
+ // every `a[i]` read forked __str_idx/__typed_idx, every `+` forked __str_concat.
56
+ //
57
+ // Lattice (per name): null (no evidence) < ctor < MIXED. `bad` evidence (a non-
58
+ // typed, non-alias RHS — number, string, call, arithmetic, compound-assign) jumps
59
+ // straight to MIXED; conflicting ctors join to MIXED. We promote a global only
60
+ // when its fixed point is a single concrete ctor — sound: every assignment then
61
+ // provably yields that typed-array kind or nullish.
62
+ const MIXED = MIXED_CTORS
63
+ const defs = new Map() // name → { ctors:Set<string>, refs:Set<string>, bad:bool }
64
+ const getDef = (name) => {
65
+ let d = defs.get(name)
66
+ if (!d) defs.set(name, d = { ctors: new Set(), refs: new Set(), bad: false })
67
+ return d
68
+ }
69
+
70
+ const isNullishLit = (e) => e == null || e === 'undefined' || e === 'null'
71
+ || (Array.isArray(e) && e[0] == null && (e[1] === undefined || e[1] === null))
72
+
73
+ // User-function names — a call to one is an alias edge to its return value
74
+ // (virtual node `@ret:<fn>`, populated from each `return`). Lets a global
75
+ // assigned `a = makeBuffer(n)` inherit makeBuffer's typed-array ctor without
76
+ // relying on the call being inlined (locals get it via inlining; globals,
77
+ // typed before inlining runs, did not). `@`/`:` can't occur in a JS identifier,
78
+ // so the virtual key never collides with a real binding.
79
+ const fnames = new Set()
80
+ for (const f of ctx.func.list) if (f.body && !f.raw && typeof f.name === 'string') fnames.add(f.name)
81
+ // Typed-array methods that preserve the receiver's element ctor: `.subarray`
82
+ // and `.slice` (same-kind view/copy), `.map` (same-kind, per propagateTyped).
83
+ const CTOR_PRESERVING = new Set(['subarray', 'slice', 'map'])
84
+
85
+ // Record one assignment `name = rhs` as evidence. Nullish contributes nothing
86
+ // (consistent with any typed-array value); a bare identifier, a ctor-preserving
87
+ // method on a name, or a call to a user function are alias edges; anything else
88
+ // that isn't a typed ctor poisons the name.
89
+ // Scope-qualified binding key. A module global is ONE node program-wide (bare
90
+ // name); a function-local is unique to its scope `sid`. Keying locals by bare
91
+ // name made a numeric counter `let s = 0` in one function poison a typed
92
+ // swap-temp `let s = a` in another — cascading MIXED into the double-buffer
93
+ // globals so every `a[i]` fell back to runtime __str_idx/__typed_idx dispatch
94
+ // (lbm: 3.9× slower than JS). `@ret:` virtual nodes stay bare (module-wide
95
+ // return-value anchors).
96
+ const key = (name, sid) => name[0] === '@' || ctx.scope.userGlobals.has(name) ? name : sid + '\x00' + name
97
+
98
+ const observe = (name, rhs, sid) => {
99
+ const d = getDef(key(name, sid))
100
+ if (isNullishLit(rhs)) return
101
+ const ctor = typedElemCtor(rhs) ?? ternaryCtorOfRhs(rhs)
102
+ if (ctor === MIXED) { d.bad = true; return }
103
+ if (ctor) { d.ctors.add(ctor); return }
104
+ if (typeof rhs === 'string') { d.refs.add(key(rhs, sid)); return }
105
+ if (Array.isArray(rhs) && rhs[0] === '()') {
106
+ const callee = rhs[1]
107
+ // `recv.subarray(...)` / `recv.slice(...)` / `recv.map(...)` → inherit recv's ctor.
108
+ if (Array.isArray(callee) && callee[0] === '.' && typeof callee[1] === 'string'
109
+ && CTOR_PRESERVING.has(callee[2])) { d.refs.add(key(callee[1], sid)); return }
110
+ // `fn(...)` to a user function → inherit its return ctor.
111
+ if (typeof callee === 'string' && fnames.has(callee)) { d.refs.add('@ret:' + callee); return }
112
+ }
113
+ d.bad = true
114
+ }
115
+
116
+ // Scope-aware walk. Every `=>` opens a fresh scope so same-named locals across
117
+ // functions (and sibling closures) stay distinct. A function bound to a name
118
+ // descends in a name-stable scope (`fn\0name`) so the ast descent and the
119
+ // func.list sweep below visit it identically (idempotent), and so `return`
120
+ // exprs anchor on `@ret:name`.
121
+ let sidc = 0
122
+ const walk = (node, sid, retFn) => {
123
+ if (!Array.isArray(node)) return
124
+ const op = node[0]
125
+ if (op === '=>') { walk(node[2], 's' + (++sidc), null); return }
126
+ if (op === 'return' && retFn != null) observe('@ret:' + retFn, node[1], sid)
127
+ const assign = (name, rhs) => {
128
+ if (Array.isArray(rhs) && rhs[0] === '=>') { observe(name, rhs, sid); enterFn(rhs[2], name); return }
129
+ observe(name, rhs, sid); walk(rhs, sid, retFn)
130
+ }
131
+ if (op === '=' && typeof node[1] === 'string') return assign(node[1], node[2])
132
+ if ((op === 'let' || op === 'const') && node.length > 1) {
133
+ for (let i = 1; i < node.length; i++) {
134
+ const d = node[i]
135
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') assign(d[1], d[2])
136
+ else walk(d, sid, retFn)
137
+ }
138
+ return
139
+ }
140
+ // Compound-assigns (`+=`, `++`, …) can't preserve a typed-array kind — poison.
141
+ if (ASSIGN_OPS.has(op) && typeof node[1] === 'string') { getDef(key(node[1], sid)).bad = true; walk(node[2], sid, retFn); return }
142
+ for (let i = 1; i < node.length; i++) walk(node[i], sid, retFn)
143
+ }
144
+ // Descend into a function body anchored on `@ret:fn`. An arrow expr-body IS the
145
+ // implicit return (`(n) => new Float64Array(n)`); a `{}` block uses explicit
146
+ // `return` nodes (captured in walk). Without the implicit-return capture, a
147
+ // global assigned `a = mk(n)` from an expr-body fn never inherited mk's ctor.
148
+ const enterFn = (body, fn) => {
149
+ if (Array.isArray(body) && body[0] !== '{}') observe('@ret:' + fn, body, 'fn\x00' + fn)
150
+ walk(body, 'fn\x00' + fn, fn)
151
+ }
152
+ walk(ast, 'mod', null)
153
+ // Defensive sweep: cover any func.list body not reachable by descent from `ast`
154
+ // (hoisted / submodule). Name-stable scope keeps it idempotent with the descent.
155
+ for (const f of ctx.func.list) if (f.body && !f.raw) enterFn(f.body, f.name)
156
+
157
+ // Least-fixed-point over the alias graph. join: null is bottom, MIXED is top.
158
+ const join = (a, b) => a === MIXED || b === MIXED ? MIXED : a == null ? b : b == null ? a : a === b ? a : MIXED
159
+ const state = new Map() // name → null | ctor | MIXED
160
+ // A ref to a name with no tracked defs resolves via an already-known typed
161
+ // global (const typed array / earlier-recorded rep); otherwise it's opaque → MIXED.
162
+ const refState = (r) => defs.has(r) ? (state.get(r) ?? null)
163
+ : ctx.scope.globalValTypes?.get(r) === VAL.TYPED ? (ctx.scope.globalTypedElem?.get(r) ?? MIXED)
164
+ : MIXED
165
+ let changed = true
166
+ while (changed) {
167
+ changed = false
168
+ for (const [name, d] of defs) {
169
+ let cur = d.bad ? MIXED : null
170
+ if (cur !== MIXED) for (const c of d.ctors) cur = join(cur, c)
171
+ if (cur !== MIXED) for (const r of d.refs) cur = join(cur, refState(r))
172
+ if (cur !== (state.get(name) ?? null)) { state.set(name, cur); changed = true }
173
+ }
174
+ }
175
+
176
+ for (const name of ctx.scope.userGlobals) {
177
+ const ctor = state.get(name)
178
+ if (!ctor || ctor === MIXED) continue
179
+ if (ctx.scope.globalValTypes?.get(name) === VAL.TYPED) continue
180
+ ;(ctx.scope.globalValTypes ||= new Map()).set(name, VAL.TYPED)
181
+ ;(ctx.scope.globalTypedElem ||= new Map()).set(name, ctor)
182
+ }
183
+ }
184
+
185
+ export const unboxConstTypedGlobals = () => {
186
+ if (!ctx.scope.globalTypedElem || !ctx.scope.consts) return
187
+ for (const [name, ctor] of ctx.scope.globalTypedElem) {
188
+ if (!ctx.scope.consts.has(name)) continue
189
+ if (ctx.scope.globalValTypes?.get(name) !== VAL.TYPED) continue
190
+ const aux = typedElemAux(ctor)
191
+ if (aux == null) continue
192
+ const decl = ctx.scope.globals.get(name)
193
+ if (!(decl?.mut && decl.type === 'f64')) continue
194
+ declGlobal(name, 'i32')
195
+ updateGlobalRep(name, { ptrKind: VAL.TYPED, ptrAux: aux })
196
+ }
197
+ }
198
+
199
+ // Integer-global type inference — narrow purpose-focused numeric module globals
200
+ // (counters, sizes, strides, indices: `N`, `width`, `offset`, …) from f64 to i32.
201
+ //
202
+ // Principle: in purpose-focused code an integer-initialized numeric global is an
203
+ // integer unless an assignment *proves* it fractional. Sizes/strides/indices are
204
+ // the overwhelming majority; demanding the user annotate them (asm.js `x | 0`)
205
+ // defeats clean code. So we assume i32 and demote only on positive proof of a
206
+ // fraction — a non-integer literal, `/` or `**`, a float-valued `Math.*`, or a
207
+ // reference to an already-fractional value. (jz already truncates fractional
208
+ // array indices, so a stray fraction in an integer slot is a pre-existing bug,
209
+ // not one this introduces; a future advisory can flag it.)
210
+ //
211
+ // The payoff cascades: an i32 `width` makes `mem[y*width+x]` a fully-i32 index
212
+ // (the per-access `trunc_sat` and the index-counter widen both vanish), and an
213
+ // i32 `N` makes the loop guard `i < N` pure-i32 (no per-iteration convert),
214
+ // unlocking SIMD — all from idiomatic source, no hints.
215
+ const FRACTIONAL_MATH = new Set([
216
+ 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2',
217
+ 'sinh', 'cosh', 'tanh', 'asinh', 'acosh', 'atanh',
218
+ 'sqrt', 'cbrt', 'exp', 'expm1', 'log', 'log2', 'log10', 'log1p',
219
+ 'pow', 'hypot', 'random', 'fround',
220
+ ])
221
+ const INT_COERCE_OPS = new Set(['&', '|', '^', '<<', '>>', '>>>', '~'])
222
+ const COMPARE_OPS = new Set(['<', '>', '<=', '>=', '==', '===', '!=', '!==', '!', 'in', 'instanceof'])
223
+ const FRAC_COMPOUND = new Set(['/=', '**='])
224
+ const INT_COMPOUND = new Set(['&=', '|=', '^=', '<<=', '>>=', '>>>='])
225
+
226
+ export const inferModuleIntGlobals = (ast) => {
227
+ if (!ctx.scope.userGlobals?.size) return
228
+ // Candidates: mutable f64 scalar globals with positive numeric-initializer
229
+ // evidence and not a function. (const-folded / typed-pointer globals already
230
+ // carry a non-`(mut f64)` decl, so they're excluded.)
231
+ const candidates = new Set()
232
+ for (const name of ctx.scope.userGlobals) {
233
+ const decl = ctx.scope.globals.get(name)
234
+ if (!(decl?.mut && decl.type === 'f64')) continue
235
+ if (ctx.scope.globalValTypes?.get(name) !== VAL.NUMBER) continue
236
+ if (ctx.func.names?.has(name)) continue
237
+ candidates.add(name)
238
+ }
239
+ if (!candidates.size) return
240
+
241
+ const fractional = new Set()
242
+ const refIsFractional = (ref) => {
243
+ if (candidates.has(ref)) return fractional.has(ref)
244
+ const gt = ctx.scope.globalTypes?.get(ref)
245
+ if (gt === 'i32') return false
246
+ if (gt === 'f64') {
247
+ const vt = ctx.scope.globalValTypes?.get(ref)
248
+ return vt === VAL.NUMBER || vt == null // a fractional f64 number; pointers aren't
249
+ }
250
+ return false // param / local / unknown numeric → assume integer
251
+ }
252
+ // Does `e` provably evaluate to a non-integer? Integer-coercing ops (bitwise,
253
+ // shifts) and comparisons launder any fraction; only the *value*-bearing
254
+ // branches of ternary/logical ops carry it.
255
+ const producesFraction = (e) => {
256
+ if (e == null) return false
257
+ if (typeof e === 'number') return !Number.isInteger(e)
258
+ if (typeof e === 'string') return refIsFractional(e)
259
+ if (!Array.isArray(e)) return false
260
+ const op = e[0]
261
+ if (op == null) return typeof e[1] === 'number' && !Number.isInteger(e[1])
262
+ if (op === '/' || op === '**') return true
263
+ if (INT_COERCE_OPS.has(op) || COMPARE_OPS.has(op)) return false
264
+ if (op === '?:') return producesFraction(e[2]) || producesFraction(e[3])
265
+ if (op === '&&' || op === '||' || op === '??') return producesFraction(e[1]) || producesFraction(e[2])
266
+ if (op === '()') {
267
+ const callee = e[1]
268
+ if (Array.isArray(callee) && callee[0] === '?') return producesFraction(callee[2]) || producesFraction(callee[3])
269
+ if (Array.isArray(callee) && callee[0] === '.' && callee[1] === 'Math' && FRACTIONAL_MATH.has(callee[2])) return true
270
+ return false // unknown call → assume integer
271
+ }
272
+ for (let i = 1; i < e.length; i++) if (producesFraction(e[i])) return true
273
+ return false
274
+ }
275
+
276
+ // A numeric-initialized global later assigned a provably non-numeric value
277
+ // (string/object/array/arrow/`new`/boolean literal) must stay the f64 NaN-box
278
+ // carrier — narrowing it to i32 would corrupt the boxed value. Disqualify it.
279
+ const looksNonNumeric = (e) => {
280
+ if (!Array.isArray(e)) return false
281
+ const op = e[0]
282
+ if (op == null) { const v = e[1]; return typeof v === 'string' || typeof v === 'boolean' }
283
+ // `[` is prepare's array-literal form; `[]` length-2 is the raw (pre-prepare) one.
284
+ return op === '{}' || op === '[' || (op === '[]' && e.length === 2) || op === '=>' || op === 'new' || op === 'str' || op === '`'
285
+ }
286
+
287
+ // Collect every assignment RHS (init + reassignments, program-wide). `fromParam`
288
+ // records (global → the function it was assigned a parameter-derived value in) —
289
+ // a parameter is an f64 of unknown integrality, so an i32-narrowed global fed from
290
+ // one may silently truncate a fractional Number (DSP/filter state). We do NOT
291
+ // demote on this (the integer default is the load-bearing index/size perf win);
292
+ // we surface it on the opt-in warn channel below.
293
+ const rhsByName = new Map()
294
+ const fromParam = new Map()
295
+ for (const name of candidates) rhsByName.set(name, [])
296
+ const record = (name, rhs, scope) => {
297
+ if (!candidates.has(name)) return
298
+ if (looksNonNumeric(rhs)) { candidates.delete(name); rhsByName.delete(name); return }
299
+ rhsByName.get(name)?.push(rhs)
300
+ if (scope && !fromParam.has(name) && refsAny(rhs, scope.params, { skipBindingPositions: true }))
301
+ fromParam.set(name, scope.fn)
302
+ }
303
+ const walk = (node, scope) => {
304
+ if (!Array.isArray(node)) return
305
+ const op = node[0]
306
+ if (op === '=' && typeof node[1] === 'string') record(node[1], node[2], scope)
307
+ else if ((op === 'let' || op === 'const') && node.length > 1) {
308
+ for (let i = 1; i < node.length; i++) {
309
+ const d = node[i]
310
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') record(d[1], d[2], scope)
311
+ }
312
+ } else if (ASSIGN_OPS.has(op) && op !== '=' && typeof node[1] === 'string' && candidates.has(node[1])) {
313
+ if (FRAC_COMPOUND.has(op)) fractional.add(node[1]) // `/=`, `**=` → fractional outright
314
+ else if (!INT_COMPOUND.has(op)) record(node[1], node[2], scope) // `+= -= *= %= ||= &&= ??=` → as their rhs
315
+ }
316
+ for (let i = 1; i < node.length; i++) walk(node[i], scope)
317
+ }
318
+ walk(ast, null)
319
+ for (const f of ctx.func.list) {
320
+ if (!f.body || f.raw) continue
321
+ const params = new Set((f.sig?.params || []).map(p => p.name))
322
+ walk(f.body, params.size ? { params, fn: f.name } : null)
323
+ }
324
+
325
+ // Fixpoint: demote any candidate with a provably-fractional assignment; repeat
326
+ // so fractionality propagates through globals that reference each other.
327
+ let changed = true
328
+ while (changed) {
329
+ changed = false
330
+ for (const name of candidates) {
331
+ if (fractional.has(name)) continue
332
+ if (rhsByName.get(name).some(producesFraction)) { fractional.add(name); changed = true }
333
+ }
334
+ }
335
+
336
+ for (const name of candidates) {
337
+ if (fractional.has(name)) continue
338
+ declGlobal(name, 'i32')
339
+ // Advisory only (off unless opts.warnings): the value flows in from a parameter,
340
+ // which may be a fractional Number that the i32 carrier truncates.
341
+ if (ctx.warnings && fromParam.has(name))
342
+ warn('int-global-truncation',
343
+ `module global '${name}' is inferred i32 (integer) but is assigned from a parameter — if it can hold a fractional Number (e.g. DSP/filter state), the fraction is truncated; store fractional state in a Float64Array instead`,
344
+ { fn: fromParam.get(name) })
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Function-namespace scalar replacement + devirtualization.
350
+ *
351
+ * A property of a user function compiles, by default, as a dynamic object: each
352
+ * `f.prop` write is a `__dyn_set` into a closure-keyed hash side-table, each
353
+ * read a `__dyn_get`. But a function's property table can never be observed by
354
+ * the host (the host receives only the callable; the table lives in jz linear
355
+ * memory), so jz sees every `f.prop` site — the slot is a closed, fully-known
356
+ * cell. Per property of a non-escaping namespace:
357
+ *
358
+ * - reassigned (`multiProp`) slot → dissolve into a plain f64 module global:
359
+ * `__dyn_get/__dyn_set` → `global.get/global.set`. The indirect call stays
360
+ * (a genuinely reassigned function pointer needs `call_indirect`). Pure
361
+ * storage relocation: the global inits to the undefined NaN atom, exactly mirroring
362
+ * "key never set → __dyn_get yields undefined".
363
+ * - written once to its lifted `$f$prop` function and only ever *called*
364
+ * (never read as a value) → the `__dyn_set` is dead: emit already lowers
365
+ * `f.prop()` to a direct `call $f$prop`. Drop the write entirely.
366
+ *
367
+ * Disqualified namespaces (`f` escapes as a bare value / is computed-indexed —
368
+ * an alias could reach the table) keep the dynamic path. Together these can
369
+ * eliminate the `__dyn_*` machinery from a namespace-only program outright.
370
+ */
371
+ export const flattenFuncNamespaces = (ast) => {
372
+ const names = ctx.func.names
373
+ if (!names?.size) return false
374
+ // Cheap structural gate: a flattenable namespace exists only if some lifted
375
+ // `f$prop` name's `f` is itself a function (prepare lifts every `f.prop =
376
+ // arrow` — multiProp slots included). The base `f` may itself carry a module
377
+ // prefix (`mod$f`), so scan every `$` boundary, not just the first; a
378
+ // populated `multiProp` registry is itself a direct namespace witness.
379
+ let hasNs = ctx.func.multiProp.size > 0
380
+ if (!hasNs) outer: for (const n of names) {
381
+ for (let i = n.indexOf('$'); i > 0; i = n.indexOf('$', i + 1))
382
+ if (names.has(n.slice(0, i))) { hasNs = true; break outer }
383
+ }
384
+ if (!hasNs) return false
385
+ const ns = analyzeFuncNamespaces(ast)
386
+ if (!ns.size) return false
387
+ // f → Map<prop, decision>; decision is { global } (SROA) or { drop } (dead
388
+ // write to an only-called single-write slot).
389
+ const flat = new Map()
390
+ for (const [f, info] of ns) {
391
+ if (info.disq) continue
392
+ let decide
393
+ const plan = (prop, d) => { if (!decide) flat.set(f, decide = new Map()); decide.set(prop, d) }
394
+ for (const prop of info.props) {
395
+ if (ctx.func.multiProp.has(`${f}.${prop}`)) { plan(prop, { global: `${f}${T}${prop}` }); continue }
396
+ const w = info.writes.get(prop)
397
+ // Single write of the lifted `$f$prop`, never read as a value → drop it.
398
+ if (w && w.length === 1 && w[0].atInit && w[0].rhs === `${f}$${prop}` && !info.valRead.has(prop))
399
+ plan(prop, { drop: true })
400
+ }
401
+ }
402
+ if (!flat.size) return false
403
+ for (const decide of flat.values())
404
+ for (const d of decide.values())
405
+ if (d.global && !ctx.scope.globals.has(d.global)) {
406
+ declGlobal(d.global, 'f64', `nan:${UNDEF_NAN}`)
407
+ }
408
+ const decisionFor = (obj, prop) =>
409
+ typeof obj === 'string' && typeof prop === 'string' && flat.has(obj)
410
+ ? flat.get(obj).get(prop) : undefined
411
+ const isEmptySeq = (n) => Array.isArray(n) && n.length === 1 && n[0] === ';'
412
+ const rewrite = (node) => {
413
+ if (!Array.isArray(node)) return node
414
+ const op = node[0]
415
+ if (op === '.' || op === '?.') {
416
+ const d = decisionFor(node[1], node[2])
417
+ if (d?.global) return d.global // drop-decisions leave reads/calls alone
418
+ }
419
+ if (op === '=' && Array.isArray(node[1]) && (node[1][0] === '.' || node[1][0] === '?.')) {
420
+ const d = decisionFor(node[1][1], node[1][2])
421
+ if (d?.global) return ['=', d.global, rewrite(node[2])]
422
+ if (d?.drop) return [';'] // dead write — emit nothing
423
+ }
424
+ const out = [op]
425
+ // Filter dropped writes out of statement sequences (an empty `[';']` left in
426
+ // a body would lower to an unrenderable node).
427
+ for (let i = 1; i < node.length; i++) {
428
+ const c = rewrite(node[i])
429
+ if (op === ';' && isEmptySeq(c)) continue
430
+ out.push(c)
431
+ }
432
+ return out
433
+ }
434
+ const newAst = rewrite(ast)
435
+ ast.length = 0
436
+ for (let i = 0; i < newAst.length; i++) ast.push(newAst[i])
437
+ invalidateProgramFactsCache(ast)
438
+ for (const fn of ctx.func.list)
439
+ if (fn.body && !fn.raw) fn.body = rewrite(fn.body)
440
+ // The defining `f.prop = …` writes live in moduleInits for bundled programs —
441
+ // rewrite them too, or reads would resolve to an unwritten global.
442
+ if (ctx.module.moduleInits)
443
+ for (let i = 0; i < ctx.module.moduleInits.length; i++)
444
+ ctx.module.moduleInits[i] = rewrite(ctx.module.moduleInits[i])
445
+ return true
446
+ }
447
+
448
+ /**
449
+ * Closure devirtualization.
450
+ *
451
+ * `flattenFuncNamespaces` dissolves a reassigned `f.prop` function slot into a
452
+ * module global, but the call through it stays a `call_indirect` on a
453
+ * `global.get`, dispatched via an ABI-adapting trampoline. When that global is
454
+ * written *only* by unconditional module-init assignments it holds, for the
455
+ * entire post-init program, one statically-known function — so every call
456
+ * through it collapses to a direct `call`: no table lookup, no trampoline, no
457
+ * 8-wide padding ABI, no closure type guard.
458
+ *
459
+ * A global G qualifies iff:
460
+ * 1. every assignment to G is an unconditional module-init statement — none in
461
+ * a function body, none nested inside init control flow;
462
+ * 2. G's final init value resolves (through global aliases) to a top-level
463
+ * function F;
464
+ * 3. G is never *called* by module-init code, nor by any function reachable
465
+ * from it — so every call site runs strictly post-init, where G ≡ F.
466
+ * Devirt then only swaps an indirect call for a direct call to the very same
467
+ * callee: it cannot change behavior, only drop dispatch overhead. The result is
468
+ * recorded in `ctx.func.globalDevirt` (`Map<global, fn>`) and consumed by emit.
469
+ */
470
+ export const devirtGlobalCalls = (ast) => {
471
+ const fnNames = ctx.func.names
472
+ if (!fnNames?.size || !ctx.scope.globals?.size) return
473
+
474
+ // Module-init statement stream, in execution order: moduleInits run first in
475
+ // `$__start`, then the main module's top-level.
476
+ const initStmts = []
477
+ const flatten = (n) => {
478
+ if (Array.isArray(n) && n[0] === ';') for (let i = 1; i < n.length; i++) flatten(n[i])
479
+ else if (n != null) initStmts.push(n)
480
+ }
481
+ for (const mi of ctx.module.moduleInits || []) flatten(mi)
482
+ flatten(ast)
483
+
484
+ const isGlobal = (s) => typeof s === 'string' && ctx.scope.globals.has(s)
485
+ // `[target, rhs]` pairs for a `=` / `let` / `const` node assigning a global.
486
+ const writesOf = (node) => {
487
+ if (!Array.isArray(node)) return []
488
+ if (node[0] === '=' && isGlobal(node[1])) return [[node[1], node[2]]]
489
+ if (node[0] === 'let' || node[0] === 'const') {
490
+ const out = []
491
+ for (let i = 1; i < node.length; i++) {
492
+ const d = node[i]
493
+ if (Array.isArray(d) && d[0] === '=' && isGlobal(d[1])) out.push([d[1], d[2]])
494
+ }
495
+ return out
496
+ }
497
+ return []
498
+ }
499
+
500
+ // Poison a global assigned anywhere but an unconditional init statement — in a
501
+ // function body, or nested in init control flow. Its value is then not a
502
+ // fixed post-init constant.
503
+ const poison = new Set()
504
+ const scanWrites = (node, topInit) => {
505
+ if (!Array.isArray(node)) return
506
+ const op = node[0]
507
+ if (op === 'let' || op === 'const') {
508
+ // A declarator `=` is part of the declaration, not a nested assignment —
509
+ // poison only when the declaration itself is non-top-level.
510
+ for (let i = 1; i < node.length; i++) {
511
+ const d = node[i]
512
+ if (Array.isArray(d) && d[0] === '=') {
513
+ if (!topInit && isGlobal(d[1])) poison.add(d[1])
514
+ scanWrites(d[2], false)
515
+ } else scanWrites(d, false)
516
+ }
517
+ return
518
+ }
519
+ if (op === '=') {
520
+ if (!topInit && isGlobal(node[1])) poison.add(node[1])
521
+ scanWrites(node[1], false)
522
+ scanWrites(node[2], false)
523
+ return
524
+ }
525
+ for (let i = 1; i < node.length; i++) scanWrites(node[i], false)
526
+ }
527
+ for (const stmt of initStmts) scanWrites(stmt, true)
528
+ for (const fn of ctx.func.map.values())
529
+ if (fn.body && !fn.raw) scanWrites(fn.body, false)
530
+
531
+ // Resolve each global's value by a linear pass over init in execution order.
532
+ const env = new Map()
533
+ const evalFn = (rhs) =>
534
+ typeof rhs !== 'string' ? null
535
+ : fnNames.has(rhs) ? rhs
536
+ : env.has(rhs) ? env.get(rhs)
537
+ : null
538
+ for (const stmt of initStmts)
539
+ for (const [g, rhs] of writesOf(stmt)) env.set(g, evalFn(rhs))
540
+
541
+ const devirt = new Map()
542
+ for (const [g, fn] of env)
543
+ if (fn && fnNames.has(fn) && !poison.has(g)) devirt.set(g, fn)
544
+ if (!devirt.size) return
545
+
546
+ // Condition 3: a call through G that runs *during* init would see an
547
+ // intermediate value. Drop any candidate G called by init code, or by a
548
+ // function reachable from it.
549
+ //
550
+ // `walkStraightLine` follows only straight-line execution: a nested `=>`
551
+ // literal is a closure *constructed* here, not run here, so its body is
552
+ // skipped — an IIFE callee `(=> …)()` is the one exception, its body does
553
+ // run. This is what keeps operator-registration init (`binary('+', 11)`
554
+ // builds, but does not invoke, a parselet closure) from dragging the parser
555
+ // into the init-reachable set, and keeps a wrapper body's `space()` call —
556
+ // which fires at parse time — from counting as an init call. (Soundness
557
+ // rests on a closure constructed during init not also being invoked during
558
+ // init: true of function-slot wrappers, which are registered then called at
559
+ // use time.)
560
+ const walkStraightLine = (node, onCall) => {
561
+ if (!Array.isArray(node)) return
562
+ const op = node[0]
563
+ if (op === '()') {
564
+ onCall(node[1])
565
+ if (Array.isArray(node[1]) && node[1][0] === '=>') walkStraightLine(node[1][2], onCall)
566
+ for (let i = 2; i < node.length; i++) walkStraightLine(node[i], onCall)
567
+ return
568
+ }
569
+ if (op === '=>' || op === 'function') return
570
+ for (let i = 1; i < node.length; i++) walkStraightLine(node[i], onCall)
571
+ }
572
+ const reachable = new Set()
573
+ const queue = []
574
+ const seedCalls = (node) => walkStraightLine(node, (c) => {
575
+ if (typeof c === 'string' && fnNames.has(c)) queue.push(c)
576
+ })
577
+ for (const s of initStmts) seedCalls(s)
578
+ while (queue.length) {
579
+ const f = queue.pop()
580
+ if (reachable.has(f)) continue
581
+ reachable.add(f)
582
+ const fn = ctx.func.map.get(f)
583
+ if (fn?.body && !fn.raw) seedCalls(fn.body)
584
+ }
585
+ const calledInInit = new Set()
586
+ const collectCalled = (node) => walkStraightLine(node, (c) => {
587
+ if (devirt.has(c)) calledInInit.add(c)
588
+ })
589
+ for (const s of initStmts) collectCalled(s)
590
+ for (const f of reachable) { const fn = ctx.func.map.get(f); if (fn?.body) collectCalled(fn.body) }
591
+ for (const g of calledInInit) devirt.delete(g)
592
+
593
+ if (devirt.size) ctx.func.globalDevirt = devirt
594
+ }
595
+
596
+ export const materializeAutoBoxSchemas = (programFacts) => {
597
+ if (!ctx.schema.register) return
598
+ for (const [name, props] of programFacts.propMap) {
599
+ if (ctx.schema.vars.has(name)) {
600
+ const existing = ctx.schema.resolve(name)
601
+ const newProps = [...props].filter(prop => !existing.includes(prop))
602
+ if (newProps.length) {
603
+ const merged = [...existing, ...newProps]
604
+ const mergedId = ctx.schema.register(merged)
605
+ ctx.schema.vars.set(name, mergedId)
606
+ }
607
+ continue
608
+ }
609
+ const valueProps = [...props].filter(prop => !ctx.func.names.has(`${name}$${prop}`))
610
+ if (!valueProps.length) continue
611
+ const allProps = [...props]
612
+ const schema = ['__inner__', ...allProps]
613
+ const schemaId = ctx.schema.register(schema)
614
+ ctx.schema.vars.set(name, schemaId)
615
+ if (ctx.func.names.has(name) && !ctx.scope.globals.has(name))
616
+ declGlobal(name, 'f64')
617
+ if (!ctx.schema.autoBox) ctx.schema.autoBox = new Map()
618
+ ctx.schema.autoBox.set(name, { schemaId, schema })
619
+ }
620
+ }
621
+
622
+ export const resolveClosureWidth = (programFacts) => {
623
+ if (!ctx.closure.make) return
624
+ const { hasSpread, hasRest, maxCall, maxDef, valueUsed } = programFacts
625
+ const floor = ctx.closure.floor ?? 0
626
+ // A top-level function used as a first-class value gets a boundary trampoline
627
+ // that forwards $__a0..$__a{arity-1} into it (emit.js). The uniform closure
628
+ // ABI must therefore be at least as wide as any table-resident function's
629
+ // fixed arity — maxDef only counts surviving `=>` literals, so lifted/hoisted
630
+ // function definitions slip past it (their bodies are walked, their param
631
+ // lists aren't). Without this, e.g. an arity-3 function used only via a
632
+ // 1-arg indirect call emits `(local.get $__a2)` against a 2-param trampoline.
633
+ let maxValueArity = 0
634
+ if (valueUsed) for (const name of valueUsed) {
635
+ const n = ctx.func.map.get(name)?.sig?.params?.length ?? 0
636
+ if (n > maxValueArity) maxValueArity = n
637
+ }
638
+ ctx.closure.width = (hasSpread && hasRest)
639
+ ? MAX_CLOSURE_ARITY
640
+ : Math.min(MAX_CLOSURE_ARITY, Math.max(maxCall, maxDef + (hasRest ? 1 : 0), maxValueArity, floor))
641
+ }
642
+
643
+ export const canSkipWholeProgramNarrowing = (programFacts) =>
644
+ programFacts.callSites.length === 0 &&
645
+ programFacts.valueUsed.size === 0 &&
646
+ !programFacts.anyDyn &&
647
+ programFacts.propMap.size === 0 &&
648
+ !programFacts.hasSchemaLiterals &&
649
+ !ctx.closure.make