@asmlift/core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +25 -24
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +149 -0
  4. package/src/backend/c.ts +2 -0
  5. package/src/backend/cfamily.ts +197 -61
  6. package/src/backend/cpp.ts +3 -1
  7. package/src/backend/lines.ts +10 -0
  8. package/src/backend/pascal.ts +47 -16
  9. package/src/cdecl/parse.ts +21 -0
  10. package/src/contracts.ts +246 -36
  11. package/src/declare.ts +69 -20
  12. package/src/frontend/arm-body.ts +155 -0
  13. package/src/frontend/asmdata.ts +9 -2
  14. package/src/frontend/call-plan.ts +1117 -61
  15. package/src/frontend/callee-body.ts +370 -0
  16. package/src/frontend/dead-reads.ts +628 -0
  17. package/src/frontend/device-pins.ts +33 -4
  18. package/src/frontend/emit.ts +4 -3
  19. package/src/frontend/fpu.ts +224 -17
  20. package/src/frontend/frame-objects.ts +1689 -206
  21. package/src/frontend/frontend.ts +84 -3
  22. package/src/frontend/high-half.ts +53 -4
  23. package/src/frontend/jump-table.ts +545 -0
  24. package/src/frontend/local-object.ts +158 -32
  25. package/src/frontend/mips.ts +2296 -217
  26. package/src/frontend/own-struct-return.ts +500 -0
  27. package/src/frontend/ppc.ts +2748 -245
  28. package/src/frontend/register-struct-call.ts +399 -0
  29. package/src/frontend/reloc-symbol.ts +22 -7
  30. package/src/frontend/ssa.ts +1721 -43
  31. package/src/frontend/stackargs.ts +199 -72
  32. package/src/frontend/thumb-entry.ts +340 -0
  33. package/src/frontend/thumb.ts +2400 -325
  34. package/src/ir/alias.ts +65 -2
  35. package/src/ir/bits.ts +1699 -4
  36. package/src/ir/core.ts +254 -9
  37. package/src/ir/discipline.ts +86 -21
  38. package/src/ir/entry.ts +157 -0
  39. package/src/{structure → ir}/loops.ts +6 -2
  40. package/src/ir/opcodes.ts +137 -9
  41. package/src/ir/parse.ts +1 -0
  42. package/src/ir/simplify.ts +61 -10
  43. package/src/ir/types.ts +86 -2
  44. package/src/ir/verify.ts +96 -11
  45. package/src/l3/argbase.ts +2 -0
  46. package/src/l3/ast.ts +582 -18
  47. package/src/l3/basecse.ts +68 -15
  48. package/src/l3/caseendgoto.ts +223 -0
  49. package/src/l3/coalesce.ts +42 -14
  50. package/src/l3/constinit.ts +410 -0
  51. package/src/l3/dce.ts +53 -9
  52. package/src/l3/initfirst.ts +42 -11
  53. package/src/l3/intcell.ts +2 -1
  54. package/src/l3/narrowdecl.ts +77 -36
  55. package/src/l3/pollguard.ts +8 -2
  56. package/src/l3/ptrcell.ts +33 -34
  57. package/src/l3/ptrfield.ts +123 -8
  58. package/src/l3/regspell.ts +11 -4
  59. package/src/l3/reindex.ts +295 -99
  60. package/src/l3/scopebase.ts +2 -0
  61. package/src/l3/storage.ts +98 -1
  62. package/src/l3/symbol-refs.ts +84 -6
  63. package/src/l3/tailmerge.ts +6 -2
  64. package/src/l3/typing.ts +108 -15
  65. package/src/l3/unreduce.ts +6 -5
  66. package/src/l3/volatileptr.ts +17 -54
  67. package/src/mangle.ts +30 -0
  68. package/src/pattern/engine.ts +361 -73
  69. package/src/pipeline.ts +1039 -38
  70. package/src/proto-context.ts +1039 -45
  71. package/src/proto.ts +555 -40
  72. package/src/raise/aggmembers.ts +290 -0
  73. package/src/raise/arrays.ts +5 -1
  74. package/src/raise/const.ts +25 -0
  75. package/src/raise/declared-volatile.ts +207 -31
  76. package/src/raise/divpow2.ts +192 -0
  77. package/src/raise/floathelpers.ts +254 -33
  78. package/src/raise/gvn.ts +3 -3
  79. package/src/raise/latch.ts +162 -6
  80. package/src/raise/memberarrays.ts +10 -4
  81. package/src/raise/offsetnames.ts +422 -36
  82. package/src/raise/pairparams.ts +33 -0
  83. package/src/raise/paramwidth.ts +196 -6
  84. package/src/raise/pre-recovery.ts +107 -21
  85. package/src/raise/pre-structure.ts +73 -0
  86. package/src/raise/recover.ts +12 -5
  87. package/src/raise/reroll.ts +674 -0
  88. package/src/raise/shortcircuit.ts +217 -34
  89. package/src/raise/softdiv.ts +1 -1
  90. package/src/raise/storesign.ts +153 -0
  91. package/src/raise/struct-arrays.ts +30 -11
  92. package/src/raise/structs.ts +396 -33
  93. package/src/raise/truncload.ts +4 -3
  94. package/src/raise/unpre.ts +594 -0
  95. package/src/raise/unthread.ts +400 -0
  96. package/src/rank-declare.ts +118 -12
  97. package/src/rank-variations.ts +186 -35
  98. package/src/rank.ts +536 -68
  99. package/src/runtime-helpers.ts +140 -22
  100. package/src/structure/analysis.ts +217 -8
  101. package/src/structure/arith-ops.ts +23 -0
  102. package/src/structure/cross-jumped-tail.ts +216 -0
  103. package/src/structure/globalaccess.ts +157 -10
  104. package/src/structure/hazards.ts +16 -64
  105. package/src/structure/local-statics.ts +145 -9
  106. package/src/structure/namecoalesce.ts +1 -1
  107. package/src/structure/pointer-spelling.ts +1106 -0
  108. package/src/structure/redundant-test.ts +1 -1
  109. package/src/structure/structure.ts +6192 -1213
  110. package/src/structure/switch-recover.ts +583 -120
  111. package/src/structure/unfolded-branch-constant.ts +90 -0
  112. package/src/symbols.ts +58 -15
  113. package/src/target.ts +1028 -54
  114. package/src/trace.ts +49 -17
  115. package/src/variation-definitions.ts +333 -20
  116. package/src/variation-gates.ts +4 -0
  117. package/src/variation-tokens.ts +68 -3
