@asmlift/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -164
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +341 -22
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +24 -6
  10. package/src/frontend/opaque.ts +31 -18
  11. package/src/frontend/ppc.ts +54 -7
  12. package/src/frontend/ssa.ts +632 -13
  13. package/src/frontend/thumb.ts +2786 -286
  14. package/src/ir/alias.ts +129 -0
  15. package/src/ir/bits.ts +75 -0
  16. package/src/ir/core.ts +337 -2
  17. package/src/ir/opcodes.ts +156 -27
  18. package/src/ir/parse.ts +19 -2
  19. package/src/ir/print.ts +27 -2
  20. package/src/ir/simplify.ts +190 -3
  21. package/src/ir/struct-names.ts +42 -0
  22. package/src/ir/verify.ts +43 -49
  23. package/src/l3/address.ts +62 -0
  24. package/src/l3/argbase.ts +8 -2
  25. package/src/l3/ast.ts +464 -49
  26. package/src/l3/basecse.ts +709 -88
  27. package/src/l3/coalesce.ts +521 -66
  28. package/src/l3/dce.ts +54 -19
  29. package/src/l3/gates.ts +88 -0
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +113 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +110 -85
  42. package/src/l3/reindex.ts +715 -78
  43. package/src/l3/scopebase.ts +649 -219
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +23 -4
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +263 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +236 -13
  57. package/src/pipeline.ts +206 -49
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/divpow2.ts +4 -3
  61. package/src/raise/globalshape.ts +1038 -0
  62. package/src/raise/gvn.ts +44 -19
  63. package/src/raise/latch.ts +126 -0
  64. package/src/raise/memberarrays.ts +594 -0
  65. package/src/raise/narrow.ts +124 -0
  66. package/src/raise/narrowlocal.ts +556 -0
  67. package/src/raise/paramwidth.ts +179 -0
  68. package/src/raise/pre-recovery.ts +101 -16
  69. package/src/raise/recover.ts +56 -23
  70. package/src/raise/retsink.ts +215 -14
  71. package/src/raise/shortcircuit.ts +477 -79
  72. package/src/raise/struct-arrays.ts +21 -3
  73. package/src/raise/structs.ts +61 -3
  74. package/src/rank-axes.ts +630 -0
  75. package/src/rank-declare.ts +256 -0
  76. package/src/rank.ts +1726 -251
  77. package/src/structure/analysis.ts +1516 -220
  78. package/src/structure/bitfields.ts +332 -0
  79. package/src/structure/globalaccess.ts +274 -0
  80. package/src/structure/hazards.ts +411 -20
  81. package/src/structure/loops.ts +2 -49
  82. package/src/structure/namecoalesce.ts +435 -0
  83. package/src/structure/structure.ts +2850 -533
  84. package/src/structure/switch-recover.ts +688 -147
  85. package/src/symbols.ts +62 -1
  86. package/src/target.ts +367 -24
  87. package/src/trace.ts +111 -32
package/src/l3/dce.ts CHANGED
@@ -15,8 +15,11 @@
15
15
  // read as live throughout), so a removal only happens when the local is provably dead. Only
16
16
  // names in `locals` are eligible — globals (side effects, referenced by name from headers) and
17
17
  // params are never touched — and a value carrying a side effect / gap signal / memory load is
18
- // never dropped (see `mustKeep`). Locals are never address-taken in L3 (`addr` names a global),
19
- // so `var` reads are a COMPLETE account of a local's uses.
18
+ // never dropped (see `mustKeep`). An `addr` node can now name a LOCAL as well as a global (the
19
+ // frame-local object a Thumb `laddr` declares structure.ts), and an address-taken local's stores
20
+ // are observable through the escaped pointer whether or not any `var` read follows — so an `addr`
21
+ // name counts as a READ below, which pins the local and every store to it. For globals that is a
22
+ // no-op (they were never eligible), so the single rule covers both.
20
23
  //
21
24
  // Ordering: structureChecked runs `assertResolved` BEFORE this pass, so in strict mode an
22
25
  // unresolved `?` value trips the contract first and never reaches DCE; `mustKeep` treating `?` as
@@ -25,9 +28,10 @@ import type { Expr, SFn, Stmt } from './ast';
25
28
  import { exprChildren, negateCond, stmtChildren, stmtExprs } from './ast';
26
29
 
