@asmlift/core 0.3.0 → 0.5.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 (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +130 -4
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +181 -4
  7. package/src/declare.ts +35 -9
  8. package/src/frontend/mips.ts +37 -29
  9. package/src/frontend/opaque.ts +70 -20
  10. package/src/frontend/ppc.ts +18 -7
  11. package/src/frontend/ssa.ts +279 -56
  12. package/src/frontend/thumb.ts +1372 -87
  13. package/src/ir/alias.ts +75 -0
  14. package/src/ir/opcodes.ts +57 -3
  15. package/src/ir/simplify.ts +72 -0
  16. package/src/l3/argbase.ts +221 -0
  17. package/src/l3/ast.ts +127 -5
  18. package/src/l3/basecse.ts +58 -62
  19. package/src/l3/coalesce.ts +215 -0
  20. package/src/l3/dce.ts +33 -41
  21. package/src/l3/gates.ts +67 -0
  22. package/src/l3/hoist.ts +65 -0
  23. package/src/l3/reindex.ts +7 -0
  24. package/src/l3/scopebase.ts +440 -0
  25. package/src/l3/tailmerge.ts +124 -0
  26. package/src/macros.ts +222 -13
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +65 -6
  29. package/src/raise/divpow2.ts +227 -0
  30. package/src/raise/gvn.ts +151 -0
  31. package/src/raise/pre-recovery.ts +39 -3
  32. package/src/raise/recover.ts +24 -7
  33. package/src/raise/retsink.ts +37 -7
  34. package/src/raise/shortcircuit.ts +262 -22
  35. package/src/raise/struct-arrays.ts +2 -1
  36. package/src/raise/structs.ts +41 -3
  37. package/src/rank.ts +196 -20
  38. package/src/structure/analysis.ts +175 -89
  39. package/src/structure/structure.ts +588 -55
  40. package/src/structure/switch-recover.ts +117 -30
  41. package/src/symbols.ts +128 -13
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +9 -0
package/src/l3/basecse.ts CHANGED
@@ -21,11 +21,16 @@
21
21
  import { type IrType, T, scalarTypeForAccess } from '../ir/types';
22
22
  import type { Expr, SFn, Stmt } from './ast';
23
23
  import { mapExprChildren, stmtChildren, stmtExprs } from './ast';
24
+ import { type Gate, firstRejection } from './gates';
25
+ import { nameAllocator } from './hoist';
24
26
 
25
27
  // A HOISTABLE base is a bare `addr` (a global address) or a bare `const` (a numeric pointer
26
28
  // address). Both are relocation-invariant leaves whose value the compiler keeps in one register
27
29
  // when it indexes them at 2+ sites. Anything else (a local var, a struct-element `p[a0]`, arbitrary
28
- // arithmetic) is NOT — agbcc may re-derive it.
30
+ // arithmetic) is NOT — agbcc may re-derive it. Admitting the bare `var` that scopebase.ts and
31
+ // argbase.ts take is the obvious consolidation and it is wrong twice over: this pass has no `lead`
32
+ // handling, so a rank-aware `g[0][i]` comes out as `p[0][i]` through a scalar pointer, and it
33
+ // undoes raise/gvn.ts's hoist on exactly the rows a symbol map serves (test/addr-placement.test.ts).
29
34
  type HoistableBase = Extract<Expr, { k: 'addr' } | { k: 'const' }>;
30
35
  const isHoistableBase = (e: Expr): e is HoistableBase => e.k === 'addr' || e.k === 'const';
31
36
  const baseId = (b: HoistableBase): string => (b.k === 'addr' ? `a:${b.name}` : `c:${b.value}`);
@@ -39,12 +44,11 @@ interface Collected {
39
44
  meta: Map<string, { base: HoistableBase; width: number; signed: boolean }>;
40
45
  /** keys with ANY use inside a loop — disqualified (see the loop note in `hoistReusedGlobalBases`). */
41
46
  inLoop: Set<string>;
42
- /** per key, how many times each CONSTANT offset was accessed. A constant offset touched 2+ times
43
- * is a SCALAR access at one fixed location (a `*(T*)C |= x` MMIO read-modify-write, or repeated
44
- * `*p`), which the compiler re-materializes rather than register-holds hoisting it MISMATCHES
45
- * (it broke the ProcessHBlankWait match). A key with ANY repeated constant offset is therefore
46
- * disqualified, even if it ALSO has distinct-offset uses (a mixed scalar+array base). A genuine
47
- * reused array base touches each constant offset once, or uses a variable index (not tallied). */
47
+ /** per key, how many times each CONSTANT offset was accessed the input to the
48
+ * `repeated-const-offset` gate, which losing the ProcessHBlankWait match is what bought. A
49
+ * genuine reused array base touches each constant offset once, or indexes by a variable (not
50
+ * tallied); a repeat means a scalar re-access, and ONE is enough to disqualify the base even
51
+ * when it also has distinct-offset uses. */
48
52
  constOffCount: Map<string, Map<number, number>>;
49
53
  }
50
54
 
@@ -135,45 +139,62 @@ function rewriteStmt(s: Stmt, localFor: Map<string, string>): Stmt {
135
139
  }
136
140
  }