package/src/l3/basecse.ts CHANGED
@@ -203,13 +203,16 @@ interface Collected {
203
203
  * pass has rebuilt one node and dropped the stamp, and requiring all of them makes that a
204
204
  * refusal rather than a half-homed base. */
205
205
  ordered: Map<string, boolean>;
206
+ /** per key, whether EVERY access so far sits under some connective's right operand — `false`
207
+ * once one access is under none (the input to `guardedOperand`) */
208
+ guard: Map<string, boolean>;
206
209
  }
207
210
 
208
211
  /** Every `index` node whose base is a hoistable leaf, tallied by key (the gates' use count) and in
209
212
  * first-appearance order (so the hoisted assignments emit in the order the bases are first used,
210
213
  * matching the compiler's pool-load order). `loop` marks uses nested in a while/do-while/for. */
211
214
  function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
212
- const visitExpr = (e: Expr, inLoop: boolean): void => {
215
+ const visitExpr = (e: Expr, inLoop: boolean, guard: boolean): void => {
213
216
  if (e.k === 'index' && isHoistableBase(e.base)) {
214
217
  const k = keyOf(e.base, e.width, e.signed);
215
218
  if (!c.count.has(k)) {
@@ -232,9 +235,11 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
232
235
  c.operandOff.add(k);
233
236
  }
234
237
  c.ordered.set(k, (c.ordered.get(k) ?? true) && e.baseOrdered === true);
238
+ c.guard.set(k, (c.guard.get(k) ?? true) && guard);
235
239
  }
240
+ const rhs = e.k === 'bin' && (e.op === '&&' || e.op === '||') ? e.r : null;
236
241
  for (const ch of exprChildren(e)) {
237
- visitExpr(ch, inLoop);
242
+ visitExpr(ch, inLoop, guard || ch === rhs);
238
243
  }
239
244
  };
240
245
  for (const s of stmts) {
@@ -242,21 +247,41 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
242
247
  // there is loop-invariant just like a body use — visit it with `nested`, not the outer flag.
243
248
  const nested = loop || isLoop(s);
244
249
  for (const e of stmtExprs(s)) {
245
- visitExpr(e, nested);
250
+ visitExpr(e, nested, false);
246
251
  }
247
252
  collect(stmtChildren(s), c, nested);
248
253
  }
249
254
  }
250
255
 
