watr 4.7.2 → 5.1.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.
package/src/optimize.js CHANGED
@@ -7,8 +7,10 @@
7
7
  * @module wat/optimize
8
8
  */
9
9
 
10
- import compile, { size } from './compile.js'
10
+ import { numdata, size } from './compile.js'
11
+ import { IMM, OPCODE, resultType } from './const.js'
11
12
  import parse from './parse.js'
13
+ import { clone, walk, walkPost } from './util.js'
12
14
 
13
15
  // Fixpoint round caps — empirical convergence bounds, not correctness limits.
14
16
  // Each pass only makes monotonic progress, so hitting a cap merely leaves a few
@@ -18,38 +20,6 @@ const MAX_INLINE_ROUNDS = 16 // single-caller inline-chain depth (deep generate
18
20
 
19
21
  // === WAT optimizer passes ===
20
22
 
21
- // — AST helpers (formerly watr/util.js) — every node is an s-expression
22
- // array `[head, ...args]`; non-arrays are immediates.
23
- const clone = (node) => Array.isArray(node) ? node.map(clone) : node
24
-
25
- /** Walk depth-first pre-order, read-only. fn(node, parent, idx). */
26
- const walk = (node, fn, parent, idx) => {
27
- fn(node, parent, idx)
28
- if (Array.isArray(node)) for (let i = 0; i < node.length; i++) walk(node[i], fn, node, i)
29
- }
30
-
31
- /** Walk depth-first post-order. fn may return a replacement node or mutate in place. */
32
- const walkPost = (node, fn, parent, idx) => {
33
- if (Array.isArray(node)) for (let i = 0; i < node.length; i++) walkPost(node[i], fn, node, i)
34
- const result = fn(node, parent, idx)
35
- if (result !== undefined && parent) parent[idx] = result
36
- return result !== undefined ? result : node
37
- }
38
-
39
- /** Result value type of an op from its name prefix (formerly watr/const.js).
40
- * Comparisons/eqz on scalar int/float collapse to i32; else the name prefix. */
41
- const resultType = (op) => {
42
- if (typeof op !== 'string') return null
43
- const dot = op.indexOf('.')
44
- if (dot < 0) return null
45
- const prefix = op.slice(0, dot)
46
- const scalar = prefix === 'i32' || prefix === 'i64' || prefix === 'f32' || prefix === 'f64'
47
- if (scalar && /^(eqz?|ne|[lg][te])(_[su])?$/.test(op.slice(dot + 1))) return 'i32'
48
- if (scalar || prefix === 'v128') return prefix
49
- if (op === 'memory.size' || op === 'memory.grow') return 'i32'
50
- return null
51
- }
52
-
53
23
  /**
54
24
  * Recursively count AST nodes — fast size heuristic without compiling.
55
25
  * @param {any} node
@@ -147,13 +117,22 @@ const treeshake = (ast) => {
147
117
 
148
118
  let funcIdx = 0, globalIdx = 0, typeIdx = 0, tableIdx = 0, memIdx = 0
149
119
  const elems = [], data = [], exports = [], starts = []
120
+ // Highest index referenced by a bare NUMERAL per space, from surviving sites only.
121
+ // Removing entry i shifts every later index down, so numeric refs cap removal:
122
+ // only entries above the cap may be dropped (named refs re-resolve; numerals don't).
123
+ const numRef = { func: -1, global: -1, type: -1, table: -1, memory: -1 }
124
+ // Inline-import defs ((global $g (import …) …)) occupy the import-first region of the
125
+ // binary index space, so declaration-order idx diverges from binary idx — numeric
126
+ // comparisons are unreliable there. Numeric refs + inline imports → freeze the space.
127
+ const inlineImport = { func: false, global: false, type: false }
150
128
 
151
129
  for (const node of ast.slice(1)) {
152
130
  if (!Array.isArray(node)) continue
153
131
  const kind = node[0]
132
+ const inlImp = node.some(s => Array.isArray(s) && s[0] === 'import')
154
133
  if (kind === 'type') register(types, node, typeIdx++)
155
- else if (kind === 'func') register(funcs, node, funcIdx++)
156
- else if (kind === 'global') register(globals, node, globalIdx++)
134
+ else if (kind === 'func') register(funcs, node, funcIdx++), inlImp && (inlineImport.func = true)
135
+ else if (kind === 'global') register(globals, node, globalIdx++), inlImp && (inlineImport.global = true)
157
136
  else if (kind === 'table') register(tables, node, tableIdx++)
158
137
  else if (kind === 'memory') register(memories, node, memIdx++)
159
138
  else if (kind === 'import') {
@@ -172,20 +151,23 @@ const treeshake = (ast) => {
172
151
  else if (kind === 'data') data.push(node)
173
152
  }
174
153
 
175
- // Worklist: function entries whose body still needs to be scanned for refs.
154
+ // Worklist: entries (funcs, globals, tables, types) whose node awaits a ref scan.
176
155
  const work = []
177
156
  const enqueue = (entry) => { if (entry && !entry.scanned) work.push(entry) }
178
- const markFunc = (ref) => {
179
- const e = funcs.get(ref); if (!e) return
180
- if (!e.used) e.used = true
181
- enqueue(e)
157
+ // Resolve a ref, coercing bare numerals ('3' → 3) onto the shared idx key and
158
+ // recording the numeric-removal cap for the space.
159
+ const deref = (map, space, ref) => {
160
+ if (typeof ref === 'string' && ref[0] !== '$' && ref !== '' && !isNaN(ref)) ref = +ref
161
+ if (typeof ref === 'number') numRef[space] = Math.max(numRef[space], ref)
162
+ return map.get(ref)
182
163
  }
183
- const markGlobal = (ref) => { const e = globals.get(ref); if (e) e.used = true }
184
- const markTable = (ref) => { const e = tables.get(ref); if (e) e.used = true }
185
- const markMemory = (ref) => { if (typeof ref === 'string' && ref[0] !== '$') ref = +ref; const e = memories.get(ref); if (e) e.used = true }
186
- const markType = (ref) => { const e = types.get(ref); if (e) e.used = true }
164
+ const markFunc = (ref) => { const e = deref(funcs, 'func', ref); if (e) e.used = true, enqueue(e) }
165
+ const markGlobal = (ref) => { const e = deref(globals, 'global', ref); if (e) e.used = true, enqueue(e) }
166
+ const markTable = (ref) => { const e = deref(tables, 'table', ref); if (e) e.used = true, enqueue(e) }
167
+ const markMemory = (ref) => { const e = deref(memories, 'memory', ref); if (e) e.used = true }
168
+ const markType = (ref) => { const e = deref(types, 'type', ref); if (e) e.used = true, enqueue(e) }
187
169
 
188
- // Roots: explicit exports, start funcs, elem-referenced funcs, inline-exported items.
170
+ // Roots: explicit exports, start funcs, elem/data segments, inline-exported items.
189
171
  for (const exp of exports) {
190
172
  for (const sub of exp) {
191
173
  if (!Array.isArray(sub)) continue
@@ -196,50 +178,101 @@ const treeshake = (ast) => {
196
178
  else if (kind === 'memory') markMemory(ref)
197
179
  }
198
180
  }
199
- for (const start of starts) {
200
- let ref = start[1]
201
- if (typeof ref === 'string' && ref[0] !== '$') ref = +ref
202
- markFunc(ref)
181
+ // A start func with an empty body is a no-op: drop the (start) root itself and
182
+ // let ordinary liveness collect the func (renumbering absorbs the index shift).
183
+ const START_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
184
+ const emptyBody = (fn) => {
185
+ let b = typeof fn[1] === 'string' && fn[1][0] === '$' ? 2 : 1
186
+ while (b < fn.length && Array.isArray(fn[b]) && START_HEAD.has(fn[b][0])) b++
187
+ return b >= fn.length
188
+ }
189
+ const deadStarts = new Set()
190
+ for (const st of starts) {
191
+ const e = deref(funcs, 'func', st[1])
192
+ if (e && !e.isImport && emptyBody(e.node)) deadStarts.add(st)
193
+ else markFunc(st[1])
203
194
  }
195
+ const elemTarget = new Map() // active elem node → target table entry (write-only edge, not liveness)
196
+ const elemKind = new Map() // elem node → 'active' | 'passive' | 'declare'
204
197
  for (const elem of elems) {
205
- walk(elem, n => {
206
- if (Array.isArray(n) && n[0] === 'ref.func') markFunc(n[1])
207
- else if (typeof n === 'string' && n[0] === '$') markFunc(n)
208
- })
198
+ // (elem declare? (table t)? offset-expr? reftype? item*) — items are bare
199
+ // $names/numerals or (item …)/(ref.func …) exprs; offsets may read globals.
200
+ let target, active = false
201
+ if (elem.includes('declare')) elemKind.set(elem, 'declare')
202
+ for (const part of elem.slice(1)) {
203
+ if (Array.isArray(part)) {
204
+ // the TARGET ref is a write edge — it caps the index space (deref) but does
205
+ // not make the table live; only reads (code/exports) do
206
+ if (part[0] === 'table') target = deref(tables, 'table', part[1])
207
+ else {
208
+ if (part[0] === 'offset' || (part[0] !== 'item' && typeof part[0] === 'string' && !part[0].startsWith('ref'))) active = true
209
+ walk(part, n => {
210
+ if (Array.isArray(n) && n[0] === 'ref.func') markFunc(n[1])
211
+ else if (Array.isArray(n) && n[0] === 'global.get') markGlobal(n[1])
212
+ else if (typeof n === 'string' && n[0] === '$') markFunc(n)
213
+ })
214
+ }
215
+ }
216
+ else if (typeof part === 'string' && part !== 'func' && part !== 'declare' && (part[0] === '$' || !isNaN(part))) markFunc(part)
217
+ }
218
+ if (active) elemTarget.set(elem, target ?? tables.get(0))
219
+ if (!elemKind.has(elem)) elemKind.set(elem, active ? 'active' : 'passive')
209
220
  }
210
221
  for (const d of data) {
211
222
  const first = d[1]
212
223
  if (Array.isArray(first) && first[0] === 'memory') markMemory(first[1])
213
224
  else if (typeof first === 'string' && first[0] === '$') markMemory(first)
214
225
  else if (Array.isArray(first)) markMemory(0)
226
+ walk(d, n => { if (Array.isArray(n) && n[0] === 'global.get') markGlobal(n[1]) })
215
227
  }
216
228
  for (const m of [funcs, globals, tables, memories]) for (const e of m.values()) if (e.used) enqueue(e)
217
229
 
218
- // If nothing anchors the module (no exports, start, elem, or inline exports),
219
- // assume the module is consumed elsewhere and keep everything.
220
- const hasAnchor = exports.length > 0 || starts.length > 0 || elems.length > 0 || work.length > 0
221
- if (!hasAnchor) {
222
- for (const m of [funcs, globals, tables, memories]) for (const e of m.values()) e.used = true
223
- return ast
224
- }
225
-
226
- // Drain worklist: each function body gets walked exactly once.
230
+ // Drain worklist: each live node (func body, global/table init, type def) is
231
+ // walked exactly once. IMM (the instruction registry's immediate types) says
232
+ // which index space an op's first immediate addresses one source of truth
233
+ // for call/ref.func/global.get/struct.new/call_ref/…, named or numeric.
234
+ let elemIdxUsed = false // table.init/elem.drop consume element indices forbids elem removal
235
+ let flatForm = false // bare instruction tokens in bodies — operands unattributable
236
+ const refFunced = new Set() // funcs referenced by ref.func in live code — they must STAY declared
227
237
  while (work.length) {
228
238
  const entry = work.pop()
229
239
  if (entry.scanned) continue
230
240
  entry.scanned = true
231
241
  if (entry.isImport) continue
232
- walk(entry.node, n => {
242
+ walk(entry.node, (n, parent, idx) => {
243
+ if (Array.isArray(n)) {
244
+ const op = n[0]
245
+ if (op === 'table.init' || op === 'elem.drop' || op === 'array.new_elem' || op === 'array.init_elem') elemIdxUsed = true
246
+ else if (op === 'ref.func') refFunced.add(deref(funcs, 'func', n[1]))
247
+ }
248
+ // a bare token OUTSIDE op position is flat-form usage whose operands we can't
249
+ // attribute — freeze segment removal and index renumbering
250
+ else if (typeof n === 'string' && idx !== 0 && OPCODE[n] !== undefined) {
251
+ flatForm = true
252
+ if (n === 'table.init' || n === 'elem.drop' || n === 'array.new_elem' || n === 'array.init_elem' || n === 'ref.func') elemIdxUsed = true
253
+ }
233
254
  if (!Array.isArray(n)) {
234
- if (typeof n === 'string' && n[0] === '$') markFunc(n)
255
+ // Bare $name (flat-form immediate, label, any position): the flat style
256
+ // gives no structure to say which space it addresses — mark them all.
257
+ // A named global.set target is exempt: a write alone doesn't keep a
258
+ // global alive (its decl + sets are dropped together below).
259
+ if (typeof n === 'string' && n[0] === '$' && !(parent?.[0] === 'global.set' && idx === 1))
260
+ markFunc(n), markGlobal(n), markTable(n), markMemory(n), markType(n)
235
261
  return
236
262
  }
237
263
  const [op, ref] = n
238
- if (op === 'call' || op === 'return_call' || op === 'ref.func') markFunc(ref)
239
- else if (op === 'global.get' || op === 'global.set') markGlobal(ref)
240
- else if (op === 'type') markType(ref)
241
- else if (op === 'call_indirect' || op === 'return_call_indirect') {
242
- for (const sub of n) if (typeof sub === 'string' && sub[0] === '$') markTable(sub)
264
+ if (op === 'type') return markType(ref)
265
+ if (op === 'call_indirect' || op === 'return_call_indirect') {
266
+ for (const sub of n) if (typeof sub === 'string' && sub[0] === '$') return markTable(sub)
267
+ return markTable(0) // implicit table 0
268
+ }
269
+ if (op === 'global.set') { if (!(typeof ref === 'string' && ref[0] === '$')) markGlobal(ref); return }
270
+ const imm = typeof op === 'string' ? IMM[op] : null
271
+ if (imm) {
272
+ if (imm.startsWith('funcidx')) markFunc(ref)
273
+ else if (imm.startsWith('globalidx')) markGlobal(ref)
274
+ else if (imm.startsWith('typeidx')) markType(ref)
275
+ else if (imm.startsWith('tableidx')) markTable(ref)
243
276
  }
244
277
  if (typeof op === 'string' && (op.startsWith('memory.') || op.includes('.load') || op.includes('.store'))) {
245
278
  markMemory(0)
@@ -247,26 +280,110 @@ const treeshake = (ast) => {
247
280
  })
248
281
  }
249
282
 
283
+ // Removal gate. func/global/table indices are RENUMBERED after filtering, so their
284
+ // numeric refs don't gate removal — unless bodies use flat tokens (operands
285
+ // unattributable) or inline imports skew declaration order vs binary order. Types
286
+ // keep the cap: type refs embed in type definitions, ref annotations and field
287
+ // types, far beyond what the renumberer rewrites.
288
+ const renumberable = (space) => (space === 'func' || space === 'global' || space === 'table') && !flatForm && !inlineImport[space]
289
+ const cap = (space) => inlineImport[space] && numRef[space] >= 0 ? Infinity : numRef[space]
290
+ const droppable = (sub, space) => {
291
+ const e = nodeMap.get(sub)
292
+ return e && !e.used && (renumberable(space) || e.idx > cap(space))
293
+ }
294
+
295
+ // Dead tables: never read by code or exports — their active elem segments only fill
296
+ // unobservable slots, so table + segments go together (funcs those segments pinned
297
+ // are re-judged next round). Element-index consumers forbid segment removal.
298
+ const dropNodes = new Set()
299
+ if (!elemIdxUsed) {
300
+ for (const e of new Set(tables.values())) {
301
+ if (e.used || e.isImport || e.idx <= cap('table')) continue
302
+ dropNodes.add(e.node)
303
+ for (const [elem, t] of elemTarget) if (t === e) dropNodes.add(elem)
304
+ }
305
+ // With no element-index consumers, a passive segment is unreachable outright and a
306
+ // declare segment carries no runtime data — BUT both also serve as the declaration
307
+ // that validates in-code ref.func. Droppable only when every ref.func'd function in
308
+ // the segment stays anchored elsewhere (export or surviving active segment).
309
+ const elemFuncs = (elem) => {
310
+ const out = []
311
+ walk(elem, n => { const e = typeof n === 'string' && n !== 'declare' && n !== 'func' && (n[0] === '$' || !isNaN(n)) ? deref(funcs, 'func', n) : Array.isArray(n) && n[0] === 'ref.func' ? deref(funcs, 'func', n[1]) : null; if (e) out.push(e) })
312
+ return out
313
+ }
314
+ const anchored = new Set()
315
+ for (const [elem, kind] of elemKind) if (kind === 'active' && !dropNodes.has(elem)) for (const e of elemFuncs(elem)) anchored.add(e)
316
+ for (const exp of exports) for (const sub of exp) if (Array.isArray(sub) && sub[0] === 'func') { const e = deref(funcs, 'func', sub[1]); if (e) anchored.add(e) }
317
+ for (const [elem, kind] of elemKind) {
318
+ if (kind !== 'passive' && kind !== 'declare') continue
319
+ if (elemFuncs(elem).every(e => !refFunced.has(e) || anchored.has(e))) dropNodes.add(elem)
320
+ }
321
+ }
322
+
250
323
  // Filter: keep used definitions. nodeMap handles unnamed entries directly.
251
324
  const result = ['module']
325
+ const deadGlobals = new Set() // named write-only globals whose decl is dropped
252
326
  for (const node of ast.slice(1)) {
253
327
  if (!Array.isArray(node)) { result.push(node); continue }
328
+ if (dropNodes.has(node) || deadStarts.has(node)) continue
254
329
  const kind = node[0]
255
330
  if (kind === 'func' || kind === 'global' || kind === 'type') {
256
- if (nodeMap.get(node)?.used) result.push(node)
331
+ if (!droppable(node, kind)) result.push(node)
332
+ else if (kind === 'global' && typeof node[1] === 'string' && node[1][0] === '$') deadGlobals.add(node[1])
257
333
  } else if (kind === 'import') {
258
- // Keep import only if any of its sub-items is used.
259
- let used = false
260
- for (const sub of node) {
261
- if (!Array.isArray(sub)) continue
262
- const e = nodeMap.get(sub)
263
- if (e?.used) { used = true; break }
264
- }
265
- if (used) result.push(node)
334
+ // Keep import unless every tracked sub-item is droppable (untracked kinds — tag — stay).
335
+ const subs = node.filter(sub => Array.isArray(sub) && nodeMap.has(sub))
336
+ if (!subs.length || subs.some(sub => !droppable(sub, sub[0]))) result.push(node)
266
337
  } else {
267
338
  result.push(node)
268
339
  }
269
340
  }
341
+ // Neuter writes to dropped write-only globals: (global.set $dead V) → (drop V)
342
+ // (vacuum erases the drop when V is pure).
343
+ if (deadGlobals.size) walkPost(result, n => {
344
+ if (Array.isArray(n) && n[0] === 'global.set' && deadGlobals.has(n[1])) return ['drop', n[2]]
345
+ })
346
+
347
+ // Renumber surviving bare-numeric refs for the shifted spaces. New indices come
348
+ // from the RESULT's declaration order (imports interleave in watr's index model);
349
+ // named refs re-resolve on their own.
350
+ const remap = { func: new Map(), global: new Map(), table: new Map() }
351
+ const counters = { func: 0, global: 0, table: 0 }
352
+ const note = (node, space) => { const e = nodeMap.get(node); e ? remap[space].set(e.idx, counters[space]++) : counters[space]++ }
353
+ for (const node of result.slice(1)) {
354
+ if (!Array.isArray(node)) continue
355
+ const k = node[0]
356
+ if (k === 'func' || k === 'global' || k === 'table') note(node, k)
357
+ else if (k === 'import') for (const sub of node)
358
+ if (Array.isArray(sub) && (sub[0] === 'func' || sub[0] === 'global' || sub[0] === 'table')) note(sub, sub[0])
359
+ }
360
+ const shifted = (space) => renumberable(space) && [...remap[space]].some(([o, n]) => o !== n)
361
+ if (shifted('func') || shifted('global') || shifted('table')) {
362
+ const isNum = (r) => typeof r === 'number' || (typeof r === 'string' && r !== '' && r[0] !== '$' && !isNaN(r))
363
+ const renum = (space, ref) => {
364
+ if (!renumberable(space) || !isNum(ref)) return ref
365
+ const n = remap[space].get(+ref)
366
+ return n === undefined ? ref : typeof ref === 'number' ? n : String(n)
367
+ }
368
+ walkPost(result, n => {
369
+ if (!Array.isArray(n)) return
370
+ const op = n[0]
371
+ if (op === 'start' || op === 'ref.func' || op === 'call' || op === 'return_call') n[1] = renum('func', n[1])
372
+ else if (op === 'global.get' || op === 'global.set') n[1] = renum('global', n[1])
373
+ else if (op === 'export' && Array.isArray(n[2]) && remap[n[2][0]]) n[2][1] = renum(n[2][0], n[2][1])
374
+ else if (op === 'elem') for (let i = 1; i < n.length; i++) {
375
+ const part = n[i]
376
+ if (Array.isArray(part) && part[0] === 'table') part[1] = renum('table', part[1])
377
+ else if (typeof part === 'string' && part !== 'func' && part !== 'declare' && isNum(part)) n[i] = renum('func', part)
378
+ }
379
+ else if (op === 'call_indirect' || op === 'return_call_indirect') { if (isNum(n[1])) n[1] = renum('table', n[1]) }
380
+ else if (op === 'table.init') { if (n.length > 2) n[1] = renum('table', n[1]) }
381
+ else if (typeof op === 'string' && IMM[op] && IMM[op].startsWith('tableidx')) {
382
+ n[1] = renum('table', n[1])
383
+ if (IMM[op] === 'tableidx_tableidx') n[2] = renum('table', n[2])
384
+ }
385
+ })
386
+ }
270
387
  return result
271
388
  }
272
389
 
@@ -601,23 +718,13 @@ const makeConst = (type, value) => {
601
718
  * @param {Array} ast
602
719
  * @returns {Array}
603
720
  */
