@asmlift/core 0.8.1 → 0.10.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 (130) hide show
  1. package/README.md +36 -31
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +397 -0
  4. package/src/backend/c.ts +6 -2
  5. package/src/backend/cfamily.ts +370 -56
  6. package/src/backend/cpp.ts +116 -10
  7. package/src/backend/pascal.ts +69 -11
  8. package/src/cdecl/constant.ts +59 -0
  9. package/src/cdecl/lex.ts +209 -0
  10. package/src/cdecl/parse.ts +975 -0
  11. package/src/cdecl/spell.ts +69 -0
  12. package/src/codegen-flags.ts +38 -6
  13. package/src/compiler-diagnostics.ts +194 -0
  14. package/src/contracts.ts +777 -84
  15. package/src/declare.ts +132 -13
  16. package/src/frontend/asmdata.ts +26 -6
  17. package/src/frontend/call-plan.ts +1590 -0
  18. package/src/frontend/dead-reads.ts +628 -0
  19. package/src/frontend/device-pins.ts +296 -0
  20. package/src/frontend/emit.ts +4 -3
  21. package/src/frontend/errors.ts +5 -0
  22. package/src/frontend/flags-edge.ts +101 -0
  23. package/src/frontend/fpu.ts +374 -0
  24. package/src/frontend/frame-objects.ts +3171 -0
  25. package/src/frontend/frontend.ts +64 -3
  26. package/src/frontend/high-half.ts +55 -5
  27. package/src/frontend/jump-table.ts +530 -0
  28. package/src/frontend/local-object.ts +616 -0
  29. package/src/frontend/mips.ts +2392 -208
  30. package/src/frontend/opaque.ts +97 -18
  31. package/src/frontend/own-struct-return.ts +500 -0
  32. package/src/frontend/ppc.ts +3137 -267
  33. package/src/frontend/register-struct-call.ts +484 -0
  34. package/src/frontend/reloc-symbol.ts +109 -13
  35. package/src/frontend/splat.ts +108 -5
  36. package/src/frontend/ssa.ts +2098 -89
  37. package/src/frontend/stackargs.ts +172 -42
  38. package/src/frontend/thumb.ts +4563 -2091
  39. package/src/ir/alias.ts +71 -0
  40. package/src/ir/bits.ts +681 -1
  41. package/src/ir/core.ts +199 -6
  42. package/src/ir/discipline.ts +313 -0
  43. package/src/ir/entry.ts +159 -0
  44. package/src/ir/float-bits.ts +47 -0
  45. package/src/{structure → ir}/loops.ts +6 -2
  46. package/src/ir/opcodes.ts +193 -121
  47. package/src/ir/parse.ts +1 -0
  48. package/src/ir/types.ts +202 -2
  49. package/src/ir/verify.ts +219 -3
  50. package/src/l3/address.ts +5 -0
  51. package/src/l3/argcopy.ts +38 -6
  52. package/src/l3/ast.ts +467 -23
  53. package/src/l3/basecse.ts +33 -5
  54. package/src/l3/coalesce.ts +21 -13
  55. package/src/l3/constinit.ts +382 -0
  56. package/src/l3/dce.ts +34 -12
  57. package/src/l3/gates.ts +9 -3
  58. package/src/l3/hoist.ts +8 -7
  59. package/src/l3/initfirst.ts +68 -19
  60. package/src/l3/intcell.ts +98 -0
  61. package/src/l3/mentions.ts +10 -4
  62. package/src/l3/narrowdecl.ts +259 -0
  63. package/src/l3/nearbase.ts +10 -5
  64. package/src/l3/parkfirst.ts +8 -5
  65. package/src/l3/pollguard.ts +11 -4
  66. package/src/l3/ptrcell.ts +354 -0
  67. package/src/l3/ptrfield.ts +123 -8
  68. package/src/l3/regspell.ts +26 -7
  69. package/src/l3/reindex.ts +293 -103
  70. package/src/l3/storage.ts +98 -1
  71. package/src/l3/symbol-refs.ts +182 -23
  72. package/src/l3/tailmerge.ts +9 -20
  73. package/src/l3/typing.ts +237 -32
  74. package/src/l3/unreduce.ts +10 -9
  75. package/src/l3/volatileptr.ts +17 -54
  76. package/src/l3/volstore.ts +96 -7
  77. package/src/mangle.ts +41 -1
  78. package/src/pattern/engine.ts +373 -82
  79. package/src/pipeline.ts +589 -27
  80. package/src/proto-context.ts +1648 -0
  81. package/src/proto.ts +1203 -49
  82. package/src/raise/aggmembers.ts +290 -0
  83. package/src/raise/arrays.ts +8 -3
  84. package/src/raise/declared-volatile.ts +307 -0
  85. package/src/raise/divpow2.ts +2 -2
  86. package/src/raise/errors.ts +17 -0
  87. package/src/raise/floathelpers.ts +464 -0
  88. package/src/raise/globalshape.ts +11 -5
  89. package/src/raise/gvn.ts +29 -8
  90. package/src/raise/latch.ts +162 -6
  91. package/src/raise/memberarrays.ts +10 -4
  92. package/src/raise/narrow.ts +29 -2
  93. package/src/raise/narrowlocal.ts +11 -6
  94. package/src/raise/offsetnames.ts +494 -0
  95. package/src/raise/pairparams.ts +114 -0
  96. package/src/raise/paramwidth.ts +203 -6
  97. package/src/raise/pre-recovery.ts +174 -17
  98. package/src/raise/pre-structure.ts +54 -0
  99. package/src/raise/recover.ts +26 -15
  100. package/src/raise/reroll.ts +694 -0
  101. package/src/raise/retsink.ts +1 -1
  102. package/src/raise/shortcircuit.ts +66 -30
  103. package/src/raise/softdiv.ts +21 -23
  104. package/src/raise/storesign.ts +153 -0
  105. package/src/raise/struct-arrays.ts +34 -14
  106. package/src/raise/structs.ts +674 -98
  107. package/src/raise/truncload.ts +367 -0
  108. package/src/raise/unthread.ts +400 -0
  109. package/src/raise/widehelpers.ts +106 -0
  110. package/src/rank-declare.ts +143 -12
  111. package/src/rank-variations.ts +117 -31
  112. package/src/rank.ts +563 -122
  113. package/src/runtime-helpers.ts +405 -0
  114. package/src/stillborn.ts +289 -0
  115. package/src/structure/analysis.ts +1449 -449
  116. package/src/structure/arith-ops.ts +23 -0
  117. package/src/structure/globalaccess.ts +111 -9
  118. package/src/structure/hazards.ts +641 -114
  119. package/src/structure/local-statics.ts +517 -0
  120. package/src/structure/namecoalesce.ts +2 -2
  121. package/src/structure/pointer-spelling.ts +1106 -0
  122. package/src/structure/redundant-test.ts +99 -0
  123. package/src/structure/structure.ts +3478 -975
  124. package/src/structure/switch-recover.ts +403 -178
  125. package/src/symbols.ts +44 -3
  126. package/src/target.ts +1387 -71
  127. package/src/trace.ts +66 -28
  128. package/src/variation-definitions.ts +243 -13
  129. package/src/variation-gates.ts +2 -0
  130. package/src/variation-tokens.ts +25 -7
package/src/contracts.ts CHANGED
@@ -3,21 +3,30 @@
3
3
  // decompileRanked / decompileWithReport).
4
4
  // A pass that regresses fails AT its boundary with a diagnostic, not three stages later as
5
5
  // wrong C.
6
- import { type Fn, type Value, reachableBlocks } from './ir/core';
7
- import { type IrType, typeToString } from './ir/types';
8
- import type { BinOp, Expr, SFn, Stmt } from './l3/ast';
6
+ import { constAddressOf, globalBaseOf } from './ir/alias';
7
+ import { type Fn, type Op, type Value, calleeName, reachableBlocks } from './ir/core';
8
+ import { placedAt } from './ir/discipline';
9
+ import { MEM_BASE_OPS } from './ir/opcodes';
10
+ import { type IrType, memberOf, typeToString } from './ir/types';
11
+ import { cellAddress, qualifiedBase } from './l3/address';
12
+ import { type BinOp, type Expr, type SFn, type Stmt, calleeOf } from './l3/ast';
9
13
  import {
14
+ assignsIn,
10
15
  exprChildren,
11
16
  fieldSpellsDot,
12
17
  gapReasonFor,
13
18
  mapExprChildren,
19
+ mentionedName,
14
20
  stmtChildren,
15
21
  stmtExprs,
16
22
  stmtLists,
23
+ taintOverAssigns,
17
24
  walkExprs,
18
25
  } from './l3/ast';