137
141
 
138
- /** A name not already used by a param/local/global in `sfn`, of the form `p<n>`. */
139
- function freshName(taken: Set<string>): string {
140
- let n = 0;
141
- while (taken.has(`p${n}`)) {
142
- n++;
143
- }
144
- const nm = `p${n}`;
145
- taken.add(nm);
146
- return nm;
142
+ /** One base under consideration, keyed as `(base, width, signedness)`. */
143
+ export interface BaseKey {
144
+ key: string;
145
+ uses: number;
146
+ inLoop: boolean;
147
+ /** some CONSTANT offset through this base is touched 2+ times */
148
+ repeatedConstOffset: boolean;
147
149
  }
148
150
 
151
+ /** The admission rules. NONE is sound, and that is a property of the pass rather than an oversight:
152
+ * a wrong hoist emits the same address held in a different place, so it costs bytes and a match,
153
+ * never meaning. The zero-lost benchmark gate is what referees them.
154
+ *
155
+ * The `loop` rule is the subtle one. A loop-body base is loop-invariant, so the compiler keeps it
156
+ * in a register across the loop too — but hoisting to the FUNCTION TOP forces a callee-saved
157
+ * register, which can add the prologue push/pop the original avoided. `l3/scopebase.ts` is the
158
+ * scope-aware hoist that serves those instead. */
159
+ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
160
+ {
161
+ id: 'single-use',
162
+ why: 'one access re-materializes as cheaply as a named local',
163
+ sound: false,
164
+ rejects: (c) => c.uses < 2,
165
+ },
166
+ {
167
+ id: 'loop',
168
+ why: 'a function-top hoist of a loop base forces a callee-saved register the original avoided',
169
+ sound: false,
170
+ rejects: (c) => c.inLoop,
171
+ },
172
+ {
173
+ id: 'repeated-const-offset',
174
+ why: 'a fixed offset touched twice is a scalar RMW, which the compiler re-materializes',
175
+ sound: false,
176
+ rejects: (c) => c.repeatedConstOffset,
177
+ },
178
+ ];
179
+
149
180
  export function hoistReusedGlobalBases(sfn: SFn): SFn {
150
181
  const c: Collected = { count: new Map(), order: [], meta: new Map(), inLoop: new Set(), constOffCount: new Map() };
151
182
  collect(sfn.body, c, false);
152
- // A repeated CONSTANT offset means a scalar re-access at a fixed location (MMIO RMW / repeated
153
- // `*p`) the compiler re-materializes — disqualify the whole base, even mixed with array uses.
154
- const hasRepeatedConstOffset = (k: string): boolean => {
155
- for (const n of c.constOffCount.get(k)?.values() ?? []) {
156
- if (n >= 2) {
157
- return true;
158
- }
159
- }
160
- return false;
161
- };
162
-
163
- // Reuse 2+ and NOT used inside a loop. A loop-body base is loop-invariant, so the compiler ALSO
164
- // keeps it in a register across the loop — but hoisting it to the function top forces a
165
- // callee-saved register that can add prologue push/pop the original avoided, worsening the match
166
- // (register-pressure matching, not a correctness issue). Straight-line / branch reuse is the safe
167
- // win; a loop-body base is left inline for a future scope-aware hoist.
168
183
  const { count, order, meta } = c;
169
- const hoisted = order.filter((k) => (count.get(k) ?? 0) >= 2 && !c.inLoop.has(k) && !hasRepeatedConstOffset(k));
184
+ const hoisted = order.filter(
185
+ (k) =>
186
+ firstRejection(BASECSE_GATES, {
187
+ key: k,
188
+ uses: count.get(k) ?? 0,
189
+ inLoop: c.inLoop.has(k),
190
+ repeatedConstOffset: [...(c.constOffCount.get(k)?.values() ?? [])].some((n) => n >= 2),
191
+ }) === null,
192
+ );
170
193
  if (hoisted.length === 0) {
171
194
  return sfn;
172
195
  }
173
196
 
174
- const taken = new Set<string>([...sfn.params.map((p) => p.name), ...sfn.locals.map((l) => l.name)]);
175
- // globals are referenced by name; a hoist name must not shadow one that appears in the body.
176
- collectNames(sfn.body, taken);
197
+ const fresh = nameAllocator(sfn);
177
198
 
178
199
  const localFor = new Map<string, string>();
179
200
  const newLocals: { name: string; type: IrType }[] = [];
@@ -181,7 +202,7 @@ export function hoistReusedGlobalBases(sfn: SFn): SFn {
181
202
  for (const k of hoisted) {
182
203
  const m = meta.get(k)!;
183
204
  const ptrType = T.ptr(scalarTypeForAccess(m.width, m.signed));
184
- const nm = freshName(taken);
205
+ const nm = fresh();
185
206
  localFor.set(k, nm);
186
207
  newLocals.push({ name: nm, type: ptrType });
187
208
  // `p = (T *)base` — the cast makes the local the access's pointer type so each `p[i]` strides it.
@@ -191,28 +212,3 @@ export function hoistReusedGlobalBases(sfn: SFn): SFn {
191
212
  const body = [...hoistStmts, ...sfn.body.map((s) => rewriteStmt(s, localFor))];
192
213
  return { ...sfn, body, locals: [...sfn.locals, ...newLocals] };
193
214
  }
194
-
195
- /** Every `var`/`addr`/called-function name mentioned anywhere in `stmts` (so a hoist name collides
196
- * with none — a global via `addr`, a local via `var`, OR a callee via `call.fn`). */
197
- function collectNames(stmts: Stmt[], out: Set<string>): void {
198
- const walk = (e: Expr): void => {
199
- if (e.k === 'var' || e.k === 'addr') {
200
- out.add(e.name);
201
- }
202
- if (e.k === 'call') {
203
- out.add(e.fn); // a hoist local must not shadow a called function symbol
204
- }
205
- for (const c of exprChildrenOf(e)) {
206
- walk(c);
207
- }
208
- };
209
- for (const s of stmts) {
210
- if (s.k === 'assign') {
211
- out.add(s.name);
212
- }
213
- for (const e of stmtExprs(s)) {
214
- walk(e);
215
- }
216
- collectNames(stmtChildren(s), out);
217
- }
218
- }
@@ -0,0 +1,215 @@
1
+ import { typeToString } from '../ir/types';
2
+ import type { Expr, SFn, Stmt } from './ast';
3
+ import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
4
+ import { type Gate, firstRejection } from './gates';
5
+
6
+ function namesIn(e: Expr, out: Set<string>): void {
7
+ // `addr` names a GLOBAL, never a local — collected anyway. A name reaching BOTH forms would
8
+ // otherwise get a span that ignores its `addr` mentions, and a SHORT span is a clobber while a
9
+ // long one is only a missed merge. `structure.ts` keeps locals to /^[vt]\d+$/ and excludes global
10
+ // names, so this cannot fire today; collecting is the direction that stays safe if that changes.
11
+ if (e.k === 'var' || e.k === 'addr') out.add(e.name);
12
+ for (const c of exprChildren(e)) namesIn(c, out);
13
+ }
14
+
15
+ /** Does `e` mention `n` anywhere? */
16
+ function mentions(e: Expr, n: string): boolean {
17
+ const seen = new Set<string>();
18
+ namesIn(e, seen);
19
+ return seen.has(n);
20
+ }
21
+ export interface Span {
22
+ first: number;
23
+ last: number;
24
+ inLoop: boolean;
25
+ constFed: boolean;
26
+ /** the local's FIRST mention is a write, not a read */
27
+ firstIsWrite: boolean;
28
+ }
29
+ function spans(body: Stmt[]): Map<string, Span> {
30
+ const out = new Map<string, Span>();
31
+ let at = 0;
32
+ const walk = (list: Stmt[], inLoop: boolean): void => {
33
+ for (const s of list) {
34
+ at++;
35
+ const here = new Set<string>();
36
+ if (s.k === 'assign') here.add(s.name);
37
+ for (const e of stmtExprs(s)) namesIn(e, here);
38
+ for (const n of here) {
39
+ const sp = out.get(n) ?? {
40
+ first: at,
41
+ last: at,
42
+ inLoop,
43
+ constFed: true,
44
+ // an assign that ALSO READS the name (`b = g(b)`) is not a pure write; treating it as one
45
+ // let `g` receive the absorbed value
46
+ firstIsWrite: s.k === 'assign' && s.name === n && !stmtExprs(s).some((e) => mentions(e, n)),
47
+ };
48
+ sp.last = at;
49
+ sp.inLoop ||= inLoop;
50
+ if (s.k === 'assign' && s.name === n && s.value.k !== 'const') sp.constFed = false;
51
+ out.set(n, sp);
52
+ }
53
+ walk(stmtChildren(s), inLoop || s.k === 'while' || s.k === 'dowhile' || s.k === 'for');
54
+ }
55
+ };
56
+ walk(body, false);
57
+ return out;
58
+ }
59
+ function rename(body: Stmt[], from: string, to: string): Stmt[] {
60
+ const inExpr = (e: Expr): Expr =>
61
+ e.k === 'var' && e.name === from ? { ...e, name: to } : mapExprChildren(e, inExpr);
62
+ const inStmt = (s: Stmt): Stmt => {
63
+ const r = { ...s } as Record<string, unknown>;
64
+ if (s.k === 'assign' && s.name === from) r.name = to;
65
+ for (const key of ['value', 'lval', 'cond', 'scrutinee'] as const) {
66
+ const v = (s as Record<string, unknown>)[key];
67
+ if (v !== undefined) r[key] = inExpr(v as Expr);
68
+ }
69
+ for (const key of ['then', 'else', 'body', 'default'] as const) {
70
+ const v = (s as Record<string, unknown>)[key];
71
+ if (Array.isArray(v)) r[key] = (v as Stmt[]).map(inStmt);
72
+ }
73
+ if (s.k === 'for') {
74
+ r.init = inStmt(s.init);
75
+ r.inc = inStmt(s.inc);
76
+ }
77
+ if (s.k === 'switch') r.cases = s.cases.map((c) => ({ ...c, body: c.body.map(inStmt) }));
78
+ return r as Stmt;
79
+ };
80
+ return body.map(inStmt);
81
+ }
82
+ /** One candidate merge under consideration: absorb `a` into `b`. */
83
+ export interface MergePair {
84
+ a: string;
85
+ b: string;
86
+ /** `a`'s span */
87
+ x: Span;
88
+ /** `b`'s span — the SURVIVOR's, which is why the asymmetric gates read `y` */
89
+ y: Span;
90
+ sameType: boolean;
91
+ eitherIsParam: boolean;
92
+ }
93
+
94
+ /** The admission rules, in evaluation order. Two arguments the `why` fields have no room for:
95
+ *
96
+ * WHAT `loop` BUYS is the right to read preorder statement order as liveness. Preorder is a
97
+ * topological order of the CFG except where a later-indexed statement can run before an earlier
98
+ * one, and every position that does that — a `for`'s `init`/`inc`, any loop body — is inside a
99
+ * loop. A loop's own CONDITION is not covered: it is visited at the loop statement's own index with
100
+ * the ENCLOSING flag, which is safe only because a condition cannot WRITE.
101
+ *
102
+ * ABLATE `first-is-write` ALONE AND NOTHING HAPPENS — `const-fed` masks it, so a survivor first
103
+ * mentioned by a read was uninitialized there in the original too. Drop both to see what it does,
104
+ * which is to bound the accepted class below by an order of magnitude. `const-fed` likewise bounds
105
+ * candidate growth: merges go as `L(L-1)/2` in the local count, each a distinct compile. */
106
+ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
107
+ {
108
+ id: 'param',
109
+ why: 'a param is the function’s own signature, not a recovered local',
110
+ sound: false,
111
+ rejects: (c) => c.eitherIsParam,
112
+ },
113
+ {
114
+ id: 'type',
115
+ why: 'the survivor keeps its own declared type, so the two must agree',
116
+ sound: false,
117
+ rejects: (c) => !c.sameType,
118
+ },
119
+ {
120
+ id: 'loop',
121
+ why: 'a back edge can run a later statement first, so preorder stops implying disjoint liveness',
122
+ sound: true,
123
+ guardedBy: 'coalesce-fuzz.test.ts: dropping it clobbers a defined read',
124
+ rejects: (c) => c.x.inLoop || c.y.inLoop,
125
+ },
126
+ {
127
+ id: 'const-fed',
128
+ why: 'a load-fed local is one the compiler had a reason to keep where it was',
129
+ sound: false,
130
+ rejects: (c) => !c.x.constFed || !c.y.constFed,
131
+ },
132
+ {
133
+ id: 'overlap',
134
+ why: 'the ranges must not overlap — the survivor would absorb a value still live',
135
+ sound: true,
136
+ guardedBy: 'coalesce.test.ts: OVERLAPPING ranges never merge',
137
+ rejects: (c) => c.x.last >= c.y.first,
138
+ },
139
+ {
140
+ id: 'first-is-write',
141
+ why: 'a survivor first MENTIONED by a read would see the absorbed value there',
142
+ sound: false,
143
+ rejects: (c) => !c.y.firstIsWrite,
144
+ },
145
+ ];
146
+
147
+ /** Every legal single merge, each as its own tree — NOT one committed choice.
148
+ *
149
+ * Which pair a register allocator coalesced is not derivable from the L3 tree, and first-fit gets
150
+ * it wrong. Run kleod:UpdateHUDCounterDisplay's published repro script (results.json carries it)
151
+ * and read the candidate table: of its two legal merges, one scores WORSE than not merging at all
152
+ * and declaration order is the one that picks it. Emitting no merges at all costs that row its
153
+ * match, which is what guards this file. `rank.ts` already has the idiom for exactly this —
154
+ * `/regcopy`'s "the tail choice is allocator-ambiguous, so both are ranked" — so every candidate is
155
+ * emitted and the differ referees.
156
+ *
157
+ * ACCEPTED, NOT FIXED: a survivor assigned only on SOME paths still absorbs the other's value on
158
+ * the paths that skip it. The original read an uninitialized local there, so both spellings are
159
+ * ill-defined rather than one being wrong — but this is a real difference and the differ, not any
160
+ * gate, is what keeps it from faking a match. The fuzz asserts it stays reachable, so the carve-out
161
+ * that excuses it cannot quietly become dead. */
162
+ export function coalesceCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
163
+ return coalesceUnder(COALESCE_GATES, sfn).candidates;
164
+ }
165
+
166
+ /** `coalesceCandidates` with the gate table supplied, plus which gate refused each pair.
167
+ *
168
+ * The parameter exists so a test can run the pass with one gate DROPPED — the ablation as a value,
169
+ * rather than as a flag compiled into the shipped path or an input rewritten to dodge a predicate.
170
+ * `refusals` is what makes a gate's reachability checkable: a rule nothing ever reaches is a rule
171
+ * no test can be failing on purpose. */
172
+ export function coalesceUnder(
173
+ gates: readonly Gate<MergePair>[],
174
+ sfn: SFn,
175
+ ): { candidates: { merged: string; sfn: SFn }[]; refusals: Map<string, number> } {
176
+ const refusals = new Map<string, number>();
177
+ if (sfn.locals.length < 2) {
178
+ return { candidates: [], refusals };
179
+ }
180
+ const params = new Set(sfn.params.map((p) => p.name));
181
+ const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
182
+ const sp = spans(sfn.body);
183
+ const candidates: { merged: string; sfn: SFn }[] = [];
184
+ for (const a of sfn.locals.map((l) => l.name)) {
185
+ for (const b of sfn.locals.map((l) => l.name)) {
186
+ const x = sp.get(a);
187
+ const y = sp.get(b);
188
+ // Not a gate: this is what makes the pair a pair at all. A name with no span is one the body
189
+ // never mentions, so there is no range to reason about.
190
+ if (a === b || !x || !y) {
191
+ continue;
192
+ }
193
+ const refused = firstRejection(gates, {
194
+ a,
195
+ b,
196
+ x,
197
+ y,
198
+ sameType: typeOf.get(a) === typeOf.get(b),
199
+ eitherIsParam: params.has(a) || params.has(b),
200
+ });
201
+ if (refused !== null) {
202
+ refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
203
+ continue;
204
+ }
205
+ // Labelled by the PAIR, not by an index into enumeration order: an index silently re-points
206
+ // at a different merge if `sfn.locals` ordering ever changes, leaving a recorded provenance
207
+ // that is wrong but plausible.
208
+ candidates.push({
209
+ merged: `${a}-${b}`,
210
+ sfn: { ...sfn, body: rename(sfn.body, a, b), locals: sfn.locals.filter((l) => l.name !== a) },
211
+ });
212
+ }
213
+ }
214
+ return { candidates, refusals };
215
+ }
package/src/l3/dce.ts CHANGED
@@ -15,19 +15,23 @@
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
23
26
  // keep is defense-in-depth for any future caller that skips that check.
24
27
  import type { Expr, SFn, Stmt } from './ast';
25
- import { exprChildren, stmtChildren, stmtExprs } from './ast';
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)) {
@@ -48,15 +52,17 @@ function reads(e: Expr): Set<string> {
48
52
  * value asmlift could NOT lift slip past `assertResolved`, silently downgrading a loud gap;
49
53
  * - a memory load (`index`/`field`) — asmlift models no `volatile`, 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> = new Set()): 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,33 +76,13 @@ function allReadsInto(stmts: Stmt[], out: Set<string>): void {
70
76
  }
71
77
  }
