jz 0.5.0 → 0.6.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 (80) hide show
  1. package/README.md +288 -314
  2. package/bench/README.md +319 -0
  3. package/bench/bench.svg +112 -0
  4. package/cli.js +32 -24
  5. package/index.js +177 -55
  6. package/interop.js +88 -159
  7. package/jz.svg +5 -0
  8. package/jzify/arguments.js +97 -0
  9. package/jzify/bundler.js +382 -0
  10. package/jzify/classes.js +328 -0
  11. package/jzify/hoist-vars.js +177 -0
  12. package/jzify/index.js +51 -0
  13. package/jzify/names.js +37 -0
  14. package/jzify/switch.js +106 -0
  15. package/jzify/transform.js +349 -0
  16. package/layout.js +179 -0
  17. package/module/array.js +322 -153
  18. package/module/collection.js +603 -145
  19. package/module/console.js +55 -43
  20. package/module/core.js +281 -142
  21. package/module/date.js +15 -3
  22. package/module/function.js +73 -6
  23. package/module/index.js +2 -1
  24. package/module/json.js +226 -61
  25. package/module/math.js +461 -185
  26. package/module/number.js +306 -60
  27. package/module/object.js +448 -184
  28. package/module/regex.js +255 -25
  29. package/module/schema.js +24 -6
  30. package/module/simd.js +85 -0
  31. package/module/string.js +591 -220
  32. package/module/symbol.js +1 -1
  33. package/module/timer.js +9 -14
  34. package/module/typedarray.js +45 -48
  35. package/package.json +41 -12
  36. package/src/abi/index.js +39 -23
  37. package/src/abi/string.js +38 -41
  38. package/src/ast.js +460 -0
  39. package/src/autoload.js +26 -24
  40. package/src/bridge.js +111 -0
  41. package/src/compile/analyze-scans.js +661 -0
  42. package/src/compile/analyze.js +1565 -0
  43. package/src/compile/emit-assign.js +408 -0
  44. package/src/compile/emit.js +3201 -0
  45. package/src/compile/flow-types.js +103 -0
  46. package/src/{compile.js → compile/index.js} +497 -125
  47. package/src/{infer.js → compile/infer.js} +27 -98
  48. package/src/{narrow.js → compile/narrow.js} +302 -96
  49. package/src/compile/plan/advise.js +316 -0
  50. package/src/compile/plan/common.js +150 -0
  51. package/src/compile/plan/index.js +118 -0
  52. package/src/compile/plan/inline.js +679 -0
  53. package/src/compile/plan/literals.js +984 -0
  54. package/src/compile/plan/loops.js +472 -0
  55. package/src/compile/plan/scope.js +573 -0
  56. package/src/compile/program-facts.js +404 -0
  57. package/src/ctx.js +176 -58
  58. package/src/ir.js +540 -171
  59. package/src/kind-traits.js +105 -0
  60. package/src/kind.js +462 -0
  61. package/src/op-policy.js +57 -0
  62. package/src/{optimize.js → optimize/index.js} +1106 -446
  63. package/src/optimize/vectorize.js +1874 -0
  64. package/src/param-reps.js +65 -0
  65. package/src/parse.js +44 -0
  66. package/src/{prepare.js → prepare/index.js} +600 -205
  67. package/src/reps.js +115 -0
  68. package/src/resolve.js +12 -3
  69. package/src/static.js +199 -0
  70. package/src/type.js +647 -0
  71. package/src/{assemble.js → wat/assemble.js} +86 -48
  72. package/src/wat/optimize.js +3760 -0
  73. package/transform.js +21 -0
  74. package/wasi.js +47 -5
  75. package/src/analyze.js +0 -3818
  76. package/src/emit.js +0 -2997
  77. package/src/jzify.js +0 -1553
  78. package/src/plan.js +0 -2132
  79. package/src/vectorize.js +0 -1088
  80. /package/src/{codegen.js → wat/codegen.js} +0 -0
