watr 4.5.1 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/optimize.js CHANGED
@@ -6,8 +6,10 @@
6
6
  */
7
7
 
8
8
  import parse from './parse.js'
9
+ import compile from './compile.js'
9
10
 
10
- /** Optimizations that can be applied */
11
+ /** Optimizations that can be applied.
12
+ * Passes defaulting to false can bloat output or are expensive — opt-in only. */
11
13
  const OPTS = {
12
14
  treeshake: true, // remove unused funcs/globals/types/tables
13
15
  fold: true, // constant folding
@@ -16,20 +18,21 @@ const OPTS = {
16
18
  identity: true, // remove identity ops (x + 0 → x)
17
19
  strength: true, // strength reduction (x * 2 → x << 1)
18
20
  branch: true, // simplify constant branches
19
- propagate: true, // constant propagation through locals
20
- inline: true, // inline tiny functions
21
+ propagate: true, // forward-propagate single-use locals & tiny consts (never inflates)
22
+ inline: false, // inline tiny functions — can duplicate bodies
23
+ inlineOnce: true, // inline single-call functions into their lone caller (never duplicates)
21
24
  vacuum: true, // remove nops, drop-of-pure, empty branches
25
+ mergeBlocks: true, // unwrap `(block $L …)` whose label is never targeted
26
+ coalesce: true, // share local slots between same-type non-overlapping locals
22
27
  peephole: true, // x-x→0, x&0→0, etc.
23
28
  globals: true, // propagate immutable global constants
24
29
  offset: true, // fold add+const into load/store offset
25
30
  unbranch: true, // remove redundant br at end of own block
26
31
  stripmut: true, // strip mut from never-written globals
27
32
  brif: true, // if-then-br → br_if
28
- foldarms: true, // merge identical trailing if arms
29
- // minify: true, // NOTE: disabled — renaming $ids has no binary-size effect
30
- // without a names section, and risks local-name collisions.
33
+ foldarms: false, // merge identical trailing if arms — can add block wrapper
31
34
  dedupe: true, // eliminate duplicate functions
32
- reorder: true, // put hot functions first for smaller LEBs
35
+ reorder: false, // put hot functions first no AST reduction
33
36
  dedupTypes: true, // merge identical type definitions
34
37
  packData: true, // trim trailing zeros, merge adjacent data segments
35
38
  minifyImports: false, // shorten import names — enable only when you control the host
@@ -38,6 +41,27 @@ const OPTS = {
38
41
  /** All optimization names */
39
42
  const ALL = Object.keys(OPTS)
40
43
 
44
+ /**
45
+ * Recursively count AST nodes — fast size heuristic without compiling.
46
+ * @param {any} node
47
+ * @returns {number}
48
+ */
49
+ const count = (node) => {
50
+ if (!Array.isArray(node)) return 1
51
+ let n = 1
52
+ for (let i = 0; i < node.length; i++) n += count(node[i])
53
+ return n
54
+ }
55
+
56
+ /**
57
+ * Compile AST and measure binary size in bytes.
58
+ * @param {Array} ast
59
+ * @returns {number}
60
+ */
61
+ const binarySize = (ast) => {
62
+ try { return compile(ast).length } catch { return Infinity }
63
+ }
64
+
41
65
  /**
42
66
  * Fast structural equality of two AST nodes.
43
67
  * Stops at first difference. Handles BigInt without stringification.
@@ -62,12 +86,9 @@ const normalize = (opts) => {
62
86
  if (opts === false) return {}
63
87
  if (typeof opts === 'string') {
64
88
  const set = new Set(opts.split(/\s+/).filter(Boolean))
65
- // Special case: a single explicit pass name (e.g. 'fold') enables only that pass,
66
- // rather than treating it as a sparse map where everything else is disabled.
67
- if (set.size === 1 && ALL.includes([...set][0])) {
68
- return Object.fromEntries(ALL.map(f => [f, set.has(f)]))
69
- }
70
- return Object.fromEntries(ALL.map(f => [f, set.has(f) || set.has('all')]))
89
+ if (set.has('all')) return Object.fromEntries(ALL.map(f => [f, true]))
90
+ // Explicit pass names enable ONLY those passes (not the full default set).
91
+ return Object.fromEntries(ALL.map(f => [f, set.has(f)]))
71
92
  }
72
93
  return { ...OPTS, ...opts }
73
94
  }
@@ -440,7 +461,7 @@ const makeConst = (type, value) => {
440
461
  * @returns {Array}
441
462
  */
442
463
  const fold = (ast) => {
443
- return walkPost(clone(ast), (node) => {
464
+ return walkPost(ast, (node) => {
444
465
  if (!Array.isArray(node)) return
445
466
  const entry = FOLDABLE[node[0]]
446
467
  if (!entry) return
@@ -526,7 +547,7 @@ const IDENTITIES = {
526
547
  * @returns {Array}
527
548
  */
528
549
  const identity = (ast) => {
529
- return walkPost(clone(ast), (node) => {
550
+ return walkPost(ast, (node) => {
530
551
  if (!Array.isArray(node) || node.length !== 3) return
531
552
  const fn = IDENTITIES[node[0]]
532
553
  if (!fn) return
@@ -544,7 +565,7 @@ const identity = (ast) => {
544
565
  * @returns {Array}
545
566
  */
546
567
  const strength = (ast) => {
547
- return walkPost(clone(ast), (node) => {
568
+ return walkPost(ast, (node) => {
548
569
  if (!Array.isArray(node) || node.length !== 3) return
549
570
  const [op, a, b] = node
550
571
 
@@ -614,7 +635,7 @@ const strength = (ast) => {
614
635
  * @returns {Array}
615
636
  */
616
637
  const branch = (ast) => {
617
- return walkPost(clone(ast), (node) => {
638
+ return walkPost(ast, (node) => {
618
639
  if (!Array.isArray(node)) return
619
640
  const op = node[0]
620
641
 
@@ -665,10 +686,8 @@ const TERMINATORS = new Set(['unreachable', 'return', 'br', 'br_table'])
665
686
  * @returns {Array}
666
687
  */
667
688
  const deadcode = (ast) => {
668
- const result = clone(ast)
669
-
670
689
  // Process each function body
671
- walk(result, (node) => {
690
+ walk(ast, (node) => {
672
691
  if (!Array.isArray(node)) return
673
692
  const kind = node[0]
674
693
 
@@ -686,7 +705,7 @@ const deadcode = (ast) => {
686
705
  }
687
706
  })
688
707
 
689
- return result
708
+ return ast
690
709
  }
691
710
 
692
711
  /**
@@ -741,9 +760,7 @@ const eliminateDeadInBlock = (block) => {
741
760
  * @returns {Array}
742
761
  */
743
762
  const localReuse = (ast) => {
744
- const result = clone(ast)
745
-
746
- walk(result, (node) => {
763
+ walk(ast, (node) => {
747
764
  if (!Array.isArray(node) || node[0] !== 'func') return
748
765
 
749
766
  // Collect local declarations and their types
@@ -792,7 +809,7 @@ const localReuse = (ast) => {
792
809
  }
793
810
  })
794
811
 
795
- return result
812
+ return ast
796
813
  }
797
814
 
798
815
  // ==================== PROPAGATION & LOCAL ELIMINATION ====================
@@ -841,8 +858,33 @@ const countLocalUses = (node) => {
841
858
  return counts
842
859
  }
843
860
 
844
- /** Can this tracked value be substituted for a local.get? */
845
- const canSubst = (k) => getConst(k.val) || (k.pure && k.singleUse)
861
+ /** A constant whose inlined form (opcode + immediate) is no wider than the ~2 B
862
+ * `local.get` it would replace so propagating it to every use is byte-neutral
863
+ * at worst, and still drops the `local.set` + the `local` decl. f32/f64 consts
864
+ * (5/9 B) lose on reuse, so only narrow i32/i64 literals qualify. */
865
+ const isTinyConst = (node) => {
866
+ const c = getConst(node)
867
+ if (!c) return false
868
+ if (c.type === 'i32') { const v = c.value | 0; return v >= -64 && v <= 63 }
869
+ if (c.type === 'i64') { const v = typeof c.value === 'bigint' ? c.value : BigInt(c.value); return v >= -64n && v <= 63n }
870
+ return false
871
+ }
872
+
873
+ /** Can this tracked value be substituted for a local.get?
874
+ * - single use of a pure value: always shrinks (drops the set, the lone get, the decl);
875
+ * - any use of a tiny constant: byte-neutral at worst, still drops the set + decl.
876
+ * Anything else (a wide constant reused many times, an impure expr) could inflate
877
+ * or reorder side effects, so it's left alone. */
878
+ const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val)
879
+
880
+ /** Drop tracked values that read `$name`: rewriting `$name` makes them stale. */
881
+ const purgeRefs = (known, name) => {
882
+ for (const [key, tracked] of known) {
883
+ let refs = false
884
+ walk(tracked.val, n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === name) refs = true })
885
+ if (refs) known.delete(key)
886
+ }
887
+ }
846
888
 
847
889
  /** Try substitute local.get nodes with known values */
848
890
  const substGets = (node, known) => walkPost(node, n => {
@@ -870,10 +912,12 @@ const forwardPropagate = (funcNode, params, useCounts) => {
870
912
 
871
913
  if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
872
914
 
873
- // Track local.set values
874
- if (op === 'local.set' && instr.length === 3 && typeof instr[1] === 'string') {
915
+ // Track local.set / local.tee values (tee writes too — its result also leaves
916
+ // the value on the stack but the local is updated identically to set).
917
+ if ((op === 'local.set' || op === 'local.tee') && instr.length === 3 && typeof instr[1] === 'string') {
875
918
  substGets(instr[2], known) // substitute known values in RHS
876
919
  const uses = getUseCount(instr[1])
920
+ purgeRefs(known, instr[1]) // entries that read this local just went stale
877
921
  known.set(instr[1], {
878
922
  val: instr[2], pure: isPure(instr[2]),
879
923
  singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0
@@ -902,6 +946,14 @@ const forwardPropagate = (funcNode, params, useCounts) => {
902
946
  const prev = clone(instr)
903
947
  substGets(instr, known)
904
948
  if (!equal(prev, instr)) changed = true
949
+ // Invalidate tracking for any names written by a nested set/tee — those
950
+ // writes happened mid-expression and the substGets above used the
951
+ // pre-write tracked value (correct), but later reads must see the new
952
+ // (untracked) value, not the stale constant.
953
+ walk(instr, n => {
954
+ if (Array.isArray(n) && (n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
955
+ { known.delete(n[1]); purgeRefs(known, n[1]) }
956
+ })
905
957
  }
906
958
  }
907
959
 
@@ -1001,30 +1053,46 @@ const eliminateDeadStores = (funcNode, params, useCounts) => {
1001
1053
  * Constants propagate to all uses; pure single-use exprs inline into get site.
1002
1054
  * Multi-pass with batch counting for convergence.
1003
1055
  */
1004
- const propagate = (ast) => {
1005
- const result = clone(ast)
1056
+ /** Block-like nodes whose body is a straight-line instruction list (after any header). */
1057
+ const isScopeNode = (n) => Array.isArray(n) &&
1058
+ (n[0] === 'func' || n[0] === 'block' || n[0] === 'loop' || n[0] === 'then' || n[0] === 'else')
1006
1059
 
1007
- walk(result, (funcNode) => {
1060
+ const propagate = (ast) => {
1061
+ walk(ast, (funcNode) => {
1008
1062
  if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
1009
1063
 
1010
1064
  const params = new Set()
1011
1065
  for (const sub of funcNode)
1012
1066
  if (Array.isArray(sub) && sub[0] === 'param' && typeof sub[1] === 'string') params.add(sub[1])
1013
1067
 
1014
- // useCounts must be refreshed before every sub-pass: each mutation
1015
- // (substitution, set/get pair removal, tee creation, dead-store removal)
1016
- // changes the gets/sets/tees totals that downstream sub-passes rely on.
1017
- for (let pass = 0; pass < 4; pass++) {
1018
- let changed = false
1019
- if (forwardPropagate(funcNode, params, countLocalUses(funcNode))) changed = true
1020
- if (eliminateSetGetPairs(funcNode, params, countLocalUses(funcNode))) changed = true
1021
- if (createLocalTees(funcNode, params, countLocalUses(funcNode))) changed = true
1022
- if (eliminateDeadStores(funcNode, params, countLocalUses(funcNode))) changed = true
1023
- if (!changed) break
1068
+ // Propagation runs per straight-line scope: the function body and every nested
1069
+ // `block`/`loop`/`then`/`else` (including ones embedded in an expression, e.g. the
1070
+ // `(block (result i32) …)` an inlined call leaves behind). Collect scopes deepest-
1071
+ // first so inner simplifications shrink the use-counts the outer scopes see.
1072
+ // Use-counts are always whole-function — a set/get pair or dead store is only
1073
+ // touched when it's globally the sole occurrence, so per-scope work stays sound.
1074
+ const scopes = []
1075
+ walkPost(funcNode, n => { if (isScopeNode(n)) scopes.push(n) })
1076
+
1077
+ // One use-count per round, shared by every scope: substitutions only ever
1078
+ // *drop* gets, so a stale count can only make a sub-pass act more cautiously
1079
+ // (skip a not-yet-provably-dead store, decline a not-yet-provably-single use) —
1080
+ // never wrongly. The next round re-counts and mops up. (Recounting per sub-pass
1081
+ // per scope is O(scopes·funcSize) and crippling on big modules.)
1082
+ for (let round = 0; round < 6; round++) {
1083
+ const useCounts = countLocalUses(funcNode)
1084
+ let progressed = false
1085
+ for (const scope of scopes) {
1086
+ if (forwardPropagate(scope, params, useCounts)) progressed = true
1087
+ if (eliminateSetGetPairs(scope, params, useCounts)) progressed = true
1088
+ if (createLocalTees(scope, params, useCounts)) progressed = true
1089
+ if (eliminateDeadStores(scope, params, useCounts)) progressed = true
1090
+ }
1091
+ if (!progressed) break
1024
1092
  }
1025
1093
  })
1026
1094
 
1027
- return result
1095
+ return ast
1028
1096
  }
1029
1097
 
1030
1098
  // ==================== FUNCTION INLINING ====================
@@ -1036,12 +1104,11 @@ const propagate = (ast) => {
1036
1104
  */
1037
1105
  const inline = (ast) => {
1038
1106
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1039
- const result = clone(ast)
1040
1107
 
1041
1108
  // Collect inlinable functions
1042
1109
  const inlinable = new Map() // $name → { body, params }
1043
1110
 
1044
- for (const node of result.slice(1)) {
1111
+ for (const node of ast.slice(1)) {
1045
1112
  if (!Array.isArray(node) || node[0] !== 'func') continue
1046
1113
 
1047
1114
  const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
@@ -1102,9 +1169,9 @@ const inline = (ast) => {
1102
1169
  }
1103
1170
 
1104
1171
  // Replace calls with inlined body
1105
- if (inlinable.size === 0) return result
1172
+ if (inlinable.size === 0) return ast
1106
1173
 
1107
- walkPost(result, (node) => {
1174
+ walkPost(ast, (node) => {
1108
1175
  if (!Array.isArray(node) || node[0] !== 'call') return
1109
1176
  const fname = node[1]
1110
1177
  if (!inlinable.has(fname)) return
@@ -1131,7 +1198,374 @@ const inline = (ast) => {
1131
1198
  return substituted
1132
1199
  })
1133
1200
 
1134
- return result
1201
+ return ast
1202
+ }
1203
+
1204
+ // ==================== INLINE-ONCE ====================
1205
+
1206
+ let inlineUid = 0
1207
+
1208
+ /**
1209
+ * Inline functions that are called from exactly one place into their lone caller,
1210
+ * then delete them. Unlike {@link inline} (which duplicates tiny stateless bodies),
1211
+ * this never duplicates code and never inflates: each inlined function drops a
1212
+ * function-section entry, a type-section entry (if now unused), and a `call`
1213
+ * instruction, paying back only a `block`/`local.set` wrapper. This is what
1214
+ * `wasm-opt -Oz` does — collapsing helper chains down to a couple of functions —
1215
+ * and it's the bulk of the gap between hand-tuned WASM and naive codegen.
1216
+ *
1217
+ * A function `$f` qualifies when it is, all of:
1218
+ * • named, with named params and locals (numeric indices can't be safely renamed);
1219
+ * • referenced exactly once across the whole module, by a plain `call` (no
1220
+ * `return_call`, `ref.func`, `elem`, `export`, or `start` reference, and not
1221
+ * recursive);
1222
+ * • single-result or void (a multi-value result can't be modeled as `(block (result …))`);
1223
+ * • free of numeric (depth-relative) branch labels — those would shift under the
1224
+ * extra block nesting — and of `return_call*` in its body.
1225
+ *
1226
+ * `(call $f a0 a1 …)` becomes
1227
+ * (block $__inlN (result T)?
1228
+ * (local.set $__inlN_p0 a0) (local.set $__inlN_p1 a1) … ;; args evaluated once, in order
1229
+ * …body, params/locals renamed to $__inlN_*, `return X` → `br $__inlN X`…)
1230
+ * and the renamed params+locals are appended to the caller's `local` decls; the
1231
+ * body's own block/loop/if labels are renamed too so they can't shadow the caller's.
1232
+ * Runs to a fixpoint so helper chains fully collapse.
1233
+ *
1234
+ * @param {Array} ast
1235
+ * @returns {Array}
1236
+ */
1237
+ const inlineOnce = (ast) => {
1238
+ if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1239
+
1240
+ const HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
1241
+ const bodyStart = (fn) => {
1242
+ let i = 2
1243
+ while (i < fn.length && (typeof fn[i] === 'string' || (Array.isArray(fn[i]) && HEAD.has(fn[i][0])))) i++
1244
+ return i
1245
+ }
1246
+ const isBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
1247
+ // A subtree we can't lift into a (block …): depth-relative branch labels (shift
1248
+ // under added nesting) or tail calls (would escape the wrapping block).
1249
+ const unsafe = (n) => {
1250
+ if (!Array.isArray(n)) return false
1251
+ const op = n[0]
1252
+ if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
1253
+ if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
1254
+ if (isBranch(op)) for (let i = 1; i < n.length; i++) if (typeof n[i] === 'number' || (typeof n[i] === 'string' && /^\d+$/.test(n[i]))) return true
1255
+ for (let i = 1; i < n.length; i++) if (unsafe(n[i])) return true
1256
+ return false
1257
+ }
1258
+ const callsSelf = (n, name) => {
1259
+ if (!Array.isArray(n)) return false
1260
+ if ((n[0] === 'call' || n[0] === 'return_call') && n[1] === name) return true
1261
+ for (let i = 1; i < n.length; i++) if (callsSelf(n[i], name)) return true
1262
+ return false
1263
+ }
1264
+
1265
+ // Module-level references that pin a function (can't be removed/inlined-away).
1266
+ const collectPinned = (n, pinned) => {
1267
+ if (!Array.isArray(n)) return
1268
+ const op = n[0]
1269
+ if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
1270
+ else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
1271
+ else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
1272
+ else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
1273
+ for (const c of n) collectPinned(c, pinned)
1274
+ }
1275
+
1276
+ for (let round = 0; round < 16; round++) {
1277
+ const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
1278
+ const funcByName = new Map()
1279
+ for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
1280
+
1281
+ // Count plain-call references across the WHOLE module (anonymous exported funcs
1282
+ // call helpers too); flag any non-call reference (return_call etc.).
1283
+ const callRefs = new Map(), otherRef = new Set()
1284
+ const countRefs = (n) => {
1285
+ if (!Array.isArray(n)) return
1286
+ const op = n[0]
1287
+ if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
1288
+ else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
1289
+ for (let i = 1; i < n.length; i++) countRefs(n[i])
1290
+ }
1291
+ countRefs(ast)
1292
+ const pinned = new Set()
1293
+ for (const n of ast) if (!Array.isArray(n) || n[0] !== 'func') collectPinned(n, pinned)
1294
+ // a func may carry its own (export "name") — the signature scan below rejects those too
1295
+
1296
+ // Pick a callee.
1297
+ let calleeName = null
1298
+ for (const [name, fn] of funcByName) {
1299
+ if (pinned.has(name) || otherRef.has(name)) continue
1300
+ if (callRefs.get(name) !== 1) continue
1301
+ if (callsSelf(fn, name)) continue
1302
+ // named params/locals only; collect signature
1303
+ let ok = true, nResult = 0
1304
+ for (let i = 2; i < fn.length; i++) {
1305
+ const c = fn[i]
1306
+ if (typeof c === 'string') continue
1307
+ if (!Array.isArray(c)) { ok = false; break }
1308
+ if (c[0] === 'param' || c[0] === 'local') { if (typeof c[1] !== 'string' || c[1][0] !== '$') { ok = false; break } }
1309
+ else if (c[0] === 'result') nResult += c.length - 1
1310
+ else if (c[0] === 'export') { ok = false; break }
1311
+ else if (c[0] === 'type') continue
1312
+ else break
1313
+ }
1314
+ if (!ok || nResult > 1) continue
1315
+ let bad = false
1316
+ for (let i = bodyStart(fn); i < fn.length; i++) if (unsafe(fn[i])) { bad = true; break }
1317
+ if (bad) continue
1318
+ calleeName = name; break
1319
+ }
1320
+ if (!calleeName) break
1321
+
1322
+ const callee = funcByName.get(calleeName)
1323
+ const params = [], locals = []
1324
+ let resultType = null
1325
+ for (let i = 2; i < callee.length; i++) {
1326
+ const c = callee[i]
1327
+ if (typeof c === 'string' || !Array.isArray(c)) continue
1328
+ if (c[0] === 'param') params.push({ name: c[1], type: c[2] })
1329
+ else if (c[0] === 'result') { if (c.length > 1) resultType = c[1] }
1330
+ else if (c[0] === 'local') locals.push({ name: c[1], type: c[2] })
1331
+ else if (c[0] === 'export' || c[0] === 'type') continue
1332
+ else break
1333
+ }
1334
+ const cBody = callee.slice(bodyStart(callee))
1335
+
1336
+ const uid = ++inlineUid
1337
+ const exit = `$__inl${uid}`
1338
+ const rename = new Map()
1339
+ for (const p of params) rename.set(p.name, `$__inl${uid}_${p.name.slice(1)}`)
1340
+ for (const l of locals) rename.set(l.name, `$__inl${uid}_${l.name.slice(1)}`)
1341
+ // The callee's own block/loop/if labels would shadow same-named labels in the
1342
+ // caller after nesting (and break depth resolution) — give them fresh names too.
1343
+ const isBlockLabel = op => op === 'block' || op === 'loop' || op === 'if'
1344
+ const labelRename = new Map()
1345
+ const collectLabels = (n) => {
1346
+ if (!Array.isArray(n)) return
1347
+ if (isBlockLabel(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !labelRename.has(n[1]))
1348
+ labelRename.set(n[1], `$__inl${uid}L_${n[1].slice(1)}`)
1349
+ for (let i = 1; i < n.length; i++) collectLabels(n[i])
1350
+ }
1351
+ for (const n of cBody) collectLabels(n)
1352
+ const sub = (n) => {
1353
+ if (!Array.isArray(n)) return n
1354
+ const op = n[0]
1355
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
1356
+ return [op, rename.get(n[1]), ...n.slice(2).map(sub)]
1357
+ if (op === 'return') return ['br', exit, ...n.slice(1).map(sub)]
1358
+ if (isBlockLabel(op) && typeof n[1] === 'string' && labelRename.has(n[1]))
1359
+ return [op, labelRename.get(n[1]), ...n.slice(2).map(sub)]
1360
+ if (isBranch(op)) return [op, ...n.slice(1).map(c => (typeof c === 'string' && labelRename.has(c)) ? labelRename.get(c) : sub(c))]
1361
+ return n.map((c, i) => i === 0 ? c : sub(c))
1362
+ }
1363
+
1364
+ // Splice into the (unique) caller (which may be an anonymous exported func).
1365
+ let done = false
1366
+ for (const fn of funcs) {
1367
+ if (fn === callee || done) continue
1368
+ const start = bodyStart(fn)
1369
+ for (let i = start; i < fn.length; i++) {
1370
+ const replaced = walkPost(fn[i], (n) => {
1371
+ if (done || !Array.isArray(n) || n[0] !== 'call' || n[1] !== calleeName) return
1372
+ const args = n.slice(2)
1373
+ if (args.length !== params.length) return // arity mismatch — leave it
1374
+ const setup = params.map((p, k) => ['local.set', rename.get(p.name), args[k]])
1375
+ const inner = cBody.map(sub)
1376
+ done = true
1377
+ return resultType
1378
+ ? ['block', exit, ['result', resultType], ...setup, ...inner]
1379
+ : ['block', exit, ...setup, ...inner]
1380
+ })
1381
+ if (replaced !== fn[i]) fn[i] = replaced
1382
+ if (done) {
1383
+ const decls = [...params, ...locals].map(p => ['local', rename.get(p.name), p.type])
1384
+ if (decls.length) fn.splice(bodyStart(fn), 0, ...decls)
1385
+ break
1386
+ }
1387
+ }
1388
+ if (done) break
1389
+ }
1390
+ if (!done) break // call site not found inside a func body — give up
1391
+
1392
+ const idx = ast.indexOf(callee)
1393
+ if (idx >= 0) ast.splice(idx, 1)
1394
+ }
1395
+
1396
+ return ast
1397
+ }
1398
+
1399
+ // ==================== MERGE BLOCKS ====================
1400
+
1401
+ /**
1402
+ * Does `body` contain a branch instruction targeting `label`, ignoring inner
1403
+ * blocks/loops that re-bind the same label?
1404
+ */
1405
+ const targetsLabel = (body, label) => {
1406
+ let found = false
1407
+ const search = (n, shadowed) => {
1408
+ if (found || !Array.isArray(n)) return
1409
+ const op = n[0]
1410
+ let inner = shadowed
1411
+ if ((op === 'block' || op === 'loop') && typeof n[1] === 'string' && n[1] === label) inner = true
1412
+ if (!shadowed) {
1413
+ if (op === 'br' || op === 'br_if' || op === 'br_on_null' || op === 'br_on_non_null' ||
1414
+ op === 'br_on_cast' || op === 'br_on_cast_fail') {
1415
+ if (n[1] === label) { found = true; return }
1416
+ } else if (op === 'br_table') {
1417
+ for (let j = 1; j < n.length; j++) {
1418
+ if (typeof n[j] === 'string') { if (n[j] === label) { found = true; return } }
1419
+ else break
1420
+ }
1421
+ }
1422
+ }
1423
+ for (let i = 1; i < n.length; i++) search(n[i], inner)
1424
+ }
1425
+ for (const node of body) search(node, false)
1426
+ return found
1427
+ }
1428
+
1429
+ /**
1430
+ * Unwrap redundant `(block $L body)` whose label is never targeted, splicing
1431
+ * the body into the surrounding scope. Skips blocks with `param`/`result`/`type`
1432
+ * annotations (their stack effect would change). Each unwrap drops the
1433
+ * `block`+`end` framing bytes; iterates by walk so chained blocks collapse.
1434
+ * @param {Array} ast
1435
+ * @returns {Array}
1436
+ */
1437
+ const mergeBlocks = (ast) => {
1438
+ walk(ast, (node) => {
1439
+ if (!isScopeNode(node)) return
1440
+ let i = 1
1441
+ while (i < node.length) {
1442
+ const child = node[i]
1443
+ if (!Array.isArray(child) || child[0] !== 'block') { i++; continue }
1444
+ let bi = 1, label = null
1445
+ if (typeof child[1] === 'string' && child[1][0] === '$') { label = child[1]; bi = 2 }
1446
+ let typed = false
1447
+ for (let j = bi; j < child.length; j++) {
1448
+ const c = child[j]
1449
+ if (Array.isArray(c) && (c[0] === 'param' || c[0] === 'result' || c[0] === 'type')) { typed = true; break }
1450
+ }
1451
+ if (typed) { i++; continue }
1452
+ const body = child.slice(bi)
1453
+ if (label && targetsLabel(body, label)) { i++; continue }
1454
+ node.splice(i, 1, ...body)
1455
+ i += body.length
1456
+ }
1457
+ })
1458
+ return ast
1459
+ }
1460
+
1461
+ // ==================== COALESCE LOCALS ====================
1462
+
1463
+ /**
1464
+ * Share local slots between same-type locals with non-overlapping live ranges.
1465
+ * Live range = [first pos, last pos] of any local.get/set/tee, extended over
1466
+ * any loop containing a reference (so a value read across loop iterations stays
1467
+ * intact). Greedy slot assignment by start position. Params and unnamed/numeric
1468
+ * references are left alone; `localReuse` later removes the renamed-away decls.
1469
+ *
1470
+ * Soundness: WASM zero-initializes locals at function entry, so a local whose
1471
+ * first reference (in walk order) is a `local.get` *relies* on that implicit
1472
+ * zero — coalescing it into a slot whose previous user left a non-zero residue
1473
+ * would silently change behavior (e.g. a `for (let i=0; …)` loop counter
1474
+ * inheriting `N*4` from a sibling temp). Such "read-first" locals can still
1475
+ * serve as a slot's *primary* (the slot then keeps the function's zero start),
1476
+ * but can never be a donor merged into an existing slot.
1477
+ * @param {Array} ast
1478
+ * @returns {Array}
1479
+ */
1480
+ const coalesceLocals = (ast) => {
1481
+ walk(ast, (funcNode) => {
1482
+ if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
1483
+
1484
+ const decls = new Map()
1485
+ for (const sub of funcNode) {
1486
+ if (Array.isArray(sub) && sub[0] === 'local' &&
1487
+ typeof sub[1] === 'string' && sub[1][0] === '$' && typeof sub[2] === 'string') {
1488
+ decls.set(sub[1], sub[2])
1489
+ }
1490
+ }
1491
+ if (decls.size < 2) return
1492
+
1493
+ const uses = new Map()
1494
+ const loopStack = []
1495
+ let pos = 0, abort = false, condDepth = 0
1496
+
1497
+ const visit = (n) => {
1498
+ if (abort || !Array.isArray(n)) return
1499
+ const op = n[0]
1500
+ const isLoop = op === 'loop'
1501
+ if (isLoop) loopStack.push({ start: pos, end: pos })
1502
+ const isSet = op === 'local.set' || op === 'local.tee'
1503
+
1504
+ if (isSet || op === 'local.get') {
1505
+ const name = n[1]
1506
+ if (typeof name !== 'string' || name[0] !== '$') { abort = true; return }
1507
+ // Execution order: evaluate set/tee value BEFORE recording the write,
1508
+ // so a `(local.set $x (… (local.get $x) …))` is correctly seen as a
1509
+ // read-then-write of $x (firstOp = local.get).
1510
+ if (isSet) for (let i = 2; i < n.length; i++) visit(n[i])
1511
+ const here = pos++
1512
+ if (decls.has(name)) {
1513
+ let u = uses.get(name)
1514
+ if (!u) { u = { start: here, end: here, firstOp: op, firstCond: condDepth > 0, loops: new Set() }; uses.set(name, u) }
1515
+ if (here > u.end) u.end = here
1516
+ for (const ls of loopStack) u.loops.add(ls)
1517
+ }
1518
+ } else {
1519
+ pos++
1520
+ const isIf = op === 'if'
1521
+ for (let i = 1; i < n.length; i++) {
1522
+ const c = n[i]
1523
+ const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
1524
+ if (cond) condDepth++
1525
+ visit(c)
1526
+ if (cond) condDepth--
1527
+ }
1528
+ }
1529
+
1530
+ if (isLoop) { const ls = loopStack.pop(); ls.end = pos }
1531
+ }
1532
+ visit(funcNode)
1533
+ if (abort) return
1534
+
1535
+ // A use inside a loop must stay live for the whole loop — the next
1536
+ // iteration could read what this iteration wrote.
1537
+ for (const u of uses.values()) {
1538
+ for (const ls of u.loops) {
1539
+ if (ls.start < u.start) u.start = ls.start
1540
+ if (ls.end > u.end) u.end = ls.end
1541
+ }
1542
+ }
1543
+
1544
+ const ordered = [...uses.entries()].sort((a, b) => a[1].start - b[1].start)
1545
+ const rename = new Map()
1546
+ const slots = []
1547
+ for (const [name, range] of ordered) {
1548
+ // Read-first locals depend on the implicit zero; locals first seen inside
1549
+ // an if/else branch may be skipped on the alternate path — either way
1550
+ // they'd observe a prior slot's residue if reused. They may *start* a
1551
+ // fresh slot (the function's zero init), but never *join* one.
1552
+ const readsZero = range.firstOp === 'local.get' || range.firstCond
1553
+ const type = decls.get(name)
1554
+ const slot = readsZero ? null : slots.find(s => s.type === type && s.end < range.start)
1555
+ if (slot) { rename.set(name, slot.primary); if (range.end > slot.end) slot.end = range.end }
1556
+ else slots.push({ primary: name, type, end: range.end })
1557
+ }
1558
+ if (rename.size === 0) return
1559
+
1560
+ walk(funcNode, (n) => {
1561
+ if (Array.isArray(n) &&
1562
+ (n[0] === 'local.get' || n[0] === 'local.set' || n[0] === 'local.tee') &&
1563
+ rename.has(n[1])) {
1564
+ n[1] = rename.get(n[1])
1565
+ }
1566
+ })
1567
+ })
1568
+ return ast
1135
1569
  }
1136
1570
 
1137
1571
  // ==================== VACUUM ====================
@@ -1143,7 +1577,7 @@ const inline = (ast) => {
1143
1577
  * @returns {Array}
1144
1578
  */
1145
1579
  const vacuum = (ast) => {
1146
- return walkPost(clone(ast), (node) => {
1580
+ return walkPost(ast, (node) => {
1147
1581
  if (!Array.isArray(node)) return
1148
1582
  const op = node[0]
1149
1583
 
@@ -1269,7 +1703,7 @@ const PEEPHOLE = {
1269
1703
  * @returns {Array}
1270
1704
  */
1271
1705
  const peephole = (ast) => {
1272
- return walkPost(clone(ast), (node) => {
1706
+ return walkPost(ast, (node) => {
1273
1707
  if (!Array.isArray(node) || node.length !== 3) return
1274
1708
  const fn = PEEPHOLE[node[0]]
1275
1709
  if (!fn) return
@@ -1280,60 +1714,106 @@ const peephole = (ast) => {
1280
1714
 
1281
1715
  // ==================== GLOBAL CONSTANT PROPAGATION ====================
1282
1716
 
1717
+ /** Bytes a signed-LEB128 integer encodes to. */
1718
+ const slebSize = (v) => {
1719
+ let x = typeof v === 'bigint' ? v : BigInt(Math.trunc(Number(v) || 0))
1720
+ let n = 1
1721
+ while (true) {
1722
+ const b = x & 0x7fn
1723
+ x >>= 7n
1724
+ if ((x === 0n && (b & 0x40n) === 0n) || (x === -1n && (b & 0x40n) !== 0n)) return n
1725
+ n++
1726
+ }
1727
+ }
1728
+ /** Encoded byte size of a constant init instruction (opcode + immediate). */
1729
+ const constInstrSize = (node) => {
1730
+ if (!Array.isArray(node)) return 4
1731
+ switch (node[0]) {
1732
+ case 'i32.const': case 'i64.const': return 1 + slebSize(node[1])
1733
+ case 'f32.const': return 5
1734
+ case 'f64.const': return 9
1735
+ case 'v128.const': return 18
1736
+ default: return 4 // ref.null/ref.func/global.get — conservative
1737
+ }
1738
+ }
1739
+ const GLOBAL_GET_SIZE = 2 // 0x23 opcode + 1-byte globalidx (typical)
1740
+
1283
1741
  /**
1284
- * Replace global.get of immutable globals with their constant init values.
1742
+ * Replace `global.get` of an immutable, const-initialised global with the
1743
+ * constant — but only when it doesn't grow the module. A `global.get` costs
1744
+ * ~2 B; an `i32.const 12345` costs 4 B; an `f64.const` costs 9 B. Naively
1745
+ * inlining a big constant read from many sites trades a few cheap reads + one
1746
+ * global decl for many fat immediates — pure bloat (and the node-count size
1747
+ * guard can't see it: same number of AST nodes). So we only propagate a global
1748
+ * when `refs·constSize ≤ refs·2 + declSize`; when every read is replaced and
1749
+ * the global isn't exported, its now-dead decl is dropped here too.
1285
1750
  * @param {Array} ast
1286
1751
  * @returns {Array}
1287
1752
  */
1288
1753
  const globals = (ast) => {
1289
1754
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1290
- const result = clone(ast)
1291
1755
 
1292
- // Find immutable globals with const init
1293
- const constGlobals = new Map() // name → const node
1294
- const mutableGlobals = new Set()
1756
+ // Immutable globals with a constant init: name → init node.
1757
+ const constGlobals = new Map()
1758
+ const exported = new Set() // globals pinned by an export — keep the decl
1295
1759
 
1296
- for (const node of result.slice(1)) {
1297
- if (!Array.isArray(node) || node[0] !== 'global') continue
1760
+ for (const node of ast.slice(1)) {
1761
+ if (!Array.isArray(node)) continue
1762
+ if (node[0] === 'export' && Array.isArray(node[2]) && node[2][0] === 'global' && typeof node[2][1] === 'string') { exported.add(node[2][1]); continue }
1763
+ if (node[0] !== 'global') continue
1298
1764
  const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
1299
1765
  if (!name) continue
1300
-
1301
- // Check mutability: (global $g (mut i32) init) vs (global $g i32 init)
1302
- const hasName = typeof node[1] === 'string' && node[1][0] === '$'
1303
- const initIdx = hasName ? 3 : 2
1304
-
1305
- // Skip mutable globals
1306
- const typeSlot = hasName ? node[2] : node[1]
1307
- if (Array.isArray(typeSlot) && typeSlot[0] === 'mut') continue
1308
-
1309
- const init = node[initIdx]
1766
+ // (global $g (export "x") …) inline export → pinned
1767
+ if (node.some(c => Array.isArray(c) && c[0] === 'export')) exported.add(name)
1768
+ const typeSlot = node[2]
1769
+ if (Array.isArray(typeSlot) && typeSlot[0] === 'mut') continue // mutable
1770
+ if (Array.isArray(typeSlot) && typeSlot[0] === 'import') continue // imported
1771
+ const init = node[3]
1310
1772
  if (getConst(init)) constGlobals.set(name, init)
1311
1773
  }
1774
+ if (constGlobals.size === 0) return ast
1312
1775
 
1313
- // Also mark any global that is ever written as mutable
1314
- walk(result, (n) => {
1315
- if (!Array.isArray(n) || n[0] !== 'global.set') return
1776
+ // Drop any global that is ever written (defensive — an immutable global can't
1777
+ // be, but a malformed module might) and tally read counts.
1778
+ const reads = new Map()
1779
+ walk(ast, (n) => {
1780
+ if (!Array.isArray(n)) return
1316
1781
  const ref = n[1]
1317
- if (typeof ref === 'string' && ref[0] === '$') mutableGlobals.add(ref)
1782
+ if (typeof ref !== 'string' || ref[0] !== '$') return
1783
+ if (n[0] === 'global.set') constGlobals.delete(ref)
1784
+ else if (n[0] === 'global.get') reads.set(ref, (reads.get(ref) || 0) + 1)
1318
1785
  })
1319
1786
 
1320
- // Remove mutable ones from propagation set
1321
- for (const name of mutableGlobals) constGlobals.delete(name)
1322
- if (constGlobals.size === 0) return result
1787
+ // Keep only globals where propagation is size-neutral or better.
1788
+ const propagate = new Set()
1789
+ for (const [name, init] of constGlobals) {
1790
+ const r = reads.get(name) || 0
1791
+ if (r === 0) continue // dead anyway — leave to treeshake
1792
+ const cs = constInstrSize(init)
1793
+ const declSize = cs + 2 // valtype + mutability byte + init expr + `end`
1794
+ const before = r * GLOBAL_GET_SIZE + declSize
1795
+ const after = r * cs + (exported.has(name) ? declSize : 0)
1796
+ if (after <= before) propagate.add(name)
1797
+ }
1798
+ if (propagate.size === 0) return ast
1323
1799
 
1324
- // Substitute global.get with const
1325
- return walkPost(result, (node) => {
1800
+ walkPost(ast, (node) => {
1326
1801
  if (!Array.isArray(node) || node[0] !== 'global.get' || node.length !== 2) return
1327
- const ref = node[1]
1328
- if (constGlobals.has(ref)) return clone(constGlobals.get(ref))
1802
+ if (propagate.has(node[1])) return clone(constGlobals.get(node[1]))
1329
1803
  })
1804
+ // Their reads are all gone now — remove the decls we're free to remove.
1805
+ for (let i = ast.length - 1; i >= 1; i--) {
1806
+ const n = ast[i]
1807
+ if (Array.isArray(n) && n[0] === 'global' && typeof n[1] === 'string' && propagate.has(n[1]) && !exported.has(n[1])) ast.splice(i, 1)
1808
+ }
1809
+ return ast
1330
1810
  }
1331
1811
 
1332
1812
  // ==================== LOAD/STORE OFFSET FOLDING ====================
1333
1813
 
1334
1814
  /** Match (type.load/store (i32.add ptr (type.const N))) and fold offset */
1335
1815
  const offset = (ast) => {
1336
- return walkPost(clone(ast), (node) => {
1816
+ return walkPost(ast, (node) => {
1337
1817
  if (!Array.isArray(node)) return
1338
1818
  const op = node[0]
1339
1819
  if (typeof op !== 'string' || (!op.endsWith('load') && !op.endsWith('store'))) return
@@ -1406,9 +1886,7 @@ const offset = (ast) => {
1406
1886
  * @returns {Array}
1407
1887
  */
1408
1888
  const unbranch = (ast) => {
1409
- const result = clone(ast)
1410
-
1411
- walk(result, (node) => {
1889
+ walk(ast, (node) => {
1412
1890
  if (!Array.isArray(node)) return
1413
1891
  const op = node[0]
1414
1892
  // Loops: `br $loop_label` jumps BACK to loop top (continue), not out.
@@ -1441,11 +1919,13 @@ const unbranch = (ast) => {
1441
1919
 
1442
1920
  const last = node[lastIdx]
1443
1921
  if (Array.isArray(last) && last[0] === 'br' && last[1] === label) {
1444
- node.splice(lastIdx, 1)
1922
+ // `(br $L v…)` as a block's last instruction just leaves v… as the block's
1923
+ // result — splice the value operand(s) in its place (none → plain removal).
1924
+ node.splice(lastIdx, 1, ...last.slice(2))
1445
1925
  }
1446
1926
  })
1447
1927
 
1448
- return result
1928
+ return ast
1449
1929
  }
1450
1930
 
1451
1931
  // ==================== STRIP MUT FROM GLOBALS ====================
@@ -1458,14 +1938,13 @@ const unbranch = (ast) => {
1458
1938
  */
1459
1939
  const stripmut = (ast) => {
1460
1940
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1461
- const result = clone(ast)
1462
1941
 
1463
1942
  const written = new Set()
1464
- walk(result, (n) => {
1943
+ walk(ast, (n) => {
1465
1944
  if (Array.isArray(n) && n[0] === 'global.set' && typeof n[1] === 'string') written.add(n[1])
1466
1945
  })
1467
1946
 
1468
- return walkPost(result, (node) => {
1947
+ return walkPost(ast, (node) => {
1469
1948
  if (!Array.isArray(node) || node[0] !== 'global') return
1470
1949
  const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
1471
1950
  if (!name || written.has(name)) return
@@ -1490,7 +1969,7 @@ const stripmut = (ast) => {
1490
1969
  * @returns {Array}
1491
1970
  */
1492
1971
  const brif = (ast) => {
1493
- return walkPost(clone(ast), (node) => {
1972
+ return walkPost(ast, (node) => {
1494
1973
  if (!Array.isArray(node) || node[0] !== 'if') return
1495
1974
  const { cond, thenBranch, elseBranch } = parseIf(node)
1496
1975
  const thenEmpty = !thenBranch || thenBranch.length <= 1
@@ -1519,7 +1998,7 @@ const brif = (ast) => {
1519
1998
  * @returns {Array}
1520
1999
  */
1521
2000
  const foldarms = (ast) => {
1522
- return walkPost(clone(ast), (node) => {
2001
+ return walkPost(ast, (node) => {
1523
2002
  if (!Array.isArray(node) || node[0] !== 'if') return
1524
2003
  const { thenBranch, elseBranch } = parseIf(node)
1525
2004
  if (!thenBranch || !elseBranch) return
@@ -1611,13 +2090,12 @@ const hashFunc = (node, localNames) => {
1611
2090
  */
1612
2091
  const dedupe = (ast) => {
1613
2092
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1614
- const result = clone(ast)
1615
2093
 
1616
2094
  // Hash function bodies (normalize local/param names to avoid false negatives)
1617
2095
  const signatures = new Map() // hash → canonical $name
1618
2096
  const redirects = new Map() // duplicate $name → canonical $name
1619
2097
 
1620
- for (const node of result.slice(1)) {
2098
+ for (const node of ast.slice(1)) {
1621
2099
  if (!Array.isArray(node) || node[0] !== 'func') continue
1622
2100
  const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
1623
2101
  if (!name) continue
@@ -1645,10 +2123,10 @@ const dedupe = (ast) => {
1645
2123
  }
1646
2124
  }
1647
2125
 
1648
- if (redirects.size === 0) return result
2126
+ if (redirects.size === 0) return ast
1649
2127
 
1650
2128
  // Rewrite all references: calls, ref.func, elem segments, call_indirect type
1651
- walkPost(result, (node) => {
2129
+ walkPost(ast, (node) => {
1652
2130
  if (!Array.isArray(node)) return
1653
2131
  const op = node[0]
1654
2132
  if ((op === 'call' || op === 'return_call') && redirects.has(node[1])) {
@@ -1671,7 +2149,7 @@ const dedupe = (ast) => {
1671
2149
  }
1672
2150
  })
1673
2151
 
1674
- return result
2152
+ return ast
1675
2153
  }
1676
2154
 
1677
2155
  // ==================== TYPE DEDUPLICATION ====================
@@ -1684,12 +2162,11 @@ const dedupe = (ast) => {
1684
2162
  */
1685
2163
  const dedupTypes = (ast) => {
1686
2164
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1687
- const result = clone(ast)
1688
2165
 
1689
2166
  const signatures = new Map() // hash → canonical $name
1690
2167
  const redirects = new Map() // duplicate $name → canonical $name
1691
2168
 
1692
- for (const node of result.slice(1)) {
2169
+ for (const node of ast.slice(1)) {
1693
2170
  if (!Array.isArray(node) || node[0] !== 'type') continue
1694
2171
  const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
1695
2172
  if (!name) continue
@@ -1704,18 +2181,18 @@ const dedupTypes = (ast) => {
1704
2181
  }
1705
2182
  }
1706
2183
 
1707
- if (redirects.size === 0) return result
2184
+ if (redirects.size === 0) return ast
1708
2185
 
1709
2186
  // Remove duplicate type nodes
1710
- for (let i = result.length - 1; i >= 0; i--) {
1711
- const node = result[i]
2187
+ for (let i = ast.length - 1; i >= 0; i--) {
2188
+ const node = ast[i]
1712
2189
  if (Array.isArray(node) && node[0] === 'type') {
1713
2190
  const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
1714
- if (name && redirects.has(name)) result.splice(i, 1)
2191
+ if (name && redirects.has(name)) ast.splice(i, 1)
1715
2192
  }
1716
2193
  }
1717
2194
 
1718
- walkPost(result, (node) => {
2195
+ walkPost(ast, (node) => {
1719
2196
  if (!Array.isArray(node)) return
1720
2197
  const op = node[0]
1721
2198
 
@@ -1755,7 +2232,7 @@ const dedupTypes = (ast) => {
1755
2232
  }
1756
2233
  })
1757
2234
 
1758
- return result
2235
+ return ast
1759
2236
  }
1760
2237
 
1761
2238
  // ==================== DATA SEGMENT PACKING ====================
@@ -1895,10 +2372,9 @@ const mergeDataSegments = (a, b) => {
1895
2372
  */
1896
2373
  const packData = (ast) => {
1897
2374
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1898
- let result = clone(ast)
1899
2375
 
1900
2376
  // Trim trailing zeros
1901
- for (const node of result) {
2377
+ for (const node of ast) {
1902
2378
  if (!Array.isArray(node) || node[0] !== 'data') continue
1903
2379
  let contentStart = 1
1904
2380
  if (typeof node[1] === 'string' && node[1][0] === '$') contentStart = 2
@@ -1917,8 +2393,8 @@ const packData = (ast) => {
1917
2393
 
1918
2394
  // Merge adjacent active segments with same memory and consecutive offsets
1919
2395
  const dataNodes = []
1920
- for (let i = 0; i < result.length; i++) {
1921
- const node = result[i]
2396
+ for (let i = 0; i < ast.length; i++) {
2397
+ const node = ast[i]
1922
2398
  if (Array.isArray(node) && node[0] === 'data') {
1923
2399
  const info = getDataOffset(node)
1924
2400
  if (info) {
@@ -1947,10 +2423,10 @@ const packData = (ast) => {
1947
2423
  }
1948
2424
 
1949
2425
  if (toRemove.size > 0) {
1950
- result = result.filter((_, i) => !toRemove.has(i))
2426
+ ast = ast.filter((_, i) => !toRemove.has(i))
1951
2427
  }
1952
2428
 
1953
- return result
2429
+ return ast
1954
2430
  }
1955
2431
 
1956
2432
  // ==================== IMPORT FIELD MINIFICATION ====================
@@ -1980,11 +2456,10 @@ const makeShortener = () => {
1980
2456
  */
1981
2457
  const minifyImports = (ast) => {
1982
2458
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1983
- const result = clone(ast)
1984
2459
  const shortMod = makeShortener()
1985
2460
  const shortField = makeShortener()
1986
2461
 
1987
- for (const node of result) {
2462
+ for (const node of ast) {
1988
2463
  if (!Array.isArray(node) || node[0] !== 'import') continue
1989
2464
  if (typeof node[1] === 'string' && node[1][0] === '"') {
1990
2465
  node[1] = '"' + shortMod(node[1].slice(1, -1)) + '"'
@@ -1994,7 +2469,7 @@ const minifyImports = (ast) => {
1994
2469
  }
1995
2470
  }
1996
2471
 
1997
- return result
2472
+ return ast
1998
2473
  }
1999
2474
 
2000
2475
  // ==================== REORDER FUNCTIONS ====================
@@ -2033,10 +2508,9 @@ const reorder = (ast) => {
2033
2508
  // Sorting changes the function index space. Skip if any reference is numeric,
2034
2509
  // since we'd silently retarget unnamed callers/start/elem entries.
2035
2510
  if (!reorderSafe(ast)) return ast
2036
- const result = clone(ast)
2037
2511
 
2038
2512
  const callCounts = new Map()
2039
- walk(result, (n) => {
2513
+ walk(ast, (n) => {
2040
2514
  if (!Array.isArray(n)) return
2041
2515
  if (n[0] === 'call' || n[0] === 'return_call') {
2042
2516
  callCounts.set(n[1], (callCounts.get(n[1]) || 0) + 1)
@@ -2045,7 +2519,7 @@ const reorder = (ast) => {
2045
2519
 
2046
2520
  // Imports must precede defined funcs (compile.js assigns indices in AST order).
2047
2521
  const imports = [], funcs = [], others = []
2048
- for (const node of result.slice(1)) {
2522
+ for (const node of ast.slice(1)) {
2049
2523
  if (!Array.isArray(node)) { others.push(node); continue }
2050
2524
  if (node[0] === 'import') imports.push(node)
2051
2525
  else if (node[0] === 'func') funcs.push(node)
@@ -2072,14 +2546,24 @@ const reorder = (ast) => {
2072
2546
  */
2073
2547
  export default function optimize(ast, opts = true) {
2074
2548
  if (typeof ast === 'string') ast = parse(ast)
2075
- ast = clone(ast)
2549
+ const strictGuard = opts === true // default: zero tolerance for bloat
2076
2550
  opts = normalize(opts)
2077
2551
 
2078
- // Each pass clones its input before mutating, so the original `before`
2079
- // reference stays untouched and can be used for the convergence check
2080
- // without an extra deep clone.
2081
- for (let round = 0; round < 6; round++) {
2082
- const before = ast
2552
+ const log = opts.log ? (msg, delta) => opts.log(msg, delta) : () => {}
2553
+ const verbose = opts.verbose || opts.log
2554
+
2555
+ ast = clone(ast)
2556
+ let beforeRound = null
2557
+
2558
+ // Size guard works on encoded bytes, not AST node count: passes like
2559
+ // `globals` / `inlineOnce` are node-count-neutral yet move real bytes
2560
+ // (a `global.get` ↔ a fat `f64.const`; a `call` ↔ an inlined body), so a
2561
+ // node-count guard can't tell when a round bloated — or shrank. `binarySize`
2562
+ // also returns Infinity if a round produced invalid wat, so a broken round
2563
+ // reverts instead of escaping.
2564
+ for (let round = 0; round < 3; round++) {
2565
+ beforeRound = clone(ast)
2566
+ const sizeBefore = binarySize(ast)
2083
2567
 
2084
2568
  if (opts.stripmut) ast = stripmut(ast)
2085
2569
  if (opts.globals) ast = globals(ast)
@@ -2089,6 +2573,7 @@ export default function optimize(ast, opts = true) {
2089
2573
  if (opts.strength) ast = strength(ast)
2090
2574
  if (opts.branch) ast = branch(ast)
2091
2575
  if (opts.propagate) ast = propagate(ast)
2576
+ if (opts.inlineOnce) ast = inlineOnce(ast)
2092
2577
  if (opts.inline) ast = inline(ast)
2093
2578
  if (opts.offset) ast = offset(ast)
2094
2579
  if (opts.unbranch) ast = unbranch(ast)
@@ -2096,6 +2581,8 @@ export default function optimize(ast, opts = true) {
2096
2581
  if (opts.foldarms) ast = foldarms(ast)
2097
2582
  if (opts.deadcode) ast = deadcode(ast)
2098
2583
  if (opts.vacuum) ast = vacuum(ast)
2584
+ if (opts.mergeBlocks) ast = mergeBlocks(ast)
2585
+ if (opts.coalesce) ast = coalesceLocals(ast)
2099
2586
  if (opts.locals) ast = localReuse(ast)
2100
2587
  if (opts.dedupe) ast = dedupe(ast)
2101
2588
  if (opts.dedupTypes) ast = dedupTypes(ast)
@@ -2103,10 +2590,35 @@ export default function optimize(ast, opts = true) {
2103
2590
  if (opts.reorder) ast = reorder(ast)
2104
2591
  if (opts.treeshake) ast = treeshake(ast)
2105
2592
  if (opts.minifyImports) ast = minifyImports(ast)
2106
- if (equal(before, ast)) break
2593
+ // Second propagate sweep: `inlineOnce`/`inline` (above) leave fresh
2594
+ // `(local.set $p arg) … (local.get $p)` wrappers around each inlined call;
2595
+ // re-running propagation collapses them within this same round, so the size
2596
+ // guard scores the cleaned result instead of waiting a round (which it may
2597
+ // never get if `equal()` declares a fixpoint first).
2598
+ if (opts.propagate && (opts.inlineOnce || opts.inline)) ast = propagate(ast)
2599
+
2600
+ const sizeAfter = binarySize(ast)
2601
+ const delta = sizeAfter - sizeBefore
2602
+
2603
+ if (verbose || delta !== 0) {
2604
+ log(` round ${round + 1}: ${delta > 0 ? '+' : ''}${delta} bytes`, delta)
2605
+ }
2606
+
2607
+ // Size guard: default optimize must never inflate. Explicit passes get a
2608
+ // little leniency (a round may grow a few bytes setting up a bigger win).
2609
+ const tolerance = strictGuard ? 0 : 16
2610
+ if (delta > tolerance) {
2611
+ if (verbose) log(` ⚠ round ${round + 1} inflated by ${delta} bytes, reverting`, delta)
2612
+ ast = beforeRound
2613
+ break
2614
+ }
2615
+
2616
+ if (equal(beforeRound, ast)) break
2107
2617
  }
2108
2618
 
2109
2619
  return ast
2110
2620
  }
2111
2621
 
2112
- export { optimize, treeshake, fold, deadcode, localReuse, identity, strength, branch, propagate, inline, normalize, OPTS, vacuum, peephole, globals, offset, unbranch, stripmut, brif, foldarms, dedupe, reorder, dedupTypes, packData, minifyImports }
2622
+ /** Count AST nodes (fast size heuristic). */
2623
+ export { count as size, count, binarySize }
2624
+ export { optimize, treeshake, fold, deadcode, localReuse, identity, strength, branch, propagate, inline, inlineOnce, normalize, OPTS, vacuum, peephole, globals, offset, unbranch, stripmut, brif, foldarms, dedupe, reorder, dedupTypes, packData, minifyImports, mergeBlocks, coalesceLocals }