19
26
  import { mentionedLocals } from './l3/mentions';
27
+ import { localAddresses } from './l3/storage';
20
28
  import { declaredTypes, exprCType } from './l3/typing';
29
+ import { castOffDeclaration } from './l3/volstore';
21
30
 
22
31
  export class ContractError extends Error {
23
32
  constructor(message: string) {
@@ -68,9 +77,14 @@ export function assertResolved(sfn: SFn): void {
68
77
  // Every Expr kind that CARRIES a name, not just `var` — each is read through the same
69
78
  // `map.get(d)!` / `attrs.x as string` and prints straight into the source. `carriesName` is asked
70
79
  // separately because an ABSENT name is the case being caught: keying off `nameOf` alone refuses nothing.
71
- const carriesName = (e: Expr): boolean => e.k === 'var' || e.k === 'addr' || e.k === 'field' || e.k === 'call';
80
+ const carriesName = (e: Expr): boolean =>
81
+ e.k === 'var' ||
82
+ e.k === 'addr' ||
83
+ e.k === 'field' ||
84
+ (e.k === 'call' && typeof e.fn === 'string') ||
85
+ e.k === 'postincr';
72
86
  const nameOf = (e: Expr): string | undefined =>
73
- e.k === 'call' ? e.fn : e.k === 'marker' ? undefined : (e as { name?: string }).name;
87
+ e.k === 'call' ? calleeOf(e) : e.k === 'marker' ? undefined : (e as { name?: string }).name;
74
88
  const badExpr = (e: Expr): boolean => (carriesName(e) && badName(nameOf(e))) || exprChildren(e).some(badExpr);
75
89
  // An `assign`'s DESTINATION is a bare string field, so the expression walk never reaches it.
76
90
  const badStmt = (s: Stmt): boolean =>
@@ -86,10 +100,10 @@ export function assertResolved(sfn: SFn): void {
86
100
  // ── effects: executed once, never dropped ──────────────────────────────────────────────────
87
101
  //
88
102
  // The three contracts around this one are about TYPING and SPELLABILITY. Nothing checked the
89
- // property the structurer's materialization model exists to preserve: a call in the asm must run
90
- // exactly as often in the emitted source. Its two failure modes are the two that hurt most —
91
- // asmlift's first rule is that a loud failure beats a silently wrong answer, and both of these are
92
- // silent:
103
+ // property the structurer's materialization model exists to preserve: an execution the asm makes
104
+ // must happen exactly as often in the emitted source. Its two failure modes are the two that hurt
105
+ // most — asmlift's first rule is that a loud failure beats a silently wrong answer, and both of
106
+ // these are silent:
93
107
  //
94
108
  // • DROPPED — a call the asm makes has no counterpart in the tree at all;
95
109
  // • RE-RUN — inlining a call's value at more than one render position (or a structuring copy
@@ -97,22 +111,55 @@ export function assertResolved(sfn: SFn): void {
97
111
  // recovered switch fall-through hit exactly this shape, and only an adversarial reviewer
98
112
  // caught it.
99
113
  //
100
- // Deliberately narrow, so it never declines a function that is fine:
114
+ // What is counted is what each render executes (ir/discipline.ts `counted`): a call, a memory access
115
+ // the lift pinned (the `device` placement), whose qualified spelling the recompile performs once per
116
+ // render, and a read of an object the symbol map declares volatile (`declared`), which the tree
117
+ // spells by the object's name. A store to such an object is checked for its spelling only.
118
+ // Deliberately narrow beyond that, so it never declines a function that is fine:
101
119
  //
102
- // • CALLS only. Loads legitimately re-render (that is the whole point of the inline-at-use
103
- // model, and the alias gate governs it); stores are checked by neither direction here because
104
- // the readability DCE pass is allowed to drop a provably dead one.
120
+ // • CALLS AND QUALIFIED ACCESSES only. A plain load legitimately re-renders (that is the whole point
121
+ // of the inline-at-use model, and the alias gate governs it); a plain store is checked by
122
+ // neither direction because the readability DCE pass is allowed to drop a provably dead one.
105
123
  // • PER PATH, not per tree. Structuring may legitimately emit one block twice — two exclusive
106
- // switch arms sharing a body, a duplicated return merge — and each path still executes it
107
- // once. So the duplication rule compares the maximum over syntactic root-to-leaf paths (a
108
- // branch takes the max of its arms, a loop body counts once, a fall-through arm chains into
109
- // the next) against the IR's static count.
110
- // • Names the IR does not have are ignored, and only calls carrying a target symbol are counted
111
- // (every frontend that emits `call` today stamps one).
112
- type CallCounts = Map<string, number>;
124
+ // switch arms sharing a body, a duplicated return merge, an exit tail copied into each
125
+ // `if (…) { …; return; }` that leaves a loop — and each path still executes it once. So the
126
+ // duplication rule compares the maximum over syntactic root-to-leaf paths (a branch takes the
127
+ // max of its arms, a loop body counts once, a fall-through arm chains into the next, and a
128
+ // `return` ends its path) against the IR's static count. What follows a statement every path
129
+ // leaves is on no path, so it is refused rather than left uncounted: it is code the asm never
130
+ // ran, and a region duplicated there would pass the count unseen.
131
+ // • Names the IR does not have are ignored. A call through a register names no callee, so every
132
+ // one is counted under the single key `call:(*pointer)`: the count holds for indirect calls as
133
+ // a whole, and a pass that makes one of them call what another calls goes unseen.
134
+ // • A pinned access is keyed by whether it reads or writes, and by the constant address both sides
135
+ // can read off it — the IR's base and offset, the tree's `cellAddress` — or `?` where a side
136
+ // cannot. A read never stands for a write. A `?` render may stand for any pinned access and a
137
+ // `?` access may render at any address, and each render is one execution, so both rules are
138
+ // Hall's condition over that compatibility: the renders on a path at any set of addresses are
139
+ // at most those addresses' accesses plus the `?` accesses, and the accesses at any set of
140
+ // addresses have at least as many renders there plus the `?` renders. An access through a named
141
+ // global is counted for re-runs and never as dropped: its qualifier may be the global's
142
+ // declaration, which leaves nothing in the tree.
143
+ // • A declared read is keyed by the object it reads, and counted for re-runs only. The tree side
144
+ // counts each access whose address is that object's name plus arithmetic, once, however it is
145
+ // spelled — `gSym`, `gSym[i]`, `gSym.f`, `gPtr->f`, a `volatile` cast through `&gSym` — and
146
+ // never a store target. An access whose base is another access is the inner one's: through
147
+ // `gPtr->arr[i]` the object is read once. One that reaches the object through a cast with no
148
+ // `volatile` — the tree's own, or the one the printer puts on a base that does not stride the
149
+ // access (l3/volstore.ts `castOffDeclaration`) — is a plain read the compiler may hoist or
150
+ // delete, and is refused. Objects the asm also reads unstamped are skipped, since nothing tells
151
+ // their renders apart. A pinned access through the object's name may render as one of its
152
+ // reads.
153
+ // • A declared store is not counted: it renders at its own position, once. One spelled through a
154
+ // cast with no `volatile` is a plain store the compiler may delete or sink, and is refused, as
155
+ // the read is. Objects the asm also writes unstamped are skipped, as for reads.
156
+ /** Executions per key: `call:<target>`, or `call:(*pointer)` for every call through a register;
157
+ * `device:r:<0xaddress>` for a pinned read and `device:w:<0xaddress>` for a pinned write, with `?`
158
+ * for the address where the counting side cannot read one; or `declared:<object>`. */
159
+ type EffectCounts = Map<string, number>;
113
160
 
114
161
  /** per-key combine of two count maps (`sum` for sequence, `max` for exclusive alternatives) */
115
- function combine(a: CallCounts, b: CallCounts, f: (x: number, y: number) => number): CallCounts {
162
+ function combine(a: EffectCounts, b: EffectCounts, f: (x: number, y: number) => number): EffectCounts {
116
163
  const out = new Map(a);
117
164
  for (const [k, v] of b) {
118
165
  out.set(k, f(out.get(k) ?? 0, v));
@@ -120,80 +167,325 @@ function combine(a: CallCounts, b: CallCounts, f: (x: number, y: number) => numb
120
167
  return out;
121
168
  }
122
169
 
123
- /** every `call` expression under `e`, counted by target name */
124
- function callsInExpr(e: Expr, into: CallCounts): void {
170
+ const DECLARED = 'declared:';
171
+ const STRIPPED = 'stripped:';
172
+ const STRIPPED_WRITE = 'stripped-write:';
173
+ /** The one callee key every indirect call is counted under: no identifier can spell it. */
174
+ const INDIRECT_CALL = '(*pointer)';
175
+ type Direction = 'r' | 'w';
176
+ const deviceKey = (dir: Direction, addr: number | null): string =>
177
+ `device:${dir}:${addr === null ? '?' : `0x${addr.toString(16)}`}`;
178
+
179
+ /** Is this access spelled through a `volatile` pointee? A dot member qualifies through the element
180
+ * or member it selects from, any other access through its own base's cast chain. */
181
+ function qualifiedAccessChain(e: Expr): boolean {
182
+ if (e.k === 'index') {
183
+ return qualifiedBase(e.base);
184
+ }
185
+ if (e.k === 'field') {
186
+ return fieldSpellsDot(e)
187
+ ? (e.base.k === 'index' || e.base.k === 'field') && qualifiedAccessChain(e.base)
188
+ : qualifiedBase(e.base);
189
+ }
190
+ return false;
191
+ }
192
+
193
+ /** The global an access's address is named by: its base through casts, address arithmetic and the
194
+ * element or member a dot member selects from, down to `&gSym`, or to a `gSym` that spells its own
195
+ * address — an array's name, which decays to it (`addresses`), or the struct a dot member selects
196
+ * from (`dotted`). Any other bare `gSym` is the object's VALUE: `*(u16 *)gPtr` reads what the
197
+ * pointer points at, not the pointer, so the access names no object. Null too when the base is
198
+ * anything else — another access, a local. */
199
+ function accessedObject(base: Expr, addresses: ReadonlySet<string>, dotted = false): string | null {
200
+ switch (base.k) {
201
+ case 'var':
202
+ return dotted || addresses.has(base.name) ? base.name : null;
203
+ case 'addr':
204
+ return base.name;
205
+ case 'cast':
206
+ return accessedObject(base.e, addresses);
207
+ case 'bin':
208
+ return base.op === '+' || base.op === '-'
209
+ ? (accessedObject(base.l, addresses) ?? accessedObject(base.r, addresses))
210
+ : null;
211
+ default:
212
+ return null;
213
+ }
214
+ }
215
+
216
+ /** What the tree side reads a render against: the objects whose every read the asm makes is
217
+ * declared, those whose every write is, and the rendered C type of an expression, which decides
218
+ * where the printer casts. */
219
+ interface TreeContext {
220
+ declared: ReadonlySet<string>;
221
+ written: ReadonlySet<string>;
222
+ typeOf: (e: Expr) => IrType | undefined;
223
+ /** The names whose bare spelling is the object's address: the array-declared globals the
224
+ * structurer subscripts by name (SFn.globals), and the array or struct statics it defines. */
225
+ addresses: ReadonlySet<string>;
226
+ }
227
+
228
+ /** every call under `e`, counted by target name; every qualified memory access, by the address it
229
+ * denotes, or by its object where that object's reads are declared, and not at all where it is a
230
+ * write the object's declaration places; and every other access and bare global read, by the
231
+ * object it names — under `stripped:` (`stripped-write:` for a store target) where the access
232
+ * reaches a declared object through a cast that drops its qualifier. `selected` marks an element or member a dot
233
+ * member selects from: that is a part of the one access the member spells, not an access of its
234
+ * own. `named` marks the base of an access: a `gSym` there is the access's address, counted with
235
+ * it. `target` marks a store's target, which is a write. */
236
+ function effectsInExpr(
237
+ e: Expr,
238
+ into: EffectCounts,
239
+ ctx: TreeContext,
240
+ selected = false,
241
+ named = false,
242
+ target = false,
243
+ ): void {
244
+ const bump = (k: string) => into.set(k, (into.get(k) ?? 0) + 1);
245
+ const access = e.k === 'index' || e.k === 'field';
246
+ const inner = access && !selected ? dotSelectedAccess(e) : null;
247
+ const object =
248
+ inner === null ? null : accessedObject(inner.base, ctx.addresses, inner.k === 'field' && fieldSpellsDot(inner));
249
+ const ofDeclared = object !== null && !target && ctx.declared.has(object);
250
+ const writesDeclared = object !== null && target && ctx.written.has(object);
125
251
  if (e.k === 'call') {
126
- into.set(e.fn, (into.get(e.fn) ?? 0) + 1);
252
+ bump(`call:${calleeOf(e) ?? INDIRECT_CALL}`);
253
+ } else if (!selected && qualifiedAccessChain(e)) {
254
+ if (!writesDeclared) {
255
+ bump(ofDeclared ? `${DECLARED}${object}` : deviceKey(target ? 'w' : 'r', cellAddress(e)));
256
+ }
257
+ } else if (!selected && target && access) {
258
+ if (writesDeclared && castOffDeclaration(e, ctx.typeOf)) {
259
+ bump(`${STRIPPED_WRITE}${object}`);
260
+ }
261
+ } else if (!selected && access) {
262
+ if (object !== null) {
263
+ bump(`${ofDeclared && castOffDeclaration(e, ctx.typeOf) ? STRIPPED : DECLARED}${object}`);
264
+ }
265
+ } else if (!target && e.k === 'var' && !(named && (selected || ctx.addresses.has(e.name)))) {
266
+ // A bare name is a read of the object's value unless it spells the address of an access, which
267
+ // counts it: the struct a dot member selects from, or an array's decayed name.
268
+ bump(`${DECLARED}${e.name}`);
127
269
  }
128
- exprChildren(e).forEach((c) => callsInExpr(c, into));
270
+ const partOfThis = e.k === 'field' && fieldSpellsDot(e);
271
+ exprChildren(e).forEach((c) =>
272
+ effectsInExpr(c, into, ctx, partOfThis && c === e.base, access && c === e.base ? true : named && !access, false),
273
+ );
129
274
  }
130
275
 
131
- /** `total` = every occurrence in the tree; `path` = the most any single syntactic path executes */
132
- function countCalls(stmts: Stmt[]): { total: CallCounts; path: CallCounts } {
133
- let total: CallCounts = new Map();
134
- let path: CallCounts = new Map();
135
- const add = (r: { total: CallCounts; path: CallCounts }, pathF: (x: number, y: number) => number) => {
136
- total = combine(total, r.total, (x, y) => x + y);
137
- path = combine(path, r.path, pathF);
138
- };
276
+ type Access = Extract<Expr, { k: 'index' } | { k: 'field' }>;
277
+
278
+ /** A dot member's innermost access, past the element or member it selects from: its base is the
279
+ * address of the one access the member spells. */
280
+ function dotSelectedAccess(e: Access): Access {
281
+ if (e.k === 'field' && fieldSpellsDot(e) && (e.base.k === 'index' || e.base.k === 'field')) {
282
+ return dotSelectedAccess(e.base);
283
+ }
284
+ return e;
285
+ }
286
+
287
+ /** The executions on the paths through a statement list, split by how each path LEAVES it: falling off
288
+ * the end, or by a `return`, `break` or `continue`. Null when no path leaves that way. Each is the
289
+ * per-name maximum over the paths of that kind. */
290
+ interface PathCounts {
291
+ through: EffectCounts | null;
292
+ ret: EffectCounts | null;
293
+ brk: EffectCounts | null;
294
+ cont: EffectCounts | null;
295
+ }
296
+
297
+ const sum = (a: EffectCounts, b: EffectCounts): EffectCounts => combine(a, b, (x, y) => x + y);
298
+ const most = (...xs: (EffectCounts | null)[]): EffectCounts | null =>
299
+ xs.reduce<EffectCounts | null>((acc, x) => (x === null ? acc : acc === null ? x : combine(acc, x, Math.max)), null);
300
+ /** `c` run ahead of every path of `p` */
301
+ const after = (c: EffectCounts, p: PathCounts): PathCounts => ({
302
+ through: p.through && sum(c, p.through),
303
+ ret: p.ret && sum(c, p.ret),
304
+ brk: p.brk && sum(c, p.brk),
305
+ cont: p.cont && sum(c, p.cont),
306
+ });
307
+ /** `a` then `b`: only a path that falls off the end of `a` reaches `b` */
308
+ const seq = (a: PathCounts, b: PathCounts): PathCounts =>
309
+ a.through === null
310
+ ? a
311
+ : {
312
+ through: b.through && sum(a.through, b.through),
313
+ ret: most(a.ret, b.ret && sum(a.through, b.ret)),
314
+ brk: most(a.brk, b.brk && sum(a.through, b.brk)),
315
+ cont: most(a.cont, b.cont && sum(a.through, b.cont)),
316
+ };
317
+ /** whichever of the alternatives runs */
318
+ const either = (...ps: PathCounts[]): PathCounts => ({
319
+ through: most(...ps.map((p) => p.through)),
320
+ ret: most(...ps.map((p) => p.ret)),
321
+ brk: most(...ps.map((p) => p.brk)),
322
+ cont: most(...ps.map((p) => p.cont)),
323
+ });
324
+ const falls = (c: EffectCounts): PathCounts => ({ through: c, ret: null, brk: null, cont: null });
325
+
326
+ /** `total` = every occurrence in the tree; `paths` = the most any single syntactic path executes,
327
+ * by how it leaves `stmts`. A path that leaves by `return` runs nothing after it, so two sequenced
328
+ * `if (…) { f(); return; }` arms are one call on any path, not two. `unreached` collects every
329
+ * statement that follows one no path falls past: the path count skips it, so the caller refuses it
330
+ * rather than let a duplicated region hide there. */
331
+ function countEffects(
332
+ stmts: Stmt[],
333
+ ctx: TreeContext,
334
+ unreached: Stmt[] = [],
335
+ ): { total: EffectCounts; paths: PathCounts } {
336
+ let total: EffectCounts = new Map();
337
+ let paths = falls(new Map());
139
338
  for (const s of stmts) {
140
- const own: CallCounts = new Map();
141
- stmtExprs(s).forEach((e) => callsInExpr(e, own));
142
- add({ total: own, path: own }, (x, y) => x + y);
339
+ if (paths.through === null) {
340
+ unreached.push(s);
341
+ }
342
+ const own: EffectCounts = new Map();
343
+ stmtExprs(s).forEach((e) => effectsInExpr(e, own, ctx, false, false, s.k === 'store' && e === s.lval));
344
+ total = sum(total, own);
345
+ let here: PathCounts;
143
346
  if (s.k === 'if') {
144
- const t = countCalls(s.then);
145
- const e = countCalls(s.else);
146
- // exclusive arms: the path count is whichever arm runs, the total counts both
147
- add(
148
- { total: combine(t.total, e.total, (x, y) => x + y), path: combine(t.path, e.path, Math.max) },
149
- (x, y) => x + y,
150
- );
347
+ const t = countEffects(s.then, ctx, unreached);
348
+ const e = countEffects(s.else, ctx, unreached);
349
+ total = sum(total, sum(t.total, e.total));
350
+ here = after(own, either(t.paths, e.paths));
151
351
  } else if (s.k === 'switch') {
152
- const arms = s.cases.map((c) => countCalls(c.body));
153
- const dflt = countCalls(s.default ?? []);
352
+ const arms = s.cases.map((c) => countEffects(c.body, ctx, unreached));
353
+ const dflt = countEffects(s.default ?? [], ctx, unreached);
354
+ total = arms.reduce((acc, a) => sum(acc, a.total), sum(total, dflt.total));
154
355
  // A fall-through arm continues into the NEXT one emitted (the last into `default`), so a
155
356
  // path through arm i runs the chain starting at i — the shape the fall-through round's
156
- // CRITICAL took. Built from the end; `chain[i]` is that arm's per-path count.
157
- const chain: CallCounts[] = new Array(arms.length);
357
+ // CRITICAL took. Built from the end; `chain[i]` is that arm's paths.
358
+ const chain: PathCounts[] = new Array(arms.length);
158
359
  for (let i = arms.length - 1; i >= 0; i--) {
159
- const next = i + 1 < arms.length ? chain[i + 1] : dflt.path;
160
- chain[i] = s.cases[i].fallsThrough ? combine(arms[i].path, next, (x, y) => x + y) : arms[i].path;
360
+ const next = i + 1 < arms.length ? chain[i + 1] : dflt.paths;
361
+ chain[i] = s.cases[i].fallsThrough ? seq(arms[i].paths, next) : arms[i].paths;
161
362
  }
162
- const armTotal = arms.reduce((acc, a) => combine(acc, a.total, (x, y) => x + y), dflt.total);
163
- const armPath = chain.reduce((acc, c) => combine(acc, c, Math.max), dflt.path);
164
- add({ total: armTotal, path: armPath }, (x, y) => x + y);
363
+ // No `default` is one more alternative: no arm runs. A `break` leaves the switch, so it
364
+ // continues after it as a fall-off-the-end does.
365
+ const inner = either(...chain, s.default ? dflt.paths : falls(new Map()));
366
+ here = after(own, { through: most(inner.through, inner.brk), ret: inner.ret, brk: null, cont: inner.cont });
367
+ } else if (stmtChildren(s).length > 0) {
368
+ // A loop: its sequenced children (a body, a `for`'s init/inc) counted ONCE — a loop's dynamic
369
+ // trip count is not a syntactic occurrence, and the IR side is static too. A `break` or
370
+ // `continue` in the body lands after the loop or at its test, and the test may exit without
371
+ // running the body.
372
+ const body = countEffects(stmtChildren(s), ctx, unreached);
373
+ total = sum(total, body.total);
374
+ const p = body.paths;
375
+ here = after(own, { through: most(p.through, p.brk, p.cont, new Map()), ret: p.ret, brk: null, cont: null });
376
+ } else if (s.k === 'return') {
377
+ here = { through: null, ret: own, brk: null, cont: null };
378
+ } else if (s.k === 'break') {
379
+ here = { through: null, ret: null, brk: own, cont: null };
380
+ } else if (s.k === 'continue') {
381
+ here = { through: null, ret: null, brk: null, cont: own };
165
382
  } else {
166
- // Sequenced children (a loop body, a `for`'s init/inc): counted ONCE — a loop's dynamic trip
167
- // count is not a syntactic occurrence, and the IR side is static too.
168
- for (const c of stmtChildren(s)) {
169
- add(countCalls([c]), (x, y) => x + y);
170
- }
383
+ here = falls(own);
171
384
  }
385
+ paths = seq(paths, here);
172
386
  }
173
- return { total, path };
387
+ return { total, paths };
174
388
  }
175
389
 
176
390
  /**
177
- * Post structuring: every call the asm makes is emitted, and none is emitted more times than the
178
- * asm makes it on any one path. See the note above for what this deliberately does not cover.
391
+ * Post structuring: every call and pinned access the asm makes is emitted, and none of those or of
392
+ * its declared volatile reads is emitted more times than the asm makes it on any one path. See the
393
+ * note above for what this deliberately does not cover.
179
394
  */
180
395
  export function assertEffectsPreserved(fn: Fn, sfn: SFn): void {
181
396
  // Reachable blocks only: an unreachable block's call is legitimately never emitted.
182
397
  const seen = reachableBlocks(fn);
183
- const irCalls: CallCounts = new Map();
398
+ const irCalls: EffectCounts = new Map();
399
+ const irDevices: EffectCounts = new Map();
400
+ // Pinned accesses through a named global: in `irDevices` too, and exempt from the dropped rule.
401
+ const namedDevices = new Map<string, number>();
402
+ // …and the reads among them by the object they name, whose renders may be counted as its reads.
403
+ const pinnedReadsOf: EffectCounts = new Map();
404
+ const irDeclared: EffectCounts = new Map();
405
+ // Objects some unplaced read reads too, whose renders the tree side cannot tell from a declared one.
406
+ const plainlyRead = new Set<string>();
407
+ // Objects a declared store writes, and those some unplaced store writes too.
408
+ const declaredWritten = new Set<string>();
409
+ const plainlyWritten = new Set<string>();
184
410
  // Unmodelled instructions, by the mnemonic the frontend stamped. Same "never dropped" property as
185
411
  // a call, and it needs its own tally because an `opaque` carries no `target`.
186
412
  const irOpaques = new Set<string>();
413
+ const defs = new Map<Value, Op>();
414
+ for (const b of fn.blocks) {
415
+ for (const op of b.ops) {
416
+ op.results.forEach((r) => defs.set(r, op));
417
+ }
418
+ }
419
+ const bump = (m: EffectCounts, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
420
+ // Of the unplaced qualified accesses, those whose address may be a stack object the frame audit
421
+ // qualified, so a refusal names that object rather than a device register.
422
+ const stackUnplaced: Record<Direction, StackUnplaced> = { r: { may: 0, only: 0 }, w: { may: 0, only: 0 } };
423
+ const reachOf = addressReach(fn, defs);
187
424
  for (const b of seen) {
188
425
  for (const op of b.ops) {
189
- if (op.opcode === 'call' && typeof op.attrs.target === 'string') {
190
- const t = op.attrs.target;
191
- irCalls.set(t, (irCalls.get(t) ?? 0) + 1);
192
- } else if (op.opcode === 'opaque') {
193
- irOpaques.add(gapReasonFor(op.attrs.mnemonic));
426
+ const reads = op.opcode === 'load' || op.opcode === 'aload';
427
+ const placement = placedAt(op);
428
+ switch (placement) {
429
+ case 'call':
430
+ bump(irCalls, calleeName(op) ?? INDIRECT_CALL);
431
+ break;
432
+ case 'helper':
433
+ // Placed, not counted: the value it computes is pure.
434
+ break;
435
+ case 'device': {
436
+ const plain = op.opcode === 'load' || op.opcode === 'store';
437
+ const dir = reads ? 'r' : 'w';
438
+ const key = deviceKey(dir, plain ? constAddressOf(defs, op.operands[0], op.attrs.off as number) : null);
439
+ bump(irDevices, key);
440
+ const reach = key === deviceKey(dir, null) ? reachOf(op.operands[0]) : null;
441
+ if (reach?.stack === true) {
442
+ stackUnplaced[dir].may++;
443
+ if (!reach.other) {
444
+ stackUnplaced[dir].only++;
445
+ }
446
+ }
447
+ const object = globalBaseOf(defs, op.operands[0]);
448
+ if (object !== null) {
449
+ bump(namedDevices, key);
450
+ if (dir === 'r') {
451
+ bump(pinnedReadsOf, `${DECLARED}${object}`);
452
+ }
453
+ }
454
+ break;
455
+ }
456
+ case 'declared': {
457
+ const object = globalBaseOf(defs, op.operands[0]);
458
+ if (object !== null && reads) {
459
+ bump(irDeclared, `${DECLARED}${object}`);
460
+ } else if (object !== null) {
461
+ declaredWritten.add(object);
462
+ }
463
+ break;
464
+ }
465
+ case null:
466
+ if (op.opcode === 'opaque') {
467
+ irOpaques.add(gapReasonFor(op.attrs));
468
+ } else if (MEM_BASE_OPS.has(op.opcode)) {
469
+ const object = globalBaseOf(defs, op.operands[0]);
470
+ if (object !== null) {
471
+ (reads ? plainlyRead : plainlyWritten).add(object);
472
+ }
473
+ }
474
+ break;
475
+ default: {
476
+ // A new placement is keyed here before it is counted, or answered as not counted.
477
+ const unkeyed: never = placement;
478
+ throw new ContractError(`the effect contract keys no '${unkeyed as string}' placement`);
479
+ }
194
480
  }
195
481
  }
196
482
  }
483
+ for (const object of plainlyRead) {
484
+ irDeclared.delete(`${DECLARED}${object}`);
485
+ }
486
+ for (const object of plainlyWritten) {
487
+ declaredWritten.delete(object);
488
+ }
197
489
  // DROPPED only, not the RE-RUN half: a gap rendered twice is a diagnostic printed twice, which
198
490
  // costs nothing because nothing recompiles it, and structuring legitimately duplicates a shared
199
491
  // arm — so a per-path count here would fire on correct output.
@@ -216,21 +508,240 @@ export function assertEffectsPreserved(fn: Fn, sfn: SFn): void {
216
508
  }
217
509
  }
218
510
  }
219
- if (!irCalls.size) {
511
+ if (!irCalls.size && !irDevices.size && !irDeclared.size && !declaredWritten.size) {
220
512
  return;
221
513
  }
222
- const { total, path } = countCalls(sfn.body);
514
+ const unreached: Stmt[] = [];
515
+ const vt = declaredTypes(sfn);
516
+ const ctx: TreeContext = {
517
+ declared: new Set([...irDeclared.keys()].map((k) => k.slice(DECLARED.length))),
518
+ written: declaredWritten,
519
+ typeOf: (e) => exprCType(e, vt),
520
+ addresses: new Set([
521
+ ...(sfn.globals ?? []).map((g) => g.name),
522
+ ...[...(sfn.statics ?? []), ...(sfn.images ?? [])]
523
+ .filter((st) => st.type.kind === 'array' || st.type.kind === 'struct')
524
+ .map((st) => st.name),
525
+ ]),
526
+ };
527
+ const { total, paths } = countEffects(sfn.body, ctx, unreached);
528
+ if (unreached.length) {
529
+ throw new ContractError(
530
+ `structuring emitted ${unreached.length} statement(s) no path reaches in '${sfn.name}', after one every path leaves`,
531
+ );
532
+ }
533
+ const path = most(paths.through, paths.ret, paths.brk, paths.cont) ?? new Map<string, number>();
223
534
  for (const [name, n] of irCalls) {
224
- if (!(total.get(name) ?? 0)) {
535
+ if (!(total.get(`call:${name}`) ?? 0)) {
225
536
  throw new ContractError(`structuring dropped the call to '${name}' in '${sfn.name}' — its effect is lost`);
226
537
  }
227
- const p = path.get(name) ?? 0;
538
+ const p = path.get(`call:${name}`) ?? 0;
228
539
  if (p > n) {
229
540
  throw new ContractError(
230
541
  `structuring emitted ${p} calls to '${name}' on one path in '${sfn.name}', where the asm makes ${n}`,
231
542
  );
232
543
  }
233
544
  }
545
+ if (irDevices.size) {
546
+ devicesPreserved(sfn.name, irDevices, namedDevices, total, path, stackUnplaced);
547
+ }
548
+ for (const object of declaredWritten) {
549
+ if (total.has(`${STRIPPED_WRITE}${object}`)) {
550
+ throw new ContractError(
551
+ `structuring spelled a write to the volatile object '${object}' in '${sfn.name}' through a cast that ` +
552
+ 'drops its qualifier',
553
+ );
554
+ }
555
+ }
556
+ for (const [key, n] of irDeclared) {
557
+ const object = key.slice(DECLARED.length);
558
+ if (total.has(`${STRIPPED}${object}`)) {
559
+ throw new ContractError(
560
+ `structuring spelled a read of the volatile object '${object}' in '${sfn.name}' through a cast that ` +
561
+ 'drops its qualifier',
562
+ );
563
+ }
564
+ const p = path.get(key) ?? 0;
565
+ const made = n + (pinnedReadsOf.get(key) ?? 0);
566
+ if (p > made) {
567
+ throw new ContractError(
568
+ `structuring emitted ${p} reads of the volatile object '${object}' on one path in '${sfn.name}', ` +
569
+ `where the asm makes ${made}`,
570
+ );
571
+ }
572
+ }
573
+ }
574
+
575
+ /** One direction of a device count map: the count at each address it reads off, and at `?`. */
576
+ function oneDirection(m: EffectCounts, dir: Direction): { at: Map<string, number>; unplaced: number; all: number } {
577
+ const prefix = `device:${dir}:`;
578
+ const at = new Map<string, number>();
579
+ let unplaced = 0;
580
+ let all = 0;
581
+ for (const [k, v] of m) {
582
+ if (k.startsWith(prefix)) {
583
+ all += v;
584
+ if (k === `${prefix}?`) {
585
+ unplaced = v;
586
+ } else {
587
+ at.set(k.slice(prefix.length), v);
588
+ }
589
+ }
590
+ }
591
+ return { at, unplaced, all };
592
+ }
593
+
594
+ /** What an address may be: a stack object, something else, or either. */
595
+ interface AddressReach {
596
+ stack: boolean;
597
+ other: boolean;
598
+ }
599
+
600
+ /** What each address may be: a stack object (a `laddr`, moved by `add`/`sub`), anything but a named
601
+ * global, or — through a phi whose incoming values differ — either. A fixpoint, so a loop phi
602
+ * stepped from a `laddr` is a stack object only. */
603
+ function addressReach(fn: Fn, defs: ReadonlyMap<Value, Op>): (addr: Value) => AddressReach {
604
+ const incoming = new Map<Value, Value[]>();
605
+ for (const b of fn.blocks) {
606
+ for (const op of b.ops) {
607
+ for (const s of op.successors ?? []) {
608
+ s.args.forEach((arg, i) => {
609
+ const param = s.block.params[i];
610
+ if (param !== undefined) {
611
+ (incoming.get(param) ?? incoming.set(param, []).get(param)!).push(arg);
612
+ }
613
+ });
614
+ }
615
+ }
616
+ }
617
+ const NONE: AddressReach = { stack: false, other: false };
618
+ const known = new Map<Value, AddressReach>();
619
+ const at = (v: Value) => known.get(v) ?? NONE;
620
+ // A value not yet reached stays NONE, so an `add` waiting on one does not call itself `other`.
621
+ const step = (v: Value): AddressReach => {
622
+ const d = defs.get(v);
623
+ if (d === undefined) {
624
+ const from = incoming.get(v) ?? [];
625
+ return from.length === 0
626
+ ? { stack: false, other: true }
627
+ : { stack: from.some((a) => at(a).stack), other: from.some((a) => at(a).other) };
628
+ }
629
+ if (d.opcode === 'laddr') {
630
+ return { stack: true, other: false };
631
+ }
632
+ // An access through a named global is exempt from the dropped rule, so a phi side that names
633
+ // one is not what a refusal names.
634
+ if (d.opcode === 'gaddr') {
635
+ return NONE;
636
+ }
637
+ if (d.opcode !== 'add' && d.opcode !== 'sub') {
638
+ return { stack: false, other: true };
639
+ }
640
+ const roots = (d.opcode === 'add' ? d.operands : d.operands.slice(0, 1)).map(at);
641
+ const bases = roots.filter((r) => r.stack);
642
+ if (bases.length > 0) {
643
+ return { stack: true, other: bases.some((r) => r.other) };
644
+ }
645
+ return roots.some((r) => !r.other) ? NONE : { stack: false, other: true };
646
+ };
647
+ const values = [...defs.keys(), ...incoming.keys()];
648
+ for (let changed = true; changed;) {
649
+ changed = false;
650
+ for (const v of values) {
651
+ const was = at(v);
652
+ const now = step(v);
653
+ const joined = { stack: was.stack || now.stack, other: was.other || now.other };
654
+ if (joined.stack !== was.stack || joined.other !== was.other) {
655
+ known.set(v, joined);
656
+ changed = true;
657
+ }
658
+ }
659
+ }
660
+ return (addr) => (known.has(addr) ? at(addr) : { stack: false, other: true });
661
+ }
662
+
663
+ /** How many unplaced qualified accesses may be a stack object, and how many can be nothing else. */
664
+ interface StackUnplaced {
665
+ may: number;
666
+ only: number;
667
+ }
668
+
669
+ /** What an unplaced qualified access may be, given how many of `unplaced` may or must be a stack
670
+ * object. */
671
+ function unplacedNoun(stack: StackUnplaced, unplaced: number): { one: string; many: string } {
672
+ if (stack.may === 0) {
673
+ return { one: 'a device register', many: 'device registers' };
674
+ }
675
+ if (stack.only >= unplaced) {
676
+ return { one: 'an address-taken stack local', many: 'address-taken stack locals' };
677
+ }
678
+ return { one: 'a device register or a stack local', many: 'device registers or a stack local' };
679
+ }
680
+
681
+ const NOUNS: Readonly<Record<Direction, { one: string; many: string }>> = {
682
+ r: { one: 'read of', many: 'reads of' },
683
+ w: { one: 'write to', many: 'writes to' },
684
+ };
685
+
686
+ /** The pinned-access half of {@link assertEffectsPreserved}, over the counts keyed by `deviceKey`:
687
+ * first that no access is dropped, then that none is re-run, each direction on its own. */
688
+ function devicesPreserved(
689
+ name: string,
690
+ ir: EffectCounts,
691
+ named: EffectCounts,
692
+ total: EffectCounts,
693
+ path: EffectCounts,
694
+ stackUnplaced: Readonly<Record<Direction, StackUnplaced>>,
695
+ ): void {
696
+ const directions = ['r', 'w'] as const;
697
+ for (const dir of directions) {
698
+ const made = oneDirection(ir, dir);
699
+ const exempt = oneDirection(named, dir);
700
+ const renders = oneDirection(total, dir);
701
+ // An access through a named global needs no render of its own.
702
+ const owed = (a: string) => Math.max(0, (made.at.get(a) ?? 0) - (exempt.at.get(a) ?? 0));
703
+ const short = [...made.at.keys()].filter((a) => owed(a) > (renders.at.get(a) ?? 0));
704
+ const deficit = short.reduce((n, a) => n + owed(a) - (renders.at.get(a) ?? 0), 0);
705
+ if (deficit > renders.unplaced) {
706
+ throw new ContractError(
707
+ short.length === 1
708
+ ? `structuring dropped the ${NOUNS[dir].one} the device register at ${short[0]} in '${name}' — ` +
709
+ 'the machine made it and the source does not'
710
+ : `structuring dropped ${NOUNS[dir].many} the device registers at ${short.join(', ')} in '${name}' — ` +
711
+ 'the machine made them and the source does not',
712
+ );
713
+ }
714
+ const owedAll = [...made.at.keys()].reduce((n, a) => n + owed(a), 0) + Math.max(0, made.unplaced - exempt.unplaced);
715
+ if (owedAll > renders.all) {
716
+ throw new ContractError(
717
+ `structuring dropped a ${NOUNS[dir].one} ${unplacedNoun(stackUnplaced[dir], made.unplaced).one} in ` +
718
+ `'${name}' — the machine made it and the source does not`,
719
+ );
720
+ }
721
+ }
722
+ for (const dir of directions) {
723
+ const made = oneDirection(ir, dir);
724
+ const onPath = oneDirection(path, dir);
725
+ let excess = 0;
726
+ for (const [a, p] of onPath.at) {
727
+ const n = made.at.get(a) ?? 0;
728
+ if (p > n + made.unplaced) {
729
+ throw new ContractError(
730
+ `structuring emitted ${p} ${NOUNS[dir].many} the device register at ${a} on one path in '${name}', ` +
731
+ `where the asm makes ${n + made.unplaced}`,
732
+ );
733
+ }
734
+ excess += Math.max(0, p - n);
735
+ }
736
+ if (excess > made.unplaced || onPath.all > made.all) {
737
+ // past the unplaced count, the excess is at the addresses, every one a device register
738
+ const what = excess > made.unplaced ? 'device registers' : unplacedNoun(stackUnplaced[dir], made.unplaced).many;
739
+ throw new ContractError(
740
+ `structuring emitted ${onPath.all} ${NOUNS[dir].many} ${what} on one path in '${name}', where the ` +
741
+ `asm makes ${made.all}`,
742
+ );
743
+ }
744
+ }
234
745
  }
235
746
 
236
747
  /** Post structuring: a local the body READS must be WRITTEN somewhere in it. A materialized value
@@ -259,12 +770,24 @@ export function assertLocalsWritten(sfn: SFn): void {
259
770
  if ((e.k === 'var' || e.k === 'addr') && suspect.has(e.name)) {
260
771
  (e.k === 'addr' ? written : read).add(e.name);
261
772
  }
773
+ // `v++` is a read and a write, so it answers both sides.
774
+ if (e.k === 'postincr' && suspect.has(e.name)) {
775
+ read.add(e.name);
776
+ written.add(e.name);
777
+ }
262
778
  exprChildren(e).forEach(walkExpr);
263
779
  };
780
+ // A store to a member of a local struct (`ret.a = v`) writes that local.
781
+ const memberOf = (e: Expr): string | undefined =>
782
+ e.k === 'field' && e.dot === true ? (e.base.k === 'var' ? e.base.name : memberOf(e.base)) : undefined;
264
783
  const walkStmt = (st: Stmt): void => {
265
784
  if (st.k === 'assign' && suspect.has(st.name)) {
266
785
  written.add(st.name);
267
786
  }
787
+ const member = st.k === 'store' ? memberOf(st.lval) : undefined;
788
+ if (member !== undefined && suspect.has(member)) {
789
+ written.add(member);
790
+ }
268
791
  stmtExprs(st).forEach(walkExpr);
269
792
  stmtChildren(st).forEach(walkStmt);
270
793
  };
@@ -278,6 +801,46 @@ export function assertLocalsWritten(sfn: SFn): void {
278
801
  }
279
802
  }
280
803
 
804
+ /** Post structuring, and after every respell variation: a `postincr` is the ONLY mention of its
805
+ * local inside the full expression holding it.
806
+ *
807
+ * C89 leaves a program undefined when an object is modified and also read elsewhere between two
808
+ * sequence points, so `v1++ <= 9 && v1 != 0` is not a spelling asmlift may emit — the compiler
809
+ * picks either value, and the candidate still compiles and scores. The producer establishes the
810
+ * property on the tree it builds (structure.ts's `emitDoWhile`, judged by `PREUPDATE_COND_GATES`);
811
+ * what this catches is a later pass bringing a SECOND mention into that expression — `l3/coalesce.ts`
812
+ * renames one local onto another over a whole body, and asks nothing about this.
813
+ *
814
+ * PER FULL EXPRESSION, not per function: a statement boundary is a sequence point, so the loop
815
+ * body reading the same name is well-defined, and a pass that duplicates a whole expression owes
816
+ * the evaluation-trace argument to `exprHasEffect` rather than to this contract. */
817
+ export function assertPostIncrUnshared(sfn: SFn): void {
818
+ const overUpdated = (e: Expr): string | undefined => {
819
+ const mentions = new Map<string, number>();
820
+ const updated = new Set<string>();
821
+ const walk = (x: Expr): void => {
822
+ if (x.k === 'var' || x.k === 'addr' || x.k === 'postincr') {
823
+ mentions.set(x.name, (mentions.get(x.name) ?? 0) + 1);
824
+ }
825
+ if (x.k === 'postincr') {
826
+ updated.add(x.name);
827
+ }
828
+ exprChildren(x).forEach(walk);
829
+ };
830
+ walk(e);
831
+ return [...updated].find((n) => mentions.get(n)! > 1);
832
+ };
833
+ const bad = (s: Stmt): string | undefined =>
834
+ stmtExprs(s).map(overUpdated).find(Boolean) ?? stmtChildren(s).map(bad).find(Boolean);
835
+ const name = sfn.body.map(bad).find(Boolean);
836
+ if (name !== undefined) {
837
+ throw new ContractError(
838
+ `'${name}' is updated by a post-increment and read again in the same expression in '${sfn.name}' — ` +
839
+ `C89 leaves which value the other read sees undefined`,
840
+ );
841
+ }
842
+ }
843
+
281
844
  /** After a respell variation: a local a pass DELETED from the declaration list is named nowhere in the tree it
282
845
  * produced.
283
846
  *
@@ -326,6 +889,131 @@ export function assertNoOrphanedLocals(before: SFn, after: SFn): void {
326
889
  }
327
890
  }
328
891
 
892
+ /** The types a tree reads or writes LOCAL STORAGE at, other than an integer — the frame bytes
893
+ * reached by address (l3/storage.ts `localAddresses`), each access typed as it is spelled
894
+ * (`exprCType`: the cast on the storage, or the element struct's field). An aggregate is the
895
+ * element a `.field` selects from, not an access, so only scalars count. */
896
+ function localStorageNonIntegers(sfn: SFn): Set<string> {
897
+ const { isAddress, accessAddress } = localAddresses(sfn);
898
+ const vt = declaredTypes(sfn);
899
+ const out = new Set<string>();
900
+ for (const e of walkExprs(sfn.body)) {
901
+ if ((e.k === 'index' || e.k === 'field') && isAddress(accessAddress(e))) {
902
+ const t = exprCType(e, vt);
903
+ if (t !== undefined && t.kind !== 'int' && t.kind !== 'struct' && t.kind !== 'array') {
904
+ out.add(typeToString(t));
905
+ }
906
+ }
907
+ }
908
+ return out;
909
+ }
910
+
911
+ /** After a respell variation: no access to LOCAL STORAGE is re-typed to a non-integer.
912
+ *
913
+ * The frame audit's licence for a runtime index wider than a byte (frontend/frame-objects.ts
914
+ * `indexedWidth`) rests on what L3 SPELLS: agbcc at -O2 keeps a read ordered after a store to the
915
+ * same bytes only while both are integers of one width, and gives a pointer (or `long`, or a
916
+ * float) an alias set of its own, so a variation that re-types the bytes' reads hands the
917
+ * compiler a stale read the asm never had — C that compiles, scores, and is wrong. `/ptr-field`'s
918
+ * `over-local-storage` gate is the refusal that names itself; this is the backstop for the next
919
+ * variation that re-types, failing at its own stage rather than three away.
920
+ *
921
+ * A DIFFERENTIAL, like `assertNoOrphanedLocals`: it judges only a non-integer type the tree it
922
+ * derives from did not already read the storage at. The default tree is the frame audit's
923
+ * answer, and a type it gives a local object (a header-typed out-parameter's pointer member, say)
924
+ * is the source's own spelling, not a variation's claim. */
925
+ export function assertLocalStorageStaysInteger(before: SFn, after: SFn): void {
926
+ const had = localStorageNonIntegers(before);
927
+ const added = [...localStorageNonIntegers(after)].filter((t) => !had.has(t));
928
+ if (added.length) {
929
+ throw new ContractError(
930
+ `a respell variation re-typed an access to local storage in '${after.name}' as ${added
931
+ .sort()
932
+ .map((t) => `'${t}'`)
933
+ .join(', ')} — not an integer, so agbcc may read it stale past an integer store to the same bytes`,
934
+ );
935
+ }
936
+ }
937
+
938
+ /** A FRAME UNION IS READ AND WRITTEN ONLY THROUGH ITS VIEWS. The frame audit declares storage
939
+ * accessed at two widths over one byte as a union of per-width views (frontend/frame-objects.ts
940
+ * `unionViews`, structure.ts `laddrType`), because agbcc gives an access through a union member
941
+ * alias set 0 and reorders nothing past it — where a cast on the same bytes is read stale past a
942
+ * store of the other width. That licence holds only while EVERY access is a member access:
943
+ * - the union's name appears only as the base of a view (`u.half`), and an element of an array
944
+ * view is subscripted, element 0 included — `*u.half` dereferences the view's decay, an
945
+ * INDIRECT_REF agbcc types by its pointee and reads stale exactly as the cast (compiled);
946
+ * - nothing is accessed through its ADDRESS: `&u`, under casts and `+`/`-`, or a local ever
947
+ * assigned such a value, as the base of a subscript, an arrow or a deref is the cast
948
+ * spelling again. The address itself may go anywhere a value goes — a call's argument, a
949
+ * device's source register — since handing it on accesses nothing.
950
+ * ABSOLUTE, not a differential: the default tree satisfies it by construction, so any tree that
951
+ * does not was made by a variation. Checked on the default tree (pipeline.ts) and on every
952
+ * respelled one (rank.ts, beside `assertLocalStorageStaysInteger`). */
953
+ export function assertFrameUnionsViewed(sfn: SFn): void {
954
+ const unions = new Map(sfn.locals.filter((l) => l.type.kind === 'union').map((l) => [l.name, l.type]));
955
+ if (unions.size === 0) {
956
+ return;
957
+ }
958
+ const bad = (why: string): never => {
959
+ throw new ContractError(`'${sfn.name}' ${why} — agbcc may read the frame union stale through it`);
960
+ };
961
+ const reaches = (e: Expr, held: ReadonlySet<string>): boolean => {
962
+ switch (e.k) {
963
+ case 'addr':
964
+ return unions.has(e.name);
965
+ case 'var':
966
+ return held.has(e.name);
967
+ case 'cast':
968
+ return reaches(e.e, held);
969
+ case 'bin':
970
+ return (e.op === '+' || e.op === '-') && (reaches(e.l, held) || reaches(e.r, held));
971
+ default:
972
+ return false;
973
+ }
974
+ };
975
+ const held = taintOverAssigns(assignsIn(sfn.body), [], (a, t) => reaches(a.value, t));
976
+ const isView = (e: Expr): boolean =>
977
+ e.k === 'field' && e.dot === true && e.base.k === 'var' && unions.has(e.base.name);
978
+ const visit = (e: Expr): void => {
979
+ if (e.k === 'field' && isView(e)) {
980
+ if (memberOf(unions.get((e.base as { name: string }).name), e.name) === undefined) {
981
+ bad(`names '${e.name}', which is no view of the frame union`);
982
+ }
983
+ return;
984
+ }
985
+ if (e.k === 'index' && isView(e.base)) {
986
+ if (e.idx.k === 'const' && e.idx.value === 0 && e.subscript !== true) {
987
+ bad('dereferences a view of a frame union (`*u.view`) instead of subscripting it');
988
+ }
989
+ visit(e.base);
990
+ visit(e.idx);
991
+ return;
992
+ }
993
+ if ((e.k === 'index' || (e.k === 'field' && e.dot !== true)) && reaches(e.base, held)) {
994
+ bad('accesses a frame union through a pointer to its bytes');
995
+ }
996
+ if (e.k === 'var' && unions.has(e.name)) {
997
+ bad(`uses the frame union '${e.name}' other than through a view`);
998
+ }
999
+ exprChildren(e).forEach(visit);
1000
+ };
1001
+ const walk = (list: readonly Stmt[]): void => {
1002
+ for (const st of list) {
1003
+ if (st.k === 'assign' && unions.has(st.name)) {
1004
+ bad(`assigns the frame union '${st.name}' whole`);
1005
+ }
1006
+ stmtExprs(st).forEach(visit);
1007
+ // a `for`'s init and inc are statements of their own, outside both lists
1008
+ stmtLists(st).forEach(walk);
1009
+ if (st.k === 'for') {
1010
+ walk([st.init, st.inc]);
1011
+ }
1012
+ }
1013
+ };
1014
+ walk(sfn.body);
1015
+ }
1016
+
329
1017
  /** After a respell variation: every read of a MINTED local — its ADDRESS being taken included — must sit where
330
1018
  * that local's assignment has already run.
331
1019
  *
@@ -358,9 +1046,10 @@ export function assertHoistsDominate(sfn: SFn, minted: ReadonlySet<string>): voi
358
1046
  const readUndominated = (e: Expr, live: ReadonlySet<string>): string | null => {
359
1047
  // `&p` COUNTS, the same mention the placing passes query on (l3/hoist.ts): the address is what
360
1048
  // a callee reads the cell through, so an init has to precede it as surely as it must precede a
361
- // read.
362
- if ((e.k === 'var' || e.k === 'addr') && minted.has(e.name) && !live.has(e.name)) {
363
- return e.name;
1049
+ // read. `mentionedName` is the vocabulary rather than a pair of arms here for that reason.
1050
+ const n = mentionedName(e);
1051
+ if (n !== undefined && minted.has(n) && !live.has(n)) {
1052
+ return n;
364
1053
  }
365
1054
  let bad: string | null = null;
366
1055
  mapExprChildren(e, (c) => {
@@ -460,9 +1149,12 @@ export function assertDerefsTyped(sfn: SFn): void {
460
1149
  const NO_PTR_OPS = new Set<BinOp>(['&', '|', '^', '<<', '>>', '>>>', '*', '/', '/u', '%', '%u']);
461
1150
  // The comparison operators — where a bare `&SYM` operand is SIGN-ambiguous, not ill-formed.
462
1151
  const CMP_OPS = new Set(['<', '<=', '>', '>=', '==', '!=']);
463
- // 1/2/4 only: the decomp typedef vocabulary (C_TYPEDEFS) has no 64-bit scalar, so a width-8
464
- // access would print as the nonexistent `(s64 *)` — exactly the three-stages-later failure
465
- // this rule pre-empts. (If f64 loads ever land they are floats, not a scalar width here.)
1152
+ // 1/2/4 only. `s64` IS in the typedef vocabulary now (target.ts C_TYPEDEFS), and the 8-byte
1153
+ // ACCESS still is not: nothing in this pipeline recognises a pair of word loads as one 64-bit
1154
+ // read, so a width-8 index here would be a claim no pass makes. The rule pre-empts the
1155
+ // three-stages-later failure either way. (If f64 loads ever land they are floats, not a scalar
1156
+ // width here.) Adding 8 would also silently under-report in `l3/typing.ts` `exprIntWidth`, whose
1157
+ // soundness rests on memory not being a way a 64-bit value enters a rendered expression.
466
1158
  const SCALAR_WIDTHS = new Set([1, 2, 4]);
467
1159
  // Dot-form field bases (struct-array elements) carry the struct STRIDE as their width — any
468
1160
  // stride matching the element size is legal there (the tree-level struct cast governs the
@@ -477,8 +1169,9 @@ export function assertDerefsTyped(sfn: SFn): void {
477
1169
  if (e.k === 'index' && !structElem.has(e) && !SCALAR_WIDTHS.has(e.width)) {
478
1170
  bad.push(`index width ${e.width} is not a C scalar width`);
479
1171
  }
480
- // The emitter legalizes pointer operands away from these ops (structure.ts intify); a
481
- // pointer surviving here is ill-typed C the compiler will reject.
1172
+ // The emitter legalizes pointer operands away from these ops (structure/pointer-spelling.ts
1173
+ // `ARITH_ROWS`, its `bitwise` row); a pointer surviving here is ill-typed C the compiler will
1174
+ // reject.
482
1175
  if (e.k === 'bin' && NO_PTR_OPS.has(e.op)) {
483
1176
  for (const side of [e.l, e.r]) {
484
1177
  if (ctype(side)?.kind === 'ptr') {
@@ -490,9 +1183,9 @@ export function assertDerefsTyped(sfn: SFn): void {
490
1183
  // offset by sizeof(SYM), which is unknown for a header-typed global, so `&SYM + N` is byte-
491
1184
  // inexact. Nothing emits this shape anymore: a load/store base folds byte-correctly (globalOf
492
1185
  // turns `&SYM + N` into an `index`/`field` node whose base is a bare `addr`), and the additive
493
- // lowering intifies every other `addr` operand to `(u32)&SYM` (structure.ts intifyAddr — the
494
- // cast types int, so it never lands here). A bare `addr` reaching a `+`/`-` operand is therefore
495
- // a lowering REGRESSION — flag it rather than emit wrong bytes.
1186
+ // lowering intifies every other `addr` operand to `(u32)&SYM` (structure/pointer-spelling.ts
1187
+ // ARITH_ROWS — the cast types int, so it never lands here). A bare `addr` reaching a `+`/`-`
1188
+ // operand is therefore a lowering REGRESSION — flag it rather than emit wrong bytes.
496
1189
  if (e.k === 'bin' && (e.op === '+' || e.op === '-')) {
497
1190
  const addrSide = e.l.k === 'addr' ? e.l : e.r.k === 'addr' ? e.r : undefined;
498
1191
  if (addrSide) {
@@ -526,10 +1219,10 @@ export function assertDerefsTyped(sfn: SFn): void {
526
1219
  if (bt) {
527
1220
  // type-check against the same dot-vs-arrow spelling the printer will use (shared rule)
528
1221
  const st = fieldSpellsDot(e) ? bt : bt.kind === 'ptr' ? bt.to : undefined;
529
- if (!st || st.kind !== 'struct') {
1222
+ if (!st || (st.kind !== 'struct' && st.kind !== 'union')) {
530
1223
  bad.push(`member access '${e.name}' on a non-struct base (C type '${typeToString(bt)}')`);
531
- } else if (!st.fields.some((f) => f.name === e.name)) {
532
- bad.push(`member access '${e.name}' not declared on '${st.name}'`);
1224
+ } else if (memberOf(st, e.name) === undefined) {
1225
+ bad.push(`member access '${e.name}' not declared on '${typeToString(st)}'`);
533
1226
  }
534
1227
  }
535
1228
  }