251
- /** Rewrite every `index`-of-hoistable-base whose key is hoisted so its base becomes the hoist local. */
252
- function rewrite(e: Expr, localFor: Map<string, string>): Expr {
256
+ /** Rewrite every `index`-of-hoistable-base whose key is hoisted so its base becomes the hoist local.
257
+ *
258
+ * A `volatile` cast over a hoisted LEAF keeps its cast and takes the local under it:
259
+ * `((volatile s32 *)0x40000D4)[2]` beside a hoisted `p0 = (s32 *)0x40000D4` becomes
260
+ * `((volatile s32 *)p0)[2]`. The cast is never hoisted itself (`isHoistableBase`), since the local
261
+ * would drop its qualifier; under it the local is the same address, so the access keeps its
262
+ * qualifier and its cell, and reads the register the compiler already holds the base in. Inline, a
263
+ * qualified access at a nonzero offset makes agbcc materialize a second literal with the offset
264
+ * folded in (`.word 0x40000dc` + `ldr r0, [r0]`) where the source read `[r4, #0x8]` off the base
265
+ * it had loaded — the `dmaRegs[2];` that ends a DMA macro. `leafLocal` maps a hoisted leaf to its
266
+ * local, whichever key hoisted it. */
267
+ function rewrite(e: Expr, localFor: Map<string, string>, leafLocal: ReadonlyMap<string, string>): Expr {
253
268
  if (e.k === 'index' && isHoistableBase(e.base)) {
254
269
  const nm = localFor.get(keyOf(e.base, e.width, e.signed));
255
270
  if (nm) {
256
- return { ...e, base: { k: 'var', name: nm }, idx: rewrite(e.idx, localFor) };
271
+ return { ...e, base: { k: 'var', name: nm }, idx: rewrite(e.idx, localFor, leafLocal) };
257
272
  }
258
273
  }
259
- return mapExprChildren(e, (c) => rewrite(c, localFor));
274
+ if (e.k === 'index' && e.base.k === 'cast' && e.base.volatile === true && isHoistableLeaf(e.base.e)) {
275
+ const nm = leafLocal.get(leafId(e.base.e));
276
+ if (nm) {
277
+ return {
278
+ ...e,
279
+ base: { ...e.base, e: { k: 'var', name: nm } },
280
+ idx: rewrite(e.idx, localFor, leafLocal),
281
+ };
282
+ }
283
+ }
284
+ return mapExprChildren(e, (c) => rewrite(c, localFor, leafLocal));
260
285
  }
261
286
 
262
287
  /** One base under consideration, keyed as `(base, width, signedness)`. */
@@ -360,6 +385,14 @@ export interface BaseKey {
360
385
  * false for every base a compiler that has not opted in produced, which is what keeps the variation
361
386
  * off those targets. */
362
387
  orderLicensed: boolean;
388
+ /** EVERY access sits in the right operand of some `&&`/`||` — the half C evaluates only when the
389
+ * left one did not decide. A head hoist puts the base's load above that guard, where the compiler
390
+ * loaded it inside the operand: `if (gY != 5 || (gS.a | gS.d | gS.c) != 0)` reads `&gS` into a
391
+ * register after the `gY` test, while `p0 = &gS;` ahead of the `if` loads it first. Not one
392
+ * operand: accesses under two different guards (`(gY != 5 || gS.a) && (gA != 1 || gS.c)`, or the
393
+ * two in separate `if`s) are each loaded under their own, so a hoist above both moves every load
394
+ * (agbcc 15/39 and 19/40 hoisted, 9/39 inline). */
395
+ guardedOperand: boolean;
363
396
  }
364
397
 
365
398
  /** The admission rules. NONE is sound, and that is a property of the pass rather than an oversight:
@@ -421,6 +454,16 @@ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
421
454
  sound: false,
422
455
  rejects: (c) => c.repeatedConstOffset,
423
456
  },
457
+ {
458
+ // `if (gY != 5 || (gS.a | gS.d | gS.c) != 0) { … }` on agbcc: the inline spelling MATCHES and
459
+ // `p0 = (u8 *)&gS;` above the `if` does not (7 points over 44), because the literal load moves
460
+ // ahead of the first test. A placement prediction like the two above, ablated with them for
461
+ // `/livebase`, so a hoisted base before a connective stays a candidate the differ can pick.
462
+ id: 'guarded-operand',
463
+ why: 'a base read only under the second half of a condition is loaded there, after the first half',
464
+ sound: false,
465
+ rejects: (c) => c.guardedOperand,
466
+ },
424
467
  ];
425
468
 
426
469
  /** `/basefold`'s admission (rank.ts): the default rules with `single-use` EXEMPTING a base whose
@@ -453,15 +496,15 @@ export const BASEFOLD_GATES: readonly Gate<BaseKey>[] = [
453
496
  ...ablateHeuristic(BASECSE_GATES, 'single-use'),
454
497
  ];
455
498
 
456
- /** The `/livebase` variation's admission (rank.ts): the default rules with both PLACEMENT heuristics
457
- * ablated, keeping only `single-use`. `loop` and `repeated-const-offset` predict which spelling
458
- * the compiler chose, and both predictions have a counterexample — an MMIO poll (`p[2] = go;
459
- * while (p[2] & BUSY) {}`) stores and re-reads a fixed offset through ONE register the whole
460
- * time. Neither gate is `sound`, so ablating them can only change which spelling wins, never
499
+ /** The `/livebase` variation's admission (rank.ts): the default rules with the PLACEMENT heuristics
500
+ * ablated, keeping only `single-use`. `loop`, `repeated-const-offset` and `guarded-operand` predict
501
+ * which spelling the compiler chose, and the first two have a counterexample — an MMIO poll
502
+ * (`p[2] = go; while (p[2] & BUSY) {}`) stores and re-reads a fixed offset through ONE register
503
+ * the whole time. None of the three is `sound`, so ablating them can only change which spelling wins, never
461
504
  * what a candidate means; the differ referees. */
462
505
  export const LIVEBASE_GATES: readonly Gate<BaseKey>[] = ablateHeuristic(
463
- ablateHeuristic(BASECSE_GATES, 'loop'),
464
- 'repeated-const-offset',
506
+ ablateHeuristic(ablateHeuristic(BASECSE_GATES, 'loop'), 'repeated-const-offset'),
507
+ 'guarded-operand',
465
508
  );
466
509
 
467
510
  /** `/livebase-block`'s admission (rank.ts): `/livebase` plus `single-cell`. The two tables differ
@@ -705,6 +748,7 @@ function collectFrom(sfn: SFn): Collected {
705
748
  varIndexed: new Set(),
706
749
  operandOff: new Set(),
707
750
  ordered: new Map(),
751
+ guard: new Map(),
708
752
  };
709
753
  collect(sfn.body, c, false);
710
754
  return c;
@@ -725,6 +769,7 @@ function admit(sfn: SFn, gates: readonly Gate<BaseKey>[]): { c: Collected; keys:
725
769
  unfoldedOffset: c.operandOff.has(k),
726
770
  castBase: c.meta.get(k)!.base.k === 'cast',
727
771
  orderLicensed: c.ordered.get(k) === true,
772
+ guardedOperand: c.guard.get(k) === true,
728
773
  }) === null
729
774
  );
730
775
  });
@@ -784,7 +829,15 @@ export function hoistBaseLocals(
784
829
  });
785
830
  }
786
831
 
787
- const rewritten = sfn.body.map((s) => mapStmtExprs(s, (e) => rewrite(e, localFor)));
832
+ const leafLocal = new Map<string, string>();
833
+ for (const k of hoisted) {
834
+ const b = meta.get(k)!.base;
835
+ const leaf = leafId(b.k === 'cast' ? b.e : b);
836
+ if (!leafLocal.has(leaf)) {
837
+ leafLocal.set(leaf, localFor.get(k)!);
838
+ }
839
+ }
840
+ const rewritten = sfn.body.map((s) => mapStmtExprs(s, (e) => rewrite(e, localFor, leafLocal)));
788
841
  // The new inits join the tree's LEADING run of base inits rather than being prepended above it:
789
842
  // when rank's /livebase re-runs this pass the head already carries the default run's, and
790
843
  // blindly prepending would spell the new base's pool load above locals the compiler loads first.
@@ -0,0 +1,223 @@
1
+ // L3 respell variation `/case-end-goto`: a search loop's hit flag, read once after its loop where the
2
+ // code after the loop ends the switch arm the loop sits in, rewritten as the source's `goto` to that
3
+ // arm's end — the case-end goto (l3/ast.ts `caseEndGoto`, docs/asmlift-101.md §2.7):
4
+ //
5
+ // f = 0; for (j…) {
6
+ // for (j…) { if (hit) { s(j); goto case_end; }
7
+ // if (hit) { s(j); f = 1; break; } }
8
+ // } → k(i);
9
+ // if (f == 0) { k(i); } case_end:
10
+ // break; break;
11
+ //
12
+ // The structurer writes the flag where a two-level exit's second target lies past the code after
13
+ // the loop (structure.ts `TwoLevelExit`, `emitForeverExit`). Where that target is the end of the
14
+ // switch arm, agbcc threads the source's goto past the loop around the switch (jump.c
15
+ // `follow_jumps`), and on agbcc, KMC, gcc 2.7.2 and IDO the goto compiles to the source's bytes
16
+ // where the flag never does (`synthetic:sw_hitexit`); but inside asmlift's own candidates the flag
17
+ // can still score closer (gcc 2.7.2), so both are emitted, the differ referees, and a tie goes to
18
+ // the candidate without the goto (rank.ts `gotoCount`).
19
+ //
20
+ // The structurer is not the only writer the rewrite reaches: it reads the tree, not who built it,
21
+ // so a flag the source declared and the compiler kept is rewritten too where it has the same shape
22
+ // (IDO keeps a `u8 found` a source sets to 2 in a case arm, and lifts it as that flag). That is
23
+ // sound for the reason below, which reads only the tree, and ranked like the rest.
24
+ //
25
+ // SOUND OVER THE TREE ALONE. `f` is a local mentioned only by `f = 0;` right before the loop, by
26
+ // each hit's `f = c; break;` (c a nonzero constant, the `break` the loop's own: reached from the
27
+ // loop's body through `if`s only) and by the test right after the loop (`CASE_END_GOTO_GATES`
28
+ // `flag-elsewhere`). So after the loop `f == 0` exactly when it left by any other way than a hit,
29
+ // and `X` runs exactly then — which is what `goto L;` at each hit and `X` unguarded after the loop
30
+ // run. After `X` both spellings go on to whatever follows the `if`, and that is the arm's end with
31
+ // nothing run on the way (`target-not-arm-end`): a switch `break;` right after the `if`, which takes
32
+ // the label, or the `if` last in a list whose falling off runs nothing before leaving the switch,
33
+ // which gets a labelled `break;` appended. `L: break;` leaves the switch as falling off the arm
34
+ // does. The label sits in the loop's own list, no loop between it and its arm, and
35
+ // the hits' `break`s are reached through `if`s only, so no switch stands between them and it: the
36
+ // form `caseGotoViolation` holds every case-end goto to.
37
+ //
38
+ // A RESPELL, NOT A STRUCTURE VARIATION — the third fork of docs/level-tower.md. The rewrite reads
39
+ // nothing but the flag's tree, so asked of the structured tree it reaches every site a structure
40
+ // option inside `emitForeverExit` would. A structure variation doubles the settings every
41
+ // enumeration walks even where it changes no tree, which level-tower.md measures on this one; the
42
+ // respell costs one walk of each structured tree, and nothing more where no flag is read after its
43
+ // loop. What it gives up is composition with the other pre-respell variation (`/unmerge` runs on
44
+ // the structured tree beside it, not on its output); no row has asked for both.
45
+ //
46
+ // Outer sites first, and an inner site whose `if` the outer rewrite leaves right before the outer
47
+ // label takes that label, so two search loops in sequence into one case end share one `L:`.
48
+ import { type Expr, type Loop, type SFn, type Stmt, type SwitchCase, isLoop, mapStmtLists, stmtChildren } from './ast';
49
+ import { type Gate, firstRejection } from './gates';
50
+ import { localMentions, readsOf } from './mentions';
51
+
52
+ /** A flag read after its loop that the rewrite would replace (`CASE_END_GOTO_GATES`). */
53
+ export interface CaseEndSite {
54
+ /** what runs after the `if (f == 0)` is not the arm's end: no switch `break;` follows it, and it
55
+ * is not last in a list whose falling off leaves the switch with nothing run. Outside a switch
56
+ * arm, or in a loop inside one, neither can hold (a switch `break;` sits only in an arm with no
57
+ * loop between, `refuseMisboundBreak`), so this is also the rule that keeps such a flag */
58
+ readonly targetNotArmEnd: boolean;
59
+ /** `f` is a param or global, or mentioned anywhere but its `f = 0;`, its hits and its one test */
60
+ readonly flagElsewhere: boolean;
61
+ }
62
+
63
+ /** When a flag read after its loop is rewritten as a case-end goto. The argument is the file's. */
64
+ export const CASE_END_GOTO_GATES: readonly Gate<CaseEndSite>[] = [
65
+ {
66
+ id: 'target-not-arm-end',
67
+ why: 'the goto skips only the guarded code, so what follows it must leave the switch with nothing run',
68
+ sound: true,
69
+ guardedBy: [
70
+ 'case-end-goto.test.ts: a hit whose target is a join running code keeps its flag',
71
+ 'case-end-goto.test.ts: a flag read outside a switch arm, or in a loop inside one, keeps its flag',
72
+ ],
73
+ rejects: (c) => c.targetNotArmEnd,
74
+ },
75
+ {
76
+ id: 'flag-elsewhere',
77
+ why: 'the flag is zero after the loop exactly when no hit ran only if nothing else writes or reads it',
78
+ sound: true,
79
+ guardedBy: 'case-end-goto.test.ts: a flag read or written anywhere else keeps its flag',
80
+ rejects: (c) => c.flagElsewhere,
81
+ },
82
+ ];
83
+
84
+ /** `f == 0`, the structurer's test of a flag (structure.ts `emitForeverExit`) */
85
+ const flagTest = (e: Expr): string | null =>
86
+ e.k === 'bin' && e.op === '==' && e.l.k === 'var' && e.r.k === 'const' && e.r.value === 0 ? e.l.name : null;
87
+
88
+ /** The hits in `body`, a loop's body: each `f = c; break;` (c a nonzero constant) reached through
89
+ * `if`s only, so the `break` is that loop's own. */
90
+ function hitsIn(body: readonly Stmt[], flag: string): number {
91
+ return body.reduce((n, s, i) => {
92
+ const next = body[i + 1];
93
+ if (
94
+ s.k === 'assign' &&
95
+ s.name === flag &&
96
+ s.value.k === 'const' &&
97
+ s.value.value !== 0 &&
98
+ next?.k === 'break' &&
99
+ next.ofSwitch !== true &&
100
+ next.label === undefined
101
+ ) {
102
+ return n + 1;
103
+ }
104
+ return s.k === 'if' ? n + hitsIn(s.then, flag) + hitsIn(s.else, flag) : n;
105
+ }, 0);
106
+ }
107
+
108
+ /** `body` with each hit `hitsIn` counts written `goto label;` */
109
+ function jumpHits(body: readonly Stmt[], flag: string, label: string): Stmt[] {
110
+ return body.flatMap((s, i): Stmt[] => {
111
+ const prev = body[i - 1];
112
+ if (s.k === 'break' && prev?.k === 'assign' && prev.name === flag && s.ofSwitch !== true) {
113
+ return [];
114
+ }
115
+ if (s.k === 'assign' && s.name === flag) {
116
+ return [{ k: 'caseEndGoto', label }];
117
+ }
118
+ return s.k === 'if' ? [{ ...s, then: jumpHits(s.then, flag, label), else: jumpHits(s.else, flag, label) }] : [s];
119
+ });
120
+ }
121
+
122
+ /** `f = 0; LOOP; if (f == 0) { X }` starting at `list[i]`, the structurer's flag read after its loop */
123
+ function siteAt(
124
+ list: readonly Stmt[],
125
+ i: number,
126
+ ): { flag: string; loop: Loop; test: Extract<Stmt, { k: 'if' }> } | null {
127
+ const [init, loop, test] = [list[i], list[i + 1], list[i + 2]];
128
+ if (
129
+ init?.k !== 'assign' ||
130
+ init.value.k !== 'const' ||
131
+ init.value.value !== 0 ||
132
+ loop === undefined ||
133
+ !isLoop(loop) ||
134
+ test?.k !== 'if' ||
135
+ test.else.length > 0 ||
136
+ flagTest(test.cond) !== init.name
137
+ ) {
138
+ return null;
139
+ }
140
+ return { flag: init.name, loop, test };
141
+ }
142
+
143
+ /** Every flag read after its loop where the code after the loop ends its switch arm, rewritten as
144
+ * a case-end goto, or null where none is (the variation's decline). */
145
+ export function jumpToCaseEnds(sfn: SFn, gates: readonly Gate<CaseEndSite>[] = CASE_END_GOTO_GATES): SFn | null {
146
+ const mentions = localMentions(sfn);
147
+ const labels = new Set<string>();
148
+ const collect = (s: Stmt): void => {
149
+ if (s.k === 'switch') {
150
+ s.cases.forEach((c) => c.label !== undefined && labels.add(c.label));
151
+ } else if (s.k === 'break' && s.label !== undefined) {
152
+ labels.add(s.label);
153
+ }
154
+ stmtChildren(s).forEach(collect);
155
+ };
156
+ sfn.body.forEach(collect);
157
+ const fresh = (): string => {
158
+ let label = 'case_end';
159
+ for (let n = 2; labels.has(label); n++) {
160
+ label = `case_end_${n}`;
161
+ }
162
+ labels.add(label);
163
+ return label;
164
+ };
165
+ const dropped = new Set<string>();
166
+
167
+ /** `inArm`: the list is inside a switch arm with no loop between; `leaves`: falling off its end
168
+ * leaves that switch with nothing run on the way. */
169
+ const rewrite = (list: readonly Stmt[], inArm: boolean, leaves: boolean): Stmt[] => {
170
+ const out = [...list];
171
+ for (let i = 0; i < out.length; i++) {
172
+ const site = siteAt(out, i);
173
+ if (site !== null) {
174
+ const { flag, loop, test } = site;
175
+ const after = out[i + 3];
176
+ const end = after?.k === 'break' && after.ofSwitch === true ? after : undefined;
177
+ const m = mentions.get(flag);
178
+ const hits = hitsIn(loop.body, flag);
179
+ const refused = firstRejection(gates, {
180
+ targetNotArmEnd: end === undefined && !(after === undefined && leaves),
181
+ flagElsewhere:
182
+ m === undefined || hits === 0 || m.assigns !== hits + 1 || readsOf(m) !== 1 || m.addrTaken !== 0,
183
+ });
184
+ if (refused === null) {
185
+ const label = end?.label ?? fresh();
186
+ const jumped: Stmt = { ...loop, body: jumpHits(loop.body, flag, label) };
187
+ out.splice(i, end ? 4 : 3, descend(jumped, false, undefined, false), ...test.then, {
188
+ k: 'break',
189
+ ofSwitch: true,
190
+ label,
191
+ });
192
+ dropped.add(flag);
193
+ // `X` is visited next, in this list, so a site inside it whose `if` now sits right before
194
+ // the label takes that label
195
+ continue;
196
+ }
197
+ }
198
+ out[i] = descend(out[i], inArm, out[i + 1], i === out.length - 1 && leaves);
199
+ }
200
+ return out;
201
+ };
202
+ /** `s` with its lists rewritten; `next` follows it in its list, and `last` says it ends a list
203
+ * whose falling off leaves the switch */
204
+ const descend = (s: Stmt, inArm: boolean, next: Stmt | undefined, last: boolean): Stmt => {
205
+ if (s.k === 'switch') {
206
+ const arm = (c: SwitchCase): SwitchCase => ({ ...c, body: rewrite(c.body, true, !c.fallsThrough) });
207
+ return { ...s, cases: s.cases.map(arm), ...(s.default ? { default: rewrite(s.default, true, true) } : {}) };
208
+ }
209
+ if (isLoop(s)) {
210
+ return mapStmtLists(s, (l) => rewrite(l, false, false));
211
+ }
212
+ if (s.k === 'if') {
213
+ const leaves = inArm && ((next?.k === 'break' && next.ofSwitch === true) || (next === undefined && last));
214
+ return { ...s, then: rewrite(s.then, inArm, leaves), else: rewrite(s.else, inArm, leaves) };
215
+ }
216
+ return s;
217
+ };
218
+ const body = rewrite(sfn.body, false, false);
219
+ if (dropped.size === 0) {
220
+ return null;
221
+ }
222
+ return { ...sfn, body, locals: sfn.locals.filter((l) => !dropped.has(l.name)) };
223
+ }
@@ -12,7 +12,16 @@
12
12
  // and whether fall-through joins the two arms onto one path. `coalesceCandidates` offers both.
13
13
  import { typeToString } from '../ir/types';
14
14
  import type { Expr, SFn, Stmt } from './ast';
15
- import { exprChildren, isLoop, mapExprChildren, mentionedName, stmtChildren, stmtExprs } from './ast';
15
+ import {
16
+ exprChildren,
17
+ isLoop,
18
+ mapExprChildren,
19
+ mentionedName,
20
+ stmtChildren,
21
+ stmtExprs,
22
+ switchArmSuccessors,
23
+ switchJumpsBack,
24
+ } from './ast';
16
25
  import { type Gate, firstRejection } from './gates';
17
26
 
18
27
  function namesIn(e: Expr, out: Set<string>): void {
@@ -122,8 +131,12 @@ function spans(body: Stmt[]): Map<string, Span> {
122
131
  };
123
132
  /** the loop set a while/do-while's own condition and children run under — its own. A `for` is
124
133
  * routed separately: `stmtChildren` hands back its init too, and the init is the one child its
125
- * loop does not re-run. */
126
- const under = (s: Stmt, loops: readonly Stmt[]): readonly Stmt[] => (isLoop(s) ? [...loops, s] : loops);
134
+ * loop does not re-run. A switch whose case-label `goto` lands on an arm laid out above it
135
+ * (`switchJumpsBack`) counts too: that jump is a back edge with no loop statement, and the arm
136
+ * it lands on runs after the one that jumps, so a local the lower arm writes can clobber one the
137
+ * upper arm still reads. */
138
+ const under = (s: Stmt, loops: readonly Stmt[]): readonly Stmt[] =>
139
+ isLoop(s) || (s.k === 'switch' && switchJumpsBack(s)) ? [...loops, s] : loops;
127
140
  const walk = (list: Stmt[], loops: readonly Stmt[]): void => {
128
141
  for (const s of list) {
129
142
  // A `for`'s INIT runs ONCE, ahead of the condition and outside the loop; its cond, inc and
@@ -208,9 +221,11 @@ export interface MergePair {
208
221
  * earlier one — and a back edge returns only to the head of its OWN loop, whose body is one
209
222
  * contiguous preorder range. So the reordering can reach a PAIR only when some loop holds a
210
223
  * mention of both locals: there the survivor's write can be followed, on the next iteration, by
211
- * the absorbed local's read. Where no loop holds both — two sibling loops, or one local living
212
- * before the loop the other lives in — no back edge connects the two ranges and preorder IS
213
- * execution order. The ancestor chain is what the span records, not the innermost loop: an outer
224
+ * the absorbed local's read. The one other back edge L3 holds is a case-label `goto` aimed at an
225
+ * arm laid out above it, which returns into its own switch's range the same way, so `spans`
226
+ * records such a switch as a loop and this rule covers it. Where no loop holds both — two sibling
227
+ * loops, or one local living before the loop the other lives in — no back edge connects the two
228
+ * ranges and preorder IS execution order. The ancestor chain is what the span records, not the innermost loop: an outer
214
229
  * loop re-runs an inner one's statements, so it can reorder a pair that no inner loop shares.
215
230
  *
216
231
  * `first-is-write` AND `const-fed` ARE INDEPENDENT, and neither masks the other: a survivor whose
@@ -332,8 +347,9 @@ export interface ArmPair {
332
347
  b: string;
333
348
  /** the confining branch has a loop ancestor, so it can run more than once */
334
349
  ifInLoop: boolean;
335
- /** control can run from one of the two arms INTO the other — `switch` fall-through, the one
336
- * way two arms of a branch land on a single path. Always false for an `if`. */
350
+ /** control can run from one of the two arms INTO the other — `switch` fall-through or a
351
+ * case-label `goto`, the two ways two arms of a branch land on a single path. Always false for
352
+ * an `if`. */
337
353
  armsJoined: boolean;
338
354
  sameType: boolean;
339
355
  /** either local is object-volatile or carries a pointee-volatile qualifier (see MergePair) */
@@ -498,9 +514,10 @@ function mentionIndex(): {
498
514
  * `switch` are one rule here rather than two walkers.
499
515
  *
500
516
  * An `if` has exactly two arms and no way to reach one from the other. A `switch`'s arms are its
501
- * `case` bodies, and FALL-THROUGH is the one way two of them land on a single path: arm `i` runs
502
- * into arm `j` exactly when every arm from `i` up to `j` falls through, so the reach is the
503
- * TRANSITIVE chain and not merely the adjacent pair.
517
+ * `case` bodies, and two of them land on a single path where one runs on into the other: by
518
+ * FALL-THROUGH, or by the case-label `goto` that ends an arm (l3/ast.ts), which lands where a
519
+ * fall-through into its target would. The reach is the TRANSITIVE chain of both and not merely
520
+ * the adjacent pair.
504
521
  *
505
522
  * ITS REACH, MEASURED, because a soundness argument with no corpus witness should say so. Counted
506
523
  * 2026-09-19 with a throwaway counter in this walk, over `pnpm bench sweep --fan --map-modes
@@ -522,10 +539,21 @@ const branchArms = (st: Stmt): { arms: Stmt[][]; joined: (i: number, j: number)
522
539
  return { arms: [st.then, st.else], joined: () => false };
523
540
  }
524
541
  if (st.k === 'switch') {
525
- const { cases } = st;
542
+ // Arm `i` runs on into the arms `switchArmSuccessors` chains it to, by falling through or by the
543
+ // `goto` that ends it (l3/ast.ts) — two arms on one path either way — so `reach[i]` is that
544
+ // chain, the `default` included as a node (a case falling into it and jumping out of it puts
545
+ // both on the path), though never offered as an arm itself.
546
+ const next = switchArmSuccessors(st);
547
+ const reach = next.map((_, i) => {
548
+ const seen = new Set<number>();
549
+ for (let at = next[i]; at !== undefined && !seen.has(at); at = next[at]) {
550
+ seen.add(at);
551
+ }
552
+ return seen;
553
+ });
526
554
  return {
527
- arms: cases.map((c) => c.body),
528
- joined: (i, j) => cases.slice(Math.min(i, j), Math.max(i, j)).every((c) => c.fallsThrough),
555
+ arms: st.cases.map((c) => c.body),
556
+ joined: (i, j) => reach[i].has(j) || reach[j].has(i),
529
557
  };
530
558
  }
531
559
  return null;