@@ -0,0 +1,573 @@
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
+ // candidates: name → { ctor: string|null, valid: true } | { valid: false }
48
+ // valid=true with ctor=null means "still no positive evidence"; we promote
49
+ // only when ctor is non-null at the end. Assignments to nullish (undef/null)
50
+ // don't change ctor — they're consistent with any typed-array value.
51
+ // Per-candidate state in PARALLEL maps, not a shared mutable `{ctor, valid}` object
52
+ // per name: `ctorOf` holds the positive ctor evidence (name absent ⇒ not a candidate),
53
+ // `invalid` the demoted names. A prior shape stored one mutable object per name in a
54
+ // Map; under self-host those per-name objects aliased (every candidate ended up sharing
55
+ // one record), so a second global of a different kind invalidated the typed one and any
56
+ // two-global program lost its typed-array fast path. String/Set values can't alias.
57
+ const ctorOf = new Map()
58
+ const invalid = new Set()
59
+ for (const name of ctx.scope.userGlobals) ctorOf.set(name, null)
60
+
61
+ const isNullishLit = (e) => e == null || e === 'undefined' || e === 'null'
62
+ || (Array.isArray(e) && e[0] == null && (e[1] === undefined || e[1] === null))
63
+
64
+ const observe = (name, rhs) => {
65
+ if (!ctorOf.has(name) || invalid.has(name)) return
66
+ if (isNullishLit(rhs)) return
67
+ // Resolve typed-array ctor from `new TypedArrayCtor(...)`, ternary of typed,
68
+ // or a reference to a name we already know is typed.
69
+ let ctor = typedElemCtor(rhs) ?? ternaryCtorOfRhs(rhs)
70
+ if (ctor === MIXED_CTORS) { invalid.add(name); return }
71
+ if (!ctor && typeof rhs === 'string') {
72
+ if (ctx.scope.globalValTypes?.get(rhs) === VAL.TYPED)
73
+ ctor = ctx.scope.globalTypedElem?.get(rhs) ?? null
74
+ }
75
+ if (!ctor) { invalid.add(name); return }
76
+ const prev = ctorOf.get(name)
77
+ if (prev && prev !== ctor) { invalid.add(name); return }
78
+ ctorOf.set(name, ctor)
79
+ }
80
+
81
+ const walk = (node) => {
82
+ if (!Array.isArray(node)) return
83
+ const op = node[0]
84
+ if (op === '=' && typeof node[1] === 'string' && ctorOf.has(node[1])) observe(node[1], node[2])
85
+ if ((op === 'let' || op === 'const') && node.length > 1) {
86
+ for (let i = 1; i < node.length; i++) {
87
+ const d = node[i]
88
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string' && ctorOf.has(d[1]))
89
+ observe(d[1], d[2])
90
+ }
91
+ }
92
+ // Compound-assigns (`+=`, etc.) to a typed-array binding can't preserve
93
+ // the typed-array kind — invalidate.
94
+ if (ASSIGN_OPS.has(op) && op !== '=' && typeof node[1] === 'string' && ctorOf.has(node[1]))
95
+ invalid.add(node[1])
96
+ for (let i = 1; i < node.length; i++) walk(node[i])
97
+ }
98
+ walk(ast)
99
+ for (const f of ctx.func.list) if (f.body && !f.raw) walk(f.body)
100
+
101
+ for (const [name, ctor] of ctorOf) {
102
+ if (invalid.has(name) || !ctor) continue
103
+ if (ctx.scope.globalValTypes?.get(name) === VAL.TYPED) continue
104
+ ;(ctx.scope.globalValTypes ||= new Map()).set(name, VAL.TYPED)
105
+ ;(ctx.scope.globalTypedElem ||= new Map()).set(name, ctor)
106
+ }
107
+ }
108
+
109
+ export const unboxConstTypedGlobals = () => {
110
+ if (!ctx.scope.globalTypedElem || !ctx.scope.consts) return
111
+ for (const [name, ctor] of ctx.scope.globalTypedElem) {
112
+ if (!ctx.scope.consts.has(name)) continue
113
+ if (ctx.scope.globalValTypes?.get(name) !== VAL.TYPED) continue
114
+ const aux = typedElemAux(ctor)
115
+ if (aux == null) continue
116
+ const decl = ctx.scope.globals.get(name)
117
+ if (!(decl?.mut && decl.type === 'f64')) continue
118
+ declGlobal(name, 'i32')
119
+ updateGlobalRep(name, { ptrKind: VAL.TYPED, ptrAux: aux })
120
+ }
121
+ }
122
+
123
+ // Integer-global type inference — narrow purpose-focused numeric module globals
124
+ // (counters, sizes, strides, indices: `N`, `width`, `offset`, …) from f64 to i32.
125
+ //
126
+ // Principle: in purpose-focused code an integer-initialized numeric global is an
127
+ // integer unless an assignment *proves* it fractional. Sizes/strides/indices are
128
+ // the overwhelming majority; demanding the user annotate them (asm.js `x | 0`)
129
+ // defeats clean code. So we assume i32 and demote only on positive proof of a
130
+ // fraction — a non-integer literal, `/` or `**`, a float-valued `Math.*`, or a
131
+ // reference to an already-fractional value. (jz already truncates fractional
132
+ // array indices, so a stray fraction in an integer slot is a pre-existing bug,
133
+ // not one this introduces; a future advisory can flag it.)
134
+ //
135
+ // The payoff cascades: an i32 `width` makes `mem[y*width+x]` a fully-i32 index
136
+ // (the per-access `trunc_sat` and the index-counter widen both vanish), and an
137
+ // i32 `N` makes the loop guard `i < N` pure-i32 (no per-iteration convert),
138
+ // unlocking SIMD — all from idiomatic source, no hints.
139
+ const FRACTIONAL_MATH = new Set([
140
+ 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2',
141
+ 'sinh', 'cosh', 'tanh', 'asinh', 'acosh', 'atanh',
142
+ 'sqrt', 'cbrt', 'exp', 'expm1', 'log', 'log2', 'log10', 'log1p',
143
+ 'pow', 'hypot', 'random', 'fround',
144
+ ])
145
+ const INT_COERCE_OPS = new Set(['&', '|', '^', '<<', '>>', '>>>', '~'])
146
+ const COMPARE_OPS = new Set(['<', '>', '<=', '>=', '==', '===', '!=', '!==', '!', 'in', 'instanceof'])
147
+ const FRAC_COMPOUND = new Set(['/=', '**='])
148
+ const INT_COMPOUND = new Set(['&=', '|=', '^=', '<<=', '>>=', '>>>='])
149
+
150
+ export const inferModuleIntGlobals = (ast) => {
151
+ if (!ctx.scope.userGlobals?.size) return
152
+ // Candidates: mutable f64 scalar globals with positive numeric-initializer
153
+ // evidence and not a function. (const-folded / typed-pointer globals already
154
+ // carry a non-`(mut f64)` decl, so they're excluded.)
155
+ const candidates = new Set()
156
+ for (const name of ctx.scope.userGlobals) {
157
+ const decl = ctx.scope.globals.get(name)
158
+ if (!(decl?.mut && decl.type === 'f64')) continue
159
+ if (ctx.scope.globalValTypes?.get(name) !== VAL.NUMBER) continue
160
+ if (ctx.func.names?.has(name)) continue
161
+ candidates.add(name)
162
+ }
163
+ if (!candidates.size) return
164
+
165
+ const fractional = new Set()
166
+ const refIsFractional = (ref) => {
167
+ if (candidates.has(ref)) return fractional.has(ref)
168
+ const gt = ctx.scope.globalTypes?.get(ref)
169
+ if (gt === 'i32') return false
170
+ if (gt === 'f64') {
171
+ const vt = ctx.scope.globalValTypes?.get(ref)
172
+ return vt === VAL.NUMBER || vt == null // a fractional f64 number; pointers aren't
173
+ }
174
+ return false // param / local / unknown numeric → assume integer
175
+ }
176
+ // Does `e` provably evaluate to a non-integer? Integer-coercing ops (bitwise,
177
+ // shifts) and comparisons launder any fraction; only the *value*-bearing
178
+ // branches of ternary/logical ops carry it.
179
+ const producesFraction = (e) => {
180
+ if (e == null) return false
181
+ if (typeof e === 'number') return !Number.isInteger(e)
182
+ if (typeof e === 'string') return refIsFractional(e)
183
+ if (!Array.isArray(e)) return false
184
+ const op = e[0]
185
+ if (op == null) return typeof e[1] === 'number' && !Number.isInteger(e[1])
186
+ if (op === '/' || op === '**') return true
187
+ if (INT_COERCE_OPS.has(op) || COMPARE_OPS.has(op)) return false
188
+ if (op === '?:') return producesFraction(e[2]) || producesFraction(e[3])
189
+ if (op === '&&' || op === '||' || op === '??') return producesFraction(e[1]) || producesFraction(e[2])
190
+ if (op === '()') {
191
+ const callee = e[1]
192
+ if (Array.isArray(callee) && callee[0] === '?') return producesFraction(callee[2]) || producesFraction(callee[3])
193
+ if (Array.isArray(callee) && callee[0] === '.' && callee[1] === 'Math' && FRACTIONAL_MATH.has(callee[2])) return true
194
+ return false // unknown call → assume integer
195
+ }
196
+ for (let i = 1; i < e.length; i++) if (producesFraction(e[i])) return true
197
+ return false
198
+ }
199
+
200
+ // A numeric-initialized global later assigned a provably non-numeric value
201
+ // (string/object/array/arrow/`new`/boolean literal) must stay the f64 NaN-box
202
+ // carrier — narrowing it to i32 would corrupt the boxed value. Disqualify it.
203
+ const looksNonNumeric = (e) => {
204
+ if (!Array.isArray(e)) return false
205
+ const op = e[0]
206
+ if (op == null) { const v = e[1]; return typeof v === 'string' || typeof v === 'boolean' }
207
+ // `[` is prepare's array-literal form; `[]` length-2 is the raw (pre-prepare) one.
208
+ return op === '{}' || op === '[' || (op === '[]' && e.length === 2) || op === '=>' || op === 'new' || op === 'str' || op === '`'
209
+ }
210
+
211
+ // Collect every assignment RHS (init + reassignments, program-wide). `fromParam`
212
+ // records (global → the function it was assigned a parameter-derived value in) —
213
+ // a parameter is an f64 of unknown integrality, so an i32-narrowed global fed from
214
+ // one may silently truncate a fractional Number (DSP/filter state). We do NOT
215
+ // demote on this (the integer default is the load-bearing index/size perf win);
216
+ // we surface it on the opt-in warn channel below.
217
+ const rhsByName = new Map()
218
+ const fromParam = new Map()
219
+ for (const name of candidates) rhsByName.set(name, [])
220
+ const record = (name, rhs, scope) => {
221
+ if (!candidates.has(name)) return
222
+ if (looksNonNumeric(rhs)) { candidates.delete(name); rhsByName.delete(name); return }
223
+ rhsByName.get(name)?.push(rhs)
224
+ if (scope && !fromParam.has(name) && refsAny(rhs, scope.params, { skipBindingPositions: true }))
225
+ fromParam.set(name, scope.fn)
226
+ }
227
+ const walk = (node, scope) => {
228
+ if (!Array.isArray(node)) return
229
+ const op = node[0]
230
+ if (op === '=' && typeof node[1] === 'string') record(node[1], node[2], scope)
231
+ else if ((op === 'let' || op === 'const') && node.length > 1) {
232
+ for (let i = 1; i < node.length; i++) {
233
+ const d = node[i]
234
+ if (Array.isArray(d) && d[0] === '=' && typeof d[1] === 'string') record(d[1], d[2], scope)
235
+ }
236
+ } else if (ASSIGN_OPS.has(op) && op !== '=' && typeof node[1] === 'string' && candidates.has(node[1])) {
237
+ if (FRAC_COMPOUND.has(op)) fractional.add(node[1]) // `/=`, `**=` → fractional outright
238
+ else if (!INT_COMPOUND.has(op)) record(node[1], node[2], scope) // `+= -= *= %= ||= &&= ??=` → as their rhs
239
+ }
240
+ for (let i = 1; i < node.length; i++) walk(node[i], scope)
241
+ }
242
+ walk(ast, null)
243
+ for (const f of ctx.func.list) {
244
+ if (!f.body || f.raw) continue
245
+ const params = new Set((f.sig?.params || []).map(p => p.name))
246
+ walk(f.body, params.size ? { params, fn: f.name } : null)
247
+ }
248
+
249
+ // Fixpoint: demote any candidate with a provably-fractional assignment; repeat
250
+ // so fractionality propagates through globals that reference each other.
251
+ let changed = true
252
+ while (changed) {
253
+ changed = false
254
+ for (const name of candidates) {
255
+ if (fractional.has(name)) continue
256
+ if (rhsByName.get(name).some(producesFraction)) { fractional.add(name); changed = true }
257
+ }
258
+ }
259
+
260
+ for (const name of candidates) {
261
+ if (fractional.has(name)) continue
262
+ declGlobal(name, 'i32')
263
+ // Advisory only (off unless opts.warnings): the value flows in from a parameter,
264
+ // which may be a fractional Number that the i32 carrier truncates.
265
+ if (ctx.warnings && fromParam.has(name))
266
+ warn('int-global-truncation',
267
+ `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`,
268
+ { fn: fromParam.get(name) })
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Function-namespace scalar replacement + devirtualization.
274
+ *
275
+ * A property of a user function compiles, by default, as a dynamic object: each
276
+ * `f.prop` write is a `__dyn_set` into a closure-keyed hash side-table, each
277
+ * read a `__dyn_get`. But a function's property table can never be observed by
278
+ * the host (the host receives only the callable; the table lives in jz linear
279
+ * memory), so jz sees every `f.prop` site — the slot is a closed, fully-known
280
+ * cell. Per property of a non-escaping namespace:
281
+ *
282
+ * - reassigned (`multiProp`) slot → dissolve into a plain f64 module global:
283
+ * `__dyn_get/__dyn_set` → `global.get/global.set`. The indirect call stays
284
+ * (a genuinely reassigned function pointer needs `call_indirect`). Pure
285
+ * storage relocation: the global inits to the undefined NaN atom, exactly mirroring
286
+ * "key never set → __dyn_get yields undefined".
287
+ * - written once to its lifted `$f$prop` function and only ever *called*
288
+ * (never read as a value) → the `__dyn_set` is dead: emit already lowers
289
+ * `f.prop()` to a direct `call $f$prop`. Drop the write entirely.
290
+ *
291
+ * Disqualified namespaces (`f` escapes as a bare value / is computed-indexed —
292
+ * an alias could reach the table) keep the dynamic path. Together these can
293
+ * eliminate the `__dyn_*` machinery from a namespace-only program outright.
294
+ */
295
+ export const flattenFuncNamespaces = (ast) => {
296
+ const names = ctx.func.names
297
+ if (!names?.size) return false
298
+ // Cheap structural gate: a flattenable namespace exists only if some lifted
299
+ // `f$prop` name's `f` is itself a function (prepare lifts every `f.prop =
300
+ // arrow` — multiProp slots included). The base `f` may itself carry a module
301
+ // prefix (`mod$f`), so scan every `$` boundary, not just the first; a
302
+ // populated `multiProp` registry is itself a direct namespace witness.
303
+ let hasNs = ctx.func.multiProp.size > 0
304
+ if (!hasNs) outer: for (const n of names) {
305
+ for (let i = n.indexOf('$'); i > 0; i = n.indexOf('$', i + 1))
306
+ if (names.has(n.slice(0, i))) { hasNs = true; break outer }
307
+ }
308
+ if (!hasNs) return false
309
+ const ns = analyzeFuncNamespaces(ast)
310
+ if (!ns.size) return false
311
+ // f → Map<prop, decision>; decision is { global } (SROA) or { drop } (dead
312
+ // write to an only-called single-write slot).
313
+ const flat = new Map()
314
+ for (const [f, info] of ns) {
315
+ if (info.disq) continue
316
+ let decide
317
+ const plan = (prop, d) => { if (!decide) flat.set(f, decide = new Map()); decide.set(prop, d) }
318
+ for (const prop of info.props) {
319
+ if (ctx.func.multiProp.has(`${f}.${prop}`)) { plan(prop, { global: `${f}${T}${prop}` }); continue }
320
+ const w = info.writes.get(prop)
321
+ // Single write of the lifted `$f$prop`, never read as a value → drop it.
322
+ if (w && w.length === 1 && w[0].atInit && w[0].rhs === `${f}$${prop}` && !info.valRead.has(prop))
323
+ plan(prop, { drop: true })
324
+ }
325
+ }
326
+ if (!flat.size) return false
327
+ for (const decide of flat.values())
328
+ for (const d of decide.values())
329
+ if (d.global && !ctx.scope.globals.has(d.global)) {
330
+ declGlobal(d.global, 'f64', `nan:${UNDEF_NAN}`)
331
+ }
332
+ const decisionFor = (obj, prop) =>
333
+ typeof obj === 'string' && typeof prop === 'string' && flat.has(obj)
334
+ ? flat.get(obj).get(prop) : undefined
335
+ const isEmptySeq = (n) => Array.isArray(n) && n.length === 1 && n[0] === ';'
336
+ const rewrite = (node) => {
337
+ if (!Array.isArray(node)) return node
338
+ const op = node[0]
339
+ if (op === '.' || op === '?.') {
340
+ const d = decisionFor(node[1], node[2])
341
+ if (d?.global) return d.global // drop-decisions leave reads/calls alone
342
+ }
343
+ if (op === '=' && Array.isArray(node[1]) && (node[1][0] === '.' || node[1][0] === '?.')) {
344
+ const d = decisionFor(node[1][1], node[1][2])
345
+ if (d?.global) return ['=', d.global, rewrite(node[2])]
346
+ if (d?.drop) return [';'] // dead write — emit nothing
347
+ }
348
+ const out = [op]
349
+ // Filter dropped writes out of statement sequences (an empty `[';']` left in
350
+ // a body would lower to an unrenderable node).
351
+ for (let i = 1; i < node.length; i++) {
352
+ const c = rewrite(node[i])
353
+ if (op === ';' && isEmptySeq(c)) continue
354
+ out.push(c)
355
+ }
356
+ return out
357
+ }
358
+ const newAst = rewrite(ast)
359
+ ast.length = 0
360
+ for (let i = 0; i < newAst.length; i++) ast.push(newAst[i])
361
+ invalidateProgramFactsCache(ast)
362
+ for (const fn of ctx.func.list)
363
+ if (fn.body && !fn.raw) fn.body = rewrite(fn.body)
364
+ // The defining `f.prop = …` writes live in moduleInits for bundled programs —
365
+ // rewrite them too, or reads would resolve to an unwritten global.
366
+ if (ctx.module.moduleInits)
367
+ for (let i = 0; i < ctx.module.moduleInits.length; i++)
368
+ ctx.module.moduleInits[i] = rewrite(ctx.module.moduleInits[i])
369
+ return true
370
+ }
371
+
372
+ /**
373
+ * Closure devirtualization.
374
+ *
375
+ * `flattenFuncNamespaces` dissolves a reassigned `f.prop` function slot into a
376
+ * module global, but the call through it stays a `call_indirect` on a
377
+ * `global.get`, dispatched via an ABI-adapting trampoline. When that global is
378
+ * written *only* by unconditional module-init assignments it holds, for the
379
+ * entire post-init program, one statically-known function — so every call
380
+ * through it collapses to a direct `call`: no table lookup, no trampoline, no
381
+ * 8-wide padding ABI, no closure type guard.
382
+ *
383
+ * A global G qualifies iff:
384
+ * 1. every assignment to G is an unconditional module-init statement — none in
385
+ * a function body, none nested inside init control flow;
386
+ * 2. G's final init value resolves (through global aliases) to a top-level
387
+ * function F;
388
+ * 3. G is never *called* by module-init code, nor by any function reachable
389
+ * from it — so every call site runs strictly post-init, where G ≡ F.
390
+ * Devirt then only swaps an indirect call for a direct call to the very same
391
+ * callee: it cannot change behavior, only drop dispatch overhead. The result is
392
+ * recorded in `ctx.func.globalDevirt` (`Map<global, fn>`) and consumed by emit.
393
+ */
394
+ export const devirtGlobalCalls = (ast) => {
395
+ const fnNames = ctx.func.names
396
+ if (!fnNames?.size || !ctx.scope.globals?.size) return
397
+
398
+ // Module-init statement stream, in execution order: moduleInits run first in
399
+ // `$__start`, then the main module's top-level.
400
+ const initStmts = []
401
+ const flatten = (n) => {
402
+ if (Array.isArray(n) && n[0] === ';') for (let i = 1; i < n.length; i++) flatten(n[i])
403
+ else if (n != null) initStmts.push(n)
404
+ }
405
+ for (const mi of ctx.module.moduleInits || []) flatten(mi)
406
+ flatten(ast)
407
+
408
+ const isGlobal = (s) => typeof s === 'string' && ctx.scope.globals.has(s)
409
+ // `[target, rhs]` pairs for a `=` / `let` / `const` node assigning a global.
410
+ const writesOf = (node) => {
411
+ if (!Array.isArray(node)) return []
412
+ if (node[0] === '=' && isGlobal(node[1])) return [[node[1], node[2]]]
413
+ if (node[0] === 'let' || node[0] === 'const') {
414
+ const out = []
415
+ for (let i = 1; i < node.length; i++) {
416
+ const d = node[i]
417
+ if (Array.isArray(d) && d[0] === '=' && isGlobal(d[1])) out.push([d[1], d[2]])
418
+ }
419
+ return out
420
+ }
421
+ return []
422
+ }
423
+
424
+ // Poison a global assigned anywhere but an unconditional init statement — in a
425
+ // function body, or nested in init control flow. Its value is then not a
426
+ // fixed post-init constant.
427
+ const poison = new Set()
428
+ const scanWrites = (node, topInit) => {
429
+ if (!Array.isArray(node)) return
430
+ const op = node[0]
431
+ if (op === 'let' || op === 'const') {
432
+ // A declarator `=` is part of the declaration, not a nested assignment —
433
+ // poison only when the declaration itself is non-top-level.
434
+ for (let i = 1; i < node.length; i++) {
435
+ const d = node[i]
436
+ if (Array.isArray(d) && d[0] === '=') {
437
+ if (!topInit && isGlobal(d[1])) poison.add(d[1])
438
+ scanWrites(d[2], false)
439
+ } else scanWrites(d, false)
440
+ }
441
+ return
442
+ }
443
+ if (op === '=') {
444
+ if (!topInit && isGlobal(node[1])) poison.add(node[1])
445
+ scanWrites(node[1], false)
446
+ scanWrites(node[2], false)
447
+ return
448
+ }
449
+ for (let i = 1; i < node.length; i++) scanWrites(node[i], false)
450
+ }
451
+ for (const stmt of initStmts) scanWrites(stmt, true)
452
+ for (const fn of ctx.func.map.values())
453
+ if (fn.body && !fn.raw) scanWrites(fn.body, false)
454
+
455
+ // Resolve each global's value by a linear pass over init in execution order.
456
+ const env = new Map()
457
+ const evalFn = (rhs) =>
458
+ typeof rhs !== 'string' ? null
459
+ : fnNames.has(rhs) ? rhs
460
+ : env.has(rhs) ? env.get(rhs)
461
+ : null
462
+ for (const stmt of initStmts)
463
+ for (const [g, rhs] of writesOf(stmt)) env.set(g, evalFn(rhs))
464
+
465
+ const devirt = new Map()
466
+ for (const [g, fn] of env)
467
+ if (fn && fnNames.has(fn) && !poison.has(g)) devirt.set(g, fn)
468
+ if (!devirt.size) return
469
+
470
+ // Condition 3: a call through G that runs *during* init would see an
471
+ // intermediate value. Drop any candidate G called by init code, or by a
472
+ // function reachable from it.
473
+ //
474
+ // `walkStraightLine` follows only straight-line execution: a nested `=>`
475
+ // literal is a closure *constructed* here, not run here, so its body is
476
+ // skipped — an IIFE callee `(=> …)()` is the one exception, its body does
477
+ // run. This is what keeps operator-registration init (`binary('+', 11)`
478
+ // builds, but does not invoke, a parselet closure) from dragging the parser
479
+ // into the init-reachable set, and keeps a wrapper body's `space()` call —
480
+ // which fires at parse time — from counting as an init call. (Soundness
481
+ // rests on a closure constructed during init not also being invoked during
482
+ // init: true of function-slot wrappers, which are registered then called at
483
+ // use time.)
484
+ const walkStraightLine = (node, onCall) => {
485
+ if (!Array.isArray(node)) return
486
+ const op = node[0]
487
+ if (op === '()') {
488
+ onCall(node[1])
489
+ if (Array.isArray(node[1]) && node[1][0] === '=>') walkStraightLine(node[1][2], onCall)
490
+ for (let i = 2; i < node.length; i++) walkStraightLine(node[i], onCall)
491
+ return
492
+ }
493
+ if (op === '=>' || op === 'function') return
494
+ for (let i = 1; i < node.length; i++) walkStraightLine(node[i], onCall)
495
+ }
496
+ const reachable = new Set()
497
+ const queue = []
498
+ const seedCalls = (node) => walkStraightLine(node, (c) => {
499
+ if (typeof c === 'string' && fnNames.has(c)) queue.push(c)
500
+ })
501
+ for (const s of initStmts) seedCalls(s)
502
+ while (queue.length) {
503
+ const f = queue.pop()
504
+ if (reachable.has(f)) continue
505
+ reachable.add(f)
506
+ const fn = ctx.func.map.get(f)
507
+ if (fn?.body && !fn.raw) seedCalls(fn.body)
508
+ }
509
+ const calledInInit = new Set()
510
+ const collectCalled = (node) => walkStraightLine(node, (c) => {
511
+ if (devirt.has(c)) calledInInit.add(c)
512
+ })
513
+ for (const s of initStmts) collectCalled(s)
514
+ for (const f of reachable) { const fn = ctx.func.map.get(f); if (fn?.body) collectCalled(fn.body) }
515
+ for (const g of calledInInit) devirt.delete(g)
516
+
517
+ if (devirt.size) ctx.func.globalDevirt = devirt
518
+ }
519
+
520
+ export const materializeAutoBoxSchemas = (programFacts) => {
521
+ if (!ctx.schema.register) return
522
+ for (const [name, props] of programFacts.propMap) {
523
+ if (ctx.schema.vars.has(name)) {
524
+ const existing = ctx.schema.resolve(name)
525
+ const newProps = [...props].filter(prop => !existing.includes(prop))
526
+ if (newProps.length) {
527
+ const merged = [...existing, ...newProps]
528
+ const mergedId = ctx.schema.register(merged)
529
+ ctx.schema.vars.set(name, mergedId)
530
+ }
531
+ continue
532
+ }
533
+ const valueProps = [...props].filter(prop => !ctx.func.names.has(`${name}$${prop}`))
534
+ if (!valueProps.length) continue
535
+ const allProps = [...props]
536
+ const schema = ['__inner__', ...allProps]
537
+ const schemaId = ctx.schema.register(schema)
538
+ ctx.schema.vars.set(name, schemaId)
539
+ if (ctx.func.names.has(name) && !ctx.scope.globals.has(name))
540
+ declGlobal(name, 'f64')
541
+ if (!ctx.schema.autoBox) ctx.schema.autoBox = new Map()
542
+ ctx.schema.autoBox.set(name, { schemaId, schema })
543
+ }
544
+ }
545
+
546
+ export const resolveClosureWidth = (programFacts) => {
547
+ if (!ctx.closure.make) return
548
+ const { hasSpread, hasRest, maxCall, maxDef, valueUsed } = programFacts
549
+ const floor = ctx.closure.floor ?? 0
550
+ // A top-level function used as a first-class value gets a boundary trampoline
551
+ // that forwards $__a0..$__a{arity-1} into it (emit.js). The uniform closure
552
+ // ABI must therefore be at least as wide as any table-resident function's
553
+ // fixed arity — maxDef only counts surviving `=>` literals, so lifted/hoisted
554
+ // function definitions slip past it (their bodies are walked, their param
555
+ // lists aren't). Without this, e.g. an arity-3 function used only via a
556
+ // 1-arg indirect call emits `(local.get $__a2)` against a 2-param trampoline.
557
+ let maxValueArity = 0
558
+ if (valueUsed) for (const name of valueUsed) {
559
+ const n = ctx.func.map.get(name)?.sig?.params?.length ?? 0
560
+ if (n > maxValueArity) maxValueArity = n
561
+ }
562
+ ctx.closure.width = (hasSpread && hasRest)
563
+ ? MAX_CLOSURE_ARITY
564
+ : Math.min(MAX_CLOSURE_ARITY, Math.max(maxCall, maxDef + (hasRest ? 1 : 0), maxValueArity, floor))
565
+ }
566
+
567
+ export const canSkipWholeProgramNarrowing = (programFacts) =>
568
+ programFacts.callSites.length === 0 &&
569
+ programFacts.valueUsed.size === 0 &&
570
+ !programFacts.anyDyn &&
571
+ programFacts.propMap.size === 0 &&
572
+ !programFacts.hasSchemaLiterals &&
573
+ !ctx.closure.make