72
78
 
73
- /** Negate a condition, flipping a relational operator directly (`!= → ==`, `< → >=`, …) so an
74
- * empty-then flip reads cleanly; anything else wraps in `!( … )`. Both forms are semantically
75
- * exact over C's total integer order. */
76
- function negate(cond: Expr): Expr {
77
- if (cond.k === 'bin') {
78
- const table: Record<string, '==' | '!=' | '<' | '<=' | '>' | '>='> = {
79
- '==': '!=',
80
- '!=': '==',
81
- '<': '>=',
82
- '>=': '<',
83
- '>': '<=',
84
- '<=': '>',
85
- };
86
- const f = table[cond.op];
87
- if (f) {
88
- return { k: 'bin', op: f, l: cond.l, r: cond.r };
89
- }
90
- }
91
- return { k: 'un', op: '!', e: cond };
92
- }
93
-
94
79
  /** Backward live-variable walk over one block. `liveOut` is the set of locals live on exit;
95
80
  * returns the rewritten block and the set live on entry. */
96
81
  function dceBlock(
97
82
  stmts: Stmt[],
98
83
  liveOut: ReadonlySet<string>,
99
84
  locals: ReadonlySet<string>,
85
+ volatiles: ReadonlySet<string> = new Set(),
100
86
  ): { out: Stmt[]; liveIn: Set<string> } {
101
87
  const live = new Set(liveOut);
102
88
  const rev: Stmt[] = [];
@@ -110,7 +96,7 @@ function dceBlock(
110
96
  const s = stmts[i];
111
97
  switch (s.k) {
112
98
  case 'assign': {
113
- if (locals.has(s.name) && !live.has(s.name) && !mustKeep(s.value)) {
99
+ if (locals.has(s.name) && !live.has(s.name) && !mustKeep(s.value, volatiles)) {
114
100
  continue; // dead local store — drop it; liveness is unchanged (it was a no-op)
115
101
  }
116
102
  live.delete(s.name); // the write kills the name for statements before it …
@@ -152,8 +138,8 @@ function dceBlock(
152
138
  break;
153
139
  }
154
140
  case 'if': {
155
- const t = dceBlock(s.then, live, locals);
156
- const e = dceBlock(s.else, live, locals);
141
+ const t = dceBlock(s.then, live, locals, volatiles);
142
+ const e = dceBlock(s.else, live, locals, volatiles);
157
143
  const nlive = new Set<string>();
158
144
  for (const r of reads(s.cond)) {
159
145
  nlive.add(r);
@@ -167,11 +153,11 @@ function dceBlock(
167
153
  setLive(nlive);
168
154
  if (t.out.length === 0 && e.out.length === 0) {
169
155
  // both arms empty: keep only if the condition itself has a side effect
170
- if (mustKeep(s.cond)) {
156
+ if (mustKeep(s.cond, volatiles)) {
171
157
  rev.push({ k: 'exprstmt', value: s.cond });
172
158
  }
173
159
  } else if (t.out.length === 0) {
174
- rev.push({ k: 'if', cond: negate(s.cond), then: e.out, else: [] });
160
+ rev.push({ k: 'if', cond: negateCond(s.cond), then: e.out, else: [] });
175
161
  } else {
176
162
  rev.push({ k: 'if', cond: s.cond, then: t.out, else: e.out });
177
163
  }
@@ -183,7 +169,7 @@ function dceBlock(
183
169
  // loop-carried store is never cut. Body DCE removes only what is dead on EVERY path.
184
170
  const loopLive = new Set(live);
185
171
  allReadsInto([s], loopLive);
186
- const b = dceBlock(s.body, loopLive, locals);
172
+ const b = dceBlock(s.body, loopLive, locals, volatiles);
187
173
  const nlive = new Set(loopLive);
188
174
  for (const r of b.liveIn) {
189
175
  nlive.add(r);
@@ -195,7 +181,7 @@ function dceBlock(
195
181
  case 'for': {
196
182
  const loopLive = new Set(live);
197
183
  allReadsInto([s], loopLive);
198
- const b = dceBlock(s.body, loopLive, locals);
184
+ const b = dceBlock(s.body, loopLive, locals, volatiles);
199
185
  const nlive = new Set(loopLive);
200
186
  for (const r of b.liveIn) {
201
187
  nlive.add(r);
@@ -209,8 +195,8 @@ function dceBlock(
209
195
  // read anywhere in the switch as live throughout — no case-body store is ever cut.
210
196
  const swLive = new Set(live);
211
197
  allReadsInto([s], swLive);
212
- const cases = s.cases.map((c) => ({ ...c, body: dceBlock(c.body, swLive, locals).out }));
213
- const def = s.default ? dceBlock(s.default, swLive, locals).out : s.default;
198
+ const cases = s.cases.map((c) => ({ ...c, body: dceBlock(c.body, swLive, locals, volatiles).out }));
199
+ const def = s.default ? dceBlock(s.default, swLive, locals, volatiles).out : s.default;
214
200
  const nlive = new Set(swLive);
215
201
  for (const r of reads(s.scrutinee)) {
216
202
  nlive.add(r);
@@ -248,9 +234,15 @@ function referencedNames(stmts: Stmt[], out: Set<string>): void {
248
234
  /** Remove dead local stores and simplify the branches they empty out, then drop any local
249
235
  * declaration left unreferenced. Returns a new SFn; the input is not mutated. */
250
236
  export function eliminateDeadStores(sfn: SFn): SFn {
251
- const locals = new Set(sfn.locals.map((l) => l.name));
252
- const body = dceBlock(sfn.body, new Set<string>(), locals).out;
237
+ // A VOLATILE local is never eligible: its stores are observable through the escaped address (the
238
+ // DMA hardware reads them) wherever they sit. The `addr`-as-read pin alone only protected stores
239
+ // UPSTREAM of an `&sp0` occurrence in this backward walk — the legal publish-address-then-fill
240
+ // ordering (`*dmaReg = &sp0;` THEN `sp0 = v;`) had its store deleted by this very pass, defeating
241
+ // the volatile the frontend added precisely so the RECOMPILER would not delete it.
242
+ const volatiles = new Set(sfn.locals.filter((l) => l.volatile).map((l) => l.name));
243
+ const locals = new Set(sfn.locals.filter((l) => !l.volatile).map((l) => l.name));
244
+ const body = dceBlock(sfn.body, new Set<string>(), locals, volatiles).out;
253
245
  const used = new Set<string>();
254
246
  referencedNames(body, used);
255
- return { ...sfn, body, locals: sfn.locals.filter((l) => used.has(l.name)) };
247
+ return { ...sfn, body, locals: sfn.locals.filter((l) => used.has(l.name) || l.volatile) };
256
248
  }
@@ -0,0 +1,67 @@
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
+ /** Structural defects in a gate table — the part checkable without running the pass. Returns
46
+ * findings rather than throwing, so core stays free of a test-framework import. */
47
+ export function gateTableDefects<Ctx>(gates: readonly Gate<Ctx>[]): string[] {
48
+ const out: string[] = [];
49
+ const seen = new Set<string>();
50
+ for (const g of gates) {
51
+ if (seen.has(g.id)) {
52
+ out.push(`duplicate gate id '${g.id}'`);
53
+ }
54
+ seen.add(g.id);
55
+ if (!/^[a-z][a-z0-9-]*$/.test(g.id)) {
56
+ out.push(`gate id '${g.id}' is not kebab-case`);
57
+ }
58
+ if (g.why.trim().length < 12) {
59
+ out.push(`gate '${g.id}' has no usable \`why\``);
60
+ }
61
+ // the one rule that costs something to declare
62
+ if (g.sound && !g.guardedBy?.trim()) {
63
+ out.push(`gate '${g.id}' is marked sound but names no guard`);
64
+ }
65
+ }
66
+ return out;
67
+ }