27
30
  /** Accumulate every LOCAL-eligible `var` name read anywhere in `e` (recurses all sub-exprs). An
28
- * `addr` node names a global, not a local, so it is not a local read. */
31
+ * `addr` name counts too: taking a local's address makes every store to it observable through the
32
+ * escaped pointer, so an address-taken local is never dead here. */
29
33
  function readsInto(e: Expr, out: Set<string>): void {
30
- if (e.k === 'var') {
34
+ if (e.k === 'var' || e.k === 'addr') {
31
35
  out.add(e.name);
32
36
  }
33
37
  for (const c of exprChildren(e)) {
@@ -46,17 +50,19 @@ function reads(e: Expr): Set<string> {
46
50
  * - `marker` — the annotate-mode ASMLIFT_ERROR gap signal, which must survive so the gap stays loud;
47
51
  * - the strict-mode `?` unresolved sentinel (`{k:'var', name:'?'}`) — dropping it would let a
48
52
  * value asmlift could NOT lift slip past `assertResolved`, silently downgrading a loud gap;
49
- * - a memory load (`index`/`field`) — asmlift models no `volatile`, so a possibly-effectful read
53
+ * - a memory load (`index`/`field`) — a deref's volatility is unknowable here, so a possibly-effectful read
50
54
  * is never deleted (this pass never removes a memory access).
55
+ * - a read of a VOLATILE local (the frame object whose address escaped) — a volatile read is an
56
+ * observable access the machine performed; deleting the dead assignment would delete the read.
51
57
  * A dead assignment whose value contains any of these is kept. */
52
- function mustKeep(e: Expr): boolean {
58
+ function mustKeep(e: Expr, volatiles: ReadonlySet<string>): boolean {
53
59
  if (e.k === 'call' || e.k === 'marker' || e.k === 'index' || e.k === 'field') {
54
60
  return true;
55
61
  }
56
- if (e.k === 'var' && e.name === '?') {
62
+ if (e.k === 'var' && (e.name === '?' || volatiles.has(e.name))) {
57
63
  return true;
58
64
  }
59
- return exprChildren(e).some(mustKeep);
65
+ return exprChildren(e).some((c) => mustKeep(c, volatiles));
60
66
  }
61
67
 
62
68
  /** Every local read anywhere within these statements (exprs + nested statements). Used to give
@@ -70,12 +76,32 @@ function allReadsInto(stmts: Stmt[], out: Set<string>): void {
70
76
  }
71
77
  }
72
78
 
79
+ /** Every name whose ADDRESS is taken anywhere within these statements. Globals land here too and
80
+ * are harmless — they were never store-eligible. */
81
+ function allAddrNamesInto(stmts: Stmt[], out: Set<string>): void {
82
+ const walk = (e: Expr) => {
83
+ if (e.k === 'addr') {
84
+ out.add(e.name);
85
+ }
86
+ for (const c of exprChildren(e)) {
87
+ walk(c);
88
+ }
89
+ };
90
+ for (const s of stmts) {
91
+ for (const e of stmtExprs(s)) {
92
+ walk(e);
93
+ }
94
+ allAddrNamesInto(stmtChildren(s), out);
95
+ }
96
+ }
97
+
73
98
  /** Backward live-variable walk over one block. `liveOut` is the set of locals live on exit;
74
99
  * returns the rewritten block and the set live on entry. */
75
100
  function dceBlock(
76
101
  stmts: Stmt[],
77
102
  liveOut: ReadonlySet<string>,
78
103
  locals: ReadonlySet<string>,
104
+ volatiles: ReadonlySet<string>,
79
105
  ): { out: Stmt[]; liveIn: Set<string> } {
80
106
  const live = new Set(liveOut);
81
107
  const rev: Stmt[] = [];
@@ -89,7 +115,7 @@ function dceBlock(
89
115
  const s = stmts[i];
90
116
  switch (s.k) {
91
117
  case 'assign': {
92
- if (locals.has(s.name) && !live.has(s.name) && !mustKeep(s.value)) {
118
+ if (locals.has(s.name) && !live.has(s.name) && !mustKeep(s.value, volatiles)) {
93
119
  continue; // dead local store — drop it; liveness is unchanged (it was a no-op)
94
120
  }
95
121
  live.delete(s.name); // the write kills the name for statements before it …
@@ -131,8 +157,8 @@ function dceBlock(
131
157
  break;
132
158
  }
133
159
  case 'if': {
134
- const t = dceBlock(s.then, live, locals);
135
- const e = dceBlock(s.else, live, locals);
160
+ const t = dceBlock(s.then, live, locals, volatiles);
161
+ const e = dceBlock(s.else, live, locals, volatiles);
136
162
  const nlive = new Set<string>();
137
163
  for (const r of reads(s.cond)) {
138
164
  nlive.add(r);
@@ -146,7 +172,7 @@ function dceBlock(
146
172
  setLive(nlive);
147
173
  if (t.out.length === 0 && e.out.length === 0) {
148
174
  // both arms empty: keep only if the condition itself has a side effect
149
- if (mustKeep(s.cond)) {
175
+ if (mustKeep(s.cond, volatiles)) {
150
176
  rev.push({ k: 'exprstmt', value: s.cond });
151
177
  }
152
178
  } else if (t.out.length === 0) {
@@ -162,7 +188,7 @@ function dceBlock(
162
188
  // loop-carried store is never cut. Body DCE removes only what is dead on EVERY path.
163
189
  const loopLive = new Set(live);
164
190
  allReadsInto([s], loopLive);
165
- const b = dceBlock(s.body, loopLive, locals);
191
+ const b = dceBlock(s.body, loopLive, locals, volatiles);
166
192
  const nlive = new Set(loopLive);
167
193
  for (const r of b.liveIn) {
168
194
  nlive.add(r);
@@ -174,7 +200,7 @@ function dceBlock(
174
200
  case 'for': {
175
201
  const loopLive = new Set(live);
176
202
  allReadsInto([s], loopLive);
177
- const b = dceBlock(s.body, loopLive, locals);
203
+ const b = dceBlock(s.body, loopLive, locals, volatiles);
178
204
  const nlive = new Set(loopLive);
179
205
  for (const r of b.liveIn) {
180
206
  nlive.add(r);
@@ -188,8 +214,8 @@ function dceBlock(
188
214
  // read anywhere in the switch as live throughout — no case-body store is ever cut.
189
215
  const swLive = new Set(live);
190
216
  allReadsInto([s], swLive);
191
- const cases = s.cases.map((c) => ({ ...c, body: dceBlock(c.body, swLive, locals).out }));
192
- const def = s.default ? dceBlock(s.default, swLive, locals).out : s.default;
217
+ const cases = s.cases.map((c) => ({ ...c, body: dceBlock(c.body, swLive, locals, volatiles).out }));
218
+ const def = s.default ? dceBlock(s.default, swLive, locals, volatiles).out : s.default;
193
219
  const nlive = new Set(swLive);
194
220
  for (const r of reads(s.scrutinee)) {
195
221
  nlive.add(r);
@@ -227,9 +253,18 @@ function referencedNames(stmts: Stmt[], out: Set<string>): void {
227
253
  /** Remove dead local stores and simplify the branches they empty out, then drop any local
228
254
  * declaration left unreferenced. Returns a new SFn; the input is not mutated. */
229
255
  export function eliminateDeadStores(sfn: SFn): SFn {
230
- const locals = new Set(sfn.locals.map((l) => l.name));
231
- const body = dceBlock(sfn.body, new Set<string>(), locals).out;
256
+ // AN ADDRESS-TAKEN local is never eligible, whatever its qualifiers: every store to it is
257
+ // observable through the escaped pointer wherever it sits, and this walk is BACKWARD, so the
258
+ // `addr`-as-read pin above only ever protected the stores UPSTREAM of an `&sp0` occurrence.
259
+ // Publish-the-address-then-fill (`g(&sp0); sp0 = v;`) puts one downstream, and this very pass
260
+ // deleted it. `volatile` stays in the test beside it because the qualifier is a separate reason
261
+ // (an MMIO cell the frontend never rendered an `&` for), not a spelling of this one.
262
+ const addressTaken = new Set<string>();
263
+ allAddrNamesInto(sfn.body, addressTaken);
264
+ const volatiles = new Set(sfn.locals.filter((l) => l.volatile).map((l) => l.name));
265
+ const locals = new Set(sfn.locals.filter((l) => !l.volatile && !addressTaken.has(l.name)).map((l) => l.name));
266
+ const body = dceBlock(sfn.body, new Set<string>(), locals, volatiles).out;
232
267
  const used = new Set<string>();
233
268
  referencedNames(body, used);
234
- return { ...sfn, body, locals: sfn.locals.filter((l) => used.has(l.name)) };
269
+ return { ...sfn, body, locals: sfn.locals.filter((l) => used.has(l.name) || l.volatile) };
235
270
  }
@@ -0,0 +1,88 @@
1
+ // A pass's admission rules as DATA, so "does every sound gate have a test that fails without it?"
2
+ // is a query instead of an audit.
3
+ //
4
+ // Because the table is a value, a test can drop one entry and re-run the pass: the real predicate,
5
+ // on real input, with no test-only branch in the shipped path. That makes `sound` cost something to
6
+ // declare — see `gateTableDefects` and the contract test that pairs with it.
7
+ //
8
+ // `why` is a LABEL, one line. The argument for why the rule is correct belongs in the file header,
9
+ // which has room; duplicating it here is how a table stops paying for itself.
10
+ export interface Gate<Ctx> {
11
+ /** stable, kebab-case; appears in test names and in the contract report */
12
+ readonly id: string;
13
+ /** one line: the reason the rule exists */
14
+ readonly why: string;
15
+ /** Remove it and some candidate is WRONG, not merely worse. Everything else is a codegen
16
+ * heuristic the differ still referees. This flag is what makes `guardedBy` mandatory. */
17
+ readonly sound: boolean;
18
+ /** the test that fails when this gate is removed — required for a sound gate */
19
+ readonly guardedBy?: string;
20
+ /** true ⇒ REJECT this candidate */
21
+ readonly rejects: (c: Ctx) => boolean;
22
+ }
23
+
24
+ /** The id of the first gate that rejects `c`, or null when every gate admits it. FIRST, not all:
25
+ * one decisive rule is what makes a refusal attributable, and it keeps the cost the same as the
26
+ * `||` chain this replaces — evaluation still short-circuits. */
27
+ export function firstRejection<Ctx>(gates: readonly Gate<Ctx>[], c: Ctx): string | null {
28
+ for (const g of gates) {
29
+ if (g.rejects(c)) {
30
+ return g.id;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** A gate table with one entry removed — the ablation, as a value. Throws on an unknown id: a
37
+ * typo'd ablation that silently tests nothing is the failure this file exists to prevent. */
38
+ export function without<Ctx>(gates: readonly Gate<Ctx>[], id: string): readonly Gate<Ctx>[] {
39
+ if (!gates.some((g) => g.id === id)) {
40
+ throw new Error(`no gate '${id}' to ablate (have: ${gates.map((g) => g.id).join(', ')})`);
41
+ }
42
+ return gates.filter((g) => g.id !== id);
43
+ }
44
+
45
+ // NO `just(table, ids)` SELECTOR, deliberately. Selecting rule OBJECTS by id shares the predicate
46
+ // AND the `sound` claim AND the `guardedBy` guard, and a second consumer of a rule wants only the
47
+ // first of the three: a rule that is sound for a declaration is a heuristic for a generated
48
+ // candidate, and the guard the contract test then checks ablates the rule against the OTHER
49
+ // consumer. What a second consumer shares is a PREDICATE — an ordinary function — and what it owns
50
+ // is its own rule objects. `ORDER_SHAPE_GATES` (raise/globalshape.ts) is the worked example, with
51
+ // the over-admission id-selection would carry.
52
+
53
+ /** `without` for SHIPPED code. A test may ablate any gate — that is how `guardedBy` differential
54
+ * tests work — but a pass that re-runs itself with an ablated table as a ranked candidate may
55
+ * only drop a HEURISTIC: ablating a `sound: true` gate would ship semantically wrong candidates,
56
+ * and on a nonmatch row the best-scoring source is shown to the user. A derived table is a
57
+ * top-level const, so the throw fires at import — the mistake cannot ship. */
58
+ export function ablateHeuristic<Ctx>(gates: readonly Gate<Ctx>[], id: string): readonly Gate<Ctx>[] {
59
+ const g = gates.find((x) => x.id === id);
60
+ if (g?.sound) {
61
+ throw new Error(`gate '${id}' is sound — a shipped ablation of it emits wrong candidates`);
62
+ }
63
+ return without(gates, id);
64
+ }
65
+
66
+ /** Structural defects in a gate table — the part checkable without running the pass. Returns
67
+ * findings rather than throwing, so core stays free of a test-framework import. */
68
+ export function gateTableDefects<Ctx>(gates: readonly Gate<Ctx>[]): string[] {
69
+ const out: string[] = [];
70
+ const seen = new Set<string>();
71
+ for (const g of gates) {
72
+ if (seen.has(g.id)) {
73
+ out.push(`duplicate gate id '${g.id}'`);
74
+ }
75
+ seen.add(g.id);
76
+ if (!/^[a-z][a-z0-9-]*$/.test(g.id)) {
77
+ out.push(`gate id '${g.id}' is not kebab-case`);
78
+ }
79
+ if (g.why.trim().length < 12) {
80
+ out.push(`gate '${g.id}' has no usable \`why\``);
81
+ }
82
+ // the one rule that costs something to declare
83
+ if (g.sound && !g.guardedBy?.trim()) {
84
+ out.push(`gate '${g.id}' is marked sound but names no guard`);
85
+ }
86
+ }
87
+ return out;
88
+ }
package/src/l3/hoist.ts CHANGED
@@ -1,16 +1,20 @@
1
- // L3 — the naming MECHANISM shared by every pass that hoists a value into a fresh local.
1
+ // L3 — the MECHANISMS a pass needs to place a hoisted local: how a fresh name is chosen, where the
2
+ // leading run of base inits starts and ends, and where in the body that run goes.
2
3
  //
3
- // Two passes name bases today (`basecse.ts` hoists a REUSED base; `argbase.ts` names a call's
4
- // argument bases), and they differ in POLICY which bases are eligible, and when it is worth
5
- // doing but not in how a name is chosen. That half was copied, and the copy silently lost a
6
- // safety guard: basecse added the callee-name exclusion in its own audit precisely so a hoist
7
- // local could not shadow a called function, and the second implementation did not have it. A third
8
- // hoisting pass would lose it again, so the mechanism lives here and the policy stays with each
9
- // caller.
4
+ // Their users differ. Every pass that mints a local takes `nameAllocator` (or `takenNames`, to
5
+ // number its own); the three that touch basecse's leading init run read the rest basecse and
6
+ // nearbase mint into it, sinkinit moves statements out of it, and all three have to agree on where
7
+ // it stops and how a body carrying it is rebuilt. What stays with each caller is ELIGIBILITY:
8
+ // which values become a local at all, and when it is worth doing. WHERE the run goes is
9
+ // `placeBaseLocals`'s `placement` argument, and that argument is two questions rather than one
10
+ // see `HoistPlacement` and `BaseInitPlacement`. Everything lives in one file because each half was
11
+ // a per-caller copy once and every copy drifted from its original.
10
12
  import type { Expr, SFn, Stmt } from './ast';
11
- import { mapExprChildren, stmtChildren, stmtExprs } from './ast';
13
+ import { exprChildren, mapStmtLists, stmtChildren, stmtExprs, stmtLists } from './ast';
14
+ import { localMentions } from './mentions';
12
15
 
13
- /** Every identifier a hoist name must not collide with, anywhere in `sfn`.
16
+ /** Every identifier a MINTED name must not collide with, anywhere in `sfn` — the hoists below,
17
+ * and reindex's induction names.
14
18
  *
15
19
  * Wider than "the declared locals" on purpose, and each addition is a real collision:
16
20
  * - params and locals, obviously;
@@ -18,7 +22,7 @@ import { mapExprChildren, stmtChildren, stmtExprs } from './ast';
18
22
  * silently redirects every later mention of it;
19
23
  * - every CALL TARGET — a local named like a callee shadows the function;
20
24
  * - every assignment target, which includes names no declaration list carries. */
21
- function takenNames(sfn: SFn): Set<string> {
25
+ export function takenNames(sfn: SFn): Set<string> {
22
26
  const taken = new Set<string>([...sfn.params.map((p) => p.name), ...sfn.locals.map((l) => l.name)]);
23
27
  const visit = (e: Expr): void => {
24
28
  if (e.k === 'var' || e.k === 'addr') {
@@ -27,10 +31,9 @@ function takenNames(sfn: SFn): Set<string> {
27
31
  if (e.k === 'call') {
28
32
  taken.add(e.fn);
29
33
  }
30
- mapExprChildren(e, (c) => {
34
+ for (const c of exprChildren(e)) {
31
35
  visit(c);
32
- return c;
33
- });
36
+ }
34
37
  };
35
38
  const walk = (stmts: Stmt[]): void => {
36
39
  for (const s of stmts) {
@@ -63,3 +66,279 @@ export function nameAllocator(sfn: SFn): () => string {
63
66
  return nm;
64
67
  };
65
68
  }
69
+
70
+ export type BaseInit = Extract<Stmt, { k: 'assign' }>;
71
+
72
+ /** Whether `s` is a BASE INIT: a ptr-cast of an `addr`/`const` leaf assigned into a declared
73
+ * NON-VOLATILE local. It reads nothing and writes its own plain cell, which is what makes the run
74
+ * of them re-orderable and each of them movable. The volatile exclusion is load-bearing — two
75
+ * writes to `volatile` locals are observably ordered, so one at the head simply ends the run. */
76
+ function isBaseInit(s: Stmt, plainLocals: ReadonlySet<string>): s is BaseInit {
77
+ return (
78
+ s.k === 'assign' &&
79
+ plainLocals.has(s.name) &&
80
+ s.value.k === 'cast' &&
81
+ s.value.to.kind === 'ptr' &&
82
+ (s.value.e.k === 'addr' || s.value.e.k === 'const')
83
+ );
84
+ }
85
+
86
+ /** `body` split at the end of its LEADING run of base inits — the run `placeBaseLocals` places. */
87
+ function splitLeadingBaseInits(sfn: SFn, body: readonly Stmt[]): { inits: BaseInit[]; rest: Stmt[] } {
88
+ const plain = new Set(sfn.locals.filter((l) => !l.volatile).map((l) => l.name));
89
+ let n = 0;
90
+ while (n < body.length && isBaseInit(body[n], plain)) {
91
+ n++;
92
+ }
93
+ return { inits: body.slice(0, n) as BaseInit[], rest: body.slice(n) };
94
+ }
95
+
96
+ /** For each local, the index of the first TOP-LEVEL statement of `rest` that mentions it, absent
97
+ * when none does. `mentions.ts`'s notion of a mention, so `&p` counts: an init must precede the
98
+ * address being taken as surely as it must precede a read. */
99
+ function firstUseIn(sfn: SFn, rest: readonly Stmt[]): Map<string, number> {
100
+ const out = new Map<string, number>();
101
+ for (const [name, m] of localMentions({ ...sfn, body: [...rest] })) {
102
+ if (m.firstAt !== null) {
103
+ out.set(name, m.firstAt);
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Does `s` mention `name` in its OWN expressions or as its assignment target — nothing nested?
110
+ * `mentions.ts`'s notion of a mention (`&p` counts), asked of one statement rather than of a
111
+ * top-level index. */
112
+ function mentionsHere(s: Stmt, name: string): boolean {
113
+ if (s.k === 'assign' && s.name === name) {
114
+ return true;
115
+ }
116
+ let found = false;
117
+ const visit = (e: Expr): void => {
118
+ if ((e.k === 'var' || e.k === 'addr') && e.name === name) {
119
+ found = true;
120
+ }
121
+ for (const c of exprChildren(e)) {
122
+ visit(c);
123
+ }
124
+ };
125
+ stmtExprs(s).forEach(visit);
126
+ return found;
127
+ }
128
+
129
+ /** …and the same question of the whole subtree. */
130
+ function mentionsStmt(s: Stmt, name: string): boolean {
131
+ return mentionsHere(s, name) || stmtChildren(s).some((c) => mentionsStmt(c, name));
132
+ }
133
+
134
+ /** `{ list, at }` for a top-level first-use index, or null when there is none — the `first-use`
135
+ * answer in the shape the `scope` one comes back in. */
136
+ function mapTo(list: Stmt[], at: number | undefined): { list: Stmt[]; at: number } | null {
137
+ return at === undefined ? null : { list, at };
138
+ }
139
+
140
+ /** How many times each statement appears in `list`. */
141
+ function tally(list: readonly Stmt[]): Map<Stmt, number> {
142
+ const out = new Map<Stmt, number>();
143
+ for (const s of list) {
144
+ out.set(s, (out.get(s) ?? 0) + 1);
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** The innermost statement list holding EVERY mention of `name`, and the index in it of the first
150
+ * statement that mentions it. This is `firstUseIn` continued downward, and it descends only on
151
+ * three facts: exactly one statement of this list mentions the name, that statement mentions it
152
+ * nowhere OUTSIDE the lists it opens (its own condition, or a `for`'s `init`/`inc` — which are
153
+ * statements no list holds), and exactly one of those lists holds it. Any of the three failing
154
+ * means a nested list does not hold every mention, so this list is as deep as the init may go.
155
+ * Stopping at the top level reproduces `first-use` exactly, which is what lets `scope` be a
156
+ * placement rather than a second policy.
157
+ *
158
+ * DOMINATION IS THE DESCENT'S OWN INVARIANT: every mention is at or after the returned index
159
+ * within the returned list, so the init reaches all of them — including from inside a loop body,
160
+ * where it simply re-assigns the same link-time constant. `hoistBaseLocals` still CHECKS it
161
+ * (`assertHoistsDominate`), because an argument is not a check.
162
+ *
163
+ * A LOOP BODY IS ALSO A LIST NO SHIPPED CANDIDATE CAN REACH, which is a stronger statement than
164
+ * the safety argument above and the one that keeps re-assigning a base per iteration out of
165
+ * published C. Every gate table any caller pairs with `scope` keeps `BASECSE_GATES`' `loop` rule —
166
+ * `ORDERBASE_GATES`, the only roster table at this placement, ablates `cast-base` and `single-use`
167
+ * and nothing else — so a base with ANY use inside a loop is refused before a placement is
168
+ * consulted. Both halves are pinned in test/sinkinit.test.ts: neither table admits one, and where
169
+ * the mechanism is handed such a base directly the tree it emits still dominates. Loop-body bases
170
+ * are `l3/scopebase.ts`'s, which plans its own local rather than moving this run. */
171
+ function scopeSite(list: Stmt[], name: string): { list: Stmt[]; at: number } | null {
172
+ const idxs = list.flatMap((s, i) => (mentionsStmt(s, name) ? [i] : []));
173
+ if (idxs.length === 0) {
174
+ return null;
175
+ }
176
+ const here = { list, at: idxs[0] };
177
+ if (idxs.length > 1) {
178
+ return here;
179
+ }
180
+ const s = list[idxs[0]];
181
+ const lists = stmtLists(s);
182
+ const inner = lists.filter((l) => l.some((x) => mentionsStmt(x, name)));
183
+ if (inner.length !== 1) {
184
+ return here;
185
+ }
186
+ // A MULTISET difference, not a set one: `stmtChildren` yields a `for`'s `init` and `inc` beside
187
+ // its body, and one `Stmt` object may sit at two tree positions. Subtracting by identity would
188
+ // read a shared `init` as opened and miss the mention it makes before the body ever runs.
189
+ const opened = tally(lists.flat());
190
+ const outside =
191
+ mentionsHere(s, name) ||
192
+ [...tally(stmtChildren(s))].some(([c, n]) => n > (opened.get(c) ?? 0) && mentionsStmt(c, name));
193
+ return outside ? here : (scopeSite(inner[0], name) ?? here);
194
+ }
195
+
196
+ /** WHERE a run of base inits sits, once this file has put it in FIRST-USE order — the order the
197
+ * compiler loads the pool words in (`l3/basecse.ts`'s `collect`), so it is the order a reference
198
+ * spelling that named these bases would have.
199
+ *
200
+ * `head` keeps the whole ordered run at the top of the body.
201
+ * `first-use` then moves each init down to immediately before the statement that first mentions
202
+ * it, which is where a base reached ONCE was loaded and which keeps a base first touched halfway
203
+ * down the body out of the live range above it.
204
+ * `scope` reads that same query one nesting level at a time: an init whose every mention lives
205
+ * inside ONE nested list goes inside that list, at the first mention there. `first-use` stops at
206
+ * the top-level statement — a base used only inside an `if` arm still has its pool word loaded
207
+ * above the branch — and on agbcc, whose statement order survives into the object, the two are
208
+ * different bytes. Where no nested list holds every mention this IS `first-use`, which is what
209
+ * makes it a placement rather than a second policy.
210
+ *
211
+ * These three are the axis a roster admission may state (rank.ts) and the only values
212
+ * `hoistBaseLocals` accepts. */
213
+ export type HoistPlacement = 'head' | 'first-use' | 'scope';
214
+
215
+ /** `HoistPlacement` plus the ABSTENTION: `prepend` puts the minted inits above a run that keeps
216
+ * the order it arrived in, consulting neither the first-use query nor the sort. A third VALUE and
217
+ * not a third position, passed only by `l3/nearbase.ts`, whose header carries the argument for it.
218
+ *
219
+ * `hoistBaseLocals` may NOT be handed this: prepending there spells a newly minted base's pool
220
+ * load above locals the compiler loads first (`l3/basecse.ts`'s own header), so the two passes
221
+ * take different types rather than the same type and a comment. */
222
+ export type BaseInitPlacement = HoistPlacement | 'prepend';
223
+
224
+ /** `sfn.body` rebuilt with `minted` added to its leading base-init run and the whole run placed
225
+ * per `placement`, plus which inits ended up away from the head.
226
+ *
227
+ * `sfn` is both the statements and the DECLARATION ENVIRONMENT the first-use and mention queries
228
+ * resolve against, so a caller that mints must pass a shell that already declares the new names
229
+ * AND carries the rewritten body. One argument rather than two is the point: a shell whose
230
+ * declarations and statements disagree is not expressible here.
231
+ *
232
+ * ONE ORDER, THEN THE POLICY. Under both `HoistPlacement` values the run is put in FIRST-USE
233
+ * order before `placement` is consulted — pool-load order, and what makes the two COMPOSABLE
234
+ * rather than merely adjacent: `first-use` applied to a `head` result is `first-use` applied to
235
+ * the input, so `/livebase/sinkinit` (a hoist at the head that a second pass then sinks) and
236
+ * `/basefold/sinkinit` (one hoist placed at first use) are the same transform and the `/sinkinit`
237
+ * suffix names one thing wherever it appears. Order the run only on the `head` branch and they
238
+ * part company on the inits that CANNOT move, which is the half of the run whose order the
239
+ * compiler still reads. Pinned in test/sinkinit.test.ts. `prepend` opts out of all of it.
240
+ *
241
+ * Ties keep list order — existing inits before minted ones, and two inits assigning the SAME
242
+ * local in their original sequence, which a stable sort is what guarantees: they write one cell,
243
+ * so their order is the only thing that says which value it ends up holding. Two that SINK to the
244
+ * same statement keep it too, which is what the splice loop's second sort key is for.
245
+ *
246
+ * Under `first-use`, an init then moves down if the function assigns its local exactly ONCE (the
247
+ * move would otherwise cross that other write), something in the remaining body mentions it, and
248
+ * it is not already sitting at the first such statement.
249
+ *
250
+ * IT REPORTS THE MOTION rather than its size, and its callers judge those lists instead of arguing
251
+ * about them. `moved` names every init that left the leading run; `nested` is the subset that
252
+ * landed in a list OTHER than the top-level one, which only `scope` can produce.
253
+ *
254
+ * `nested` empty under `scope` means the placement DEGENERATED — every init went exactly where
255
+ * `first-use` would have put it, so the emitted tree is that placement's spelling under a second
256
+ * name. A caller offering placements as candidates has to know, or it enumerates one spelling
257
+ * twice (l3/basecse.ts's `hoistBaseLocals`). */
258
+ export function placeBaseLocals(
259
+ sfn: SFn,
260
+ minted: readonly BaseInit[],
261
+ placement: BaseInitPlacement,
262
+ ): { body: Stmt[]; moved: readonly string[]; nested: readonly string[] } {
263
+ const still = { moved: [], nested: [] };
264
+ const body = sfn.body;
265
+ const { inits: head, rest } = splitLeadingBaseInits(sfn, body);
266
+ if (head.length + minted.length === 0) {
267
+ return { body: [...body], ...still };
268
+ }
269
+ if (placement === 'prepend') {
270
+ return { body: [...minted, ...head, ...rest], ...still };
271
+ }
272
+ const firstUse = firstUseIn(sfn, rest);
273
+ const at = (s: BaseInit): number => firstUse.get(s.name) ?? rest.length;
274
+ const all = [...head, ...minted].sort((a, b) => at(a) - at(b));
275
+ if (placement === 'head') {
276
+ return { body: [...all, ...rest], ...still };
277
+ }
278
+ const whole = localMentions({ ...sfn, body: [...all, ...rest] });
279
+ const stay: BaseInit[] = [];
280
+ const sunk: { site: Stmt[]; at: number; init: BaseInit; i: number }[] = [];
281
+ for (const [i, init] of all.entries()) {
282
+ // Assigned exactly once, or the move would cross the other write.
283
+ const site =
284
+ whole.get(init.name)?.assigns === 1
285
+ ? placement === 'scope'
286
+ ? scopeSite(rest, init.name)
287
+ : mapTo(rest, firstUse.get(init.name))
288
+ : null;
289
+ // Nothing mentions it, or it is already sitting at the first statement of the top-level list:
290
+ // there is no move to make and the init stays in the leading run.
291
+ if (site === null || (site.list === rest && site.at === 0)) {
292
+ stay.push(init);
293
+ } else {
294
+ sunk.push({ site: site.list, at: site.at, init, i });
295
+ }
296
+ }
297
+ // Rebuild `rest` around the sink sites, matching each list by IDENTITY against the tree the sites
298
+ // were computed on — so the walk emits a fresh list only along the path to a site and hands every
299
+ // other statement back unchanged.
300
+ const bySite = new Map<Stmt[], typeof sunk>();
301
+ for (const s of sunk) {
302
+ bySite.set(s.site, [...(bySite.get(s.site) ?? []), s]);
303
+ }
304
+ const rebuild = (list: Stmt[]): Stmt[] => {
305
+ const here = bySite.get(list);
306
+ // CONSUMED: one `Stmt[]` object sitting at two tree positions takes the init at the FIRST of
307
+ // them, never at both, where a second splice would write the same local twice. `scopeSite`
308
+ // cannot return such a list — sharing means two statements mention the local, which stops the
309
+ // descent at their common list — so this restates that invariant where breaking it would be
310
+ // silent. Nothing in the L3 contract forbids the sharing itself (l3/scopebase.ts records a
311
+ // producer that shares an expression node).
312
+ bySite.delete(list);
313
+ let changed = here !== undefined;
314
+ const mapped = list.map((s) => {
315
+ let inner = false;
316
+ const out = mapStmtLists(s, (l) => {
317
+ const r = rebuild(l);
318
+ inner ||= r !== l;
319
+ return r;
320
+ });
321
+ changed ||= inner;
322
+ return inner ? out : s;
323
+ });
324
+ if (!changed) {
325
+ return list;
326
+ }
327
+ // Descending by target index, so an earlier insertion does not shift the position a later one
328
+ // was computed against — and descending among the inits SHARING a target too, because splicing
329
+ // each at the same index puts the last one spliced on top. Without that second key a run that
330
+ // sinks together comes out REVERSED, which is the one order this function exists to avoid: it
331
+ // is pool-load order the sort above is spelling, and `head` would have kept it.
332
+ for (const { at: to, init } of [...(here ?? [])].sort((a, b) => b.at - a.at || b.i - a.i)) {
333
+ mapped.splice(to, 0, init);
334
+ }
335
+ return mapped;
336
+ };
337
+ // `site !== rest` is the whole nesting question: every site is a list of the tree the sites were
338
+ // computed on, and the top-level one is `rest` by identity.
339
+ return {
340
+ body: [...stay, ...rebuild(rest)],
341
+ moved: sunk.map((s) => s.init.name),
342
+ nested: sunk.filter((s) => s.site !== rest).map((s) => s.init.name),
343
+ };
344
+ }