604
- const fold = (ast) => {
605
- return walkPost(ast, (node) => {
606
- if (!Array.isArray(node)) return
607
- const fn = FOLDABLE[node[0]]
608
- if (!fn) return
609
-
610
- // Arity comes from the NODE — every WAT op is fixed-arity, so node.length
611
- // fully determines unary vs binary. NEVER from Function.length: the
612
- // self-host kernel's closures don't carry a faithful `.length`, and the
613
- // old `fn.length === 1/2` checks silently disabled ALL folding in-kernel
614
- // (the L2 self-host divergence — unfolded consts fed the vectorizer
615
- // shapes native never produces).
616
- // Unary
617
- if (node.length === 2) {
618
- // NaN-payload reinterprets fold at the TEXT level — the payload double
619
- // must never ride as a raw f64 value (see _nanBitsHex). Applies in both
620
- // directions: nan: literal → i64 bits, and NaN-pattern i64 → nan: literal.
721
+ // NaN-payload reinterprets fold at the TEXT level — the payload double must never
722
+ // ride as a raw f64 value (see _nanBitsHex). Applies in both directions: nan:
723
+ // literal → i64 bits, and NaN-pattern i64 → nan: literal. These are MANDATORY
724
+ // (correctness, not size — the i64 sleb may out-size the f64 form), so the driver
725
+ // runs them once up front, keeping the rounds size-monotone for the guard.
726
+ const nanFoldNode = (node) => {
727
+ if (!Array.isArray(node) || node.length !== 2) return
621
728
  if (node[0] === 'i64.reinterpret_f64') {
622
729
  const inner = node[1]
623
730
  if (Array.isArray(inner) && inner.length === 2 && inner[0] === 'f64.const' && typeof inner[1] === 'string') {
@@ -636,11 +743,66 @@ const fold = (ast) => {
636
743
  ((hi & 0x80000000) !== 0 ? '-' : '') + 'nan:0x' + (hi & 0xfffff).toString(16).padStart(5, '0') + h.slice(8)]
637
744
  }
638
745
  }
746
+ }
747
+
748
+ // Associative-chain reassociation: (op (op x C1) C2) → (op x (C1 <combine> C2))
749
+ // when x is NOT itself const (the all-const case is ordinary folding). sub is
750
+ // non-commutative but still associates against its own const RHS:
751
+ // (x − C1) − C2 = x − (C1 + C2), so sub's combine step is add, not sub.
752
+ const CHAIN_COMBINE = {
753
+ 'i32.add': 'i32.add', 'i32.sub': 'i32.add', 'i32.and': 'i32.and', 'i32.or': 'i32.or', 'i32.xor': 'i32.xor',
754
+ 'i64.add': 'i64.add', 'i64.sub': 'i64.add', 'i64.and': 'i64.and', 'i64.or': 'i64.or', 'i64.xor': 'i64.xor',
755
+ }
756
+ const chainFoldNode = (node) => {
757
+ if (node.length !== 3) return
758
+ const op = node[0], inner = node[1]
759
+ if (!Array.isArray(inner) || inner.length !== 3) return
760
+ let combineOp = null
761
+ if (inner[0] === op) combineOp = CHAIN_COMBINE[op]
762
+ else if (CHAIN_COMBINE[op]) {
763
+ // mixed add/sub chains associate too: (sub (add x C1) C2) = add x (C1−C2),
764
+ // (add (sub x C1) C2) = sub x (C1−C2) — the output keeps the INNER op
765
+ const t = op.slice(0, 4)
766
+ if ((op === t + 'add' && inner[0] === t + 'sub') || (op === t + 'sub' && inner[0] === t + 'add'))
767
+ combineOp = t + 'sub'
768
+ }
769
+ if (!combineOp) return
770
+ if (getConst(inner[1])) return // fully const — the ordinary fold path handles it
771
+ const c1 = getConst(inner[2]), c2 = getConst(node[2])
772
+ if (!c1 || !c2) return
773
+ const r = FOLDABLE[combineOp](c1.value, c2.value)
774
+ if (r === null || r === undefined) return
775
+ const out = makeConst(resultType(op), r)
776
+ // never inflate: 2 opcodes + 2 consts become 1 opcode + 1 const — gate on the
777
+ // consts alone (strictly conservative; the opcode is always a net win)
778
+ if (constInstrSize(out) > constInstrSize(inner[2]) + constInstrSize(node[2])) return
779
+ return [inner[0], inner[1], out]
780
+ }
781
+
782
+ const foldNode = (node) => {
783
+ if (!Array.isArray(node)) return
784
+ const nan = nanFoldNode(node)
785
+ if (nan) return nan
786
+ const chain = chainFoldNode(node)
787
+ if (chain) return chain
788
+ const fn = FOLDABLE[node[0]]
789
+ if (!fn) return
790
+ // Arity comes from the NODE — every WAT op is fixed-arity, so node.length
791
+ // fully determines unary vs binary (never Function.length: self-host closures
792
+ // don't carry a faithful one).
793
+ // Unary
794
+ if (node.length === 2) {
639
795
  const a = getConst(node[1])
640
796
  if (!a) return
641
797
  const r = fn(a.value)
642
798
  if (r === null || r === undefined) return
643
- return makeConst(resultType(node[0]), r)
799
+ // Never inflate: a fixed-width f32/f64.const (5/9 B) or wide-sleb i64.const can
800
+ // out-size the 1-byte op + small const it replaces — (f32.convert_i32_s
801
+ // (i32.const 22)) is 4 B, (f32.const 22) is 5 B. The driver's mayInline fast
802
+ // path (and standalone pass callers) rely on fold being monotonic.
803
+ const out = makeConst(resultType(node[0]), r)
804
+ if (constInstrSize(out) > 1 + constInstrSize(node[1])) return
805
+ return out
644
806
  }
645
807
  // Binary
646
808
  if (node.length === 3) {
@@ -648,10 +810,15 @@ const fold = (ast) => {
648
810
  if (!a || !b) return
649
811
  const r = fn(a.value, b.value)
650
812
  if (r === null || r === undefined) return
651
- return makeConst(resultType(node[0]), r)
813
+ // same monotonicity gate: e.g. (i64.shl (i64.const 1) (i64.const 63)) is 5 B,
814
+ // its folded i64.const is 11 B
815
+ const out = makeConst(resultType(node[0]), r)
816
+ if (constInstrSize(out) > 1 + constInstrSize(node[1]) + constInstrSize(node[2])) return
817
+ return out
652
818
  }
653
- })
654
819
  }
820
+ /** Constant folding as a standalone pass. */
821
+ const fold = (ast) => walkPost(ast, foldNode)
655
822
 
656
823
  // ==================== IDENTITY REMOVAL ====================
657
824
 
@@ -708,21 +875,57 @@ const IDENTITIES = {
708
875
  // f * 1 → x (careful with NaN, skip for floats)
709
876
  }
710
877
 
878
+ // Unary cast round-trips `outer(inner(x)) → x`. Each pair is bit-for-bit identity:
879
+ // reinterpret∘reinterpret — a value bit-cast to the other repr and back is unchanged.
880
+ // wrap_i64∘extend_i32_{s,u} — extend fills the high 32 bits, wrap drops them, low 32 = x.
881
+ // Generic wasm identities (Binaryen folds them); a NaN-box language leans on them heavily,
882
+ // since (un)boxing a pointer is exactly a wrap∘reinterpret∘reinterpret∘extend chain that
883
+ // collapses to nothing once these fire bottom-up.
884
+ const ROUNDTRIP = {
885
+ 'i64.reinterpret_f64': 'f64.reinterpret_i64',
886
+ 'f64.reinterpret_i64': 'i64.reinterpret_f64',
887
+ 'i32.reinterpret_f32': 'f32.reinterpret_i32',
888
+ 'f32.reinterpret_i32': 'i32.reinterpret_f32',
889
+ 'i32.wrap_i64': new Set(['i64.extend_i32_u', 'i64.extend_i32_s']),
890
+ }
891
+
711
892
  /**
712
893
  * Remove identity operations.
713
894
  * @param {Array} ast
714
895
  * @returns {Array}
715
896
  */
716
- const identity = (ast) => {
717
- return walkPost(ast, (node) => {
718
- if (!Array.isArray(node) || node.length !== 3) return
897
+ // Integer comparison complements: eqz(REL a b) INVERT(REL) a b — ints partition the
898
+ // whole domain with no third outcome. Floats are EXCLUDED: eqz(lt(NaN,x))=1 but ge(NaN,x)=0.
899
+ const INVERT = {
900
+ 'i32.eq': 'i32.ne', 'i32.ne': 'i32.eq', 'i32.lt_s': 'i32.ge_s', 'i32.ge_s': 'i32.lt_s',
901
+ 'i32.lt_u': 'i32.ge_u', 'i32.ge_u': 'i32.lt_u', 'i32.gt_s': 'i32.le_s', 'i32.le_s': 'i32.gt_s',
902
+ 'i32.gt_u': 'i32.le_u', 'i32.le_u': 'i32.gt_u',
903
+ 'i64.eq': 'i64.ne', 'i64.ne': 'i64.eq', 'i64.lt_s': 'i64.ge_s', 'i64.ge_s': 'i64.lt_s',
904
+ 'i64.lt_u': 'i64.ge_u', 'i64.ge_u': 'i64.lt_u', 'i64.gt_s': 'i64.le_s', 'i64.le_s': 'i64.gt_s',
905
+ 'i64.gt_u': 'i64.le_u', 'i64.le_u': 'i64.gt_u',
906
+ }
907
+
908
+ const identityNode = (node) => {
909
+ if (!Array.isArray(node)) return
910
+ // (i32.eqz (REL a b)) → (INVREL a b) — one byte, same operands, same order
911
+ if (node[0] === 'i32.eqz' && node.length === 2 && Array.isArray(node[1]) && INVERT[node[1][0]] && node[1].length === 3)
912
+ return [INVERT[node[1][0]], node[1][1], node[1][2]]
913
+ // Unary cast round-trip: outer(inner(x)) → x (post-order, so an inner pair already
914
+ // collapsed before the outer op sees it — the whole box/unbox chain unwinds in one walk).
915
+ if (node.length === 2 && Array.isArray(node[1]) && node[1].length === 2) {
916
+ const inv = ROUNDTRIP[node[0]]
917
+ if (inv && (typeof inv === 'string' ? node[1][0] === inv : inv.has(node[1][0]))) return node[1][1]
918
+ return
919
+ }
920
+ if (node.length !== 3) return
719
921
  const fn = IDENTITIES[node[0]]
720
922
  if (!fn) return
721
923
  const result = fn(node[1], node[2])
722
924
  if (result === null) return // no optimization, keep original
723
925
  return result
724
- })
725
926
  }
927
+ /** Identity elimination as a standalone pass. */
928
+ const identity = (ast) => walkPost(ast, identityNode)
726
929
 
727
930
  // ==================== STRENGTH REDUCTION ====================
728
931
 
@@ -731,8 +934,7 @@ const identity = (ast) => {
731
934
  * @param {Array} ast
732
935
  * @returns {Array}
733
936
  */
734
- const strength = (ast) => {
735
- return walkPost(ast, (node) => {
937
+ const strengthNode = (node) => {
736
938
  if (!Array.isArray(node) || node.length !== 3) return
737
939
  const [op, a, b] = node
738
940
 
@@ -786,8 +988,9 @@ const strength = (ast) => {
786
988
  if (vb != null && vb > 0n && (vb & (vb - 1n)) === 0n)
787
989
  return ['i64.and', a, ['i64.const', '0x' + _i64Hex16(vb - 1n)]]
788
990
  }
789
- })
790
991
  }
992
+ /** Strength reduction as a standalone pass. */
993
+ const strength = (ast) => walkPost(ast, strengthNode)
791
994
 
792
995
  // ==================== BRANCH SIMPLIFICATION ====================
793
996
 
@@ -797,6 +1000,87 @@ const strength = (ast) => {
797
1000
  * @returns {Array}
798
1001
  */
799
1002
  const branch = (ast) => {
1003
+ // if-arm value threading: (if C (then (local.set $x A)) (else (local.set $x B)))
1004
+ // → (local.set $x (if (result T) C (then A) (else B))) — the set happens on both
1005
+ // paths anyway, and the value-form if is one select-promotion away from collapsing.
1006
+ // Needs the local's declared type for the result annotation, hence the per-func walk.
1007
+ walk(ast, (fn) => {
1008
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
1009
+ const ltype = new Map()
1010
+ for (const c of fn)
1011
+ if (Array.isArray(c) && (c[0] === 'local' || c[0] === 'param') && typeof c[1] === 'string' && typeof c[2] === 'string') ltype.set(c[1], c[2])
1012
+ if (!ltype.size) return
1013
+ walkPost(fn, (node) => {
1014
+ if (!Array.isArray(node) || node[0] !== 'if' || node.length !== 4) return
1015
+ const { cond, thenBranch, elseBranch } = parseIf(node)
1016
+ if (!Array.isArray(cond) || !(thenBranch?.length >= 2) || !(elseBranch?.length >= 2)) return
1017
+ const a = thenBranch[thenBranch.length - 1], b = elseBranch[elseBranch.length - 1]
1018
+ if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3 || !Array.isArray(a[2])) return
1019
+ if (!Array.isArray(b) || b[0] !== 'local.set' || b.length !== 3 || b[1] !== a[1] || !Array.isArray(b[2])) return
1020
+ const t = ltype.get(a[1])
1021
+ if (typeof t !== 'string' || !/^([if](32|64)|v128)$/.test(t)) return
1022
+ // an early exit inside an arm would skip the hoisted set — arms must be branch-free
1023
+ let jumps = false
1024
+ walk([thenBranch, elseBranch], n => {
1025
+ const o = Array.isArray(n) ? n[0] : n
1026
+ if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' ||
1027
+ o === 'throw' || o === 'return_call' || o === 'return_call_indirect' || o === 'try_table') jumps = true
1028
+ })
1029
+ if (jumps) return
1030
+ return ['local.set', a[1], ['if', ['result', t], cond,
1031
+ ['then', ...thenBranch.slice(1, -1), a[2]],
1032
+ ['else', ...elseBranch.slice(1, -1), b[2]]]]
1033
+ })
1034
+ })
1035
+ // dead local round-trip: (if C (then STMTS (local.set $x A))) with NO else, sitting
1036
+ // as the function's second-to-last statement, immediately followed by the function's
1037
+ // own tail (local.get $x) — when that trailing get is $x's ONLY read anywhere in the
1038
+ // function, $x never needs to leave the stack at all: fuse the pair into one tail
1039
+ // if-expression (explicit else = keep $x), dropping the local.set that fed the
1040
+ // trailing get. A `(i32.eqz Y)` condition additionally swaps arms to drop the eqz
1041
+ // (i32 only — an i64 value is not a valid if condition).
1042
+ walk(ast, (fn) => {
1043
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
1044
+ const bodyStart = typeof fn[1] === 'string' && fn[1][0] === '$' ? 2 : 1
1045
+ const results = fn.reduce((k, c) => Array.isArray(c) && c[0] === 'result' ? k + c.length - 1 : k, 0)
1046
+ if (results !== 1 || fn.length < bodyStart + 2) return
1047
+ const last = fn[fn.length - 1], prev = fn[fn.length - 2]
1048
+ if (!Array.isArray(last) || last[0] !== 'local.get' || last.length !== 2) return
1049
+ const x = last[1]
1050
+ if (typeof x !== 'string' || x[0] !== '$') return
1051
+ if (fn.some(c => Array.isArray(c) && c[0] === 'param' && c[1] === x)) return
1052
+ if (!Array.isArray(prev) || prev[0] !== 'if') return
1053
+ const { cond, thenBranch, elseBranch } = parseIf(prev)
1054
+ if (elseBranch || !Array.isArray(cond) || !(thenBranch?.length >= 2)) return
1055
+ const a = thenBranch[thenBranch.length - 1]
1056
+ if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3 || a[1] !== x || !Array.isArray(a[2])) return
1057
+ let t = null
1058
+ for (const c of fn) if (Array.isArray(c) && (c[0] === 'local' || c[0] === 'param') && c[1] === x) t = c[2]
1059
+ if (typeof t !== 'string' || !/^([if](32|64)|v128)$/.test(t)) return
1060
+ // an early exit inside the arm would skip the fused tail value — must be branch-free
1061
+ let jumps = false
1062
+ walk(thenBranch, n => {
1063
+ const o = Array.isArray(n) ? n[0] : n
1064
+ if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' ||
1065
+ o === 'throw' || o === 'return_call' || o === 'return_call_indirect' || o === 'try_table') jumps = true
1066
+ })
1067
+ if (jumps) return
1068
+ // $x must be dead outside this exact pair: its only static read anywhere in the
1069
+ // function is the trailing get we're about to consume (a `local.tee $x` inside
1070
+ // `cond` is a legitimate write for the condition test, untouched by this fusion)
1071
+ const uses = countLocalUses(fn).get(x)
1072
+ if (!uses || uses.gets !== 1) return
1073
+ let ifCond = cond
1074
+ let thenArm = ['then', ...thenBranch.slice(1, -1), a[2]]
1075
+ let elseArm = ['else', ['local.get', x]]
1076
+ if (Array.isArray(cond) && cond.length === 2 && cond[0] === 'i32.eqz') {
1077
+ ifCond = cond[1]
1078
+ const swappedThen = ['then', ...elseArm.slice(1)]
1079
+ elseArm = ['else', ...thenArm.slice(1)]
1080
+ thenArm = swappedThen
1081
+ }
1082
+ fn.splice(fn.length - 2, 2, ['if', ['result', t], ifCond, thenArm, elseArm])
1083
+ })
800
1084
  return walkPost(ast, (node) => {
801
1085
  if (!Array.isArray(node)) return
802
1086
  const op = node[0]
@@ -806,7 +1090,45 @@ const branch = (ast) => {
806
1090
  if (op === 'if') {
807
1091
  const { condIdx, cond, thenBranch, elseBranch } = parseIf(node)
808
1092
  const c = getConst(cond)
809
- if (!c) return
1093
+ // (if (result T) c (then A) (else B)) → (select A B c) for small pure numeric
1094
+ // arms — drops ~3 B of block framing per site. select evaluates BOTH arms, so
1095
+ // each must be pure AND trap-free (no int div/rem, no float→int trunc), and
1096
+ // cheap enough that speculating it costs nothing.
1097
+ if (!c) {
1098
+ if (!Array.isArray(cond)) return
1099
+ const rt = node.find(p => Array.isArray(p) && p[0] === 'result')
1100
+ if (!rt || rt.length !== 2 || !/^[if](32|64)$/.test(rt[1])) return
1101
+ if (thenBranch?.length !== 2 || elseBranch?.length !== 2) return
1102
+ const a = thenBranch[1], b = elseBranch[1]
1103
+ if (!isPure(a) || !isPure(b) || count(a) > 6 || count(b) > 6) return
1104
+ // speculation gate: the if ran only the TAKEN arm — select runs both, so a
1105
+ // trap-capable op in either arm (int div/rem, float trunc, and LOADS — an
1106
+ // out-of-bounds address on the untaken path is a brand-new trap) is out
1107
+ if (hasTrap(a) || hasTrap(b) || readsMemory(a) || readsMemory(b)) return
1108
+ // evaluation-order gate: the if evaluated its CONDITION first, select
1109
+ // evaluates it LAST — an arm must not read anything the condition writes
1110
+ // (a tee'd local, a set global, memory a callee stores to), else the arm
1111
+ // sees pre-condition state (a NaN-boxed local read before its tee fed the
1112
+ // kernel a stale pointer — one wrong byte in a self-hosted compile)
1113
+ if (!isPure(cond)) {
1114
+ const aw = scanVal(a), bw = scanVal(b)
1115
+ let clash = false
1116
+ walk(cond, (n, p2, i2) => {
1117
+ if (!Array.isArray(n)) { if (i2 !== 0 && typeof n === 'string' && OPCODE[n] !== undefined) clash = true; return }
1118
+ const o = n[0]
1119
+ if (typeof o !== 'string') return
1120
+ if ((o === 'local.set' || o === 'local.tee') && (aw.refs.has(n[1]) || bw.refs.has(n[1]))) clash = true
1121
+ else if (o === 'global.set' && (aw.grefs.has(n[1]) || bw.grefs.has(n[1]))) clash = true
1122
+ else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect') {
1123
+ const e = callFx(n)
1124
+ if (!e) { if (aw.grefs.size || bw.grefs.size) clash = true }
1125
+ else if ([...aw.grefs, ...bw.grefs].some(g => e.wGlob.has(g))) clash = true
1126
+ }
1127
+ })
1128
+ if (clash) return
1129
+ }
1130
+ return ['select', a, b, cond]
1131
+ }
810
1132
  const taken = c.value !== 0 && c.value !== ZERO64 ? thenBranch : elseBranch
811
1133
  if (taken && taken.length > 1) {
812
1134
  const contents = taken.slice(1)
@@ -833,12 +1155,18 @@ const branch = (ast) => {
833
1155
 
834
1156
  // (select a b (i32.const 0)) → b
835
1157
  // (select a b (i32.const N)) → a (N != 0)
1158
+ // `select` evaluates BOTH arms before choosing, so a side effect in the DISCARDED
1159
+ // arm (a `local.tee`/`local.set`, store, or call) must still happen — folding to the
1160
+ // kept arm would drop it (e.g. `p=0` compiled as the else arm of `cond?0:p`). Only
1161
+ // fold when the discarded arm is pure; otherwise leave the select for a later pass.
836
1162
  if (op === 'select' && node.length >= 4) {
837
1163
  const cond = node[node.length - 1]
838
1164
  const c = getConst(cond)
839
1165
  if (!c) return
840
- if (c.value === 0 || c.value === ZERO64) return node[2] // b
841
- return node[1] // a
1166
+ const zero = c.value === 0 || c.value === ZERO64
1167
+ const keep = zero ? node[2] : node[1], discard = zero ? node[1] : node[2]
1168
+ if (!isPure(discard)) return
1169
+ return keep
842
1170
  }
843
1171
  })
844
1172
  }
@@ -1074,46 +1402,35 @@ const deadcode = (ast) => {
1074
1402
  }
1075
1403
 
1076
1404
  /**
1077
- * Remove instructions after terminators within a block.
1405
+ * Remove instructions after the first terminator within a block.
1406
+ *
1407
+ * Depth-aware of WAT's flat (unfolded) control style, which parse keeps as bare
1408
+ * sibling STRINGS: 'block'/'loop'/'if' open a sub-block whose matching bare 'end'
1409
+ * closes a live branch-landing label — a terminator inside it only ends that
1410
+ * sub-block's own tail, and anything after a bare 'end'/'else' is reachable again
1411
+ * (Duff's-device br_table dispatch relies on exactly this). So a cut only starts
1412
+ * at depth 0 and is cancelled by any later flat landing token.
1413
+ * Bare 'br'/'br_table' strings carry their immediates as following siblings, so
1414
+ * they never start a cut (their extent is unknowable here); folded arrays and the
1415
+ * immediate-less 'return'/'unreachable' do.
1078
1416
  * @param {Array} block
1079
1417
  */
1080
1418
  const eliminateDeadInBlock = (block) => {
1081
- let terminated = false
1082
- let firstTerminator = -1
1083
-
1419
+ let cut = -1, depth = 0
1084
1420
  for (let i = 1; i < block.length; i++) {
1085
- const node = block[i]
1086
-
1087
- // Skip type annotations
1088
- if (Array.isArray(node)) {
1089
- const op = node[0]
1090
- if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
1091
-
1092
- if (terminated) {
1093
- if (firstTerminator === -1) firstTerminator = i
1094
- }
1095
-
1096
- if (TERMINATORS.has(op)) {
1097
- terminated = true
1098
- firstTerminator = i + 1
1099
- }
1100
- } else if (typeof node === 'string') {
1101
- // String instructions like 'unreachable', 'return', 'drop', 'nop'
1102
- if (terminated) {
1103
- if (firstTerminator === -1) firstTerminator = i
1104
- }
1105
-
1106
- if (TERMINATORS.has(node)) {
1107
- terminated = true
1108
- firstTerminator = i + 1
1109
- }
1421
+ const node = block[i], op = Array.isArray(node) ? node[0] : node
1422
+ if (typeof op !== 'string') continue
1423
+ // skip head annotations
1424
+ if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
1425
+ if (typeof node === 'string') {
1426
+ if (op === 'block' || op === 'loop' || op === 'if') { depth++; continue }
1427
+ if (op === 'end') { depth && depth--, cut = -1; continue }
1428
+ if (op === 'else') { cut = -1; continue }
1110
1429
  }
1430
+ if (depth || cut >= 0) continue
1431
+ if (TERMINATORS.has(op) && (Array.isArray(node) || op === 'return' || op === 'unreachable')) cut = i + 1
1111
1432
  }
1112
-
1113
- // Remove dead code
1114
- if (firstTerminator > 0 && firstTerminator < block.length) {
1115
- block.splice(firstTerminator)
1116
- }
1433
+ if (cut > 0 && cut < block.length) block.splice(cut)
1117
1434
  }
1118
1435
 
1119
1436
  // ==================== LOCAL REUSE ====================
@@ -1155,23 +1472,54 @@ const localReuse = (ast) => {
1155
1472
  }
1156
1473
 
1157
1474
  // Find which locals are actually used
1475
+ let numericRef = false
1158
1476
  walk(node, (n) => {
1159
1477
  if (!Array.isArray(n)) return
1160
1478
  const op = n[0]
1161
1479
  if (op === 'local.get' || op === 'local.set' || op === 'local.tee') {
1162
1480
  const ref = n[1]
1163
- if (typeof ref === 'string') usedLocals.add(ref)
1481
+ if (typeof ref === 'string') { usedLocals.add(ref); if (ref[0] !== '$') numericRef = true }
1482
+ else if (typeof ref === 'number') { usedLocals.add(String(ref)); numericRef = true }
1164
1483
  }
1165
1484
  })
1166
1485
 
1167
- // Remove unused local declarations
1168
- for (let i = localDecls.length - 1; i >= 0; i--) {
1486
+ // Remove unused named declarations — but any bare-numeric ref pins the whole
1487
+ // index layout (removal would shift every later slot), so only the trailing
1488
+ // prune below applies then.
1489
+ if (!numericRef) for (let i = localDecls.length - 1; i >= 0; i--) {
1169
1490
  const { idx, node: decl } = localDecls[i]
1170
1491
  const name = typeof decl[1] === 'string' && decl[1][0] === '$' ? decl[1] : null
1171
1492
  if (name && !usedLocals.has(name)) {
1172
1493
  node.splice(idx, 1)
1173
1494
  }
1174
1495
  }
1496
+
1497
+ // Trailing UNNAMED slots prune from the end only — no later index can shift.
1498
+ let params = 0
1499
+ for (const sub of node) if (Array.isArray(sub) && sub[0] === 'param')
1500
+ params += typeof sub[1] === 'string' && sub[1][0] === '$' ? 1 : sub.length - 1
1501
+ const decls = []
1502
+ for (let i = 1; i < node.length; i++) if (Array.isArray(node[i]) && node[i][0] === 'local') decls.push(node[i])
1503
+ let slot = params
1504
+ const slotOf = new Map() // decl → first slot index
1505
+ for (const d of decls) {
1506
+ slotOf.set(d, slot)
1507
+ slot += typeof d[1] === 'string' && d[1][0] === '$' ? 1 : d.length - 1
1508
+ }
1509
+ for (let i = decls.length - 1; i >= 0; i--) {
1510
+ const d = decls[i]
1511
+ if (typeof d[1] === 'string' && d[1][0] === '$') {
1512
+ if (usedLocals.has(d[1]) || usedLocals.has(String(slotOf.get(d)))) break
1513
+ node.splice(node.indexOf(d), 1)
1514
+ continue
1515
+ }
1516
+ let len = d.length
1517
+ while (len > 1 && !usedLocals.has(String(slotOf.get(d) + len - 2))) len--
1518
+ if (len === d.length) break
1519
+ d.length = len
1520
+ if (len === 1) node.splice(node.indexOf(d), 1)
1521
+ else break
1522
+ }
1175
1523
  })
1176
1524
 
1177
1525
  return ast
@@ -1201,6 +1549,9 @@ const IMPURE_SUBSTRINGS = ['.store', 'memory.', '.atomic.']
1201
1549
  * Conservative — returns false for anything that might trap, mutate state, or branch.
1202
1550
  */
1203
1551
  const isPure = (node) => {
1552
+ // A bare string can be a stack-style INSTRUCTION token ('return', 'drop', …), not
1553
+ // just an immediate — judge it by the same op tables as the folded form.
1554
+ if (typeof node === 'string') return !IMPURE_OPS.has(node) && !IMPURE_SUBSTRINGS.some(s => node.includes(s))
1204
1555
  if (!Array.isArray(node)) return true
1205
1556
  const op = node[0]
1206
1557
  if (typeof op !== 'string') return false
@@ -1210,6 +1561,75 @@ const isPure = (node) => {
1210
1561
  return true
1211
1562
  }
1212
1563
 
1564
+ // ==================== INTERPROCEDURAL EFFECT SUMMARY ====================
1565
+ // Transitive per-function write effects: does calling $f (and everything it can
1566
+ // reach) write linear memory, or which globals can it set? Any escape from the
1567
+ // analyzable world — an imported func, call_indirect, a table/exception op, a
1568
+ // numeric or unresolvable callee — marks the function unknown (= writes
1569
+ // everything). Computed once per optimize() entry; passes only ever use it to
1570
+ // RELAX a conservative default, and optimization never adds effects to a body
1571
+ // that didn't have them, so a stale summary stays sound.
1572
+ let CALLFX = null
1573
+
1574
+ const computeCallEffects = (ast) => {
1575
+ const fx = new Map() // name → { wMem, wGlob:Set, unknown, calls:Set }
1576
+ if (!Array.isArray(ast) || ast[0] !== 'module') return fx
1577
+ for (const n of ast.slice(1)) {
1578
+ if (!Array.isArray(n)) continue
1579
+ if (n[0] === 'import') {
1580
+ const f = n.find(c => Array.isArray(c) && c[0] === 'func')
1581
+ if (f && typeof f[1] === 'string' && f[1][0] === '$')
1582
+ fx.set(f[1], { wMem: true, rMem: true, wGlob: new Set(), rGlob: new Set(), unknown: true, calls: new Set() })
1583
+ continue
1584
+ }
1585
+ if (n[0] !== 'func' || typeof n[1] !== 'string' || n[1][0] !== '$') continue
1586
+ const e = { wMem: false, rMem: false, wGlob: new Set(), rGlob: new Set(), unknown: false, calls: new Set() }
1587
+ const op = (o, tgt) => {
1588
+ if (o === 'call' || o === 'return_call') { typeof tgt === 'string' && tgt[0] === '$' ? e.calls.add(tgt) : e.unknown = true }
1589
+ else if (o === 'call_indirect' || o === 'return_call_indirect' || o === 'throw' || o === 'throw_ref') e.unknown = true
1590
+ else if (o === 'global.set') { typeof tgt === 'string' ? e.wGlob.add(tgt) : e.unknown = true }
1591
+ else if (o === 'global.get') { typeof tgt === 'string' ? e.rGlob.add(tgt) : e.unknown = true }
1592
+ else if (o.includes('.store') || o === 'memory.copy' || o === 'memory.fill' ||
1593
+ o === 'memory.init' || o === 'memory.grow' || o.includes('table.') || o.includes('.atomic')) e.wMem = e.rMem = true
1594
+ else if (o.includes('.load') || o === 'memory.size') e.rMem = true
1595
+ }
1596
+ walk(n, (c, parent, idx) => {
1597
+ if (!Array.isArray(c)) {
1598
+ // bare flat tokens carry the same effects as their folded forms
1599
+ if (idx === 0 || typeof c !== 'string' || OPCODE[c] === undefined) return
1600
+ op(c, parent[idx + 1])
1601
+ return
1602
+ }
1603
+ if (typeof c[0] === 'string') op(c[0], c[1])
1604
+ })
1605
+ fx.set(n[1], e)
1606
+ }
1607
+ // propagate through call edges to a fixpoint (effects only grow — terminates)
1608
+ for (let dirty = true; dirty;) {
1609
+ dirty = false
1610
+ for (const e of fx.values()) {
1611
+ if (e.unknown) continue
1612
+ for (const callee of e.calls) {
1613
+ const t = fx.get(callee)
1614
+ if (!t) { e.unknown = true; dirty = true; break }
1615
+ if (t.unknown && !e.unknown) { e.unknown = true; dirty = true; break }
1616
+ if (t.wMem && !e.wMem) { e.wMem = true; dirty = true }
1617
+ if (t.rMem && !e.rMem) { e.rMem = true; dirty = true }
1618
+ for (const g of t.wGlob) if (!e.wGlob.has(g)) { e.wGlob.add(g); dirty = true }
1619
+ for (const g of t.rGlob) if (!e.rGlob.has(g)) { e.rGlob.add(g); dirty = true }
1620
+ }
1621
+ }
1622
+ }
1623
+ return fx
1624
+ }
1625
+
1626
+ /** Effect summary for a call NODE — null when the callee can't be summarized. */
1627
+ const callFx = (n) => {
1628
+ if (!CALLFX || !Array.isArray(n) || (n[0] !== 'call' && n[0] !== 'return_call') || typeof n[1] !== 'string') return null
1629
+ const e = CALLFX.get(n[1])
1630
+ return e && !e.unknown ? e : null
1631
+ }
1632
+
1213
1633
  // Structured / control-flow forms: they do NOT evaluate all children eagerly
1214
1634
  // (an `if` runs one arm; a `block`/`loop` scopes branches), so their side effects
1215
1635
  // can't be flattened to the children's — they stay whole under a drop. (`br*`,
@@ -1243,17 +1663,51 @@ const dropEffects = (node) => {
1243
1663
  }
1244
1664
 
1245
1665
  /** Count all local.get/set/tee occurrences in one walk */
1246
- const countLocalUses = (node) => {
1247
- const counts = new Map()
1248
- const ensure = name => { if (!counts.has(name)) counts.set(name, { gets: 0, sets: 0, tees: 0 }); return counts.get(name) }
1249
- walk(node, n => {
1250
- if (!Array.isArray(n) || n.length < 2 || typeof n[1] !== 'string') return
1251
- if (n[0] === 'local.get') ensure(n[1]).gets++
1252
- else if (n[0] === 'local.set') ensure(n[1]).sets++
1253
- else if (n[0] === 'local.tee') ensure(n[1]).tees++
1666
+ // One tally walker serves the from-scratch count AND the maintained-count
1667
+ // deltas a second implementation would drift.
1668
+ const tallyLocals = (node, counts, d) => {
1669
+ const ensure = name => { let c = counts.get(name); if (!c) counts.set(name, c = { gets: 0, sets: 0, tees: 0 }); return c }
1670
+ walk(node, (n, parent, idx) => {
1671
+ if (Array.isArray(n)) {
1672
+ if (n.length < 2 || typeof n[1] !== 'string') return
1673
+ if (n[0] === 'local.get') ensure(n[1]).gets += d
1674
+ else if (n[0] === 'local.set') ensure(n[1]).sets += d
1675
+ else if (n[0] === 'local.tee') ensure(n[1]).tees += d
1676
+ return
1677
+ }
1678
+ // bare flat form: `local.get` `$x` as sibling tokens (idx 0 is a folded
1679
+ // node's own head). Uncounted refs would let exact-occurrence passes treat
1680
+ // a flat-referenced local as dead.
1681
+ if (idx > 0 && (n === 'local.get' || n === 'local.set' || n === 'local.tee')) {
1682
+ const tgt = parent[idx + 1]
1683
+ if (typeof tgt === 'string' && tgt[0] === '$')
1684
+ ensure(tgt)[n === 'local.get' ? 'gets' : n === 'local.set' ? 'sets' : 'tees'] += d
1685
+ }
1254
1686
  })
1255
1687
  return counts
1256
1688
  }
1689
+ const countLocalUses = (node) => tallyLocals(node, new Map(), 1)
1690
+
1691
+ // Maintained whole-function use-counts for the propagate family: every mutation
1692
+ // site reports the removed/inserted subtree instead of the driver re-counting
1693
+ // the function each round. Null outside propagate — the helpers no-op. The
1694
+ // recount ORACLE (globalThis.__CNT_ORACLE) re-derives from scratch after every
1695
+ // sub-pass and throws on divergence; the test battery runs with it enabled.
1696
+ let CNT = null, CNT_FN = null
1697
+ const cntSub = (node) => { if (CNT) tallyLocals(node, CNT, -1) }
1698
+ const cntAdd = (node) => { if (CNT) tallyLocals(node, CNT, 1) }
1699
+ const cntOracle = (funcNode, site) => {
1700
+ if (!CNT || !globalThis.__CNT_ORACLE) return
1701
+ const fresh = countLocalUses(funcNode)
1702
+ const names = new Set([...fresh.keys(), ...CNT.keys()])
1703
+ for (const k of names) {
1704
+ const a = fresh.get(k) || { gets: 0, sets: 0, tees: 0 }
1705
+ const b = CNT.get(k) || { gets: 0, sets: 0, tees: 0 }
1706
+ if (a.gets !== b.gets || a.sets !== b.sets || a.tees !== b.tees) {
1707
+ throw Error(`counts drift after ${site} in ${funcNode[1]}: ${k} fresh ${JSON.stringify(a)} maintained ${JSON.stringify(b)}`)
1708
+ }
1709
+ }
1710
+ }
1257
1711
 
1258
1712
  /** A constant whose inlined form (opcode + immediate) is no wider than the ~2 B
1259
1713
  * `local.get` it would replace — so propagating it to every use is byte-neutral
@@ -1290,11 +1744,7 @@ const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val) || k.copy
1290
1744
 
1291
1745
  /** Drop tracked values that read `$name`: rewriting `$name` makes them stale. */
1292
1746
  const purgeRefs = (known, name) => {
1293
- for (const [key, tracked] of known) {
1294
- let refs = false
1295
- walk(tracked.val, n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === name) refs = true })
1296
- if (refs) known.delete(key)
1297
- }
1747
+ for (const [key, tracked] of known) if (tracked.refs.has(name)) known.delete(key)
1298
1748
  }
1299
1749
 
1300
1750
  /** Drop tracked values that read global `$name`: a `global.set $name` makes them stale.
@@ -1303,11 +1753,25 @@ const purgeRefs = (known, name) => {
1303
1753
  * intervening `f = …` and substitute the NEW global. That silently breaks the canonical
1304
1754
  * pointer swap `let s = f; f = g; g = s` (g would read post-swap f, i.e. itself). */
1305
1755
  const purgeGlobalRefs = (known, name) => {
1306
- for (const [key, tracked] of known) {
1307
- let refs = false
1308
- walk(tracked.val, n => { if (Array.isArray(n) && n[0] === 'global.get' && n[1] === name) refs = true })
1309
- if (refs) known.delete(key)
1310
- }
1756
+ for (const [key, tracked] of known) if (tracked.grefs.has(name)) known.delete(key)
1757
+ }
1758
+
1759
+ /** One walk over a tracked value collecting every fact the invalidation paths ask
1760
+ * later: which locals/globals it reads, whether it reads memory, and whether it
1761
+ * reads any state a call could mutate (memory, globals, tables, nested calls). */
1762
+ const scanVal = (val) => {
1763
+ const refs = new Set(), grefs = new Set()
1764
+ let mem = false, ext = false
1765
+ walk(val, n => {
1766
+ if (!Array.isArray(n)) return
1767
+ const o = n[0]
1768
+ if (o === 'local.get' || o === 'local.tee') { if (typeof n[1] === 'string') refs.add(n[1]) }
1769
+ else if (o === 'global.get') { if (typeof n[1] === 'string') grefs.add(n[1]); ext = true }
1770
+ else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect' ||
1771
+ o === 'table.get' || o === 'table.size') ext = true
1772
+ else if (typeof o === 'string' && (o.includes('.load') || o === 'memory.copy' || o === 'memory.size')) mem = ext = true
1773
+ })
1774
+ return { refs, grefs, mem, ext }
1311
1775
  }
1312
1776
 
1313
1777
  /** True if `node` recursively contains an op that may read linear memory.
@@ -1345,7 +1809,7 @@ const writesMemory = (node) => {
1345
1809
  if (!Array.isArray(node)) return false
1346
1810
  const op = node[0]
1347
1811
  if (typeof op === 'string') {
1348
- if (op.endsWith('.store') || op === 'memory.copy' || op === 'memory.fill' || op === 'memory.init') return true
1812
+ if (op.includes('.store') || op === 'memory.copy' || op === 'memory.fill' || op === 'memory.init' || op === 'memory.grow') return true
1349
1813
  // Atomic RMW / store / notify all mutate memory; `.atomic.load` doesn't.
1350
1814
  if (op.includes('.atomic.') && !op.endsWith('.load')) return true
1351
1815
  }
@@ -1383,7 +1847,7 @@ const substGets = (node, known) => {
1383
1847
  }
1384
1848
  for (let i = 1; i < node.length; i++) {
1385
1849
  const r = substGets(node[i], inner)
1386
- if (r !== node[i]) node[i] = r
1850
+ if (r !== node[i]) { cntSub(node[i]); cntAdd(r); node[i] = r }
1387
1851
  // WASM evaluates operands left-to-right. A `local.set`/`local.tee` in this
1388
1852
  // child updates the local before the next sibling reads it — drop tracked
1389
1853
  // entries that are now stale, else a pre-tee constant leaks into the next
@@ -1418,10 +1882,56 @@ const forwardPropagate = (funcNode, params, useCounts) => {
1418
1882
  let changed = false
1419
1883
  const getUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
1420
1884
  const known = new Map()
1885
+ // depth of flat (bare-token) control nesting; each binding records the depth it
1886
+ // was created at — see the bare-token barrier below for why that bounds validity
1887
+ let depth = 0
1421
1888
 
1422
1889
  for (let i = 1; i < funcNode.length; i++) {
1423
1890
  const instr = funcNode[i]
1424
- if (!Array.isArray(instr)) continue
1891
+ // Flat-form instructions live as bare sibling TOKENS, invisible to the array
1892
+ // handlers below (wax: folded exprs inside a flat control skeleton). Only
1893
+ // tokens that can invalidate a binding are barriers; bare VALUE ops can't
1894
+ // touch locals, so tracking flows straight across them. Validity across flat
1895
+ // control rests on one structural fact: wasm forward-branches only to ends of
1896
+ // ENCLOSING blocks, so any path that skips a write also skips every later
1897
+ // point inside the write's block — a binding is path-valid anywhere within
1898
+ // the flat block that created it, and dies at that block's 'end'/'else'.
1899
+ // A 'loop' back-edge re-executes an unknown suffix, so only bindings whose
1900
+ // local has no other write and whose value reads no mutable state (locals,
1901
+ // globals, memory, calls) may cross a loop header.
1902
+ if (!Array.isArray(instr)) {
1903
+ if (typeof instr !== 'string') continue
1904
+ if (instr === 'block' || instr === 'if') depth++
1905
+ else if (instr === 'loop') {
1906
+ depth++
1907
+ for (const [key, t] of known) {
1908
+ const uses = getUseCount(key)
1909
+ if (!(uses.sets + uses.tees <= 1 && t.pure && !t.refs.size && !t.ext)) known.delete(key)
1910
+ }
1911
+ }
1912
+ else if (instr === 'else') { for (const [key, t] of known) if (t.depth >= depth) known.delete(key) }
1913
+ else if (instr === 'end') { depth = depth > 0 ? depth - 1 : 0; for (const [key, t] of known) if (t.depth > depth) known.delete(key) }
1914
+ else if (instr === 'try' || instr === 'try_table') { depth++; known.clear() }
1915
+ else if (instr === 'delegate') { depth = depth > 0 ? depth - 1 : 0; known.clear() }
1916
+ else if (instr === 'catch' || instr === 'catch_all') known.clear()
1917
+ else if (instr === 'local.set' || instr === 'local.tee') {
1918
+ const tgt = funcNode[i + 1]
1919
+ if (typeof tgt === 'string') { known.delete(tgt); purgeRefs(known, tgt) }
1920
+ else known.clear() // numeric index may alias a tracked name — give up
1921
+ }
1922
+ else if (instr === 'global.set') {
1923
+ const tgt = funcNode[i + 1]
1924
+ typeof tgt === 'string' ? purgeGlobalRefs(known, tgt) : known.clear()
1925
+ }
1926
+ else if (instr === 'call' || instr === 'call_indirect' || instr === 'return_call' || instr === 'return_call_indirect') {
1927
+ for (const [key, tracked] of known) if (tracked.ext) known.delete(key)
1928
+ }
1929
+ else if (instr.includes('.store') || instr === 'memory.copy' || instr === 'memory.fill' || instr === 'memory.init' ||
1930
+ (instr.includes('.atomic.') && !instr.endsWith('.load'))) {
1931
+ for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
1932
+ }
1933
+ continue
1934
+ }
1425
1935
  const op = instr[0]
1426
1936
 
1427
1937
  if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
@@ -1433,7 +1943,7 @@ const forwardPropagate = (funcNode, params, useCounts) => {
1433
1943
  // resolves to a substitution (bare `(local.get $x)` root case) — assign
1434
1944
  // back so the bare-RHS pattern actually propagates.
1435
1945
  const sr = substGets(instr[2], known)
1436
- if (sr !== instr[2]) { instr[2] = sr; changed = true }
1946
+ if (sr !== instr[2]) { cntSub(instr[2]); cntAdd(sr); instr[2] = sr; changed = true }
1437
1947
  // Nested `local.set`/`local.tee` inside the RHS already ran when the next
1438
1948
  // statement begins — drop tracked values that read those locals, else a
1439
1949
  // later `local.get` substitutes a stale expression (e.g. `$ptr`'s
@@ -1452,29 +1962,49 @@ const forwardPropagate = (funcNode, params, useCounts) => {
1452
1962
  if (writesMemory(instr[2])) {
1453
1963
  for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
1454
1964
  }
1965
+ const facts = scanVal(instr[2])
1455
1966
  known.set(instr[1], {
1456
1967
  val: instr[2], pure: isPure(instr[2]),
1457
- readsMem: readsMemory(instr[2]),
1968
+ refs: facts.refs, grefs: facts.grefs, readsMem: facts.mem, ext: facts.ext,
1458
1969
  singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0,
1459
- copy: isLocalCopy(instr[2], instr[1])
1970
+ copy: isLocalCopy(instr[2], instr[1]),
1971
+ depth
1460
1972
  })
1461
1973
  continue
1462
1974
  }
1463
1975
 
1976
+ // An if's TEST evaluates before the branch is entered — substitute into it
1977
+ // with the pre-branch knowledge before invalidating. substGets mutates
1978
+ // INTERIOR nodes in place and returns the same root — a root compare misses
1979
+ // those, and an unreported substitution skips the driver's use-count refresh,
1980
+ // handing sinkSets stale counts (its single-use substitute then deletes a
1981
+ // store whose second, freshly-substituted read still exists: an orphaned get
1982
+ // reading zero — one wrong NaN-boxed byte in a self-hosted compile).
1983
+ if (op === 'if') {
1984
+ const { condIdx, cond } = parseIf(instr)
1985
+ if (Array.isArray(cond)) {
1986
+ const prev = clone(cond)
1987
+ const r = substGets(cond, known)
1988
+ if (r !== cond) { cntSub(cond); cntAdd(r); instr[condIdx] = r; changed = true }
1989
+ else if (!equal(prev, cond)) changed = true
1990
+ }
1991
+ }
1464
1992
  // Invalidate at control-flow boundaries
1465
1993
  if (isBranchScope(op)) known.clear()
1466
1994
  // Calls invalidate tracked values that read state a callee can mutate
1467
1995
  // (memory, globals, tables, nested calls). Pure expressions over locals
1468
1996
  // and constants survive — callees can't reach caller locals.
1469
1997
  if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect')
1470
- for (const [key, tracked] of known) if (readsCallableState(tracked.val)) known.delete(key)
1998
+ for (const [key, tracked] of known) if (tracked.ext) known.delete(key)
1471
1999
 
1472
2000
  // Substitute: standalone local.get (walkPost can't replace root)
1473
2001
  if (op === 'local.get' && instr.length === 2 && typeof instr[1] === 'string') {
1474
2002
  const tracked = known.get(instr[1])
1475
2003
  if (tracked && canSubst(tracked)) {
1476
2004
  const replacement = clone(tracked.val)
2005
+ cntSub(instr)
1477
2006
  instr.length = 0; instr.push(...(Array.isArray(replacement) ? replacement : [replacement]))
2007
+ cntAdd(instr)
1478
2008
  changed = true; continue
1479
2009
  }
1480
2010
  }
@@ -1510,63 +2040,423 @@ const forwardPropagate = (funcNode, params, useCounts) => {
1510
2040
  }
1511
2041
 
1512
2042
  /**
1513
- * Remove adjacent (local.set $x expr) (local.get $x) pairs when $x has no other uses.
1514
- * Returns true if any pair was removed.
1515
- * @param {Array} funcNode
2043
+ * A `(local.set $x V)` (pure V) immediately before a `loop` is dead when the write can
2044
+ * never be observed: (a) inside the loop, every path from the top reaches a write of $x
2045
+ * before any read — checked by an evaluation-order scan where if-arms fork (a read
2046
+ * counts against EITHER arm, a def only when BOTH arms guarantee it), nested loops and
2047
+ * try_table are opaque (reads fail, defs aren't credited), br/br_table/return end their
2048
+ * path, br_if falls through, and any br inside a block resets the def state after it —
2049
+ * and (b) $x is read NOWHERE outside that loop body, so the loop's exit-time value is
2050
+ * unobservable regardless of which exit path ran (the counterexample class: a zero-trip
2051
+ * inner loop leaking the previous outer iteration's value to a post-loop read).
2052
+ */
2053
+ const deadThroughLoop = (funcNode, name, loop) => {
2054
+ let total = 0, inside = 0
2055
+ walk(funcNode, n => { if (Array.isArray(n) && n[0] === 'local.get' && n[1] === name) total++ })
2056
+ walk(loop, n => { if (Array.isArray(n) && n[0] === 'local.get' && n[1] === name) inside++ })
2057
+ if (total !== inside) return false // (b) — read outside the loop may observe it
2058
+ const readsAny = (n) => { let r = false; walk(n, c => { if (Array.isArray(c) && c[0] === 'local.get' && c[1] === name) r = true }); return r }
2059
+ const hasBr = (n) => { let r = false; walk(n, c => { const o = Array.isArray(c) ? c[0] : c; if (o === 'br' || o === 'br_if' || o === 'br_table') r = true }); return r }
2060
+ const scanList = (list, from, def) => {
2061
+ for (let i = from; i < list.length; i++) {
2062
+ const r = scanExpr(list[i], def)
2063
+ if (!r.ok) return r
2064
+ def = r.def
2065
+ if (r.end) return { ok: true, def, end: true }
2066
+ }
2067
+ return { ok: true, def }
2068
+ }
2069
+ const scanExpr = (n, def) => {
2070
+ if (!Array.isArray(n)) {
2071
+ if (typeof n === 'string' && OPCODE[n] !== undefined) return { ok: false } // flat token — order unattributable
2072
+ return { ok: true, def }
2073
+ }
2074
+ const op = n[0]
2075
+ if (op === 'param' || op === 'result' || op === 'type' || op === 'local' || op === 'export') return { ok: true, def }
2076
+ if (op === 'local.get') return n[1] === name && !def ? { ok: false } : { ok: true, def }
2077
+ if (op === 'local.set' || op === 'local.tee') {
2078
+ const r = n.length > 2 ? scanExpr(n[2], def) : { ok: true, def }
2079
+ if (!r.ok || r.end) return r
2080
+ return { ok: true, def: r.def || n[1] === name }
2081
+ }
2082
+ if (op === 'if') {
2083
+ const { condIdx, thenBranch, elseBranch } = parseIf(n)
2084
+ let d = def
2085
+ for (let k = 1; k <= condIdx && k < n.length; k++) {
2086
+ if (n[k] === thenBranch || n[k] === elseBranch) continue
2087
+ const r = scanExpr(n[k], d)
2088
+ if (!r.ok || r.end) return r
2089
+ d = r.def
2090
+ }
2091
+ const t = thenBranch ? scanList(thenBranch, 1, d) : { ok: true, def: d }
2092
+ if (!t.ok) return t
2093
+ const e = elseBranch ? scanList(elseBranch, 1, d) : { ok: true, def: d }
2094
+ if (!e.ok) return e
2095
+ return { ok: true, def: (t.end ? d : t.def) && (e.end ? d : e.def) }
2096
+ }
2097
+ if (op === 'loop' || op === 'try_table') return readsAny(n) ? { ok: false } : { ok: true, def }
2098
+ if (op === 'block') {
2099
+ const r = scanList(n, 1, def)
2100
+ if (!r.ok) return r
2101
+ // brs inside a block reconverge after it: a def already made before the block
2102
+ // survives; one made inside only counts when nothing could jump around it
2103
+ return { ok: true, def: def || (r.def && !hasBr(n)) }
2104
+ }
2105
+ if (op === 'br' || op === 'br_table' || op === 'return' || op === 'unreachable' || op === 'throw' ||
2106
+ op === 'return_call' || op === 'return_call_indirect') {
2107
+ let d = def
2108
+ for (let k = 1; k < n.length; k++) { const r = scanExpr(n[k], d); if (!r.ok) return r; d = r.def }
2109
+ return { ok: true, def: d, end: true }
2110
+ }
2111
+ let d = def
2112
+ for (let k = 1; k < n.length; k++) {
2113
+ const r = scanExpr(n[k], d)
2114
+ if (!r.ok || r.end) return r
2115
+ d = r.def
2116
+ }
2117
+ return { ok: true, def: d }
2118
+ }
2119
+ return scanList(loop, 1, false).ok
2120
+ }
2121
+
2122
+ /**
2123
+ * Sink (local.set $x V) into the next statement when that statement's FIRST-evaluated
2124
+ * instruction is (local.get $x): sole-use pairs substitute V outright, multi-use ones
2125
+ * fuse into (local.tee $x V). Subsumes the old adjacent-only set/get-pair and tee
2126
+ * passes — the get may sit arbitrarily deep (call argument, store address, br_if
2127
+ * condition) as long as it is on the first-evaluated path, which keeps V's
2128
+ * evaluation order identical.
2129
+ * @param {Array} funcNode - straight-line scope (body / block / then / else)
1516
2130
  * @param {Set<string>} params
1517
2131
  * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
1518
2132
  */
1519
- const eliminateSetGetPairs = (funcNode, params, useCounts) => {
1520
- let changed = false
2133
+ /** True if any conditionally- or repeatedly-entered construct appears anywhere in `n`
2134
+ * a `local.tee` found inside one does not dominate its following siblings. */
2135
+ const hasConditional = (n) => {
2136
+ let found = false
2137
+ walk(n, x => { if (Array.isArray(x) && (x[0] === 'if' || x[0] === 'loop' || x[0] === 'block' ||
2138
+ x[0] === 'then' || x[0] === 'else' || x[0] === 'try_table')) found = true })
2139
+ return found
2140
+ }
1521
2141
 
1522
- for (let i = 1; i < funcNode.length - 1; i++) {
1523
- const setNode = funcNode[i]
1524
- const getNode = funcNode[i + 1]
1525
- if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue
1526
- if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue
1527
- const name = setNode[1]
1528
- if (getNode[1] !== name || params.has(name)) continue
1529
- const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
1530
- // Must be exactly 1 set and 1 get (the pair), no tees
1531
- if (uses.sets !== 1 || uses.gets !== 1 || uses.tees !== 0) continue
1532
- // Replace the pair with just the expression
1533
- const expr = clone(setNode[2])
1534
- funcNode.splice(i, 2, ...(Array.isArray(expr) ? [expr] : [expr]))
2142
+ /**
2143
+ * Sink a pure, trap-free `(local.set $X Expr)` into the sole `if`-arm that reads it,
2144
+ * when it sits immediately before that `if`. An eagerly-computed pre-branch value that
2145
+ * only one arm ever consumes gains nothing from running on the other path — moving it
2146
+ * to be the arm's first statement is execution-order-neutral (it was already the next
2147
+ * thing to run on that path) and lets a later `sinkSets` sweep fuse it with the arm's
2148
+ * first use into a `tee`. Requires: a PURE condition (Expr crosses its evaluation — a
2149
+ * cond that writes state Expr reads would change the sunk value); exactly one arm
2150
+ * (not both, not neither) references $X; and $X has no other occurrence anywhere in
2151
+ * the function (so nothing after the `if`, and no other arm, needs it).
2152
+ */
2153
+ const sinkIntoBranch = (scope, params, counts) => {
2154
+ let changed = false
2155
+ for (let i = 1; i < scope.length - 1; i++) {
2156
+ const st = scope[i]
2157
+ if (!(Array.isArray(st) && st[0] === 'local.set' && st.length === 3 && typeof st[1] === 'string' &&
2158
+ Array.isArray(st[2]) && isPure(st[2]) && !hasTrap(st[2]))) continue
2159
+ const name = st[1]
2160
+ const nxt = scope[i + 1]
2161
+ if (!(Array.isArray(nxt) && nxt[0] === 'if')) continue
2162
+ const { cond, thenBranch, elseBranch } = parseIf(nxt)
2163
+ if (!Array.isArray(cond) || !isPure(cond)) continue
2164
+ let condTouches = false
2165
+ walk(cond, n => { if (Array.isArray(n) && typeof n[1] === 'string' && n[1] === name &&
2166
+ (n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee')) condTouches = true })
2167
+ if (condTouches) continue
2168
+ const touchCount = (branch) => {
2169
+ if (!branch) return 0
2170
+ let c = 0
2171
+ walk(branch, n => { if (Array.isArray(n) && typeof n[1] === 'string' && n[1] === name &&
2172
+ (n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee')) c++ })
2173
+ return c
2174
+ }
2175
+ const inThen = touchCount(thenBranch), inElse = touchCount(elseBranch)
2176
+ if ((inThen > 0) === (inElse > 0)) continue // must be exactly one arm
2177
+ const target = inThen ? thenBranch : elseBranch
2178
+ // total occurrences in the whole function must equal exactly this set + the
2179
+ // uses inside the target arm — nothing else anywhere reads/writes $X
2180
+ const total = counts.get(name)
2181
+ const totalOccur = total ? total.gets + total.sets + total.tees : 0
2182
+ if (totalOccur !== 1 + (inThen || inElse)) continue
2183
+ scope.splice(i, 1)
2184
+ target.splice(1, 0, st)
1535
2185
  changed = true
1536
- i-- // adjust index because we removed 2 and inserted 1
2186
+ i--
1537
2187
  }
1538
-
1539
2188
  return changed
1540
2189
  }
1541
2190
 
1542
2191
  /**
1543
- * Convert (local.set $x expr) (local.get $x) to (local.tee $x expr)
1544
- * when $x has additional uses beyond this pair.
1545
- * @param {Array} funcNode
1546
- * @param {Set<string>} params
1547
- * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
2192
+ * Fuse a plain copy `(local.set $A (local.get $B))` into an earlier, dominating
2193
+ * `(local.tee $B V)` found by scanning backward through a short run of straight-line
2194
+ * (branch-free) sibling statements: rename that tee's target to $A and drop the copy.
2195
+ * Sound when — $B is a non-param written exactly once (that tee) and read exactly
2196
+ * once (this copy, so nothing survives $B's name afterward); the scan never crosses
2197
+ * a statement containing a conditional/loop (a tee inside one wouldn't dominate);
2198
+ * and NOTHING between the tee and the copy touches $A — including the tee's own
2199
+ * statement (an $A operand evaluated after the tee would read the renamed write).
1548
2200
  */
1549
- const createLocalTees = (funcNode, params, useCounts) => {
2201
+ const mergeCopyThroughTee = (scope, params, counts) => {
2202
+ let changed = false
2203
+ for (let i = 1; i < scope.length; i++) {
2204
+ const st = scope[i]
2205
+ if (!(Array.isArray(st) && st[0] === 'local.set' && st.length === 3 &&
2206
+ Array.isArray(st[2]) && st[2][0] === 'local.get' && st[2].length === 2)) continue
2207
+ const A = st[1], B = st[2][1]
2208
+ if (typeof A !== 'string' || typeof B !== 'string' || A === B) continue
2209
+ if (params.has(B)) continue
2210
+ const cb = counts.get(B)
2211
+ if (!cb || cb.sets !== 0 || cb.tees !== 1 || cb.gets !== 1) continue
2212
+ let teeNode = null
2213
+ for (let j = i - 1; j >= 1 && j >= i - 5; j--) {
2214
+ const prev = scope[j]
2215
+ if (!Array.isArray(prev) || hasConditional(prev)) break
2216
+ let touchesA = false, tee = null
2217
+ walk(prev, n => {
2218
+ if (!Array.isArray(n)) return
2219
+ if (n[0] === 'local.tee' && n.length === 3 && n[1] === B) tee = n
2220
+ else if (typeof n[1] === 'string' && n[1] === A &&
2221
+ (n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee')) touchesA = true
2222
+ })
2223
+ if (tee) { if (!touchesA) teeNode = tee; break }
2224
+ if (touchesA) break
2225
+ }
2226
+ if (!teeNode) continue
2227
+ cntSub(teeNode); teeNode[1] = A; cntAdd(teeNode)
2228
+ cntSub(st)
2229
+ scope.splice(i, 1)
2230
+ changed = true
2231
+ i--
2232
+ }
2233
+ return changed
2234
+ }
2235
+
2236
+ // Two adjacent sets whose reads sit under one commutative integer node in
2237
+ // ANTI-order can never both sink — each value would have to cross the other's
2238
+ // effects. Swapping the node's (pure, trap-free) operands reorders the reads to
2239
+ // match statement order; the ordinary sink then fuses both, preserving the
2240
+ // original side-effect order. Pure arms make the swap itself unobservable, and
2241
+ // the anti-order trigger makes it idempotent.
2242
+ const COMMUTATIVE = new Set(['i32.add', 'i32.mul', 'i32.and', 'i32.or', 'i32.xor',
2243
+ 'i64.add', 'i64.mul', 'i64.and', 'i64.or', 'i64.xor'])
2244
+ const commuteForSink = (scope) => {
1550
2245
  let changed = false
2246
+ for (let i = 1; i < scope.length - 2; i++) {
2247
+ const a = scope[i], b = scope[i + 1], stmt = scope[i + 2]
2248
+ if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3 || typeof a[1] !== 'string') continue
2249
+ if (!Array.isArray(b) || b[0] !== 'local.set' || b.length !== 3 || typeof b[1] !== 'string' || a[1] === b[1]) continue
2250
+ if (!Array.isArray(stmt)) continue
2251
+ const gets = (sub, name) => { let k = 0; walk(sub, c => { if (Array.isArray(c) && c[0] === 'local.get' && c[1] === name) k++ }); return k }
2252
+ walk(stmt, n => {
2253
+ if (!Array.isArray(n) || !COMMUTATIVE.has(n[0]) || n.length !== 3) return
2254
+ const l = n[1], r = n[2]
2255
+ if (!Array.isArray(l) || !Array.isArray(r)) return
2256
+ if (gets(l, b[1]) && gets(r, a[1]) && !gets(l, a[1]) && !gets(r, b[1]) &&
2257
+ isPure(l) && isPure(r) && !hasTrap(l) && !hasTrap(r)) {
2258
+ n[1] = r; n[2] = l
2259
+ changed = true
2260
+ }
2261
+ })
2262
+ }
2263
+ return changed
2264
+ }
2265
+
2266
+ const sinkSets = (funcNode, params, useCounts) => {
2267
+ let changed = false
2268
+
2269
+ // Per-statement interference summaries, memoized by statement IDENTITY (splice-
2270
+ // proof): each candidate's bounded lookahead re-examined the same statements —
2271
+ // one walk per statement now serves every candidate that crosses it.
2272
+ const SINFO = new Map()
2273
+ const sinfo = (stmt) => {
2274
+ let S = SINFO.get(stmt)
2275
+ if (S) return S
2276
+ S = { flat: false, touch: new Set(), wL: new Set(), gS: new Set(), gSAny: false, gG: new Set(),
2277
+ calls: false, branchy: false, wMem: false, loadish: false, storeish: false }
2278
+ walk(stmt, (n, p2, i2) => {
2279
+ if (!Array.isArray(n)) { if (i2 !== 0 && typeof n === 'string' && OPCODE[n] !== undefined) S.flat = true; return }
2280
+ const o = n[0]
2281
+ if (typeof o !== 'string') return
2282
+ if (o === 'local.get' || o === 'local.set' || o === 'local.tee') {
2283
+ if (typeof n[1] === 'string') { S.touch.add(n[1]); if (o !== 'local.get') S.wL.add(n[1]) }
2284
+ }
2285
+ else if (o === 'global.set') { S.gSAny = true; if (typeof n[1] === 'string') S.gS.add(n[1]) }
2286
+ else if (o === 'global.get') { if (typeof n[1] === 'string') S.gG.add(n[1]) }
2287
+ else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect') S.calls = true
2288
+ else if (isBranchScope(o) || o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' || o === 'throw') S.branchy = true
2289
+ else {
2290
+ if (o.includes('.store') || o === 'memory.copy' || o === 'memory.fill' || o === 'memory.init' || o === 'memory.grow' ||
2291
+ (o.includes('.atomic.') && !o.endsWith('.load'))) S.wMem = true
2292
+ if (o.includes('.load') || o === 'memory.size') S.loadish = true
2293
+ if (o.includes('.store') || o === 'memory.copy' || o === 'memory.fill' || o === 'memory.init' || o === 'memory.grow' ||
2294
+ o.includes('.atomic') || o.includes('table.')) S.storeish = true
2295
+ }
2296
+ })
2297
+ SINFO.set(stmt, S)
2298
+ return S
2299
+ }
1551
2300
 
1552
2301
  for (let i = 1; i < funcNode.length - 1; i++) {
1553
2302
  const setNode = funcNode[i]
1554
- const getNode = funcNode[i + 1]
1555
- if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue
1556
- if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue
2303
+ // node[2] must be the folded value EXPRESSION — a bare string there is a
2304
+ // trailing stack-style instruction riding the same node, not a value
2305
+ if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3 || !Array.isArray(setNode[2])) continue
1557
2306
  const name = setNode[1]
1558
- if (getNode[1] !== name || params.has(name)) continue
2307
+ if (typeof name !== 'string') continue // params sink like locals: past this set the argument value is dead
2308
+ const val = setNode[2]
2309
+ // The landing site is the statement that first touches $name — a PURE value may
2310
+ // cross up to a few non-interfering statements to reach it (bounded lookahead:
2311
+ // the win class is near-adjacent; unbounded scans cost quadratic time for
2312
+ // nothing). Crossed statements must not write the value's inputs, and when the
2313
+ // value reads memory they must not write memory or call out.
2314
+ const vLocals = new Set(), vGlobals = new Set()
2315
+ walk(val, n => {
2316
+ if (!Array.isArray(n)) return
2317
+ if ((n[0] === 'local.get' || n[0] === 'local.tee') && typeof n[1] === 'string') vLocals.add(n[1])
2318
+ else if (n[0] === 'global.get' && typeof n[1] === 'string') vGlobals.add(n[1])
2319
+ })
2320
+ const vMem = readsMemory(val)
2321
+ const vPure = isPure(val)
2322
+ // A value impure ONLY through calls with a known read-only-ish summary may
2323
+ // still cross local-only statements — collect the union of callee effects,
2324
+ // or null when anything unsummarizable makes the value opaque.
2325
+ let vFx = null
2326
+ if (!vPure) {
2327
+ const u = { wMem: false, rMem: false, wGlob: new Set(), rGlob: new Set() }
2328
+ let opaque = false
2329
+ walk(val, (n, parent, idx) => {
2330
+ if (!Array.isArray(n)) { if (idx !== 0 && typeof n === 'string' && OPCODE[n] !== undefined) opaque = true; return }
2331
+ const o = n[0]
2332
+ if (typeof o !== 'string') { opaque = true; return }
2333
+ if (o === 'call' || o === 'return_call') {
2334
+ const e = callFx(n)
2335
+ if (!e) { opaque = true; return }
2336
+ u.wMem ||= e.wMem; u.rMem ||= e.rMem
2337
+ for (const g of e.wGlob) u.wGlob.add(g)
2338
+ for (const g of e.rGlob) u.rGlob.add(g)
2339
+ }
2340
+ else if (IMPURE_OPS.has(o) || IMPURE_SUBSTRINGS.some(sub => o.includes(sub))) opaque = true
2341
+ })
2342
+ if (!opaque) vFx = u
2343
+ }
2344
+ let hit = null, skipped = null, hitJ = -1
2345
+ for (let j = i + 1; j < funcNode.length && j <= i + 5; j++) {
2346
+ const stmt = funcNode[j]
2347
+ if (!Array.isArray(stmt)) break // flat token — order unattributable
2348
+ const h0 = stmt[0]
2349
+ if (h0 === 'param' || h0 === 'result' || h0 === 'local' || h0 === 'type' || h0 === 'export') continue
2350
+ const S = sinfo(stmt)
2351
+ if (S.touch.has(name)) {
2352
+ skipped = new Set()
2353
+ hitJ = j
2354
+ const state = { reads: false }
2355
+ hit = stmt[0] === 'local.get' && stmt[1] === name && stmt.length === 2
2356
+ ? [funcNode, j] : firstEvalGet(stmt, name, skipped, state)
2357
+ // crossed pure subtrees may read globals/memory — an effectful value must
2358
+ // not move past them unless its summarized callees write neither
2359
+ if (hit && state.reads && !vPure && !(vFx && !vFx.wMem && !vFx.wGlob.size)) hit = null
2360
+ break
2361
+ }
2362
+ // a PURE value crosses on interference rules alone; a summarized call-valued
2363
+ // one additionally requires the crossed statement to be free of OBSERVABLE
2364
+ // writes and calls (the call may trap — a skipped store would be visible
2365
+ // post-trap), with reads disjoint from the callee-side writes
2366
+ let bad = (!vPure && !vFx) || (vMem && S.wMem) || S.flat || S.branchy ||
2367
+ (S.calls && (vMem || vGlobals.size))
2368
+ if (!bad) for (const x of vLocals) if (S.wL.has(x)) { bad = true; break }
2369
+ if (!bad && S.gS.size) for (const x of vGlobals) if (S.gS.has(x)) { bad = true; break }
2370
+ if (!bad && !vPure) {
2371
+ bad = S.calls || S.gSAny || S.storeish || (vFx.wMem && S.loadish)
2372
+ if (!bad && vFx.wGlob.size) for (const g of S.gG) if (vFx.wGlob.has(g)) { bad = true; break }
2373
+ }
2374
+ if (bad) break
2375
+ }
2376
+ if (!hit) continue
2377
+ // the sunk value now evaluates AFTER the skipped leaves — it must not write them
2378
+ if (skipped.size) {
2379
+ let writes = false
2380
+ walk(setNode[2], n => { if (Array.isArray(n) && (n[0] === 'local.set' || n[0] === 'local.tee') && skipped.has(n[1])) writes = true })
2381
+ if (writes) continue
2382
+ }
1559
2383
  const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
1560
- // Only if there's more than just this set+get pair
1561
- if (uses.sets + uses.gets + uses.tees <= 2) continue
1562
- // Replace with local.tee (set+get combined)
1563
- funcNode.splice(i, 2, ['local.tee', name, clone(setNode[2])])
2384
+ // Sole set+get pair substitute the value outright (decl dies next sweep);
2385
+ // otherwise fuse into a tee at the get site. Either way the value's evaluation
2386
+ // point is unchanged — the get was the first instruction executed after the set.
2387
+ let single = uses.sets === 1 && uses.gets === 1 && uses.tees === 0
2388
+ // defense in depth against stale counts: the landing statement itself may hold
2389
+ // a SECOND read of the local beyond the hit (the exact shape a missed count
2390
+ // refresh produces) — a substitute would orphan it, a tee stays correct
2391
+ if (single) {
2392
+ let extra = 0
2393
+ walk(funcNode[hitJ], n => { if (Array.isArray(n) && n[0] === 'local.get' && n[1] === name) extra++ })
2394
+ if (extra > 1) single = false
2395
+ }
2396
+ cntSub(hit[0][hit[1]])
2397
+ hit[0][hit[1]] = single ? clone(setNode[2]) : ['local.tee', name, clone(setNode[2])]
2398
+ cntAdd(hit[0][hit[1]])
2399
+ SINFO.delete(funcNode[hitJ]) // the landing statement changed under its summary
2400
+ cntSub(setNode)
2401
+ funcNode.splice(i, 1)
1564
2402
  changed = true
2403
+ i--
1565
2404
  }
1566
2405
 
1567
2406
  return changed
1568
2407
  }
1569
2408
 
2409
+ /**
2410
+ * Locate the (local.get $name) that is provably the FIRST instruction executed in
2411
+ * `stmt`, descending the first-evaluated path: at each level the first non-immediate
2412
+ * (array) child of a left-to-right op is what runs first. Annotation heads are
2413
+ * skipped; bodies that are entered conditionally or repeatedly (then/else arms,
2414
+ * loops, try_table) are opaque — descent stops there. → [parent, idx] or null.
2415
+ */
2416
+ const firstEvalGet = (stmt, name, skipped, state) => {
2417
+ // Full evaluation-order scan: operands are visited left-to-right; the target get
2418
+ // may sit at any nesting level. A completed subtree may be CROSSED (the sunk value
2419
+ // then evaluates after it) only when it is pure and trap-free — its local reads are
2420
+ // recorded in `skipped` (the caller verifies the value doesn't write them) and any
2421
+ // global/memory read sets `state.reads` (the caller then requires a pure value).
2422
+ // Conditionally or repeatedly (re-)entered bodies stay opaque.
2423
+ let hit = null
2424
+ const scan = (cur) => {
2425
+ if (hit) return true
2426
+ if (!Array.isArray(cur)) return true
2427
+ const h = cur[0]
2428
+ if (h === 'loop' || h === 'then' || h === 'else' || h === 'try_table') return false
2429
+ for (let i = 1; i < cur.length; i++) {
2430
+ const c = cur[i]
2431
+ if (!Array.isArray(c)) continue // strings/numbers: immediates, labels, memargs
2432
+ const ch = c[0]
2433
+ if (ch === 'result' || ch === 'param' || ch === 'type' || ch === 'local' || ch === 'export') continue
2434
+ if (ch === 'local.get' && c.length === 2) {
2435
+ if (c[1] === name) { hit = [cur, i]; return true }
2436
+ skipped?.add(c[1])
2437
+ continue
2438
+ }
2439
+ if (isPure(c) && !hasTrap(c)) {
2440
+ let containsTarget = false
2441
+ walk(c, x => { if (Array.isArray(x) && x[0] === 'local.get' && x[1] === name) containsTarget = true })
2442
+ if (containsTarget) return scan(c) // the target lives here — descend, same rules
2443
+ // crossable — collect what it observes
2444
+ walk(c, x => {
2445
+ if (!Array.isArray(x)) return
2446
+ if (x[0] === 'local.get' && typeof x[1] === 'string') skipped?.add(x[1])
2447
+ else if (x[0] === 'global.get' || (typeof x[0] === 'string' && x[0].includes('.load'))) state && (state.reads = true)
2448
+ })
2449
+ continue
2450
+ }
2451
+ // first non-crossable child: the spine — descend; failure inside blocks the scan
2452
+ return scan(c)
2453
+ }
2454
+ return true
2455
+ }
2456
+ const ok = scan(stmt)
2457
+ return ok && hit ? hit : null
2458
+ }
2459
+
1570
2460
  /**
1571
2461
  * Remove dead stores and unused local declarations in a reverse pass.
1572
2462
  * Returns true if anything was removed.
@@ -1574,66 +2464,957 @@ const createLocalTees = (funcNode, params, useCounts) => {
1574
2464
  * @param {Set<string>} params
1575
2465
  * @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
1576
2466
  */
1577
- const eliminateDeadStores = (funcNode, params, useCounts) => {
1578
- let changed = false
1579
- const getPostUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
2467
+ const eliminateDeadStores = (funcNode, params, useCounts) => {
2468
+ let changed = false
2469
+ const getPostUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
2470
+
2471
+ // Wasm zero-initializes locals — an explicit `(local.set $x (T.const 0))` whose
2472
+ // local was never touched before restates the default. The default only holds at
2473
+ // FUNCTION entry (this runs per scope — a loop-body scope re-enters with carried
2474
+ // values). Candidates: top-level statements only, before any flat control token
2475
+ // (inside a loop a reset is load-bearing). Touches: EVERY get/set/tee anywhere
2476
+ // reached so far — including nested arms (`(if P (then (set $x 5))) (set $x 0)`
2477
+ // keeps the reset) and bare flat-form refs (target = next sibling token).
2478
+ if (funcNode[0] === 'func') {
2479
+ const touched = new Set()
2480
+ // flat control nesting at the current statement — a candidate is valid only at
2481
+ // depth 0 (outermost sequence, executed at most once per call; a zero-init
2482
+ // inside a flat loop is an each-iteration reset)
2483
+ let depth = 0
2484
+ const isZero = (v) => {
2485
+ const c = getConst(v)
2486
+ if (!c) return false
2487
+ return c.type === 'i32' ? c.value === 0 : c.type === 'i64' ? c.value === 0n :
2488
+ Object.is(c.value, 0) // floats: +0 only — -0 is not the default
2489
+ }
2490
+ const mark = (n, parent, idx) => {
2491
+ if (Array.isArray(n)) {
2492
+ if ((n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') touched.add(n[1])
2493
+ } else if (idx > 0 && (n === 'local.get' || n === 'local.set' || n === 'local.tee')) {
2494
+ const tgt = parent[idx + 1]
2495
+ typeof tgt === 'string' ? touched.add(tgt) : touched.add('*') // numeric ref: give up
2496
+ }
2497
+ }
2498
+ for (let i = 1; i < funcNode.length && !touched.has('*'); i++) {
2499
+ const n = funcNode[i]
2500
+ if (!Array.isArray(n)) {
2501
+ if (typeof n !== 'string') continue
2502
+ if (n === 'block' || n === 'loop' || n === 'if' || n === 'try' || n === 'try_table') depth++
2503
+ else if (n === 'end' || n === 'delegate') depth = depth > 0 ? depth - 1 : 0
2504
+ else mark(n, funcNode, i)
2505
+ continue
2506
+ }
2507
+ const op = n[0]
2508
+ if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
2509
+ if (depth === 0 && op === 'local.set' && n.length === 3 && typeof n[1] === 'string' &&
2510
+ !params.has(n[1]) && !touched.has(n[1]) && isZero(n[2])) {
2511
+ cntSub(n)
2512
+ funcNode.splice(i, 1); i--; changed = true
2513
+ continue
2514
+ }
2515
+ walk(n, mark)
2516
+ }
2517
+ }
2518
+
2519
+ // Dead tee (statement-level or nested): written but never read back — the value on
2520
+ // the stack is all that matters, so unwrap it (the decl dies via the unused sweep).
2521
+ walkPost(funcNode, n => {
2522
+ if (Array.isArray(n) && n[0] === 'local.tee' && n.length === 3 &&
2523
+ typeof n[1] === 'string' && getPostUseCount(n[1]).gets === 0) {
2524
+ changed = true
2525
+ if (CNT) { const c = CNT.get(n[1]); if (c) c.tees-- }
2526
+ return n[2]
2527
+ }
2528
+ })
2529
+
2530
+ // Locally-dead tee: the local IS read somewhere, but every path from this tee
2531
+ // reaches an unconditional top-level rewrite (or the function's end) before any
2532
+ // read — the whole-function gate above can't see that. Conservative forward
2533
+ // proof over the FUNC body's top-level siblings only: the tee's own statement
2534
+ // may hold no other reference to the local (an operand evaluated after the tee
2535
+ // reads the teed value), no sibling may touch the local or branch (recursively,
2536
+ // bare flat tokens included) before the kill.
2537
+ if (funcNode[0] === 'func') {
2538
+ // One summary pass over the body (per-statement local-touch counts, branch/flat
2539
+ // flags, tee sites), then candidate scans are index lookups — the naive version
2540
+ // re-walked the whole tail of the function per candidate tee.
2541
+ const info = []
2542
+ let anyTee = false
2543
+ for (let i = 0; i < funcNode.length; i++) {
2544
+ const stmt = funcNode[i]
2545
+ if (!Array.isArray(stmt)) { info.push(null); continue }
2546
+ const touches = new Map()
2547
+ let branchy = false, flat = false, tees = null
2548
+ walk(stmt, (c, p2, i2) => {
2549
+ if (!Array.isArray(c)) { if (i2 !== 0 && typeof c === 'string' && OPCODE[c] !== undefined) flat = true; return }
2550
+ const o = c[0]
2551
+ if ((o === 'local.get' || o === 'local.set' || o === 'local.tee') && typeof c[1] === 'string') {
2552
+ touches.set(c[1], (touches.get(c[1]) || 0) + 1)
2553
+ if (o === 'local.tee' && c.length === 3 && p2) (tees ??= []).push([c, p2, i2])
2554
+ }
2555
+ else if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' ||
2556
+ o === 'throw' || o === 'return_call' || o === 'return_call_indirect' || o === 'try_table') branchy = true
2557
+ })
2558
+ if (tees) anyTee = true
2559
+ info.push({ touches, branchy, flat, tees,
2560
+ setTarget: stmt[0] === 'local.set' && stmt.length === 3 && typeof stmt[1] === 'string' ? stmt[1] : null })
2561
+ }
2562
+ if (anyTee) for (let i = 1; i < funcNode.length; i++) {
2563
+ const inf = info[i]
2564
+ if (!inf?.tees || inf.flat) continue
2565
+ for (const [n, parent, idx] of inf.tees) {
2566
+ if (parent[idx] !== n) continue // an earlier replacement rewrote this slot
2567
+ const x = n[1]
2568
+ if (inf.touches.get(x) !== 1) continue
2569
+ let kill = false, unsafe = false
2570
+ for (let j = i + 1; j < funcNode.length && !kill && !unsafe; j++) {
2571
+ const jn = info[j]
2572
+ if (!jn) {
2573
+ const t = funcNode[j]
2574
+ if (typeof t === 'string' && OPCODE[t] !== undefined) unsafe = true
2575
+ continue
2576
+ }
2577
+ if (jn.flat) { unsafe = true; continue }
2578
+ if (jn.setTarget === x) {
2579
+ // the rewrite's RHS must not read x (its own touch is the 1)
2580
+ jn.touches.get(x) > 1 ? unsafe = true : kill = true
2581
+ continue
2582
+ }
2583
+ if (jn.branchy || jn.touches.has(x)) unsafe = true
2584
+ }
2585
+ // reaching the function's end without a read is a kill too — the frame dies
2586
+ if (!unsafe) {
2587
+ if (CNT) { const c = CNT.get(x); if (c) c.tees-- }
2588
+ parent[idx] = n[2]; changed = true
2589
+ }
2590
+ }
2591
+ }
2592
+ }
2593
+
2594
+ for (let i = funcNode.length - 1; i >= 1; i--) {
2595
+ const sub = funcNode[i]
2596
+ if (!Array.isArray(sub)) continue
2597
+ const name = typeof sub[1] === 'string' ? sub[1] : null
2598
+ if (!name) continue
2599
+ const uses = getPostUseCount(name)
2600
+ // Dead store: set but never read.
2601
+ if (sub[0] === 'local.set' && uses.gets === 0 && uses.tees === 0) {
2602
+ // `(local.set $D (CMP (local.tee $L X) CONST))` with $D never read: the
2603
+ // comparison is non-trapping and its other operand a literal — only the
2604
+ // tee's store matters. Collapse to `(local.set $L X)`.
2605
+ if (sub.length === 3 && Array.isArray(sub[2]) && sub[2].length === 3 &&
2606
+ /\.(eq|ne|[lg][te](_[su])?)$/.test(sub[2][0])) {
2607
+ const [, a, b] = sub[2]
2608
+ const tee = Array.isArray(a) && a[0] === 'local.tee' ? a : Array.isArray(b) && b[0] === 'local.tee' ? b : null
2609
+ const other = tee === a ? b : a
2610
+ if (tee && tee.length === 3 && tee[1] !== name && Array.isArray(other) && other[0]?.endsWith?.('.const')) {
2611
+ cntSub(funcNode[i])
2612
+ funcNode[i] = ['local.set', tee[1], tee[2]]
2613
+ cntAdd(funcNode[i])
2614
+ changed = true
2615
+ continue
2616
+ }
2617
+ }
2618
+ // `(local.set $x VALUE)` — drop the store with its value, but only when
2619
+ // VALUE is pure (its side effects would otherwise still need to run);
2620
+ // an impure but trap-free VALUE reduces to its side-effect core.
2621
+ if (sub.length === 3) {
2622
+ if (isPure(sub[2])) { cntSub(sub); funcNode.splice(i, 1); changed = true }
2623
+ else if (!hasTrap(sub[2])) {
2624
+ cntSub(sub)
2625
+ const eff = dropEffects(sub[2])
2626
+ for (const e of eff) cntAdd(e)
2627
+ funcNode.splice(i, 1, ...eff)
2628
+ changed = true
2629
+ }
2630
+ }
2631
+ // Bare `(local.set $x)` — the value is implicit on the stack (e.g. an
2632
+ // exception payload landing from a `try_table` catch). Demote to `drop`
2633
+ // so the dead store goes away without unbalancing the stack.
2634
+ else if (sub.length === 2) {
2635
+ cntSub(sub)
2636
+ funcNode[i] = ['drop']; changed = true
2637
+ }
2638
+ }
2639
+ // Unused local declaration
2640
+ else if (sub[0] === 'local' && name[0] === '$' && uses.gets === 0 && uses.sets === 0 && uses.tees === 0) {
2641
+ funcNode.splice(i, 1); changed = true
2642
+ }
2643
+ }
2644
+
2645
+ return changed
2646
+ }
2647
+
2648
+ /**
2649
+ * Drop `(local.set $x A)` when the very next statement re-sets $x without reading it
2650
+ * first (A pure). The two writes are adjacent, so A's value is overwritten before any
2651
+ * observation — it's dead. The whole-function {@link eliminateDeadStores} misses this:
2652
+ * it only fires when $x is read NOWHERE, whereas here $x is live later, just not
2653
+ * between these two writes. Pairs with copy-propagation, which rewrites
2654
+ * `$x=$y; $x=f($x)` to `$x=$y; $x=f($y)` — an adjacent dead store this removes,
2655
+ * collapsing the round-trip jz's value-model lowering leaves behind.
2656
+ * @param {Array} funcNode a straight-line scope (body / block / loop / then / else)
2657
+ * @param {Set<string>} params
2658
+ */
2659
+ const eliminateAdjacentDeadStores = (funcNode, params) => {
2660
+ let changed = false
2661
+ for (let i = 1; i < funcNode.length - 1; i++) {
2662
+ const a = funcNode[i], b = funcNode[i + 1]
2663
+ // `a` must be a plain set (a tee leaves its value on the stack — not removable);
2664
+ // `b` may be a set OR a tee (both overwrite the local before `a`'s value is read).
2665
+ if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3) continue
2666
+ if (!Array.isArray(b) || (b[0] !== 'local.set' && b[0] !== 'local.tee') || b.length !== 3 || b[1] !== a[1]) continue
2667
+ if (params.has(a[1]) || !isPure(a[2])) continue
2668
+ // Dead only if b's value doesn't read $x before overwriting it.
2669
+ let reads = false
2670
+ walk(b[2], n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === a[1]) reads = true })
2671
+ if (reads) continue
2672
+ cntSub(a)
2673
+ funcNode.splice(i, 1); changed = true; i--
2674
+ }
2675
+ return changed
2676
+ }
2677
+
2678
+ // Conservative LOW estimate of a subtree's encoded bytes (under-estimating keeps the
2679
+ // CSE profit gate honest: never fire on a loss).
2680
+ const estBytes = (n) => {
2681
+ if (!Array.isArray(n)) return typeof n === 'number' ? 2 : 1
2682
+ let b = OPCODE[n[0]] > 0xffff ? 2 : 1
2683
+ for (let i = 1; i < n.length; i++) b += estBytes(n[i])
2684
+ return b
2685
+ }
2686
+
2687
+ /**
2688
+ * Local common-subexpression elimination: identical pure subtrees repeated within a
2689
+ * straight-line scope compute once into a fresh local (first site tees, later sites
2690
+ * get). Grouping stops at every invalidation: a statement that writes a local the
2691
+ * expression reads, writes memory (for memory-reading exprs), or calls out (memory/
2692
+ * global-reading exprs). Candidates come only from the unconditional part of each
2693
+ * statement — nested control bodies are separate scopes with their own table.
2694
+ * Fires only when the byte win is provable: (n−1)·bytes(expr) > tee+gets+decl cost.
2695
+ * @param {Array} ast
2696
+ * @returns {Array}
2697
+ */
2698
+ const cse = (ast) => {
2699
+ let uid = 0
2700
+ walk(ast, (fn) => {
2701
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
2702
+ const all = []
2703
+ // Scope tree with availability inheritance: an if's arms and a block's body
2704
+ // are entered with everything still live at that point — a group tee'd in the
2705
+ // parent DOMINATES its arm sites, so cross-block repeats share one local.
2706
+ // A loop body starts FRESH: a tee outside the loop goes stale on iteration 2
2707
+ // if anything the expression reads is rewritten later in the body (linear
2708
+ // invalidation can't see the back edge). try_table bodies likewise fresh.
2709
+ const processScope = (scope, inherited) => {
2710
+ const live = new Map(inherited)
2711
+ let si = 1
2712
+ // leading label of a block/loop is structure, not a flat token — skip
2713
+ // without clearing what was inherited
2714
+ if (typeof scope[si] === 'string' && scope[si][0] === '$') si++
2715
+ for (; si < scope.length; si++) {
2716
+ const stmt = scope[si]
2717
+ if (!Array.isArray(stmt)) { live.clear(); continue } // flat token — unknown order
2718
+ const h = stmt[0]
2719
+ if (h === 'param' || h === 'result' || h === 'local' || h === 'type' || h === 'export') continue
2720
+ // this statement's write effects
2721
+ const wLocals = new Set(), wGlobals = new Set()
2722
+ let wMem = writesMemory(stmt), call = false
2723
+ walk(stmt, n => {
2724
+ if (!Array.isArray(n)) return
2725
+ const o = n[0]
2726
+ if ((o === 'local.set' || o === 'local.tee') && typeof n[1] === 'string') wLocals.add(n[1])
2727
+ else if (o === 'global.set' && typeof n[1] === 'string') wGlobals.add(n[1])
2728
+ else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect') call = true
2729
+ })
2730
+ // candidates from the unconditional spine of the statement
2731
+ const collect = (n, parent, idx) => {
2732
+ if (!Array.isArray(n)) return
2733
+ const o = n[0]
2734
+ // an if's CONDITION evaluates unconditionally — candidates there are as
2735
+ // dominant as any statement's; the arms stay opaque here (recursed below
2736
+ // for top-level statements, fresh for expression-nested ifs)
2737
+ if (o === 'if') {
2738
+ const { condIdx, cond, thenBranch, elseBranch } = parseIf(n)
2739
+ if (Array.isArray(cond)) collect(cond, n, condIdx)
2740
+ if (n !== stmt) { // expression-nested arms: own fresh scopes (the
2741
+ // statement-level branch below owns top-level arms, with inheritance)
2742
+ if (thenBranch) processScope(thenBranch, EMPTY_MAP)
2743
+ if (elseBranch) processScope(elseBranch, EMPTY_MAP)
2744
+ }
2745
+ return
2746
+ }
2747
+ if (o === 'block' || o === 'loop' || o === 'try_table') {
2748
+ if (n !== stmt) processScope(n, EMPTY_MAP)
2749
+ return
2750
+ }
2751
+ if (o === 'then' || o === 'else') return
2752
+ if (typeof o === 'string' && resultType(o) && isPure(n)) {
2753
+ const est = estBytes(n)
2754
+ if (est >= 4) {
2755
+ const key = hashFunc(n, EMPTY_SET)
2756
+ let g = live.get(key)
2757
+ if (!g) {
2758
+ g = { expr: n, sites: [], est, type: resultType(o), reads: new Set(), mem: readsMemory(n), glob: false }
2759
+ walk(n, c => {
2760
+ if (!Array.isArray(c)) return
2761
+ if (c[0] === 'local.get' && typeof c[1] === 'string') g.reads.add(c[1])
2762
+ else if (c[0] === 'global.get') g.glob = true
2763
+ })
2764
+ live.set(key, g); all.push(g)
2765
+ }
2766
+ g.sites.push([parent, idx])
2767
+ // repeats never yield sub-candidates: a group seeded inside a repeat
2768
+ // would tee into a subtree the outer conversion detaches
2769
+ if (g.sites.length > 1) return
2770
+ }
2771
+ }
2772
+ for (let i = 1; i < n.length; i++) collect(n[i], n, i)
2773
+ }
2774
+ collect(stmt, scope, si)
2775
+ // recurse structured bodies. An arm enters with what was live AFTER the
2776
+ // condition evaluated: the condition may tee a local or call out (jz
2777
+ // emits `(if (tee …) …)` pervasively), and an arm site reusing the
2778
+ // parent's pre-condition value would read stale state — fence the
2779
+ // inherited copy with the CONDITION's effects only (an arm's own writes
2780
+ // are handled by its linear scan; the sibling arm never ran first). A
2781
+ // block has no condition: its body inherits everything as-is.
2782
+ if (h === 'if') {
2783
+ const { cond, thenBranch, elseBranch } = parseIf(stmt)
2784
+ const cw = new Set(); let cGlob = false, cMemCall = false
2785
+ walk(cond, n => {
2786
+ if (!Array.isArray(n)) return
2787
+ const o = n[0]
2788
+ if ((o === 'local.set' || o === 'local.tee') && typeof n[1] === 'string') cw.add(n[1])
2789
+ else if (o === 'global.set') cGlob = true
2790
+ else if (o === 'call' || o === 'call_indirect' || o === 'return_call' || o === 'return_call_indirect') { cGlob = true; cMemCall = true }
2791
+ else if (typeof o === 'string' && (o.includes('.store') || o === 'memory.copy' || o === 'memory.fill' ||
2792
+ o === 'memory.init' || o === 'memory.grow' || (o.includes('.atomic.') && !o.endsWith('.load')))) cMemCall = true
2793
+ })
2794
+ const armLive = new Map()
2795
+ for (const [k, g] of live) {
2796
+ if (g.mem && cMemCall) continue
2797
+ if (g.glob && cGlob) continue
2798
+ let stale = false
2799
+ for (const l of cw) if (g.reads.has(l)) { stale = true; break }
2800
+ if (!stale) armLive.set(k, g)
2801
+ }
2802
+ if (thenBranch) processScope(thenBranch, armLive)
2803
+ if (elseBranch) processScope(elseBranch, armLive)
2804
+ } else if (h === 'block') {
2805
+ processScope(stmt, live)
2806
+ } else if (h === 'loop' || h === 'try_table') {
2807
+ processScope(stmt, EMPTY_MAP)
2808
+ }
2809
+ // invalidate against this statement's writes (arms included — a
2810
+ // conditional write still poisons everything after the statement)
2811
+ for (const [k, g] of live) {
2812
+ if (g.mem && (wMem || call)) { live.delete(k); continue }
2813
+ if (g.glob && (call || wGlobals.size)) { live.delete(k); continue }
2814
+ for (const l of wLocals) if (g.reads.has(l)) { live.delete(k); break }
2815
+ }
2816
+ }
2817
+ }
2818
+ processScope(fn, EMPTY_MAP)
2819
+ const decls = []
2820
+ // A fresh local appended to the locals vector costs 2 B (new count+type run) —
2821
+ // unless its type matches the run it lands after, where the count just
2822
+ // increments for free. Track the run tail (locals only — params aren't in the
2823
+ // binary locals vector) so the gate prices each candidate exactly.
2824
+ let tailType = null
2825
+ for (let i = 1; i < fn.length; i++) if (Array.isArray(fn[i]) && fn[i][0] === 'local') {
2826
+ const t = fn[i][fn[i].length - 1]
2827
+ tailType = typeof t === 'string' ? t : null
2828
+ }
2829
+ for (const g of all) {
2830
+ const n = g.sites.length
2831
+ const declCost = g.type === tailType ? 0 : 2
2832
+ if (n < 2 || (n - 1) * (g.est - 2) <= 2 + declCost) continue
2833
+ const name = '$cse' + uid++
2834
+ decls.push(['local', name, g.type])
2835
+ tailType = g.type
2836
+ const [p0, i0] = g.sites[0]
2837
+ p0[i0] = ['local.tee', name, g.expr]
2838
+ for (let k = 1; k < n; k++) { const [p, i] = g.sites[k]; p[i] = ['local.get', name] }
2839
+ }
2840
+ if (decls.length) {
2841
+ let at = typeof fn[1] === 'string' && fn[1][0] === '$' ? 2 : 1
2842
+ while (at < fn.length && Array.isArray(fn[at]) &&
2843
+ (fn[at][0] === 'export' || fn[at][0] === 'type' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
2844
+ fn.splice(at, 0, ...decls)
2845
+ }
2846
+ })
2847
+ return ast
2848
+ }
2849
+
2850
+ /**
2851
+ * Macro inlining: a function whose whole body is ONE small expression using each
2852
+ * param exactly once, in declaration order, expands at every call site by
2853
+ * substituting the arguments positionally — no wrapper, no locals, argument
2854
+ * evaluation order preserved verbatim (so impure args stay sound). The husk loses
2855
+ * its callers and treeshake collects it; the expansion feeds fold/offset/cse.
2856
+ * @param {Array} ast
2857
+ * @returns {Array}
2858
+ */
2859
+ const inlineMacro = (ast, { pin = EMPTY_SET } = {}) => {
2860
+ if (!Array.isArray(ast) || ast[0] !== 'module') return ast
2861
+ // expansion trades ~2B call for the body's own bytes per site — profitable only
2862
+ // below a small call count (the def's fixed ~9B amortizes). Empirical, not
2863
+ // byte-exact: two static models measured WORSE — body-size alone (20 B+ off on
2864
+ // wax modules) and per-site fold-SIMULATED expansion (still +40 B net: the
2865
+ // profit comes from folding around the expansion in caller context, which no
2866
+ // expression-local model sees). The outline pass re-extracts any expansion
2867
+ // that turns out not to pay, so over-expanding here is recoverable.
2868
+ const CAP = 3
2869
+ const callCount = new Map()
2870
+ walk(ast, n => { if (Array.isArray(n) && n[0] === 'call' && typeof n[1] === 'string') callCount.set(n[1], (callCount.get(n[1]) || 0) + 1) })
2871
+ const macros = new Map()
2872
+ for (const n of ast.slice(1)) {
2873
+ if (!Array.isArray(n) || n[0] !== 'func' || typeof n[1] !== 'string' || n[1][0] !== '$') continue
2874
+ if (n.some(c => Array.isArray(c) && c[0] === 'export')) continue
2875
+ if (pin.has(n[1]) || (callCount.get(n[1]) || 0) > CAP) continue
2876
+ const params = [], body = []
2877
+ let results = 0, ok = true
2878
+ for (let i = 2; i < n.length && ok; i++) {
2879
+ const c = n[i]
2880
+ if (c === 'return' && i === n.length - 1) continue // trailing bare return is a no-op
2881
+ if (!Array.isArray(c)) ok = false
2882
+ else if (c[0] === 'param') (typeof c[1] === 'string' && c[1][0] === '$' && c.length === 3) ? params.push(c[1]) : ok = false
2883
+ else if (c[0] === 'result') results += c.length - 1
2884
+ else if (c[0] === 'local') ok = false
2885
+ else if (c[0] === 'type') continue
2886
+ else body.push(c)
2887
+ }
2888
+ if (!ok || body.length !== 1 || results !== 1) continue
2889
+ const expr = body[0]
2890
+ if (estBytes(expr) > 12) continue
2891
+ // params each read once, in order; no writes, control, or self-recursion inside
2892
+ const seq = []
2893
+ let bad = false
2894
+ walk(expr, c => {
2895
+ // arrays only: walk also hands back each node's own head token as a leaf,
2896
+ // which would double-count every get ('local.get' matching again as a string)
2897
+ if (!Array.isArray(c)) { if (c === 'return' || (typeof c === 'string' && FLAT_CTRL.has(c))) bad = true; return }
2898
+ const o = c[0]
2899
+ if (o === 'local.get') seq.push(c[1])
2900
+ else if (o === 'local.set' || o === 'local.tee' || o === 'return' || o === 'br' || o === 'br_if' ||
2901
+ o === 'br_table' || o === 'block' || o === 'loop' || o === 'if' || o === 'try_table' ||
2902
+ ((o === 'call' || o === 'return_call') && c[1] === n[1])) bad = true
2903
+ })
2904
+ if (bad || seq.length !== params.length || seq.some((x, i) => x !== params[i])) continue
2905
+ macros.set(n[1], { params, expr })
2906
+ }
2907
+ if (!macros.size) return ast
2908
+ walkPost(ast, n => {
2909
+ if (!Array.isArray(n) || n[0] !== 'call' || !macros.has(n[1])) return
2910
+ const m = macros.get(n[1])
2911
+ if (n.length - 2 !== m.params.length) return
2912
+ inflations++
2913
+ const idx = new Map(m.params.map((p, i) => [p, i]))
2914
+ const out = clone(m.expr)
2915
+ const expanded = walkPost(out, c => {
2916
+ if (Array.isArray(c) && c[0] === 'local.get' && idx.has(c[1])) return n[2 + idx.get(c[1])]
2917
+ })
2918
+ return expanded
2919
+ })
2920
+ return ast
2921
+ }
2922
+
2923
+ /**
2924
+ * Specialize parameters that every call site passes the same constant: the param
2925
+ * becomes a local initialized to that constant, and the argument disappears from
2926
+ * every site. Named, unexported callees whose name appears ONLY as folded
2927
+ * (call $f …) sites qualify; int constants compare by canonical value, floats by
2928
+ * Object.is on non-NaN (per the signed-zero refutation — -0/NaN defer).
2929
+ * @param {Array} ast
2930
+ * @returns {Array}
2931
+ */
2932
+ const specializeParams = (ast) => {
2933
+ if (!Array.isArray(ast) || ast[0] !== 'module') return ast
2934
+ const sameConst = (a, b) => {
2935
+ if (!Array.isArray(a) || !Array.isArray(b) || a[0] !== b[0] || !a[0].endsWith?.('.const')) return false
2936
+ const ca = getConst(a), cb = getConst(b)
2937
+ if (!ca || !cb) return false
2938
+ if (a[0] === 'i32.const' || a[0] === 'i64.const') return String(ca.value) === String(cb.value)
2939
+ const na = Number(ca.value), nb = Number(cb.value)
2940
+ return !Number.isNaN(na) && !Number.isNaN(nb) && Object.is(na, nb)
2941
+ }
2942
+ // one module walk: call sites and raw name occurrences for every func
2943
+ const allSites = new Map(), occ = new Map()
2944
+ walk(ast, n => {
2945
+ if (Array.isArray(n)) {
2946
+ if (n[0] === 'call' && typeof n[1] === 'string') {
2947
+ let a = allSites.get(n[1])
2948
+ if (!a) allSites.set(n[1], a = [])
2949
+ a.push(n)
2950
+ }
2951
+ return
2952
+ }
2953
+ if (typeof n === 'string' && n[0] === '$') occ.set(n, (occ.get(n) || 0) + 1)
2954
+ })
2955
+ for (const fn of ast.slice(1)) {
2956
+ if (!Array.isArray(fn) || fn[0] !== 'func' || typeof fn[1] !== 'string' || fn[1][0] !== '$') continue
2957
+ if (fn.some(c => Array.isArray(c) && (c[0] === 'export' || c[0] === 'type'))) continue
2958
+ const name = fn[1]
2959
+ const params = []
2960
+ for (const c of fn) if (Array.isArray(c) && c[0] === 'param') {
2961
+ if (typeof c[1] !== 'string' || c[1][0] !== '$' || c.length !== 3) { params.length = 0; break }
2962
+ params.push(c)
2963
+ }
2964
+ if (!params.length) continue
2965
+ // every textual occurrence of the name must be a folded call head or the def
2966
+ const sites = allSites.get(name) || []
2967
+ if (!sites.length || (occ.get(name) || 0) !== sites.length + 1) continue
2968
+ if (sites.some(c => c.length - 2 !== params.length)) continue
2969
+ for (let k = params.length - 1; k >= 0; k--) {
2970
+ const first = sites[0][2 + k]
2971
+ if (!sites.every(c => sameConst(c[2 + k], first))) continue
2972
+ const pd = params[k]
2973
+ // never-written single-read param: splice the const straight onto the one
2974
+ // get — no local, no set, no coalesce slot. A const is a pure leaf, so any
2975
+ // position the read occupied is equivalent (invariance guaranteed by
2976
+ // writes === 0, including across loop back-edges).
2977
+ let reads = 0, writes = 0, readSite = null
2978
+ walk(fn, n => {
2979
+ if (!Array.isArray(n) || n[1] !== pd[1]) return
2980
+ if (n[0] === 'local.get') { reads++; readSite = n }
2981
+ else if (n[0] === 'local.set' || n[0] === 'local.tee') writes++
2982
+ })
2983
+ const direct = writes === 0 && reads <= 1
2984
+ const cost = direct ? (reads === 0 ? 0 : Math.max(0, constInstrSize(first) - 2))
2985
+ : 4 + constInstrSize(first) // local decl + set + const at callee
2986
+ const gain = sites.length * constInstrSize(first) + 1
2987
+ if (gain <= cost) continue
2988
+ const pi = fn.indexOf(pd)
2989
+ fn.splice(pi, 1)
2990
+ if (direct) {
2991
+ if (readSite) { readSite.length = 0; readSite.push(...clone(first)) }
2992
+ } else {
2993
+ let at = 2
2994
+ while (at < fn.length && Array.isArray(fn[at]) &&
2995
+ (fn[at][0] === 'export' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
2996
+ fn.splice(at, 0, ['local', pd[1], pd[2]], ['local.set', pd[1], clone(first)])
2997
+ }
2998
+ for (const c of sites) c.splice(2 + k, 1)
2999
+ params.splice(k, 1)
3000
+ }
3001
+ }
3002
+ return ast
3003
+ }
3004
+
3005
+ /**
3006
+ * Elide a function-tail `return`: falling off the body returns the same values, so
3007
+ * the explicit return (folded, or wax's stacked VALUE-`return` pair) is framing.
3008
+ * Sound only when everything before the producer is provably stack-neutral —
3009
+ * `return` DISCARDS extra stack values, fall-through does not. The statement that
3010
+ * produces the result value is exempt from neutrality exactly when its arity is
3011
+ * known to match the function's single result (plain value ops; block/loop/if with
3012
+ * an explicit matching (result); calls to known-void functions never qualify).
3013
+ * Runs AFTER tailmerge — the return markers key its epilogue matching.
3014
+ * @param {Array} ast
3015
+ * @returns {Array}
3016
+ */
3017
+ const rettail = (ast) => {
3018
+ if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3019
+ const voidFuncs = collectVoidFuncs(ast)
3020
+ const stackNeutral = (n) => {
3021
+ if (n === 'nop' || n === 'unreachable' || n === 'return') return true
3022
+ if (!Array.isArray(n)) return false
3023
+ const op = n[0]
3024
+ if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') return true
3025
+ if (op === 'local.set' || op === 'global.set' || op === 'drop' || op === 'nop' || op === 'return' ||
3026
+ op === 'br' || op === 'unreachable' || op === 'memory.copy' || op === 'memory.fill' ||
3027
+ op?.includes?.('.store')) return true
3028
+ if (op === 'br_if') return n.length <= 3
3029
+ if (op === 'block' || op === 'loop' || op === 'if')
3030
+ return !n.some(c => Array.isArray(c) && c[0] === 'result')
3031
+ if (op === 'call') return voidFuncs.has(n[1])
3032
+ return false
3033
+ }
3034
+ // does this statement produce exactly ONE value (the single-result case)?
3035
+ const producesOne = (n) => {
3036
+ if (!Array.isArray(n)) return false
3037
+ const op = n[0]
3038
+ if (op === 'block' || op === 'loop' || op === 'if')
3039
+ return n.some(c => Array.isArray(c) && c[0] === 'result' && c.length === 2)
3040
+ if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect')
3041
+ return false // result arity unknown here — only the void set is tracked
3042
+ if (op === 'local.set' || op === 'global.set' || op === 'drop' || op?.includes?.('.store')) return false
3043
+ return true // plain value op: exactly one value in this IR
3044
+ }
3045
+ // Convert a tail-position statement into the value form it feeds a (result T)
3046
+ // scope: an unwrapped `return V` becomes V; a diverging br/unreachable stays; an
3047
+ // untyped if recurses into both arms (each must convert). null = doesn't qualify.
3048
+ const convertTail = (nd, T) => {
3049
+ if (!Array.isArray(nd)) return null // bare/unfolded token forms: bail (soundness fence)
3050
+ const op = nd[0]
3051
+ if (op === 'br' || op === 'unreachable') return nd // diverging — still valid under (result T)
3052
+ if (op === 'return') {
3053
+ if (nd.length !== 2 || !producesOne(nd[1])) return null
3054
+ return nd[1]
3055
+ }
3056
+ if (op === 'if') {
3057
+ if (nd.some(c => Array.isArray(c) && c[0] === 'result')) return null // already typed — not our shape
3058
+ const { cond, thenBranch, elseBranch } = parseIf(nd)
3059
+ if (!thenBranch || !elseBranch || thenBranch.length < 2 || elseBranch.length < 2) return null
3060
+ const thenBody = thenBranch.slice(1), elseBody = elseBranch.slice(1)
3061
+ if (!thenBody.slice(0, -1).every(stackNeutral)) return null
3062
+ if (!elseBody.slice(0, -1).every(stackNeutral)) return null
3063
+ const thenConv = convertTail(thenBody[thenBody.length - 1], T)
3064
+ const elseConv = convertTail(elseBody[elseBody.length - 1], T)
3065
+ if (thenConv === null || elseConv === null) return null
3066
+ return ['if', ['result', T], cond,
3067
+ ['then', ...thenBody.slice(0, -1), thenConv],
3068
+ ['else', ...elseBody.slice(0, -1), elseConv]]
3069
+ }
3070
+ return null
3071
+ }
3072
+ // A void trailing loop whose only exits are `br` back to itself or `return V` at
3073
+ // (possibly if-nested) tail positions: type the loop (result T), unwrap the
3074
+ // returns, and drop the dead trailing filler that only satisfied the void shape.
3075
+ const tryLoopLift = (node, bodyStart, T) => {
3076
+ const n = node.length
3077
+ const loopIdx = Array.isArray(node[n - 1]) && node[n - 1][0] === 'loop' ? n - 1
3078
+ : n >= 2 && Array.isArray(node[n - 2]) && node[n - 2][0] === 'loop' ? n - 2 : -1
3079
+ if (loopIdx < 0) return
3080
+ const loop = node[loopIdx]
3081
+ if (loop.some(c => Array.isArray(c) && (c[0] === 'result' || c[0] === 'param'))) return
3082
+ // every statement before the loop must be stack-neutral — the lifted loop's
3083
+ // value becomes the function's fall-through result, alone on the stack
3084
+ if (!node.slice(bodyStart, loopIdx).every(stackNeutral)) return
3085
+ const label = typeof loop[1] === 'string' && loop[1][0] === '$' ? loop[1] : null
3086
+ if (!label) return // named label required — branch-target safety is checked by identity
3087
+ const body = loop.slice(2)
3088
+ if (!body.length) return
3089
+ // conservative fence: every branch anywhere in the loop must target only this
3090
+ // loop's own label (never escape outward, never a sibling scope)
3091
+ let branchesSafe = true
3092
+ walk(loop, (nd, parent, idx) => {
3093
+ // bare flat-form tokens (idx 0 is a folded node's own head, not a flat token)
3094
+ if (!Array.isArray(nd)) { if (idx !== 0 && typeof nd === 'string' && OPCODE[nd] !== undefined) branchesSafe = false; return }
3095
+ const o = nd[0]
3096
+ if (o === 'br' || o === 'br_if') { if (nd[1] !== label) branchesSafe = false }
3097
+ else if (o === 'br_table') { for (const t of nd.slice(1, -1)) if (t !== label) branchesSafe = false }
3098
+ else if (o === 'return_call' || o === 'return_call_indirect' || o === 'try_table' || o === 'throw') branchesSafe = false
3099
+ // nested scopes (their own labels, own exits) are conservatively out of scope
3100
+ else if (nd !== loop && (o === 'block' || o === 'loop')) branchesSafe = false
3101
+ })
3102
+ if (!branchesSafe) return
3103
+ if (!body.slice(0, -1).every(stackNeutral)) return
3104
+ const converted = convertTail(body[body.length - 1], T)
3105
+ if (converted === null) return
3106
+ // every convertible tail diverges or returns — the original loop never fell
3107
+ // through, so a trailing filler statement was unreachable: delete it
3108
+ loop.splice(2 + body.length - 1, 1, converted)
3109
+ loop.splice(2, 0, ['result', T])
3110
+ if (loopIdx === n - 2) node.pop()
3111
+ }
3112
+ for (const node of ast.slice(1)) {
3113
+ if (!Array.isArray(node) || node[0] !== 'func') continue
3114
+ const results = node.reduce((k, c) => Array.isArray(c) && c[0] === 'result' ? k + c.length - 1 : k, 0)
3115
+ if (results > 1) continue
3116
+ const bodyStart = typeof node[1] === 'string' && node[1][0] === '$' ? 2 : 1
3117
+ const last = node[node.length - 1]
3118
+ const isRet = last === 'return' ? 1 : Array.isArray(last) && last[0] === 'return' ? 2 : 0
3119
+ if (!isRet) {
3120
+ if (results === 1) {
3121
+ const rc = node.find(c => Array.isArray(c) && c[0] === 'result' && c.length === 2)
3122
+ if (rc) tryLoopLift(node, bodyStart, rc[1])
3123
+ }
3124
+ continue
3125
+ }
3126
+ if (isRet === 1) {
3127
+ // stacked form: the statement before `return` is the value producer — exempt
3128
+ // it only when its single-value arity matches the declared single result
3129
+ const end = results === 1 ? node.length - 2 : node.length - 1
3130
+ if (results === 1 && !producesOne(node[end])) continue
3131
+ if (!node.slice(bodyStart, end).every(stackNeutral)) continue
3132
+ node.pop()
3133
+ } else {
3134
+ if (!node.slice(bodyStart, -1).every(stackNeutral)) continue
3135
+ node.splice(node.length - 1, 1, ...last.slice(1))
3136
+ }
3137
+ }
3138
+ return ast
3139
+ }
3140
+
3141
+ /**
3142
+ * Tail-merge duplicated early-exit epilogues: several `(if C (then STMTS… (return V)))`
3143
+ * sites whose arm bodies are byte-identical collapse to `(br_if $L C)` into one shared
3144
+ * copy placed after a block wrapping the body. Sound under the verifier's contract:
3145
+ * arm bodies are strictly branch-free straight-line code (calls / local & global
3146
+ * accesses / numeric ops) ending in a function-level `return` — the only relocated
3147
+ * instructions are position-independent, and each branch arrives with whatever state
3148
+ * its site had, exactly as the duplicated copy would. The function's own last
3149
+ * statement must already terminate, so the block can never fall through into the
3150
+ * shared copy.
3151
+ * @param {Array} ast
3152
+ * @returns {Array}
3153
+ */
3154
+ // ==================== OUTLINING ====================
1580
3155
 
1581
- for (let i = funcNode.length - 1; i >= 1; i--) {
1582
- const sub = funcNode[i]
1583
- if (!Array.isArray(sub)) continue
1584
- const name = typeof sub[1] === 'string' ? sub[1] : null
1585
- if (!name || params.has(name)) continue
1586
- const uses = getPostUseCount(name)
1587
- // Dead store: set but never read.
1588
- if (sub[0] === 'local.set' && uses.gets === 0 && uses.tees === 0) {
1589
- // `(local.set $x VALUE)` drop the store with its value, but only when
1590
- // VALUE is pure (its side effects would otherwise still need to run).
1591
- if (sub.length === 3) {
1592
- if (isPure(sub[2])) { funcNode.splice(i, 1); changed = true }
1593
- }
1594
- // Bare `(local.set $x)` the value is implicit on the stack (e.g. an
1595
- // exception payload landing from a `try_table` catch). Demote to `drop`
1596
- // so the dead store goes away without unbalancing the stack.
1597
- else if (sub.length === 2) {
1598
- funcNode[i] = ['drop']; changed = true
3156
+ /**
3157
+ * Extract repeated pure expressions into a shared helper function — CSE across
3158
+ * function boundaries. A candidate is a pure value expression (trap ops allowed:
3159
+ * the call evaluates at the same point, so a div/load traps identically) whose
3160
+ * only variable leaves are typed named locals; those become the helper's params,
3161
+ * positionally canonicalized so sites in different functions with different
3162
+ * local names share one helper. Greedy: per round, collect all groups, apply the
3163
+ * single most profitable, re-collect (an applied group changes what overlapping
3164
+ * candidates still exist). Runs LAST in finish(): inlineOnce ignores multi-call
3165
+ * helpers, and inlineMacro never reruns after the rounds, so nothing re-expands
3166
+ * the extraction.
3167
+ */
3168
+ // Encoding-aware expression size for outline decisions — estBytes prices every
3169
+ // number at 2 (an f64.const is really 9), which flips boundary calls. Index
3170
+ // widths use the small-index optimism (1 byte): that UNDER-counts a site's
3171
+ // bytes, so gains are under- not over-estimated refusals stay safe.
3172
+ // per-node immediate width (children excluded) — the facts pass sums bottom-up
3173
+ const ownBytes = (n) => {
3174
+ const op = n[0], code = OPCODE[op]
3175
+ let b = typeof code === 'number' ? (code > 0xffff ? ((code & 0xffff) > 127 ? 3 : 2) : 1) : 1
3176
+ if (op === 'i32.const' || op === 'i64.const') return b + slebSize(n[1])
3177
+ if (op === 'f32.const') return b + 4
3178
+ if (op === 'f64.const') return b + 8
3179
+ if (op === 'local.get' || op === 'local.set' || op === 'local.tee' || op === 'global.get') return b + 1
3180
+ let alignTok = false, offTok = false
3181
+ for (let i = 1; i < n.length; i++) {
3182
+ const c = n[i]
3183
+ if (Array.isArray(c)) continue
3184
+ if (typeof c === 'string' && c.startsWith('offset=')) { offTok = true; b += ulebBytes(+c.slice(7)) }
3185
+ else if (typeof c === 'string' && c.startsWith('align=')) { alignTok = true; b += 1 }
3186
+ else b += 1 // name/label/small immediate — optimistic
3187
+ }
3188
+ if (op.includes('.load') || op.includes('.store')) b += (alignTok ? 0 : 1) + (offTok ? 0 : 1)
3189
+ return b
3190
+ }
3191
+ const ulebBytes = (v) => { let k = 1; v = v >>> 7; while (v) k++, v >>>= 7; return k }
3192
+
3193
+ let outUid = 0
3194
+ const outline = (ast) => {
3195
+ if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3196
+ let rescan = null // functions containing applied sites — later rounds rescan only these
3197
+ for (let round = 0; round < 3; round++) {
3198
+ let funcCount = 0
3199
+ for (const n of ast) if (Array.isArray(n) && (n[0] === 'func' ||
3200
+ (n[0] === 'import' && n.some(c => Array.isArray(c) && c[0] === 'func')))) funcCount++
3201
+ const callB = funcCount > 127 ? 3 : 2
3202
+ // One bottom-up pass per function computes composable facts — purity, encoded
3203
+ // size (own width + children, no per-root re-recursion), name-blind hash —
3204
+ // for every subtree. Candidates group by the cheap hash; exact positional
3205
+ // canonicalization is DEFERRED to apply time (once per chosen group), and
3206
+ // param collection to groups that actually collided.
3207
+ const facts = new Map()
3208
+ const groups = new Map()
3209
+ for (const fn of ast) {
3210
+ if (!Array.isArray(fn) || fn[0] !== 'func') continue
3211
+ if (rescan && !rescan.has(fn)) continue
3212
+ const ltype = new Map()
3213
+ for (const c of fn) if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local') &&
3214
+ typeof c[1] === 'string' && typeof c[2] === 'string') ltype.set(c[1], c[2])
3215
+ walkPost(fn, (n, parent, idx) => {
3216
+ if (!Array.isArray(n) || typeof n[0] !== 'string') return
3217
+ const op = n[0]
3218
+ let pure = !IMPURE_OPS.has(op) && !IMPURE_SUBSTRINGS.some(sub => op.includes(sub)) &&
3219
+ op !== 'if' && op !== 'block' && op !== 'loop' && op !== 'then' && op !== 'else' && op !== 'try_table'
3220
+ let b = ownBytes(n)
3221
+ let h = op
3222
+ for (let i = 1; i < n.length; i++) {
3223
+ const c = n[i]
3224
+ if (Array.isArray(c)) {
3225
+ const f = facts.get(c)
3226
+ if (!f) { pure = false; continue }
3227
+ pure &&= f.pure
3228
+ b += f.b
3229
+ h += ',' + (c[0] === 'local.get' && c.length === 2 ? 'L' : f.h)
3230
+ } else h += ',' + c
3231
+ }
3232
+ const rec = { pure, b, h: h.length > 64 ? 'H' + hash32(h) : h }
3233
+ facts.set(n, rec)
3234
+ if (!parent || !pure || rec.b < 10 || !resultType(op)) return
3235
+ let g = groups.get(rec.h)
3236
+ if (!g) groups.set(rec.h, g = [])
3237
+ g.push({ node: n, parent, idx, fn, ltype, bytes: rec.b })
3238
+ })
3239
+ }
3240
+ // exact grouping only within collisions: params + types collected per site,
3241
+ // canonical body built once per chosen group at apply time
3242
+ const exact = new Map()
3243
+ for (const cands of groups.values()) {
3244
+ if (cands.length < 2) continue
3245
+ for (const cand of cands) {
3246
+ const params = []
3247
+ let ok = true
3248
+ walk(cand.node, (c) => {
3249
+ if (!ok || !Array.isArray(c)) return
3250
+ if (c[0] === 'local.get') {
3251
+ if (typeof c[1] !== 'string') { ok = false; return }
3252
+ if (!params.some(p => p.name === c[1])) {
3253
+ const t = cand.ltype.get(c[1])
3254
+ if (!t) { ok = false; return }
3255
+ params.push({ name: c[1], type: t })
3256
+ }
3257
+ }
3258
+ else if (c[0] === 'local.tee' || c[0] === 'local.set') ok = false
3259
+ })
3260
+ if (!ok || params.length > 4) continue
3261
+ const rt = resultType(cand.node[0])
3262
+ const key = facts.get(cand.node).h + '|' + params.map(p => p.type).join(',') + '|' + rt
3263
+ let g = exact.get(key)
3264
+ if (!g) exact.set(key, g = { first: cand, ptypes: params.map(p => p.type), rt, bytes: cand.bytes, arity: params.length, sites: [] })
3265
+ g.sites.push({ node: cand.node, parent: cand.parent, idx: cand.idx, fn: cand.fn, args: params.map(p => p.name) })
1599
3266
  }
1600
3267
  }
1601
- // Unused local declaration
1602
- else if (sub[0] === 'local' && name[0] === '$' && uses.gets === 0 && uses.sets === 0 && uses.tees === 0) {
1603
- funcNode.splice(i, 1); changed = true
3268
+ // apply every profitable group, best first, skipping consumed overlaps
3269
+ const chosen = [...exact.values()]
3270
+ .map(g => ({ g, net: g.sites.length * (g.bytes - callB - 2 * g.arity) - (g.bytes + 5 + 3) }))
3271
+ .filter(x => x.g.sites.length >= 2 && x.net >= 4)
3272
+ .sort((a, b) => b.net - a.net)
3273
+ let applied = 0
3274
+ const consumed = new Set()
3275
+ const touched = new Set()
3276
+ for (const { g } of chosen) {
3277
+ const live = g.sites.filter(st => {
3278
+ if (st.parent[st.idx] !== st.node) return false // ancestor already replaced this slot
3279
+ if (consumed.has(st.node)) return false // inside an applied site's detached subtree
3280
+ let hit = false
3281
+ walk(st.node, c => { if (consumed.has(c)) hit = true })
3282
+ return !hit
3283
+ })
3284
+ const net = live.length * (g.bytes - callB - 2 * g.arity) - (g.bytes + 5 + 3)
3285
+ if (live.length < 2 || net < 4) continue
3286
+ // canonicalize the FIRST live site once: positional params, shared body
3287
+ const params = []
3288
+ const canon = walkPost(clone(live[0].node), (c) => {
3289
+ if (!Array.isArray(c)) return
3290
+ if (c[0] === 'local.get') {
3291
+ let i = params.indexOf(c[1])
3292
+ if (i < 0) { params.push(c[1]); i = params.length - 1 }
3293
+ return ['local.get', '$' + i]
3294
+ }
3295
+ })
3296
+ const name = '$__out' + outUid++
3297
+ ast.push(['func', name,
3298
+ ...g.ptypes.map((t, i) => ['param', '$' + i, t]),
3299
+ ['result', g.rt], canon])
3300
+ for (const st of live) {
3301
+ // mark the WHOLE subtree: a smaller candidate nested inside this site
3302
+ // still passes the parent-slot identity check on the detached tree
3303
+ walk(st.node, c => { if (Array.isArray(c)) consumed.add(c) })
3304
+ st.parent[st.idx] = ['call', name, ...st.args.map(a => ['local.get', a])]
3305
+ touched.add(st.fn)
3306
+ }
3307
+ applied++
1604
3308
  }
3309
+ if (!applied) break
3310
+ rescan = touched
1605
3311
  }
3312
+ return ast
3313
+ }
1606
3314
 
1607
- return changed
3315
+ // tiny string hash for composable weak keys (values only ever compared, never decoded)
3316
+ const hash32 = (s) => {
3317
+ let h = 2166136261
3318
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
3319
+ return (h >>> 0).toString(36)
3320
+ }
3321
+
3322
+ let tmUid = 0
3323
+ const tailmerge = (ast) => {
3324
+ walk(ast, (fn) => {
3325
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
3326
+ const isTerm = (n) => n === 'unreachable' || n === 'return' ||
3327
+ (Array.isArray(n) && (n[0] === 'return' || n[0] === 'unreachable' || n[0] === 'br'))
3328
+ const last = fn[fn.length - 1]
3329
+ if (!isTerm(last)) return
3330
+ // linear, position-independent snippet: no branch/control may relocate
3331
+ const movable = (body) => {
3332
+ let ok = true
3333
+ walk(body, x => {
3334
+ const o = Array.isArray(x) ? x[0] : (typeof x === 'string' && OPCODE[x] !== undefined ? x : null)
3335
+ if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return_call' || o === 'return_call_indirect' ||
3336
+ o === 'loop' || o === 'block' || o === 'if' || o === 'try_table' || o === 'unreachable') ok = false
3337
+ })
3338
+ return ok
3339
+ }
3340
+ const EMPTY_MAP = new Map()
3341
+ const groups = new Map()
3342
+ // sites may sit at any depth: `return` exits the function from anywhere, and the
3343
+ // replacement br_if targets the wrapper block by NAME
3344
+ walk(fn, (st, parent, idx) => {
3345
+ if (!Array.isArray(st) || st[0] !== 'if' || st.length !== 3 || !parent) return
3346
+ const { cond, thenBranch, elseBranch } = parseIf(st)
3347
+ if (!Array.isArray(cond) || !thenBranch || elseBranch) return
3348
+ const body = thenBranch.slice(1)
3349
+ if (!body.length) return
3350
+ // folded (return V) or wax's stacked `VALUE return` pair
3351
+ const ret = body[body.length - 1]
3352
+ if (!(Array.isArray(ret) && ret[0] === 'return') && ret !== 'return') return
3353
+ if (!movable(body) || estBytes(['b', ...body]) < 5) return
3354
+ const key = hashFunc(['b', ...body], EMPTY_MAP)
3355
+ let g = groups.get(key)
3356
+ if (!g) groups.set(key, g = { body, sites: [] })
3357
+ g.sites.push(st)
3358
+ })
3359
+ // every qualifying group wraps its own labeled block (inner groups nest inside
3360
+ // the earlier wrappers; br_if targets by name, so depth is irrelevant).
3361
+ // Sites are replaced by NODE IDENTITY: each wrap below splices the whole body
3362
+ // into a fresh block, so positions recorded during collection go stale — a
3363
+ // stale [parent, idx] would rewrite whatever statement NOW sits there into a
3364
+ // br_if with this group's label (silent misdirection, or a crash on a hole).
3365
+ const chosen = [...groups.values()].filter(g => g.sites.length >= 2)
3366
+ for (const g of chosen) {
3367
+ const label = '$__tm' + tmUid++
3368
+ const siteSet = new Set(g.sites)
3369
+ walk(fn, (n, parent, idx) => {
3370
+ if (parent && siteSet.has(n)) {
3371
+ const { cond } = parseIf(n)
3372
+ parent[idx] = ['br_if', label, cond]
3373
+ }
3374
+ })
3375
+ let at = typeof fn[1] === 'string' && fn[1][0] === '$' ? 2 : 1
3376
+ while (at < fn.length && Array.isArray(fn[at]) &&
3377
+ (fn[at][0] === 'export' || fn[at][0] === 'type' || fn[at][0] === 'param' || fn[at][0] === 'result' || fn[at][0] === 'local')) at++
3378
+ const stmts = fn.splice(at, fn.length - at)
3379
+ fn.push(['block', label, ...stmts], ...g.body)
3380
+ }
3381
+ })
3382
+ return ast
1608
3383
  }
1609
3384
 
1610
3385
  /**
1611
- * Drop `(local.set $x A)` when the very next statement re-sets $x without reading it
1612
- * first (A pure). The two writes are adjacent, so A's value is overwritten before any
1613
- * observationit's dead. The whole-function {@link eliminateDeadStores} misses this:
1614
- * it only fires when $x is read NOWHERE, whereas here $x is live later, just not
1615
- * between these two writes. Pairs with copy-propagation, which rewrites
1616
- * `$x=$y; $x=f($x)` to `$x=$y; $x=f($y)` an adjacent dead store this removes,
1617
- * collapsing the round-trip jz's value-model lowering leaves behind.
1618
- * @param {Array} funcNode a straight-line scope (body / block / loop / then / else)
1619
- * @param {Set<string>} params
3386
+ * Merge alias locals: `(local.set $A (local.tee $B V))` writes the same value into
3387
+ * two slots. When that is the ONLY write either local ever gets, their write
3388
+ * histories are identical every read of $A (even one sequenced before the def,
3389
+ * which sees the zero default on both) equals the same read of $B. So $A's reads
3390
+ * rename to $B and the alias write drops. Params are excluded ($B would carry a
3391
+ * call-argument value where $A held zero).
3392
+ * @param {Array} ast
3393
+ * @returns {Array}
1620
3394
  */
1621
- const eliminateAdjacentDeadStores = (funcNode, params) => {
1622
- let changed = false
1623
- for (let i = 1; i < funcNode.length - 1; i++) {
1624
- const a = funcNode[i], b = funcNode[i + 1]
1625
- // `a` must be a plain set (a tee leaves its value on the stack not removable);
1626
- // `b` may be a set OR a tee (both overwrite the local before `a`'s value is read).
1627
- if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3) continue
1628
- if (!Array.isArray(b) || (b[0] !== 'local.set' && b[0] !== 'local.tee') || b.length !== 3 || b[1] !== a[1]) continue
1629
- if (params.has(a[1]) || !isPure(a[2])) continue
1630
- // Dead only if b's value doesn't read $x before overwriting it.
1631
- let reads = false
1632
- walk(b[2], n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === a[1]) reads = true })
1633
- if (reads) continue
1634
- funcNode.splice(i, 1); changed = true; i--
1635
- }
1636
- return changed
3395
+ const mergeLocals = (ast) => {
3396
+ walk(ast, (fn) => {
3397
+ if (!Array.isArray(fn) || fn[0] !== 'func') return
3398
+ const params = new Set()
3399
+ for (const c of fn) if (Array.isArray(c) && c[0] === 'param' && typeof c[1] === 'string') params.add(c[1])
3400
+ const counts = countLocalUses(fn)
3401
+ const renames = new Map()
3402
+ walkPost(fn, (n) => {
3403
+ if (!Array.isArray(n) || n[0] !== 'local.set' || n.length !== 3 ||
3404
+ !Array.isArray(n[2]) || n[2][0] !== 'local.tee' || n[2].length !== 3) return
3405
+ const A = n[1], B = n[2][1]
3406
+ if (typeof A !== 'string' || typeof B !== 'string' || A === B) return
3407
+ if (params.has(A) || params.has(B) || renames.has(A) || renames.has(B)) return
3408
+ const ca = counts.get(A), cb = counts.get(B)
3409
+ if (!ca || !cb || ca.sets !== 1 || ca.tees !== 0 || cb.sets !== 0 || cb.tees !== 1) return
3410
+ renames.set(A, B)
3411
+ return ['local.set', B, n[2][2]]
3412
+ })
3413
+ if (renames.size) walkPost(fn, (n) => {
3414
+ if (Array.isArray(n) && n[0] === 'local.get' && renames.has(n[1])) return ['local.get', renames.get(n[1])]
3415
+ })
3416
+ })
3417
+ return ast
1637
3418
  }
1638
3419
 
1639
3420
  /**
@@ -1662,26 +3443,67 @@ const propagate = (ast) => {
1662
3443
  // first so inner simplifications shrink the use-counts the outer scopes see.
1663
3444
  // Use-counts are always whole-function — a set/get pair or dead store is only
1664
3445
  // touched when it's globally the sole occurrence, so per-scope work stays sound.
1665
- const scopes = []
1666
- walkPost(funcNode, n => { if (isScopeNode(n)) scopes.push(n) })
1667
-
1668
- // One use-count per round, shared by every scope: substitutions only ever
1669
- // *drop* gets, so a stale count can only make a sub-pass act more cautiously
1670
- // (skip a not-yet-provably-dead store, decline a not-yet-provably-single use)
1671
- // never wrongly. The next round re-counts and mops up. (Recounting per sub-pass
1672
- // per scope is O(scopes·funcSize) and crippling on big modules.)
3446
+
3447
+ // One use-count per propagation sweep: for the cautious sub-passes a stale count
3448
+ // only over-counts (skip a not-yet-provably-dead store) — never wrongly. The
3449
+ // exception is sinkSets' sole-use substitution: copy-propagation REPLICATES gets
3450
+ // of a copy's source local, so an under-count there would orphan a surviving get
3451
+ // recount once after the propagation sweep before sinking. (Recounting per
3452
+ // sub-pass per scope is O(scopes·funcSize) and crippling on big modules.)
3453
+ // Maintained counts: one from-scratch tally per FUNCTION, then every mutation
3454
+ // in the family reports its delta — the old per-round double recount (and its
3455
+ // missed-refresh bug class) goes away. The oracle flag re-derives and compares
3456
+ // after every sub-pass; the test battery runs with it on.
3457
+ CNT = countLocalUses(funcNode)
3458
+ CNT_FN = funcNode
1673
3459
  for (let round = 0; round < MAX_PROP_ROUNDS; round++) {
1674
- const useCounts = countLocalUses(funcNode)
3460
+ // Scopes RE-COLLECT each round: a spliced statement detaches any scope
3461
+ // nested in its value — the once-collected list kept mutating dead trees
3462
+ // (wasted work always; corrupted counts once they were maintained) and
3463
+ // never saw scopes newly created by substitution clones.
3464
+ const scopes = []
3465
+ walkPost(funcNode, n => { if (isScopeNode(n)) scopes.push(n) })
3466
+ const useCounts = CNT
1675
3467
  let progressed = false
3468
+ for (const scope of scopes) if (forwardPropagate(scope, params, useCounts)) progressed = true
3469
+ cntOracle(funcNode, 'forwardPropagate')
3470
+ const counts = CNT
1676
3471
  for (const scope of scopes) {
1677
- if (forwardPropagate(scope, params, useCounts)) progressed = true
1678
- if (eliminateSetGetPairs(scope, params, useCounts)) progressed = true
1679
- if (createLocalTees(scope, params, useCounts)) progressed = true
1680
- if (eliminateDeadStores(scope, params, useCounts)) progressed = true
1681
- if (eliminateAdjacentDeadStores(scope, params)) progressed = true
3472
+ // pure set feeding a loop (possibly through a run of other pure sets that
3473
+ // don't read it) which provably clobbers it on every path before any read,
3474
+ // with no reads outside the loop: dead
3475
+ for (let i = 1; i < scope.length - 1; i++) {
3476
+ const st = scope[i]
3477
+ if (!(Array.isArray(st) && st[0] === 'local.set' && st.length === 3 && typeof st[1] === 'string' &&
3478
+ Array.isArray(st[2]) && isPure(st[2]) && !hasTrap(st[2]))) continue
3479
+ let j = i + 1, blocked = false
3480
+ while (j < scope.length) {
3481
+ const nx = scope[j]
3482
+ if (Array.isArray(nx) && nx[0] === 'loop') break
3483
+ // an intervening statement may only be another pure set that neither
3484
+ // reads nor writes this local
3485
+ if (!(Array.isArray(nx) && nx[0] === 'local.set' && nx.length === 3 && nx[1] !== st[1] &&
3486
+ Array.isArray(nx[2]) && isPure(nx[2]))) { blocked = true; break }
3487
+ let touches = false
3488
+ walk(nx[2], c => { if (Array.isArray(c) && (c[0] === 'local.get' || c[0] === 'local.tee') && c[1] === st[1]) touches = true })
3489
+ if (touches) { blocked = true; break }
3490
+ j++
3491
+ }
3492
+ if (blocked || j >= scope.length || !Array.isArray(scope[j]) || scope[j][0] !== 'loop') continue
3493
+ if (deadThroughLoop(funcNode, st[1], scope[j])) { cntSub(st); scope.splice(i, 1); i--; progressed = true }
3494
+ }
3495
+ cntOracle(funcNode, 'deadThroughLoop')
3496
+ if (commuteForSink(scope)) { progressed = true; cntOracle(funcNode, 'commuteForSink') }
3497
+ if (sinkIntoBranch(scope, params, counts)) { progressed = true; cntOracle(funcNode, 'sinkIntoBranch') }
3498
+ if (mergeCopyThroughTee(scope, params, counts)) { progressed = true; cntOracle(funcNode, 'mergeCopyThroughTee') }
3499
+ if (sinkSets(scope, params, counts)) { progressed = true; cntOracle(funcNode, 'sinkSets') }
3500
+ if (eliminateDeadStores(scope, params, counts)) { progressed = true; cntOracle(funcNode, 'eliminateDeadStores') }
3501
+ if (eliminateAdjacentDeadStores(scope, params)) { progressed = true; cntOracle(funcNode, 'adjacentDSE') }
1682
3502
  }
1683
3503
  if (!progressed) break
1684
3504
  }
3505
+ CNT = null
3506
+ CNT_FN = null
1685
3507
  })
1686
3508
 
1687
3509
  return ast
@@ -1697,7 +3519,13 @@ const propagate = (ast) => {
1697
3519
  // `(block $__inlN (result T)? …)`. Only the SELECTION policy differs (one caller vs
1698
3520
  // every caller of a small body), so the lift lives here once.
1699
3521
 
3522
+ const EMPTY_MAP = new Map()
1700
3523
  let inlineUid = 0
3524
+ // Bumped by every splice that can GROW the binary (inline / inlineOnce /
3525
+ // inlineMacro expansion). The driver's exit size-guard exists solely for these —
3526
+ // when none fired during rounds, every executed pass was size-monotone and both
3527
+ // guard encodes are skipped.
3528
+ let inflations = 0
1701
3529
  const INL_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
1702
3530
  const inlBodyStart = (fn) => {
1703
3531
  let i = 2
@@ -1707,12 +3535,20 @@ const inlBodyStart = (fn) => {
1707
3535
  const inlIsBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
1708
3536
  // A subtree we can't lift into a (block …): depth-relative branch labels (which would
1709
3537
  // shift under the added nesting) or tail calls (which would escape the wrapping block).
3538
+ // Flat (unfolded) control tokens can't be relabeled under the inline wrapper's added
3539
+ // nesting — bodies carrying them don't lift. Bare 'return' is exempt: buildInline
3540
+ // rewrites it to a br. Bare value ops ('drop', 'i32.add', …) are position-independent.
3541
+ const FLAT_CTRL = new Set(['block', 'loop', 'if', 'else', 'end', 'br', 'br_if', 'br_table',
3542
+ 'try_table', 'catch', 'catch_all', 'delegate', 'rethrow', 'return_call', 'return_call_indirect'])
1710
3543
  const inlUnsafe = (n) => {
3544
+ if (typeof n === 'string') return FLAT_CTRL.has(n)
1711
3545
  if (!Array.isArray(n)) return false
1712
3546
  const op = n[0]
1713
3547
  if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
1714
3548
  if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
1715
- if (inlIsBranch(op)) for (let i = 1; i < n.length; i++) if (typeof n[i] === 'number' || (typeof n[i] === 'string' && /^\d+$/.test(n[i]))) return true
3549
+ // NUMERIC branch labels are safe to splice: internal depths are preserved verbatim,
3550
+ // and a function-frame branch (depth past every callee block) lands exactly on the
3551
+ // single wrapper block buildInline adds — the same return-to-exit semantics.
1716
3552
  for (let i = 1; i < n.length; i++) if (inlUnsafe(n[i])) return true
1717
3553
  return false
1718
3554
  }
@@ -1830,6 +3666,7 @@ const inlBodySize = (fn) => {
1830
3666
  * …body, renamed, `return X` → `br $__inlN X`…)
1831
3667
  */
1832
3668
  const buildInline = (params, locals, inlResult, cBody, args) => {
3669
+ inflations++
1833
3670
  const uid = ++inlineUid
1834
3671
  const exit = `$__inl${uid}`
1835
3672
  const rename = new Map()
@@ -1846,6 +3683,7 @@ const buildInline = (params, locals, inlResult, cBody, args) => {
1846
3683
  }
1847
3684
  for (const n of cBody) collectLabels(n)
1848
3685
  const sub = (n) => {
3686
+ if (n === 'return') return ['br', exit] // bare stack-style return — value already on stack
1849
3687
  if (!Array.isArray(n)) return n
1850
3688
  const op = n[0]
1851
3689
  if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
@@ -2265,24 +4103,27 @@ const inlineOnce = (ast, { pin = EMPTY_SET } = {}) => {
2265
4103
  const bodyStart = inlBodyStart, callsSelf = inlCallsSelf, unsafe = inlUnsafe, isBranch = inlIsBranch
2266
4104
  const zeroFor = inlZeroFor, needsReset = inlNeedsReset
2267
4105
 
2268
- for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
2269
- const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
2270
- const funcByName = new Map()
2271
- for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
4106
+ // Count plain-call references across the WHOLE module ONCE (anonymous exported
4107
+ // funcs call helpers too); flag any non-call reference (return_call etc.).
4108
+ // Splicing one callee into its lone caller MOVES the callee's own calls — the
4109
+ // module-wide counts are unchanged except the dissolved call itself, so the
4110
+ // maps are maintained incrementally instead of re-walked per round.
4111
+ const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
4112
+ const funcByName = new Map()
4113
+ for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
4114
+ const callRefs = new Map(), otherRef = new Set()
4115
+ const countRefs = (n) => {
4116
+ if (!Array.isArray(n)) return
4117
+ const op = n[0]
4118
+ if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
4119
+ else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
4120
+ for (let i = 1; i < n.length; i++) countRefs(n[i])
4121
+ }
4122
+ countRefs(ast)
4123
+ const pinned = inlBuildPinned(ast)
4124
+ // a func may carry its own (export "name") — the signature scan below rejects those too
2272
4125
 
2273
- // Count plain-call references across the WHOLE module (anonymous exported funcs
2274
- // call helpers too); flag any non-call reference (return_call etc.).
2275
- const callRefs = new Map(), otherRef = new Set()
2276
- const countRefs = (n) => {
2277
- if (!Array.isArray(n)) return
2278
- const op = n[0]
2279
- if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
2280
- else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
2281
- for (let i = 1; i < n.length; i++) countRefs(n[i])
2282
- }
2283
- countRefs(ast)
2284
- const pinned = inlBuildPinned(ast)
2285
- // a func may carry its own (export "name") — the signature scan below rejects those too
4126
+ for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
2286
4127
 
2287
4128
  // Pick a callee.
2288
4129
  let calleeName = null
@@ -2332,6 +4173,7 @@ const inlineOnce = (ast, { pin = EMPTY_SET } = {}) => {
2332
4173
  }
2333
4174
  const cBody = callee.slice(bodyStart(callee))
2334
4175
 
4176
+ inflations++
2335
4177
  const uid = ++inlineUid
2336
4178
  const exit = `$__inl${uid}`
2337
4179
  const rename = new Map()
@@ -2348,6 +4190,10 @@ const inlineOnce = (ast, { pin = EMPTY_SET } = {}) => {
2348
4190
  }
2349
4191
  for (const n of cBody) collectLabels(n)
2350
4192
  const sub = (n) => {
4193
+ // bare stack-style `return` (flat/wax form) copied verbatim would become a
4194
+ // hard FUNCTION return inside the caller, truncating everything after the
4195
+ // splice — same rewrite as the folded form: exit the inline block instead
4196
+ if (n === 'return') return ['br', exit]
2351
4197
  if (!Array.isArray(n)) return n
2352
4198
  const op = n[0]
2353
4199
  if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
@@ -2397,6 +4243,10 @@ const inlineOnce = (ast, { pin = EMPTY_SET } = {}) => {
2397
4243
 
2398
4244
  const idx = ast.indexOf(callee)
2399
4245
  if (idx >= 0) ast.splice(idx, 1)
4246
+ funcByName.delete(calleeName)
4247
+ callRefs.delete(calleeName)
4248
+ const fi = funcs.indexOf(callee)
4249
+ if (fi >= 0) funcs.splice(fi, 1)
2400
4250
  }
2401
4251
 
2402
4252
  return ast
@@ -2572,21 +4422,48 @@ const coalesceLocals = (ast) => {
2572
4422
  walk(ast, (funcNode) => {
2573
4423
  if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
2574
4424
 
2575
- const decls = new Map()
4425
+ const decls = new Map(), params = new Map()
2576
4426
  for (const sub of funcNode) {
2577
- if (Array.isArray(sub) && sub[0] === 'local' &&
2578
- typeof sub[1] === 'string' && sub[1][0] === '$' && typeof sub[2] === 'string') {
2579
- decls.set(sub[1], sub[2])
2580
- }
4427
+ if (!Array.isArray(sub) || typeof sub[1] !== 'string' || sub[1][0] !== '$' || typeof sub[2] !== 'string') continue
4428
+ if (sub[0] === 'local') decls.set(sub[1], sub[2])
4429
+ else if (sub[0] === 'param') params.set(sub[1], sub[2])
2581
4430
  }
2582
- if (decls.size < 2) return
4431
+ if (!decls.size || decls.size + params.size < 2) return
2583
4432
 
2584
4433
  const uses = new Map()
2585
- const loopStack = []
2586
- let pos = 0, abort = false, condDepth = 0
4434
+ const loopStack = [], condStack = []
4435
+ let pos = 0, abort = false
4436
+ // effective innermost arm for a local: frames where BOTH sibling arms write it
4437
+ // at statement level are transparent (the write happens on every path)
4438
+ const effArm = (name) => {
4439
+ let k = condStack.length - 1
4440
+ while (k >= 0 && condStack[k].bothW && condStack[k].bothW.has(name)) k--
4441
+ return k >= 0 ? condStack[k] : null
4442
+ }
4443
+ // statement-level writes of a branch-free arm; null when the arm can exit early
4444
+ const armSets = (arm) => {
4445
+ let ok = true
4446
+ walk(arm, x => {
4447
+ const o = Array.isArray(x) ? x[0] : x
4448
+ if (o === 'br' || o === 'br_if' || o === 'br_table' || o === 'return' || o === 'unreachable' ||
4449
+ o === 'throw' || o === 'return_call' || o === 'return_call_indirect') ok = false
4450
+ })
4451
+ if (!ok) return null
4452
+ const set = new Set()
4453
+ for (let k = 1; k < arm.length; k++) {
4454
+ const st = arm[k]
4455
+ if (Array.isArray(st) && (st[0] === 'local.set' || st[0] === 'local.tee') && typeof st[1] === 'string') set.add(st[1])
4456
+ }
4457
+ return set
4458
+ }
2587
4459
 
2588
4460
  const visit = (n) => {
2589
- if (abort || !Array.isArray(n)) return
4461
+ if (abort) return
4462
+ // flat-form control tokens make loop/arm boundaries invisible to the interval
4463
+ // model — a joined slot could leak residue across an unseen back-edge
4464
+ if (typeof n === 'string' && (n === 'loop' || n === 'block' || n === 'if' || n === 'else' || n === 'end' ||
4465
+ n === 'br' || n === 'br_if' || n === 'br_table')) { abort = true; return }
4466
+ if (!Array.isArray(n)) return
2590
4467
  const op = n[0]
2591
4468
  const isLoop = op === 'loop'
2592
4469
  if (isLoop) loopStack.push({ start: pos, end: pos })
@@ -2600,21 +4477,52 @@ const coalesceLocals = (ast) => {
2600
4477
  // read-then-write of $x (firstOp = local.get).
2601
4478
  if (isSet) for (let i = 2; i < n.length; i++) visit(n[i])
2602
4479
  const here = pos++
2603
- if (decls.has(name)) {
4480
+ if (decls.has(name) || params.has(name)) {
2604
4481
  let u = uses.get(name)
2605
- if (!u) { u = { start: here, end: here, firstOp: op, firstCond: condDepth > 0, loops: new Set() }; uses.set(name, u) }
4482
+ // A first WRITE only licenses slot-joining when it executes on EVERY path that
4483
+ // reaches a later read — else the read must see the local's implicit ZERO, and a
4484
+ // joined slot would leak the previous occupant's residue. Three skippable
4485
+ // contexts break the guarantee: an if/else arm (condDepth), a LOOP body (a
4486
+ // zero-trip loop skips the write but a read after the loop still runs — the
4487
+ // mat4 `iters=0` miscompile), and any statement AFTER a br/br_if/return in the
4488
+ // same list (the rotated-loop entry guard `(block (br_if $out …) …)` shape).
4489
+ if (!u) {
4490
+ u = { start: here, end: here, firstOp: op,
4491
+ firstArm: effArm(name), armEscapes: false,
4492
+ firstLoop: loopStack[loopStack.length - 1] ?? null, escapes: false, loops: new Set() }
4493
+ uses.set(name, u)
4494
+ } else {
4495
+ // a use outside the first write's innermost loop makes zero-trip skips
4496
+ // observable; a use outside its EFFECTIVE arm can execute on a path that
4497
+ // never wrote — either way the slot must not be joined. (A local written
4498
+ // at statement level in BOTH branch-free arms of an if is written on
4499
+ // every path through it, so those arm frames are transparent for it.)
4500
+ if ((loopStack[loopStack.length - 1] ?? null) !== u.firstLoop) u.escapes = true
4501
+ if (effArm(name) !== u.firstArm) u.armEscapes = true
4502
+ }
2606
4503
  if (here > u.end) u.end = here
2607
4504
  for (const ls of loopStack) u.loops.add(ls)
2608
4505
  }
2609
4506
  } else {
2610
4507
  pos++
2611
4508
  const isIf = op === 'if'
4509
+ let bothW = null
4510
+ if (isIf) {
4511
+ const { thenBranch, elseBranch } = parseIf(n)
4512
+ if (thenBranch && elseBranch) {
4513
+ const a = armSets(thenBranch), b = armSets(elseBranch)
4514
+ if (a && b) { bothW = new Set(); for (const x of a) if (b.has(x)) bothW.add(x); if (!bothW.size) bothW = null }
4515
+ }
4516
+ }
4517
+ let branched = false // a direct-child br/br_if/return makes the REST of this list conditional
2612
4518
  for (let i = 1; i < n.length; i++) {
2613
4519
  const c = n[i]
2614
- const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
2615
- if (cond) condDepth++
4520
+ const isArm = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
4521
+ const cond = isArm || branched
4522
+ if (cond) condStack.push({ bothW: isArm ? bothW : null })
2616
4523
  visit(c)
2617
- if (cond) condDepth--
4524
+ if (cond) condStack.pop()
4525
+ if (Array.isArray(c) && (c[0] === 'br_if' || c[0] === 'br' || c[0] === 'br_table' || c[0] === 'return' || c[0] === 'return_call' || c[0] === 'return_call_indirect' || c[0] === 'unreachable')) branched = true
2618
4526
  }
2619
4527
  }
2620
4528
 
@@ -2623,24 +4531,35 @@ const coalesceLocals = (ast) => {
2623
4531
  visit(funcNode)
2624
4532
  if (abort) return
2625
4533
 
2626
- // A use inside a loop must stay live for the whole loop — the next
2627
- // iteration could read what this iteration wrote.
4534
+ // A use inside a loop must stay live for the whole loop — the next iteration
4535
+ // could read what this iteration wrote. EXCEPT a write-first, unconditional,
4536
+ // non-escaping local: every trip rewrites it before any read, so its lifetime
4537
+ // is per-iteration and its raw [write, lastUse] range is the true one.
2628
4538
  for (const u of uses.values()) {
4539
+ if (u.firstOp !== 'local.get' && !u.escapes && (u.firstArm === null || !u.armEscapes) && u.firstLoop !== null) continue
2629
4540
  for (const ls of u.loops) {
2630
4541
  if (ls.start < u.start) u.start = ls.start
2631
4542
  if (ls.end > u.end) u.end = ls.end
2632
4543
  }
2633
4544
  }
2634
4545
 
2635
- const ordered = [...uses.entries()].sort((a, b) => a[1].start - b[1].start)
4546
+ const ordered = [...uses.entries()].filter(([n]) => decls.has(n)).sort((a, b) => a[1].start - b[1].start)
2636
4547
  const rename = new Map()
2637
- const slots = []
4548
+ // Params seed pre-colored slots: the argument value is live from entry to its last
4549
+ // use — after that the slot is free scratch for a same-typed local. Zero-reading
4550
+ // locals still never join (a param holds the caller's residue, not zero).
4551
+ const slots = [...params].map(([name, type]) => ({ primary: name, type, end: uses.get(name)?.end ?? -1 }))
2638
4552
  for (const [name, range] of ordered) {
2639
- // Read-first locals depend on the implicit zero; locals first seen inside
2640
- // an if/else branch may be skipped on the alternate path either way
2641
- // they'd observe a prior slot's residue if reused. They may *start* a
2642
- // fresh slot (the function's zero init), but never *join* one.
2643
- const readsZero = range.firstOp === 'local.get' || range.firstCond
4553
+ // Read-first locals depend on the implicit zero they may *start* a fresh
4554
+ // slot (the function's zero init) but never *join* one. A first WRITE joins
4555
+ // when it dominates every read: uses confined to the write's own if-arm run
4556
+ // only on the path that wrote (arm-contained), and uses confined to the
4557
+ // write's loop are rewritten every trip (loop-carried read-before-write
4558
+ // shows up as a firstOp get). A use escaping either region could observe a
4559
+ // skipped write — the previous occupant's residue — so it blocks joining.
4560
+ const readsZero = range.firstOp === 'local.get' ||
4561
+ (range.firstArm !== null && range.armEscapes) ||
4562
+ (range.firstLoop !== null && range.escapes)
2644
4563
  const type = decls.get(name)
2645
4564
  const slot = readsZero ? null : slots.find(s => s.type === type && s.end < range.start)
2646
4565
  if (slot) { rename.set(name, slot.primary); if (range.end > slot.end) slot.end = range.end }
@@ -2685,10 +4604,12 @@ const vacuum = (ast) => {
2685
4604
  return ['block', ...eff]
2686
4605
  }
2687
4606
 
2688
- // (select x x cond) → x — only when cond is PURE. An impure cond may set a
2689
- // local that a later op reads (e.g. an address `local.tee` the matching store
2690
- // reuses); dropping it would leave that local stale. Keep the select otherwise.
2691
- if (op === 'select' && node.length >= 4 && equal(node[1], node[2]) && isPure(node[3])) return node[1]
4607
+ // (select x x cond) → x — only when the arm AND cond are PURE. select evaluates BOTH
4608
+ // arms, so collapsing two identical IMPURE arms to one would drop a side effect (run it
4609
+ // once, not twice); and an impure cond may set a local a later op reads (an address
4610
+ // `local.tee` the matching store reuses) dropping it leaves that local stale. Keep
4611
+ // the select unless everything discarded is pure.
4612
+ if (op === 'select' && node.length >= 4 && equal(node[1], node[2]) && isPure(node[1]) && isPure(node[3])) return node[1]
2692
4613
 
2693
4614
  if (op === 'if') {
2694
4615
  const { cond, thenBranch, elseBranch } = parseIf(node)
@@ -2702,6 +4623,15 @@ const vacuum = (ast) => {
2702
4623
  if (elseBranch && elseEmpty && !thenEmpty) {
2703
4624
  return node.filter(c => c !== elseBranch)
2704
4625
  }
4626
+
4627
+ // (if cond (then) (else X)) → (if (i32.eqz cond) (then X)) — void ifs only
4628
+ // (a result-typed if needs both arms); the eqz byte costs less than the
4629
+ // else marker + arm framing it removes.
4630
+ if (thenEmpty && thenBranch && elseBranch && !elseEmpty && Array.isArray(cond) &&
4631
+ !node.some(c => Array.isArray(c) && c[0] === 'result')) {
4632
+ return node.filter(c => c !== thenBranch && c !== elseBranch && c !== cond)
4633
+ .concat([['i32.eqz', cond], ['then', ...elseBranch.slice(1)]])
4634
+ }
2705
4635
  }
2706
4636
 
2707
4637
  // Clean out nops, drop-of-pure sequences, and empty annotations from blocks
@@ -2742,6 +4672,9 @@ const vacuum = (ast) => {
2742
4672
  * still yields the same value AND runs the operand). */
2743
4673
  const selfFold = (val) => (a, b) => equal(a, b) && isPure(a) ? val : null
2744
4674
  const PEEPHOLE = {
4675
+ // (local.tee $x (local.get $x)) re-stores the exact value already held — for any
4676
+ // bit pattern — so it is the bare get.
4677
+ 'local.tee': (a, b) => Array.isArray(b) && b[0] === 'local.get' && b[1] === a ? b : null,
2745
4678
  // Self-cancelling / tautological binary ops — drop both (equal) operands.
2746
4679
  'i32.sub': selfFold(['i32.const', 0]),
2747
4680
  'i64.sub': selfFold(['i64.const', 0]),
@@ -2813,13 +4746,35 @@ const PEEPHOLE = {
2813
4746
  * @param {Array} ast
2814
4747
  * @returns {Array}
2815
4748
  */
2816
- const peephole = (ast) => {
2817
- return walkPost(ast, (node) => {
4749
+ const peepholeNode = (node) => {
2818
4750
  if (!Array.isArray(node) || node.length !== 3) return
2819
4751
  const fn = PEEPHOLE[node[0]]
2820
4752
  if (!fn) return
2821
4753
  const result = fn(node[1], node[2])
2822
4754
  if (result !== null) return result
4755
+ }
4756
+ /** Peephole rules as a standalone pass. */
4757
+ const peephole = (ast) => walkPost(ast, peepholeNode)
4758
+
4759
+ /**
4760
+ * Fused algebraic sweep — fold → identity → strength → peephole applied per node in
4761
+ * ONE bottom-up traversal instead of four, re-running the family on a node until it
4762
+ * stabilizes (children are final when the parent is visited, so cross-rule cascades
4763
+ * converge in-walk instead of across driver rounds). The rule set follows the same
4764
+ * option keys as the standalone passes.
4765
+ */
4766
+ const SIMPLIFY = [['fold', foldNode], ['identity', identityNode], ['strength', strengthNode], ['peephole', peepholeNode]]
4767
+ const SIMPLIFY_KEYS = new Set(SIMPLIFY.map(([k]) => k))
4768
+ const simplify = (ast, opts) => {
4769
+ const rules = SIMPLIFY.filter(([k]) => opts[k]).map(([, f]) => f)
4770
+ if (!rules.length) return ast
4771
+ return walkPost(ast, (node) => {
4772
+ let out
4773
+ for (let i = 0, spins = 0; i < rules.length; i++) {
4774
+ const r = rules[i](out === undefined ? node : out)
4775
+ if (r !== undefined && spins++ < 10) { out = r, i = -1 } // a hit re-runs the family
4776
+ }
4777
+ return out
2823
4778
  })
2824
4779
  }
2825
4780
 
@@ -2827,8 +4782,9 @@ const peephole = (ast) => {
2827
4782
 
2828
4783
  /** Bytes a signed-LEB128 integer encodes to. */
2829
4784
  const slebSize = (v) => {
4785
+ // BigInt() rejects signed hex ('-0x1', '+0x2') — strip the sign and reapply.
2830
4786
  let x = typeof v === 'bigint' ? v
2831
- : typeof v === 'string' ? BigInt(v.replaceAll('_', ''))
4787
+ : typeof v === 'string' ? (v = v.replaceAll('_', ''), v[0] === '-' ? -BigInt(v.slice(1)) : BigInt(v[0] === '+' ? v.slice(1) : v))
2832
4788
  : BigInt(Math.trunc(Number(v) || 0))
2833
4789
  // Signed view of raw bits — exact natively; in-kernel the arm is dead
2834
4790
  // (BigInt('0x…') already arrives as the signed i64 carrier there).
@@ -2900,6 +4856,49 @@ const globals = (ast) => {
2900
4856
  else if (n[0] === 'global.get') reads.set(ref, (reads.get(ref) || 0) + 1)
2901
4857
  })
2902
4858
 
4859
+ // Local encoded-size estimate for a small cloned expression subtree — consts and
4860
+ // global.get price at their real encoded size; every other op is 1 opcode byte +
4861
+ // its operands. Approximate (ignores multi-byte prefixed opcodes and memarg
4862
+ // immediates) but only ever used to COMPARE before/after shapes of the same
4863
+ // subtree, so the approximation errors cancel.
4864
+ const estSize = (node) => {
4865
+ if (!Array.isArray(node)) return 0
4866
+ if (node[0] === 'i32.const' || node[0] === 'i64.const' || node[0] === 'f32.const' || node[0] === 'f64.const') return constInstrSize(node)
4867
+ if (node[0] === 'global.get') return GLOBAL_GET_SIZE
4868
+ let n = 1
4869
+ for (let i = 1; i < node.length; i++) n += estSize(node[i])
4870
+ return n
4871
+ }
4872
+ // Ancestor-aware read pricing: a global.get nested under const-chain ops may fold
4873
+ // away almost entirely once literalized (chain reassociation collapses the ops).
4874
+ // Pricing every read at a flat const size misses this — clone each read's small
4875
+ // enclosing subtree, splice in the literal, fold it locally, and measure the
4876
+ // REAL shape instead of guessing. One walk collects every global's sites.
4877
+ // BOUNDED: a widely-read global (a kernel NaN-box constant with thousands of
4878
+ // reads) or a huge anchor prices flat — unbounded per-read clones of a multi-MB
4879
+ // module held at once ran a self-host build out of memory.
4880
+ const ANCESTOR_DEPTH = 2, MAX_PRICED_READS = 64, MAX_ANCHOR_NODES = 256
4881
+ const gsites = new Map()
4882
+ {
4883
+ const stack = []
4884
+ const visit = (node) => {
4885
+ if (!Array.isArray(node)) return
4886
+ if (node[0] === 'global.get' && typeof node[1] === 'string' && constGlobals.has(node[1])) {
4887
+ if ((reads.get(node[1]) || 0) <= MAX_PRICED_READS) {
4888
+ const anchor = stack.length >= ANCESTOR_DEPTH ? stack[stack.length - ANCESTOR_DEPTH] : stack[0]
4889
+ const site = anchor ?? node
4890
+ let a = gsites.get(node[1])
4891
+ if (!a) gsites.set(node[1], a = [])
4892
+ a.push(count(site) <= MAX_ANCHOR_NODES ? clone(site) : ['global.get', node[1]])
4893
+ }
4894
+ return
4895
+ }
4896
+ stack.push(node)
4897
+ for (let i = 0; i < node.length; i++) visit(node[i])
4898
+ stack.pop()
4899
+ }
4900
+ visit(ast)
4901
+ }
2903
4902
  // Keep only globals where propagation is size-neutral or better.
2904
4903
  const propagate = new Set()
2905
4904
  for (const [name, init] of constGlobals) {
@@ -2907,8 +4906,21 @@ const globals = (ast) => {
2907
4906
  if (r === 0) continue // dead anyway — leave to treeshake
2908
4907
  const cs = constInstrSize(init)
2909
4908
  const declSize = cs + 2 // valtype + mutability byte + init expr + `end`
2910
- const before = r * GLOBAL_GET_SIZE + declSize
2911
- const after = r * cs + (exported.has(name) ? declSize : 0)
4909
+ const sites = gsites.get(name)
4910
+ let before, after
4911
+ if (sites && sites.length === r) {
4912
+ before = declSize; after = exported.has(name) ? declSize : 0
4913
+ for (const site of sites) {
4914
+ before += estSize(site)
4915
+ const spliced = walkPost(clone(site), (n) => {
4916
+ if (Array.isArray(n) && n[0] === 'global.get' && n[1] === name) return clone(init)
4917
+ })
4918
+ after += estSize(fold(spliced))
4919
+ }
4920
+ } else { // beyond the pricing bounds — flat model
4921
+ before = r * GLOBAL_GET_SIZE + declSize
4922
+ after = r * cs + (exported.has(name) ? declSize : 0)
4923
+ }
2912
4924
  if (after <= before) propagate.add(name)
2913
4925
  }
2914
4926
  if (propagate.size === 0) return ast
@@ -3001,7 +5013,41 @@ const offset = (ast) => {
3001
5013
  * @param {Array} ast
3002
5014
  * @returns {Array}
3003
5015
  */
5016
+ // Func names whose signature has no result — their calls are stack-neutral
5017
+ // statements. Named only: declaration order diverges from binary index under
5018
+ // inline imports, so numeric call refs are never trusted.
5019
+ const collectVoidFuncs = (ast) => {
5020
+ const voidFuncs = new Set()
5021
+ if (Array.isArray(ast) && ast[0] === 'module') {
5022
+ for (const n of ast.slice(1)) {
5023
+ if (!Array.isArray(n)) continue
5024
+ const fn = n[0] === 'func' ? n : n[0] === 'import' ? n.find(s => Array.isArray(s) && s[0] === 'func') : null
5025
+ if (!fn) continue
5026
+ if (typeof fn[1] === 'string' && fn[1][0] === '$' &&
5027
+ !fn.some(c => Array.isArray(c) && (c[0] === 'result' || c[0] === 'type'))) voidFuncs.add(fn[1])
5028
+ }
5029
+ }
5030
+ return voidFuncs
5031
+ }
5032
+
3004
5033
  const unbranch = (ast) => {
5034
+ const voidFuncs = collectVoidFuncs(ast)
5035
+ // A statement that provably leaves nothing on the stack. `return` mid-body is fine
5036
+ // (dead tail); anything unrecognized — including bare tokens — bails the transform.
5037
+ const stackNeutral = (n) => {
5038
+ if (n === 'nop' || n === 'unreachable' || n === 'return') return true
5039
+ if (!Array.isArray(n)) return false
5040
+ const op = n[0]
5041
+ if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') return true
5042
+ if (op === 'local.set' || op === 'global.set' || op === 'drop' || op === 'nop' || op === 'return' ||
5043
+ op === 'br' || op === 'unreachable' || op === 'memory.copy' || op === 'memory.fill' ||
5044
+ op === 'store' || op?.includes?.('.store')) return true
5045
+ if (op === 'br_if') return n.length <= 3 // (br_if l cond) — no extra value operand
5046
+ if (op === 'block' || op === 'loop' || op === 'if')
5047
+ return !n.some(c => Array.isArray(c) && c[0] === 'result')
5048
+ if (op === 'call') return voidFuncs.has(n[1])
5049
+ return false
5050
+ }
3005
5051
  walk(ast, (node) => {
3006
5052
  if (!Array.isArray(node)) return
3007
5053
  const op = node[0]
@@ -3161,8 +5207,46 @@ const stripmut = (ast) => {
3161
5207
  * @param {Array} ast
3162
5208
  * @returns {Array}
3163
5209
  */
5210
+ // Dissolving an `if` removes one level of branch-target nesting (the if's own
5211
+ // implicit label). A $name label re-resolves relative to its new depth, but a raw
5212
+ // numeric (depth-relative) label goes stale and must drop by 1. Label 0 targets the
5213
+ // if's own end — no equivalent exists one level up, so that shape is not rewritten.
5214
+ const unnest = (l) => typeof l === 'string' && l[0] === '$' ? l : +l > 0 ? +l - 1 : null
5215
+
5216
+ /** Ops that can trap even when 'pure': int div/rem, float→int trunc. */
5217
+ const hasTrap = (n) => {
5218
+ let t = false
5219
+ walk(n, c => { const o = Array.isArray(c) ? c[0] : c; if (typeof o === 'string' && /\.(div|rem)_[su]$|\.trunc_f/.test(o)) t = true })
5220
+ return t
5221
+ }
5222
+
5223
+ // In TEST position (if/br_if/select condition) only non-zero-ness matters, so a
5224
+ // double eqz is a no-op there: (i32.eqz (i32.eqz X)) → X. (In value contexts it
5225
+ // normalizes to 0/1 and must stay.)
5226
+ const untest = (c) => Array.isArray(c) && c[0] === 'i32.eqz' && Array.isArray(c[1]) && c[1][0] === 'i32.eqz' ? untest(c[1][1]) : c
5227
+
3164
5228
  const brif = (ast) => {
3165
5229
  return walkPost(ast, (node) => {
5230
+ // (br_if $L A) (br_if $L B) → (br_if $L (i32.or A B)) — one branch instruction
5231
+ // instead of two. B now evaluates even when A already branches, so it must be
5232
+ // pure and trap-free; evaluation order A-then-B is preserved.
5233
+ if (Array.isArray(node) && (isScopeNode(node) || node[0] === 'if')) {
5234
+ for (let i = 1; i < node.length - 1; i++) {
5235
+ const a = node[i], b = node[i + 1]
5236
+ if (Array.isArray(a) && a[0] === 'br_if' && a.length === 3 && Array.isArray(a[2]) &&
5237
+ Array.isArray(b) && b[0] === 'br_if' && b.length === 3 && b[1] === a[1] &&
5238
+ Array.isArray(b[2]) && isPure(b[2]) && !hasTrap(b[2])) {
5239
+ node.splice(i, 2, ['br_if', a[1], ['i32.or', a[2], b[2]]])
5240
+ i--
5241
+ }
5242
+ }
5243
+ }
5244
+ // double-eqz vanishes in test position
5245
+ if (Array.isArray(node)) {
5246
+ if (node[0] === 'br_if' && node.length === 3) { const c = untest(node[2]); if (c !== node[2]) node[2] = c }
5247
+ else if (node[0] === 'if') { const { condIdx, cond } = parseIf(node); const c = untest(cond); if (c !== cond) node[condIdx] = c }
5248
+ else if (node[0] === 'select' && node.length === 4) { const c = untest(node[3]); if (c !== node[3]) node[3] = c }
5249
+ }
3166
5250
  if (!Array.isArray(node) || node[0] !== 'if') return
3167
5251
  const { cond, thenBranch, elseBranch } = parseIf(node)
3168
5252
  const thenEmpty = !thenBranch || thenBranch.length <= 1
@@ -3170,14 +5254,14 @@ const brif = (ast) => {
3170
5254
 
3171
5255
  // (if cond (then (br $l))) → (br_if $l cond)
3172
5256
  if (!thenEmpty && elseEmpty && thenBranch.length === 2) {
3173
- const t = thenBranch[1]
3174
- if (Array.isArray(t) && t[0] === 'br' && t.length === 2) return ['br_if', t[1], cond]
5257
+ const t = thenBranch[1], l = Array.isArray(t) && t[0] === 'br' && t.length === 2 && unnest(t[1])
5258
+ if (l != null && l !== false) return ['br_if', l, cond]
3175
5259
  }
3176
5260
 
3177
5261
  // (if cond (then) (else (br $l))) → (br_if $l (i32.eqz cond))
3178
5262
  if (thenEmpty && !elseEmpty && elseBranch.length === 2) {
3179
- const e = elseBranch[1]
3180
- if (Array.isArray(e) && e[0] === 'br' && e.length === 2) return ['br_if', e[1], ['i32.eqz', cond]]
5263
+ const e = elseBranch[1], l = Array.isArray(e) && e[0] === 'br' && e.length === 2 && unnest(e[1])
5264
+ if (l != null && l !== false) return ['br_if', l, ['i32.eqz', cond]]
3181
5265
  }
3182
5266
  })
3183
5267
  }
@@ -3253,11 +5337,14 @@ const hashFunc = (node, localNames) => {
3253
5337
  while (stack.length) {
3254
5338
  const v = stack.pop()
3255
5339
  if (Array.isArray(v)) {
5340
+ if (v[0] === 'export') continue // export names are identity, not body
3256
5341
  stack.push('|')
3257
5342
  for (let i = v.length - 1; i >= 0; i--) stack.push(v[i])
3258
5343
  stack.push('[')
3259
5344
  } else if (typeof v === 'string') {
3260
- parts.push(localNames.has(v) ? '$__L' : v)
5345
+ const t = localNames.get ? localNames.get(v) : localNames.has(v) ? '$__L' : undefined
5346
+ // a bare-numeric ref right after a local op is the same slot as its named twin
5347
+ parts.push(t ?? ((parts[parts.length - 1] === 'local.get' || parts[parts.length - 1] === 'local.set' || parts[parts.length - 1] === 'local.tee') && v !== '' && !isNaN(v) ? 'L' + +v : v))
3261
5348
  } else if (typeof v === 'bigint') {
3262
5349
  parts.push(v.toString() + 'n')
3263
5350
  } else if (typeof v === 'number') {
@@ -3293,21 +5380,29 @@ const dedupe = (ast) => {
3293
5380
  const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
3294
5381
  if (!name) continue
3295
5382
 
3296
- // Collect names that are internal to this function: the func name itself,
3297
- // its params, locals, and any block/loop labels nested in the body. All of
3298
- // these get normalized to a single token in the hash so that two funcs
3299
- // differing only in identifier choices still dedupe.
3300
- const localNames = new Set()
3301
- if (typeof node[1] === 'string' && node[1][0] === '$') localNames.add(node[1])
3302
- walk(node, (n) => {
3303
- if (!Array.isArray(n) || typeof n[1] !== 'string' || n[1][0] !== '$') return
3304
- const op = n[0]
3305
- if (op === 'param' || op === 'local' || isBranchScope(op)) {
3306
- localNames.add(n[1])
5383
+ // Canonical positional identity: params/locals map to their INDEX ('L0', 'L1'…
5384
+ // bare-numeric refs land on the same tokens), labels to first-occurrence
5385
+ // order ('B0'…), the func's own name to 'F'. Positional tokens make the hash a
5386
+ // faithful canonical form: same string structurally equivalent modulo naming
5387
+ // (the old single-token collapse hashed (sub $a $b) equal to (sub $b $a)).
5388
+ // Declarations leave the body hash and return as a positional type vector, so
5389
+ // a named-decl clone matches its unnamed twin.
5390
+ const canon = new Map([[name, 'F']])
5391
+ const types = []
5392
+ let li = 0, bi = 0
5393
+ const body = ['func']
5394
+ for (let i = 2; i < node.length; i++) {
5395
+ const c = node[i]
5396
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'local')) {
5397
+ if (typeof c[1] === 'string' && c[1][0] === '$') { canon.set(c[1], 'L' + li++); types.push(c[0] === 'param' ? 'p' + c[2] : c[2]) }
5398
+ else for (let k = 1; k < c.length; k++) { types.push(c[0] === 'param' ? 'p' + c[k] : c[k]); li++ }
3307
5399
  }
5400
+ else body.push(c)
5401
+ }
5402
+ walk(node, (n) => {
5403
+ if (Array.isArray(n) && isBranchScope(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !canon.has(n[1])) canon.set(n[1], 'B' + bi++)
3308
5404
  })
3309
-
3310
- const hash = hashFunc(node, localNames)
5405
+ const hash = types.join(' ') + '#' + hashFunc(body, canon)
3311
5406
 
3312
5407
  if (signatures.has(hash)) {
3313
5408
  redirects.set(name, signatures.get(hash))
@@ -3318,6 +5413,20 @@ const dedupe = (ast) => {
3318
5413
 
3319
5414
  if (redirects.size === 0) return ast
3320
5415
 
5416
+ // A duplicate's inline exports move to standalone exports of the canonical —
5417
+ // the husk loses its pin and treeshake collects it next sweep.
5418
+ for (const node of ast.slice(1)) {
5419
+ if (!Array.isArray(node) || node[0] !== 'func') continue
5420
+ const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
5421
+ if (!name || !redirects.has(name)) continue
5422
+ for (let i = node.length - 1; i >= 2; i--) {
5423
+ if (Array.isArray(node[i]) && node[i][0] === 'export') {
5424
+ ast.push(['export', node[i][1], ['func', redirects.get(name)]])
5425
+ node.splice(i, 1)
5426
+ }
5427
+ }
5428
+ }
5429
+
3321
5430
  // Rewrite all references: calls, ref.func, elem segments, call_indirect type
3322
5431
  walkPost(ast, (node) => {
3323
5432
  if (!Array.isArray(node)) return
@@ -3329,10 +5438,11 @@ const dedupe = (ast) => {
3329
5438
  return ['ref.func', redirects.get(node[1])]
3330
5439
  }
3331
5440
  if (op === 'elem') {
3332
- const funcs = node[node.length - 1]
3333
- if (Array.isArray(funcs)) {
3334
- return [...node.slice(0, -1), funcs.map(f => redirects.get(f) || f)]
3335
- }
5441
+ return node.map(p => typeof p === 'string' && redirects.has(p) ? redirects.get(p) : p)
5442
+ }
5443
+ if (op === 'start' && redirects.has(node[1])) return ['start', redirects.get(node[1])]
5444
+ if (op === 'export' && Array.isArray(node[2]) && node[2][0] === 'func' && redirects.has(node[2][1])) {
5445
+ return ['export', node[1], ['func', redirects.get(node[2][1])]]
3336
5446
  }
3337
5447
  if (op === 'call_indirect' && node.length >= 3) {
3338
5448
  const typeRef = node[1]
@@ -3484,12 +5594,13 @@ const trimTrailingZeros = (items) => {
3484
5594
  const bytes = []
3485
5595
  for (const item of items) {
3486
5596
  if (typeof item === 'string') {
5597
+ if (item[0] !== '"') continue // comment token — contributes no bytes
3487
5598
  const chunk = parseDataString(item)
3488
5599
  for (let i = 0; i < chunk.length; i++) bytes.push(chunk[i])
3489
- } else if (Array.isArray(item) && item[0] === 'i8') {
3490
- for (let i = 1; i < item.length; i++) bytes.push(Number(item[i]) & 0xff)
3491
5600
  } else {
3492
- return items // non-trimmable item
5601
+ const chunk = numdata(item) // i8/i16/i32/i64/f32/f64 lanes — compile's own encoder
5602
+ if (!chunk) return items // non-trimmable item
5603
+ for (let i = 0; i < chunk.length; i++) bytes.push(chunk[i])
3493
5604
  }
3494
5605
  }
3495
5606
  let end = bytes.length
@@ -3528,13 +5639,50 @@ const getDataLength = (node) => {
3528
5639
  let len = 0
3529
5640
  for (let i = idx; i < node.length; i++) {
3530
5641
  const item = node[i]
3531
- if (typeof item === 'string') len += parseDataString(item).length
3532
- else if (Array.isArray(item) && item[0] === 'i8') len += item.length - 1
3533
- else return null
5642
+ if (typeof item === 'string') { if (item[0] === '"') len += parseDataString(item).length }
5643
+ else { const chunk = numdata(item); if (!chunk) return null; len += chunk.length }
3534
5644
  }
3535
5645
  return len
3536
5646
  }
3537
5647
 
5648
+ // Zero-assumption fences shared by the trailing-zero trim and the zero-run split:
5649
+ // both rewrite segments on the premise that unwritten memory IS zero. That premise
5650
+ // needs (each refuted by a live counterexample when absent): a single plain
5651
+ // module-DEFINED memory (imported/shared memory is not guaranteed zero), every
5652
+ // segment active with const offset and known length, and per segment — fully
5653
+ // inside the INITIAL memory (a shrunk segment must not un-trap an out-of-bounds
5654
+ // write) and disjoint from every other segment (a later zero run may deliberately
5655
+ // overwrite an earlier segment's bytes). Returns null when the module as a whole
5656
+ // fails the shared preconditions.
5657
+ const zeroFence = (ast) => {
5658
+ let mem = null
5659
+ for (const node of ast) {
5660
+ if (!Array.isArray(node)) continue
5661
+ if (node[0] === 'memory') {
5662
+ if (mem) return null
5663
+ const rest = node.filter((c, i) => i > 0 && !(typeof c === 'string' && c[0] === '$') && !(Array.isArray(c) && c[0] === 'export'))
5664
+ const plain = rest.length >= 1 && rest.length <= 2 && rest.every(v => !Array.isArray(v) && !isNaN(Number(v))) &&
5665
+ !node.some(c => Array.isArray(c) && c[0] === 'import')
5666
+ if (!plain) return null
5667
+ mem = { min: Number(rest[0]) }
5668
+ } else if (node[0] === 'import' && node.some(c => Array.isArray(c) && c[0] === 'memory')) return null
5669
+ }
5670
+ if (!mem) return null
5671
+ const segs = []
5672
+ for (const node of ast) {
5673
+ if (!Array.isArray(node) || node[0] !== 'data') continue
5674
+ const info = getDataOffset(node), len = getDataLength(node)
5675
+ if (!info || len === null) return null
5676
+ segs.push({ node, offset: info.offset, len })
5677
+ }
5678
+ return { min: mem.min, segs }
5679
+ }
5680
+ const segSafe = (fence, node) => {
5681
+ const seg = fence.segs.find(o => o.node === node)
5682
+ return seg && seg.offset + seg.len <= fence.min * 65536 &&
5683
+ !fence.segs.some(o => o.node !== node && o.offset < seg.offset + seg.len && seg.offset < o.offset + o.len)
5684
+ }
5685
+
3538
5686
  /** Merge segment b into a (consecutive offsets, same memory) */
3539
5687
  const mergeDataSegments = (a, b) => {
3540
5688
  let aIdx = 1
@@ -3573,21 +5721,33 @@ const mergeDataSegments = (a, b) => {
3573
5721
  const packData = (ast) => {
3574
5722
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3575
5723
 
3576
- // Trim trailing zeros
3577
- for (const node of ast) {
3578
- if (!Array.isArray(node) || node[0] !== 'data') continue
3579
- let contentStart = 1
3580
- if (typeof node[1] === 'string' && node[1][0] === '$') contentStart = 2
3581
- if (contentStart < node.length && Array.isArray(node[contentStart]) &&
3582
- typeof node[contentStart][0] === 'string' && !node[contentStart][0].startsWith('"')) {
3583
- contentStart++
3584
- }
3585
- const content = node.slice(contentStart)
3586
- if (content.length === 0) continue
3587
- const trimmed = trimTrailingZeros(content)
3588
- if (trimmed.length !== content.length || (trimmed.length > 0 && trimmed[0] !== content[0])) {
3589
- node.length = contentStart
3590
- node.push(...trimmed)
5724
+ // Trim trailing zeros. Wasm zero-fills a module-DEFINED memory, so a segment's
5725
+ // trailing zero run restates the default — but ONLY under fences (each refuted
5726
+ // by a live counterexample when absent): the segment must be ACTIVE with a
5727
+ // constant offset (a passive segment's length is memory.init semantics); the
5728
+ // module's sole memory must be plain and module-defined (an imported/shared
5729
+ // memory is not guaranteed zero where the segment lands); the full segment must
5730
+ // fit the memory's INITIAL size (a shrunk segment must not un-trap an
5731
+ // out-of-bounds write); and no other active segment may touch this one's range
5732
+ // (a later zero run may deliberately overwrite an earlier segment's bytes).
5733
+ {
5734
+ const fence = zeroFence(ast)
5735
+ for (const seg of fence ? fence.segs : []) {
5736
+ if (!segSafe(fence, seg.node)) continue
5737
+ const node = seg.node
5738
+ let contentStart = 1
5739
+ if (typeof node[1] === 'string' && node[1][0] === '$') contentStart = 2
5740
+ if (contentStart < node.length && Array.isArray(node[contentStart]) &&
5741
+ typeof node[contentStart][0] === 'string' && !node[contentStart][0].startsWith('"')) {
5742
+ contentStart++
5743
+ }
5744
+ const content = node.slice(contentStart)
5745
+ if (content.length === 0) continue
5746
+ const trimmed = trimTrailingZeros(content)
5747
+ if (trimmed.length !== content.length || (trimmed.length > 0 && trimmed[0] !== content[0])) {
5748
+ node.length = contentStart
5749
+ node.push(...trimmed)
5750
+ }
3591
5751
  }
3592
5752
  }
3593
5753
 
@@ -3626,7 +5786,56 @@ const packData = (ast) => {
3626
5786
  ast = ast.filter((_, i) => !toRemove.has(i))
3627
5787
  }
3628
5788
 
3629
- return ast
5789
+ // Split active segments at long interior zero runs and shift leading zeros into
5790
+ // the offset — unwritten memory is zero-initialized anyway. Sound only when no
5791
+ // instruction references data indices (splitting renumbers segments) and no
5792
+ // memory is imported (an import's pre-existing bytes must be overwritten, not
5793
+ // skipped). Named segments are left whole (a name implies index identity).
5794
+ let refsData = false, importedMem = false
5795
+ walk(ast, n => {
5796
+ const op = Array.isArray(n) ? n[0] : n
5797
+ if (op === 'memory.init' || op === 'data.drop' || op === 'array.new_data' || op === 'array.init_data') refsData = true
5798
+ if (!Array.isArray(n)) return
5799
+ if ((op === 'import' || op === 'memory') && n.some(s => Array.isArray(s) && (s[0] === 'memory' || s[0] === 'import'))) importedMem = true
5800
+ })
5801
+ if (refsData || importedMem) return ast
5802
+
5803
+ // splitting drops zero runs — same zero-assumption fences as the trim above
5804
+ const fence = zeroFence(ast)
5805
+ const out = []
5806
+ for (const node of ast) {
5807
+ if (!Array.isArray(node) || node[0] !== 'data' || (typeof node[1] === 'string' && node[1][0] === '$')) { out.push(node); continue }
5808
+ if (!fence || !segSafe(fence, node)) { out.push(node); continue }
5809
+ let idx = 1
5810
+ const mem = Array.isArray(node[idx]) && node[idx][0] === 'memory' ? node[idx++] : null
5811
+ const off = node[idx]
5812
+ if (!Array.isArray(off) || (off[0] !== 'i32.const' && off[0] !== 'i64.const')) { out.push(node); continue }
5813
+ const items = node.slice(idx + 1)
5814
+ if (!items.length || !items.every(s => typeof s === 'string')) { out.push(node); continue }
5815
+ const bytes = items.flatMap(parseDataString)
5816
+ const base = Number(off[1])
5817
+ // collect [start, end) pieces separated by zero runs longer than the cost of a
5818
+ // fresh segment header (mode + offset expr + end + length prefix)
5819
+ const pieces = []
5820
+ let s = -1
5821
+ for (let i = 0; i <= bytes.length; i++) {
5822
+ if (i < bytes.length && bytes[i] !== 0) { s < 0 && (s = i); continue }
5823
+ if (s < 0) continue
5824
+ // extend over short zero runs: find next nonzero
5825
+ let j = i
5826
+ while (j < bytes.length && bytes[j] === 0) j++
5827
+ const segCost = 4 + slebSize(base + j) + 1
5828
+ if (j - i > segCost || j >= bytes.length) pieces.push([s, i]), s = -1
5829
+ i = j - 1
5830
+ }
5831
+ if (!pieces.length) continue // all zeros — segment is a no-op, drop it
5832
+ if (pieces.length === 1 && pieces[0][0] === 0 && pieces[0][1] === bytes.length) { out.push(node); continue }
5833
+ for (const [ps, pe] of pieces) {
5834
+ const head = mem ? ['data', mem] : ['data']
5835
+ out.push([...head, [off[0], String(base + ps)], encodeDataString(bytes.slice(ps), pe - ps)])
5836
+ }
5837
+ }
5838
+ return out
3630
5839
  }
3631
5840
 
3632
5841
  // ==================== IMPORT FIELD MINIFICATION ====================
@@ -3730,6 +5939,177 @@ const reorder = (ast) => {
3730
5939
  return ['module', ...imports, ...funcs, ...others]
3731
5940
  }
3732
5941
 
5942
+ // ==================== LOOP-INVARIANT CODE MOTION ====================
5943
+
5944
+ /**
5945
+ * licm — hoist loop-invariant PURE, NON-TRAPPING value expressions out of loops into
5946
+ * fresh locals computed once before the loop. The mechanical half of LICM: no alias
5947
+ * analysis, so memory loads and calls are never hoisted; only arithmetic/compare/
5948
+ * select/conversion/SIMD subtrees whose leaves are constants, loop-unwritten locals,
5949
+ * or immutable globals. Hoisting is speculation-safe (an `if`-arm expression now runs
5950
+ * every entry, a zero-trip loop runs it once) because every whitelisted op is pure and
5951
+ * cannot trap — trapping ops (int div/rem, non-saturating float→int trunc) and effects
5952
+ * (loads/stores/calls/tees) are excluded, so the only cost is wasted work, never a
5953
+ * changed result. Identical hoists within one loop share one local (loop-level CSE —
5954
+ * repeated `f64x2.splat (f64.const C)` broadcasts collapse to a single set).
5955
+ *
5956
+ * Runs ONCE after the rounds (like `inline`): the invariants worth hoisting only exist
5957
+ * after inlineOnce/inline splice callee bodies into the loop, and a later `propagate`
5958
+ * round would forward a single-use hoist straight back into the loop, undoing it.
5959
+ * Opt-in (speed-for-size: new locals + sets grow the binary slightly).
5960
+ *
5961
+ * Loops are processed innermost-first; a hoisted inner set whose RHS is invariant to
5962
+ * the outer loop hoists again on the outer pass (the RHS is a value position of the
5963
+ * set), cascading multi-level invariants outward one level per enclosing loop.
5964
+ */
5965
+ // Pure & non-trapping op test. v128/SIMD ops are all non-trapping except memory ops;
5966
+ // scalar arithmetic is safe except int div/rem (trap on 0/overflow) and the trapping
5967
+ // float→int truncations (`iNN.trunc_fMM_x`; the `trunc_sat` forms saturate instead).
5968
+ const licmPure = (op) => {
5969
+ if (typeof op !== 'string') return false
5970
+ if (op === 'select') return true
5971
+ if (/^(v128|[if](8x16|16x8|32x4|64x2))\./.test(op)) return !/\.(load|store)/.test(op)
5972
+ if (!/^[if](32|64)\./.test(op) && !/^f(32|64)\./.test(op)) return false
5973
+ if (/\.(load|store)/.test(op)) return false
5974
+ if (/\.(div_[su]|rem_[su])$/.test(op)) return false
5975
+ if (/^i(32|64)\.trunc_f/.test(op) && !op.includes('trunc_sat')) return false
5976
+ return true
5977
+ }
5978
+
5979
+ const licm = (ast) => {
5980
+ for (const func of ast) {
5981
+ if (!Array.isArray(func) || func[0] !== 'func') continue
5982
+
5983
+ // Local/param types (for typing hoisted expressions through local.get leaves).
5984
+ const localTypes = new Map()
5985
+ let declEnd = 1 // insertion point for fresh (local …) decls: after params/result/locals
5986
+ for (let i = 1; i < func.length; i++) {
5987
+ const c = func[i]
5988
+ if (!Array.isArray(c)) { if (typeof c === 'string' && c[0] === '$') declEnd = i + 1; continue }
5989
+ if (c[0] === 'param' || c[0] === 'local') {
5990
+ if (typeof c[1] === 'string' && c[1][0] === '$') localTypes.set(c[1], c[2])
5991
+ declEnd = i + 1
5992
+ } else if (c[0] === 'result' || c[0] === 'export' || c[0] === 'type') declEnd = i + 1
5993
+ else break
5994
+ }
5995
+
5996
+ // Immutable module globals: declared without (mut …) — reads are invariant everywhere.
5997
+ const immutGlobals = new Set()
5998
+ for (const g of ast) {
5999
+ if (!Array.isArray(g)) continue
6000
+ if (g[0] === 'global' && typeof g[1] === 'string' && !g.some(c => Array.isArray(c) && c[0] === 'mut')) immutGlobals.add(g[1])
6001
+ if (g[0] === 'import') { const d = g[g.length - 1]; if (Array.isArray(d) && d[0] === 'global' && typeof d[1] === 'string' && !d.some(c => Array.isArray(c) && c[0] === 'mut')) immutGlobals.add(d[1]) }
6002
+ }
6003
+
6004
+ // Result type of a hoistable subtree (null ⇒ untypeable ⇒ don't hoist).
6005
+ const typeOf = (n) => {
6006
+ if (!Array.isArray(n)) return null
6007
+ const op = n[0]
6008
+ if (op === 'select') return typeOf(n[1])
6009
+ if (op === 'local.get') return localTypes.get(n[1]) ?? null
6010
+ if (/\.extract_lane/.test(op)) { const p = op.slice(0, op.indexOf('.')); return p === 'f64x2' ? 'f64' : p === 'f32x4' ? 'f32' : p === 'i64x2' ? 'i64' : 'i32' }
6011
+ if (/^(v128|[if](8x16|16x8|32x4|64x2))\./.test(op)) return op.endsWith('any_true') || op.endsWith('all_true') || op.endsWith('bitmask') ? 'i32' : 'v128'
6012
+ return resultType(op)
6013
+ }
6014
+
6015
+ let minted = 0
6016
+ const freshName = () => {
6017
+ let n
6018
+ do { n = `$__licm${minted++}` } while (localTypes.has(n))
6019
+ return n
6020
+ }
6021
+ const newDecls = []
6022
+
6023
+ const processLoop = (loop, parent, idx) => {
6024
+ // Only hoist when the loop sits in a statement list we can splice into.
6025
+ const pop = Array.isArray(parent) ? parent[0] : null
6026
+ if (pop !== 'func' && pop !== 'block' && pop !== 'loop' && pop !== 'then' && pop !== 'else') return
6027
+
6028
+ // Loop effect summary: the write-set of locals, and whether globals stay stable.
6029
+ const writes = new Set()
6030
+ let hasCall = false, hasGlobalSet = false
6031
+ walk(loop, (n) => {
6032
+ if (!Array.isArray(n)) return
6033
+ const op = n[0]
6034
+ if ((op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string') writes.add(n[1])
6035
+ else if (op === 'global.set') hasGlobalSet = true
6036
+ else if (op === 'call' || op === 'call_indirect' || op === 'call_ref' || op === 'return_call' || op === 'return_call_indirect') hasCall = true
6037
+ })
6038
+
6039
+ // Invariant: every node in the subtree is a const, an unwritten local, a stable
6040
+ // global read, or a whitelisted pure op. Non-array children of whitelisted ops
6041
+ // are immediates (lane indices, shuffle masks, v128.const payload) — allowed.
6042
+ const inv = (n) => {
6043
+ if (!Array.isArray(n)) return false
6044
+ const op = n[0]
6045
+ if (typeof op !== 'string') return false
6046
+ if (op.endsWith('.const')) return true
6047
+ if (op === 'local.get') return typeof n[1] === 'string' && !writes.has(n[1])
6048
+ if (op === 'global.get') return typeof n[1] === 'string' && (immutGlobals.has(n[1]) || (!hasGlobalSet && !hasCall))
6049
+ if (!licmPure(op)) return false
6050
+ for (let i = 1; i < n.length; i++) if (Array.isArray(n[i]) && !inv(n[i])) return false
6051
+ return true
6052
+ }
6053
+ // Cost gate: ≥2 real ops (consts/gets free) — hoisting a lone add trades a local
6054
+ // for one op and mostly just grows the binary; a guard pair or splat-chain pays.
6055
+ const opCount = (n) => {
6056
+ if (!Array.isArray(n)) return 0
6057
+ const op = n[0]
6058
+ let c = (typeof op === 'string' && !op.endsWith('.const') && op !== 'local.get') ? 1 : 0
6059
+ for (let i = 1; i < n.length; i++) c += opCount(n[i])
6060
+ return c
6061
+ }
6062
+
6063
+ const hoisted = [] // [ ['local.set', name, expr] … ]
6064
+ const byKey = new Map() // stringified expr → local name (dedupe within this loop)
6065
+ const tryHoist = (node, par, i) => {
6066
+ if (!Array.isArray(node)) return
6067
+ const op = node[0]
6068
+ // Never lift a bare statement/decl; only value expressions match the pure set.
6069
+ // Cost gate: ≥2 real ops — except a v128 SPLAT root, worth hoisting even alone: a
6070
+ // broadcast re-materialized per iteration occupies a vector unit + widens live ranges
6071
+ // (the colorpq 1M-iteration splat(coeff) recompute), where scalar 1-op trees are
6072
+ // register-trivial and V8 folds them anyway.
6073
+ const isSplat = typeof node[0] === 'string' && node[0].endsWith('.splat')
6074
+ if (inv(node) && (opCount(node) >= 2 || isSplat)) {
6075
+ const ty = typeOf(node)
6076
+ if (ty) {
6077
+ let key
6078
+ try { key = JSON.stringify(node) } catch { key = null }
6079
+ let name = key != null ? byKey.get(key) : undefined
6080
+ if (name === undefined) {
6081
+ name = freshName()
6082
+ localTypes.set(name, ty)
6083
+ newDecls.push(['local', name, ty])
6084
+ hoisted.push(['local.set', name, node])
6085
+ if (key != null) byKey.set(key, name)
6086
+ }
6087
+ par[i] = ['local.get', name]
6088
+ return
6089
+ }
6090
+ }
6091
+ for (let i2 = 1; i2 < node.length; i2++) tryHoist(node[i2], node, i2)
6092
+ }
6093
+ for (let i = 1; i < loop.length; i++) if (Array.isArray(loop[i])) tryHoist(loop[i], loop, i)
6094
+ if (hoisted.length) parent.splice(idx, 0, ...hoisted)
6095
+ }
6096
+
6097
+ // Innermost-first: recurse before processing, and track (parent, idx) for splicing.
6098
+ // Indices shift as hoists are spliced in — iterate by live position.
6099
+ const visit = (parent) => {
6100
+ for (let i = 1; i < parent.length; i++) {
6101
+ const n = parent[i]
6102
+ if (!Array.isArray(n)) continue
6103
+ visit(n)
6104
+ if (n[0] === 'loop') { const before = parent.length; processLoop(n, parent, i); i += parent.length - before }
6105
+ }
6106
+ }
6107
+ visit(func)
6108
+ if (newDecls.length) func.splice(declEnd, 0, ...newDecls)
6109
+ }
6110
+ return ast
6111
+ }
6112
+
3733
6113
  // ==================== MAIN ====================
3734
6114
 
3735
6115
  /**
@@ -3748,20 +6128,28 @@ const PASSES = [
3748
6128
  ['strength', strength, true, 'strength reduction (x * 2 → x << 1)'],
3749
6129
  ['branch', branch, true, 'simplify constant branches'],
3750
6130
  ['propagate', propagate, true, 'forward-propagate single-use locals & tiny consts (never inflates)'],
6131
+ ['merge', mergeLocals, true, 'merge alias locals written once by the same set(tee) value'],
6132
+ ['macro', inlineMacro, true, 'expand single-expression functions at call sites (positional, zero wrapper)'],
6133
+ ['spec', specializeParams, true, 'drop parameters every call site passes the same constant'],
3751
6134
  ['devirt', devirt, false, 'call_indirect with a constant or known closure-const index → direct/guarded calls — grows bytes for speed'],
6135
+ ['dedupe', dedupe, true, 'eliminate duplicate functions (before inlineOnce dissolves identical single-caller helpers)'],
3752
6136
  ['inlineOnce', inlineOnce, true, 'inline single-call functions into their lone caller (never duplicates)'],
3753
6137
  ['inline', inline, false, 'inline tiny functions — can duplicate bodies'],
6138
+ ['licm', licm, false, 'hoist loop-invariant pure arithmetic out of loops — adds locals (speed-for-size); runs once after rounds'],
3754
6139
  ['offset', offset, true, 'fold add+const into load/store offset'],
6140
+ ['cse', cse, true, 'reuse repeated pure subexpressions via a tee\'d local — runs once after rounds (byte-profit gated)'],
3755
6141
  ['unbranch', unbranch, true, 'remove redundant br at end of own block'],
3756
6142
  ['loopify', loopify, true, 'collapse block+loop+brif while-idiom into loop+if'],
3757
6143
  ['brif', brif, true, 'if-then-br → br_if'],
6144
+ ['tailmerge', tailmerge, true, 'share byte-identical early-exit epilogues via block + br_if'],
6145
+ ['rettail', rettail, true, 'elide a function-tail return once epilogues are merged'],
3758
6146
  ['foldarms', foldarms, false, 'merge identical trailing if arms — can add block wrapper'],
3759
6147
  ['deadcode', deadcode, true, 'eliminate dead code after unreachable/br/return'],
3760
6148
  ['vacuum', vacuum, true, 'remove nops, drop-of-pure, empty branches'],
3761
6149
  ['mergeBlocks', mergeBlocks, true, 'unwrap `(block $L …)` whose label is never targeted'],
3762
6150
  ['coalesce', coalesceLocals, true, 'share local slots between same-type non-overlapping locals'],
3763
6151
  ['locals', localReuse, true, 'remove unused locals'],
3764
- ['dedupe', dedupe, true, 'eliminate duplicate functions'],
6152
+ ['outline', outline, true, 'extract repeated pure expressions into shared helper functions'],
3765
6153
  ['dedupTypes', dedupTypes, true, 'merge identical type definitions'],
3766
6154
  ['packData', packData, true, 'trim trailing zeros, merge adjacent data segments'],
3767
6155
  ['reorder', reorder, false, 'put hot functions first — no AST reduction'],
@@ -3846,21 +6234,8 @@ const mayInline = (ast) => {
3846
6234
  // artifact (e.g. a top-level `1 + 2;` whose dropped value the dead-code pass
3847
6235
  // removed). Drop both. Header nodes (param/result/local/export/type) don't count
3848
6236
  // as a body — a function carrying only locals still does nothing.
3849
- const START_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
3850
- function pruneEmptyStart(ast) {
3851
- if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3852
- const fn = ast.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
3853
- if (!fn) return ast
3854
- // Skip the name (index 1) and header nodes; a bare-string node past them is a
3855
- // real instruction (`drop`/`nop`/`unreachable`), so it stops the scan.
3856
- let b = 2
3857
- while (b < fn.length && Array.isArray(fn[b]) && START_HEAD.has(fn[b][0])) b++
3858
- if (b < fn.length) return ast // real instructions remain
3859
- return ast.filter(n => !(Array.isArray(n) &&
3860
- ((n[0] === 'func' && n[1] === '$__start') || (n[0] === 'start' && n[1] === '$__start'))))
3861
- }
3862
-
3863
6237
  export default function optimize(ast, opts = true) {
6238
+ CALLFX = null
3864
6239
  if (typeof ast === 'string') ast = parse(ast) // accept WAT source directly
3865
6240
  const strictGuard = opts === true // default: zero tolerance for bloat
3866
6241
  opts = normalize(opts)
@@ -3873,6 +6248,7 @@ export default function optimize(ast, opts = true) {
3873
6248
  const verbose = opts.verbose || opts.log
3874
6249
 
3875
6250
  ast = clone(ast)
6251
+ CALLFX = computeCallEffects(ast)
3876
6252
 
3877
6253
  // devirt trades bytes for speed by design (guards + duplicated args), so it
3878
6254
  // runs ONCE after the rounds — its candidate shape (select of two i64 closure
@@ -3896,7 +6272,51 @@ export default function optimize(ast, opts = true) {
3896
6272
  if (opts.coalesceLocals) a = coalesceLocals(a)
3897
6273
  return a
3898
6274
  }
3899
- const finish = (a) => { a = runInline(a); return pruneEmptyStart(opts.devirt ? devirt(a) : a) }
6275
+ // parse() returns [comment…, ['module',…]] when top-level trivia precedes the module
6276
+ // whole-module passes key on ast[0] === 'module' and would silently no-op on the wrapper.
6277
+ // Run the pipeline on the module node itself and splice it back, so comments survive.
6278
+ let wrapper = null, slot = -1
6279
+ if (Array.isArray(ast) && ast[0] !== 'module') {
6280
+ const i = ast.findIndex(n => Array.isArray(n) && n[0] === 'module')
6281
+ if (i >= 0) wrapper = ast, slot = i, ast = ast[i]
6282
+ }
6283
+
6284
+ // Strip comment trivia inside the module: parse keeps `;;`/`(;` as string children,
6285
+ // which blocks every adjacency-windowed pass (set→get fusion, dead-store pairs).
6286
+ // Top-level banner comments survive in the wrapper above.
6287
+ walkPost(ast, n => {
6288
+ if (!Array.isArray(n)) return
6289
+ for (let i = n.length - 1; i >= 0; i--) {
6290
+ const c = n[i]
6291
+ if (typeof c === 'string' && (c[0] === ';' || (c[0] === '(' && c[1] === ';'))) n.splice(i, 1)
6292
+ }
6293
+ })
6294
+
6295
+ // Mandatory NaN-payload canonicalization, ONCE before the rounds: these folds may
6296
+ // grow bytes yet must never revert — hoisting keeps the rounds size-monotone, so
6297
+ // the guard below can compare entry vs exit without penalizing required folds.
6298
+ if (opts.fold) walkPost(ast, nanFoldNode)
6299
+
6300
+ // licm runs ONCE after the rounds + inline: its invariants only exist after inlining, and a
6301
+ // later propagate round would forward a single-use hoist back into the loop, undoing it.
6302
+ // devirt must run BEFORE licm: its collector pattern-matches the in-loop closure-const
6303
+ // select chain feeding a call_indirect, and licm hoists exactly that chain into a local —
6304
+ // hoist first and no call site ever devirtualizes (jz speed tier, closures devirt tests).
6305
+ const finish = (a) => {
6306
+ a = runInline(a)
6307
+ if (opts.devirt) a = devirt(a)
6308
+ if (opts.licm) a = licm(a)
6309
+ // cse runs ONCE at fixpoint: inside the rounds its tee'd locals block the very
6310
+ // collapses (propagate/sink/merge) that would erase the expressions outright,
6311
+ // and any net wobble trips the exit guard into unwinding a whole round.
6312
+ if (opts.cse) {
6313
+ a = cse(a)
6314
+ if (opts.coalesce) a = coalesceLocals(a)
6315
+ if (opts.locals) a = localReuse(a)
6316
+ }
6317
+ if (opts.outline) a = outline(a)
6318
+ return wrapper ? (wrapper[slot] = a, wrapper) : a
6319
+ }
3900
6320
 
3901
6321
  // Fast path: jz owns this optimizer and feeds it a controlled, type-aware IR.
3902
6322
  // The only passes that can *grow* the binary are inlineOnce/inline; when no
@@ -3904,52 +6324,120 @@ export default function optimize(ast, opts = true) {
3904
6324
  // nothing can inflate, so we skip watr's per-round `binarySize` re-compile
3905
6325
  // guard — up to four full encodes per call — and iterate to a fixpoint with
3906
6326
  // zero compiles. A round that changes nothing is the natural exit.
6327
+ // Per-function convergence: passes that look only inside one function run over a
6328
+ // DIRTY set — a function untouched since the previous round is skipped (on a large
6329
+ // module the bulk converges in round one, so later rounds cost almost nothing).
6330
+ // Module-scoped passes always run; their mutations re-dirty functions through the
6331
+ // per-func snapshots.
6332
+ const MODULE_SCOPE = new Set(['stripmut', 'globals', 'guardRefine', 'macro', 'spec', 'inlineOnce',
6333
+ 'dedupe', 'dedupTypes', 'packData', 'treeshake', 'minifyImports', 'reorder', 'unbranch', 'rettail'])
6334
+ const collectFuncs = () => { const out = []; for (const n of ast) if (Array.isArray(n) && n[0] === 'func') out.push(n); return out }
6335
+ const snapshots = new Map()
6336
+ let dirty = null // null = everything
6337
+ const runRounds = (skipInline, onRoundStart) => {
6338
+ // dirty sets make extra rounds nearly free — the cap is a safety net, not a cost
6339
+ let prevLen = -1
6340
+ for (let round = 0; round < 6; round++) {
6341
+ onRoundStart?.()
6342
+ const funcsNow = collectFuncs()
6343
+ const work = dirty ? funcsNow.filter(f => dirty.has(f)) : funcsNow
6344
+ // a walkPost-shaped pass may rebuild the func ROOT (e.g. vacuum cleaning the
6345
+ // body list) — write the replacement back and carry the identity forward
6346
+ const per = (fn) => {
6347
+ for (let wi = 0; wi < work.length; wi++) {
6348
+ const f = work[wi], r = fn(f, opts)
6349
+ if (r && r !== f && Array.isArray(r)) {
6350
+ const i = ast.indexOf(f)
6351
+ if (i >= 0) ast[i] = r
6352
+ work[wi] = r
6353
+ if (dirty?.has(f)) { dirty.delete(f); dirty.add(r) }
6354
+ const snap = snapshots.get(f)
6355
+ if (snap !== undefined) { snapshots.delete(f); snapshots.set(r, snap) }
6356
+ }
6357
+ }
6358
+ }
6359
+ let fused = false
6360
+ for (const [key, fn] of PASSES) {
6361
+ if (!opts[key] || key === 'inline' || key === 'devirt' || key === 'licm' || key === 'cse' ||
6362
+ (skipInline && key === 'inlineOnce')) continue
6363
+ if (SIMPLIFY_KEYS.has(key)) {
6364
+ if (!fused) {
6365
+ fused = true
6366
+ per((f) => simplify(f, opts))
6367
+ for (const n of ast) if (Array.isArray(n) && n[0] !== 'func') simplify(n, opts)
6368
+ }
6369
+ continue
6370
+ }
6371
+ if (MODULE_SCOPE.has(key)) { ast = fn(ast, opts); continue }
6372
+ per(fn)
6373
+ }
6374
+ const next = new Set()
6375
+ // convergence via content hash, not clone+equal: one pass per function, no
6376
+ // retained deep copies. A collision only marks a changed function clean —
6377
+ // it skips further rounds, never alters what already-applied passes did.
6378
+ const nextHash = new Map()
6379
+ for (const f of collectFuncs()) {
6380
+ const h = hashFunc(f, EMPTY_MAP)
6381
+ nextHash.set(f, h)
6382
+ if (snapshots.get(f) !== h) next.add(f)
6383
+ }
6384
+ const topStable = ast.length === prevLen
6385
+ prevLen = ast.length
6386
+ for (const [f, h] of nextHash) snapshots.set(f, h)
6387
+ dirty = next
6388
+ if (verbose) log(` round ${round + 1}: ${next.size} dirty funcs`)
6389
+ if (!next.size && topStable) break
6390
+ }
6391
+ }
6392
+
3907
6393
  if (!((opts.inlineOnce || opts.inline) && mayInline(ast))) {
3908
6394
  // inlineOnce/inline can't fire here, so skip them — their candidate scan
3909
6395
  // (a 16-round whole-module walk) is the second-costliest thing after
3910
6396
  // propagate, and it would only confirm what `mayInline` already proved.
3911
- for (let round = 0; round < 3; round++) {
3912
- const beforeRound = clone(ast)
3913
- for (const [key, fn] of PASSES) if (opts[key] && key !== 'inlineOnce' && key !== 'inline' && key !== 'devirt') ast = fn(ast, opts)
3914
- if (equal(beforeRound, ast)) break // fixpoint
3915
- if (verbose) log(` round ${round + 1} applied`)
3916
- }
3917
- return finish(ast)
3918
- }
3919
-
3920
- // Guarded path: inlining can inflate (a body bigger than the call it replaces),
3921
- // so score each round on encoded bytes and revert any that grows the binary.
3922
- // `binarySize` returns Infinity for invalid wat, so a broken round reverts too.
3923
- // A round's starting size equals the prior round's ending size, so carry it
3924
- // forward and compile once per round.
3925
- let beforeRound = null
3926
- let sizeBefore = binarySize(ast)
3927
- for (let round = 0; round < 3; round++) {
3928
- beforeRound = clone(ast)
3929
-
3930
- for (const [key, fn] of PASSES) if (opts[key] && key !== 'devirt' && key !== 'inline') ast = fn(ast, opts)
3931
- // Second propagate sweep: `inlineOnce`/`inline` (above) leave fresh
3932
- // `(local.set $p arg) (local.get $p)` wrappers around each inlined call;
3933
- // re-running propagation collapses them within this same round, so the size
3934
- // guard scores the cleaned result.
3935
- if (opts.propagate && (opts.inlineOnce || opts.inline)) ast = propagate(ast)
3936
-
3937
- // A round that changed nothing can't have inflated check convergence
3938
- // before compiling so the fixpoint-confirming round costs zero compiles.
3939
- if (equal(beforeRound, ast)) break
3940
-
3941
- const sizeAfter = binarySize(ast)
3942
- const delta = sizeAfter - sizeBefore
3943
- if (verbose || delta !== 0) log(` round ${round + 1}: ${delta > 0 ? '+' : ''}${delta} bytes`, delta)
3944
-
3945
- // Default optimize must never inflate; explicit passes get slight leniency.
3946
- const tolerance = strictGuard ? 0 : 16
3947
- if (delta > tolerance) {
3948
- if (verbose) log(` ⚠ round ${round + 1} inflated by ${delta} bytes, reverting`, delta)
3949
- ast = beforeRound
3950
- break
3951
- }
3952
- sizeBefore = sizeAfter
6397
+ runRounds(true)
6398
+ // treeshake/dedupe can EXPOSE single-caller candidates mid-rounds (dropping the
6399
+ // other callers) the entry check was a snapshot; recheck before finishing
6400
+ if (!((opts.inlineOnce || opts.inline) && mayInline(ast))) return finish(ast)
6401
+ }
6402
+
6403
+ // Guarded path: inlining can inflate (a body bigger than the call it replaces).
6404
+ // Rounds run unmeasured — every non-inline pass is size-monotone (NaN folds were
6405
+ // hoisted above) — and a single exit encode compares against the entry size.
6406
+ // Net growth unwinds the last round, then everything: two encodes in the common
6407
+ // case instead of one per round; `binarySize` returns Infinity for invalid wat,
6408
+ // so a broken round unwinds the same way.
6409
+ const pristine = clone(ast)
6410
+ const countBefore = count(ast)
6411
+ const inflBefore = inflations
6412
+ let sizeBefore = null
6413
+ let beforeRound = null, cur = null
6414
+ // round-start clones exist only so the guard can unwind ONE round instead of
6415
+ // all of them — worth taking only once an inflating splice has actually fired
6416
+ // (before that, every executed pass was size-monotone; if inflation first
6417
+ // fires mid-round the unwind falls back to pristine, which is still correct)
6418
+ runRounds(false, () => { beforeRound = cur; cur = inflations !== inflBefore ? clone(ast) : null })
6419
+ // `cur` is the clone taken at the LAST round's start; if that round changed
6420
+ // nothing (converged), unwinding to it is identity — beforeRound covers the case
6421
+ if (cur && !equal(cur, ast)) beforeRound = cur
6422
+
6423
+ // Second propagate sweep on whatever remained dirty: inlineOnce leaves fresh
6424
+ // (local.set $p arg) (local.get $p) wrappers around each inlined call
6425
+ if (opts.propagate && dirty) for (const f of dirty) propagate(f)
6426
+
6427
+ // No inflating splice fired and the node count didn't grow: every executed pass
6428
+ // was size-monotone, so both guard encodes (the optimizer's dominant cost on a
6429
+ // large module) are provably redundant.
6430
+ if (inflations === inflBefore && count(ast) <= countBefore) return finish(ast)
6431
+
6432
+ // Default optimize must never inflate; explicit passes get slight leniency.
6433
+ const tolerance = strictGuard ? 0 : 16
6434
+ sizeBefore = binarySize(pristine)
6435
+ let sizeAfter = binarySize(ast)
6436
+ if (sizeAfter - sizeBefore > tolerance && beforeRound) {
6437
+ if (verbose) log(` ⚠ net +${sizeAfter - sizeBefore} bytes — unwinding last round`, sizeAfter - sizeBefore)
6438
+ ast = beforeRound
6439
+ sizeAfter = binarySize(ast)
6440
+ if (sizeAfter - sizeBefore > tolerance) ast = pristine
3953
6441
  }
3954
6442
 
3955
6443
  return finish(ast)
@@ -3957,4 +6445,4 @@ export default function optimize(ast, opts = true) {
3957
6445
 
3958
6446
  /** Count AST nodes (fast size heuristic). */
3959
6447
  export { count as size, count, binarySize }
3960
- export { optimize, treeshake, fold, deadcode, localReuse, identity, strength, branch, propagate, inline, inlineOnce, devirt, normalize, OPTS, vacuum, peephole, globals, offset, unbranch, loopify, stripmut, brif, foldarms, dedupe, reorder, dedupTypes, packData, minifyImports, mergeBlocks, coalesceLocals }
6448
+ export { optimize, treeshake, fold, deadcode, localReuse, identity, strength, branch, propagate, mergeLocals, cse, inlineMacro, tailmerge, inline, inlineOnce, devirt, normalize, OPTS, vacuum, peephole, globals, offset, unbranch, loopify, stripmut, brif, foldarms, dedupe, reorder, dedupTypes, packData, minifyImports, mergeBlocks, coalesceLocals }