@asmlift/core 0.8.0 → 0.9.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 (105) hide show
  1. package/README.md +34 -30
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +248 -0
  4. package/src/backend/c.ts +5 -2
  5. package/src/backend/cfamily.ts +236 -37
  6. package/src/backend/cpp.ts +114 -9
  7. package/src/backend/pascal.ts +39 -0
  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 +615 -79
  15. package/src/declare.ts +75 -5
  16. package/src/frontend/asmdata.ts +19 -5
  17. package/src/frontend/call-plan.ts +686 -0
  18. package/src/frontend/device-pins.ts +277 -0
  19. package/src/frontend/errors.ts +5 -0
  20. package/src/frontend/flags-edge.ts +101 -0
  21. package/src/frontend/fpu.ts +167 -0
  22. package/src/frontend/frame-objects.ts +2131 -0
  23. package/src/frontend/high-half.ts +2 -1
  24. package/src/frontend/local-object.ts +490 -0
  25. package/src/frontend/mips.ts +363 -40
  26. package/src/frontend/opaque.ts +97 -18
  27. package/src/frontend/ppc.ts +520 -129
  28. package/src/frontend/reloc-symbol.ts +94 -13
  29. package/src/frontend/splat.ts +108 -5
  30. package/src/frontend/ssa.ts +669 -76
  31. package/src/frontend/stackargs.ts +120 -39
  32. package/src/frontend/thumb.ts +2764 -2018
  33. package/src/ir/alias.ts +29 -0
  34. package/src/ir/core.ts +101 -3
  35. package/src/ir/discipline.ts +248 -0
  36. package/src/ir/float-bits.ts +47 -0
  37. package/src/ir/opcodes.ts +84 -112
  38. package/src/ir/parse.ts +1 -0
  39. package/src/ir/types.ts +118 -2
  40. package/src/ir/verify.ts +150 -3
  41. package/src/l3/address.ts +5 -0
  42. package/src/l3/argcopy.ts +38 -6
  43. package/src/l3/ast.ts +186 -14
  44. package/src/l3/coalesce.ts +21 -13
  45. package/src/l3/dce.ts +32 -10
  46. package/src/l3/gates.ts +9 -3
  47. package/src/l3/hoist.ts +8 -7
  48. package/src/l3/initfirst.ts +27 -9
  49. package/src/l3/intcell.ts +97 -0
  50. package/src/l3/mentions.ts +10 -4
  51. package/src/l3/narrowdecl.ts +218 -0
  52. package/src/l3/nearbase.ts +10 -5
  53. package/src/l3/parkfirst.ts +8 -5
  54. package/src/l3/pollguard.ts +3 -2
  55. package/src/l3/ptrcell.ts +357 -0
  56. package/src/l3/regspell.ts +17 -3
  57. package/src/l3/reindex.ts +10 -7
  58. package/src/l3/symbol-refs.ts +104 -23
  59. package/src/l3/tailmerge.ts +6 -19
  60. package/src/l3/typing.ts +137 -25
  61. package/src/l3/unreduce.ts +4 -4
  62. package/src/l3/volstore.ts +96 -7
  63. package/src/mangle.ts +11 -1
  64. package/src/pattern/engine.ts +19 -16
  65. package/src/pipeline.ts +57 -12
  66. package/src/proto-context.ts +788 -0
  67. package/src/proto.ts +689 -50
  68. package/src/raise/arrays.ts +4 -3
  69. package/src/raise/declared-volatile.ts +131 -0
  70. package/src/raise/divpow2.ts +2 -2
  71. package/src/raise/errors.ts +17 -0
  72. package/src/raise/floathelpers.ts +243 -0
  73. package/src/raise/globalshape.ts +11 -5
  74. package/src/raise/gvn.ts +26 -5
  75. package/src/raise/narrow.ts +29 -2
  76. package/src/raise/narrowlocal.ts +11 -6
  77. package/src/raise/offsetnames.ts +510 -0
  78. package/src/raise/pairparams.ts +81 -0
  79. package/src/raise/paramwidth.ts +7 -0
  80. package/src/raise/pre-recovery.ts +78 -4
  81. package/src/raise/recover.ts +14 -10
  82. package/src/raise/retsink.ts +1 -1
  83. package/src/raise/shortcircuit.ts +41 -19
  84. package/src/raise/softdiv.ts +21 -23
  85. package/src/raise/struct-arrays.ts +5 -4
  86. package/src/raise/structs.ts +311 -98
  87. package/src/raise/truncload.ts +366 -0
  88. package/src/raise/widehelpers.ts +106 -0
  89. package/src/rank-declare.ts +30 -5
  90. package/src/rank-variations.ts +36 -8
  91. package/src/rank.ts +276 -80
  92. package/src/runtime-helpers.ts +287 -0
  93. package/src/stillborn.ts +289 -0
  94. package/src/structure/analysis.ts +1285 -448
  95. package/src/structure/hazards.ts +633 -58
  96. package/src/structure/local-statics.ts +381 -0
  97. package/src/structure/namecoalesce.ts +1 -1
  98. package/src/structure/redundant-test.ts +99 -0
  99. package/src/structure/structure.ts +1772 -395
  100. package/src/structure/switch-recover.ts +159 -78
  101. package/src/symbols.ts +38 -3
  102. package/src/target.ts +662 -59
  103. package/src/trace.ts +44 -24
  104. package/src/variation-definitions.ts +119 -1
  105. package/src/variation-tokens.ts +20 -6
package/src/contracts.ts CHANGED
@@ -3,14 +3,19 @@
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 {
10
14
  exprChildren,
11
15
  fieldSpellsDot,
12
16
  gapReasonFor,
13
17
  mapExprChildren,
18
+ mentionedName,
14
19
  stmtChildren,
15
20
  stmtExprs,
16
21
  stmtLists,
@@ -18,6 +23,7 @@ import {
18
23
  } from './l3/ast';
19
24
  import { mentionedLocals } from './l3/mentions';
20
25
  import { declaredTypes, exprCType } from './l3/typing';
26
+ import { castOffDeclaration } from './l3/volstore';
21
27
 
22
28
  export class ContractError extends Error {
23
29
  constructor(message: string) {
@@ -68,9 +74,14 @@ export function assertResolved(sfn: SFn): void {
68
74
  // Every Expr kind that CARRIES a name, not just `var` — each is read through the same
69
75
  // `map.get(d)!` / `attrs.x as string` and prints straight into the source. `carriesName` is asked
70
76
  // 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';
77
+ const carriesName = (e: Expr): boolean =>
78
+ e.k === 'var' ||
79
+ e.k === 'addr' ||
80
+ e.k === 'field' ||
81
+ (e.k === 'call' && typeof e.fn === 'string') ||
82
+ e.k === 'postincr';
72
83
  const nameOf = (e: Expr): string | undefined =>
73
- e.k === 'call' ? e.fn : e.k === 'marker' ? undefined : (e as { name?: string }).name;
84
+ e.k === 'call' ? calleeOf(e) : e.k === 'marker' ? undefined : (e as { name?: string }).name;
74
85
  const badExpr = (e: Expr): boolean => (carriesName(e) && badName(nameOf(e))) || exprChildren(e).some(badExpr);
75
86
  // An `assign`'s DESTINATION is a bare string field, so the expression walk never reaches it.
76
87
  const badStmt = (s: Stmt): boolean =>
@@ -86,10 +97,10 @@ export function assertResolved(sfn: SFn): void {
86
97
  // ── effects: executed once, never dropped ──────────────────────────────────────────────────
87
98
  //
88
99
  // 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:
100
+ // property the structurer's materialization model exists to preserve: an execution the asm makes
101
+ // must happen exactly as often in the emitted source. Its two failure modes are the two that hurt
102
+ // most — asmlift's first rule is that a loud failure beats a silently wrong answer, and both of
103
+ // these are silent:
93
104
  //
94
105
  // • DROPPED — a call the asm makes has no counterpart in the tree at all;
95
106
  // • RE-RUN — inlining a call's value at more than one render position (or a structuring copy
@@ -97,22 +108,55 @@ export function assertResolved(sfn: SFn): void {
97
108
  // recovered switch fall-through hit exactly this shape, and only an adversarial reviewer
98
109
  // caught it.
99
110
  //
100
- // Deliberately narrow, so it never declines a function that is fine:
111
+ // What is counted is what each render executes (ir/discipline.ts `counted`): a call, a memory access
112
+ // the lift pinned (the `device` placement), whose qualified spelling the recompile performs once per
113
+ // render, and a read of an object the symbol map declares volatile (`declared`), which the tree
114
+ // spells by the object's name. A store to such an object is checked for its spelling only.
115
+ // Deliberately narrow beyond that, so it never declines a function that is fine:
101
116
  //
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.
117
+ // • CALLS AND QUALIFIED ACCESSES only. A plain load legitimately re-renders (that is the whole point
118
+ // of the inline-at-use model, and the alias gate governs it); a plain store is checked by
119
+ // neither direction because the readability DCE pass is allowed to drop a provably dead one.
105
120
  // • 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>;
121
+ // switch arms sharing a body, a duplicated return merge, an exit tail copied into each
122
+ // `if (…) { …; return; }` that leaves a loop — and each path still executes it once. So the
123
+ // duplication rule compares the maximum over syntactic root-to-leaf paths (a branch takes the
124
+ // max of its arms, a loop body counts once, a fall-through arm chains into the next, and a
125
+ // `return` ends its path) against the IR's static count. What follows a statement every path
126
+ // leaves is on no path, so it is refused rather than left uncounted: it is code the asm never
127
+ // ran, and a region duplicated there would pass the count unseen.
128
+ // • Names the IR does not have are ignored. A call through a register names no callee, so every
129
+ // one is counted under the single key `call:(*pointer)`: the count holds for indirect calls as
130
+ // a whole, and a pass that makes one of them call what another calls goes unseen.
131
+ // • A pinned access is keyed by whether it reads or writes, and by the constant address both sides
132
+ // can read off it — the IR's base and offset, the tree's `cellAddress` — or `?` where a side
133
+ // cannot. A read never stands for a write. A `?` render may stand for any pinned access and a
134
+ // `?` access may render at any address, and each render is one execution, so both rules are
135
+ // Hall's condition over that compatibility: the renders on a path at any set of addresses are
136
+ // at most those addresses' accesses plus the `?` accesses, and the accesses at any set of
137
+ // addresses have at least as many renders there plus the `?` renders. An access through a named
138
+ // global is counted for re-runs and never as dropped: its qualifier may be the global's
139
+ // declaration, which leaves nothing in the tree.
140
+ // • A declared read is keyed by the object it reads, and counted for re-runs only. The tree side
141
+ // counts each access whose address is that object's name plus arithmetic, once, however it is
142
+ // spelled — `gSym`, `gSym[i]`, `gSym.f`, `gPtr->f`, a `volatile` cast through `&gSym` — and
143
+ // never a store target. An access whose base is another access is the inner one's: through
144
+ // `gPtr->arr[i]` the object is read once. One that reaches the object through a cast with no
145
+ // `volatile` — the tree's own, or the one the printer puts on a base that does not stride the
146
+ // access (l3/volstore.ts `castOffDeclaration`) — is a plain read the compiler may hoist or
147
+ // delete, and is refused. Objects the asm also reads unstamped are skipped, since nothing tells
148
+ // their renders apart. A pinned access through the object's name may render as one of its
149
+ // reads.
150
+ // • A declared store is not counted: it renders at its own position, once. One spelled through a
151
+ // cast with no `volatile` is a plain store the compiler may delete or sink, and is refused, as
152
+ // the read is. Objects the asm also writes unstamped are skipped, as for reads.
153
+ /** Executions per key: `call:<target>`, or `call:(*pointer)` for every call through a register;
154
+ * `device:r:<0xaddress>` for a pinned read and `device:w:<0xaddress>` for a pinned write, with `?`
155
+ * for the address where the counting side cannot read one; or `declared:<object>`. */
156
+ type EffectCounts = Map<string, number>;
113
157
 
114
158
  /** 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 {
159
+ function combine(a: EffectCounts, b: EffectCounts, f: (x: number, y: number) => number): EffectCounts {
116
160
  const out = new Map(a);
117
161
  for (const [k, v] of b) {
118
162
  out.set(k, f(out.get(k) ?? 0, v));
@@ -120,80 +164,310 @@ function combine(a: CallCounts, b: CallCounts, f: (x: number, y: number) => numb
120
164
  return out;
121
165
  }
122
166
 
123
- /** every `call` expression under `e`, counted by target name */
124
- function callsInExpr(e: Expr, into: CallCounts): void {
167
+ const DECLARED = 'declared:';
168
+ const STRIPPED = 'stripped:';
169
+ const STRIPPED_WRITE = 'stripped-write:';
170
+ /** The one callee key every indirect call is counted under: no identifier can spell it. */
171
+ const INDIRECT_CALL = '(*pointer)';
172
+ type Direction = 'r' | 'w';
173
+ const deviceKey = (dir: Direction, addr: number | null): string =>
174
+ `device:${dir}:${addr === null ? '?' : `0x${addr.toString(16)}`}`;
175
+
176
+ /** Is this access spelled through a `volatile` pointee? A dot member qualifies through the element
177
+ * or member it selects from, any other access through its own base's cast chain. */
178
+ function qualifiedAccessChain(e: Expr): boolean {
179
+ if (e.k === 'index') {
180
+ return qualifiedBase(e.base);
181
+ }
182
+ if (e.k === 'field') {
183
+ return fieldSpellsDot(e)
184
+ ? (e.base.k === 'index' || e.base.k === 'field') && qualifiedAccessChain(e.base)
185
+ : qualifiedBase(e.base);
186
+ }
187
+ return false;
188
+ }
189
+
190
+ /** The global an access's address is named by: its base through casts, address arithmetic and the
191
+ * element or member a dot member selects from, down to `gSym` or `&gSym`. Null when the base is
192
+ * anything else — another access, a local. */
193
+ function accessedObject(base: Expr): string | null {
194
+ switch (base.k) {
195
+ case 'var':
196
+ case 'addr':
197
+ return base.name;
198
+ case 'cast':
199
+ return accessedObject(base.e);
200
+ case 'bin':
201
+ return base.op === '+' || base.op === '-' ? (accessedObject(base.l) ?? accessedObject(base.r)) : null;
202
+ default:
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /** What the tree side reads a render against: the objects whose every read the asm makes is
208
+ * declared, those whose every write is, and the rendered C type of an expression, which decides
209
+ * where the printer casts. */
210
+ interface TreeContext {
211
+ declared: ReadonlySet<string>;
212
+ written: ReadonlySet<string>;
213
+ typeOf: (e: Expr) => IrType | undefined;
214
+ }
215
+
216
+ /** every call under `e`, counted by target name; every qualified memory access, by the address it
217
+ * denotes, or by its object where that object's reads are declared, and not at all where it is a
218
+ * write the object's declaration places; and every other access and bare global read, by the
219
+ * object it names — under `stripped:` (`stripped-write:` for a store target) where the access
220
+ * reaches a declared object through a cast that drops its qualifier. `selected` marks an element or member a dot
221
+ * member selects from: that is a part of the one access the member spells, not an access of its
222
+ * own. `named` marks the base of an access: a `gSym` there is the access's address, counted with
223
+ * it. `target` marks a store's target, which is a write. */
224
+ function effectsInExpr(
225
+ e: Expr,
226
+ into: EffectCounts,
227
+ ctx: TreeContext,
228
+ selected = false,
229
+ named = false,
230
+ target = false,
231
+ ): void {
232
+ const bump = (k: string) => into.set(k, (into.get(k) ?? 0) + 1);
233
+ const access = e.k === 'index' || e.k === 'field';
234
+ const object = access && !selected ? accessedObject(dotSelected(e)) : null;
235
+ const ofDeclared = object !== null && !target && ctx.declared.has(object);
236
+ const writesDeclared = object !== null && target && ctx.written.has(object);
125
237
  if (e.k === 'call') {
126
- into.set(e.fn, (into.get(e.fn) ?? 0) + 1);
238
+ bump(`call:${calleeOf(e) ?? INDIRECT_CALL}`);
239
+ } else if (!selected && qualifiedAccessChain(e)) {
240
+ if (!writesDeclared) {
241
+ bump(ofDeclared ? `${DECLARED}${object}` : deviceKey(target ? 'w' : 'r', cellAddress(e)));
242
+ }
243
+ } else if (!selected && target && access) {
244
+ if (writesDeclared && castOffDeclaration(e, ctx.typeOf)) {
245
+ bump(`${STRIPPED_WRITE}${object}`);
246
+ }
247
+ } else if (!selected && access) {
248
+ if (object !== null) {
249
+ bump(`${ofDeclared && castOffDeclaration(e, ctx.typeOf) ? STRIPPED : DECLARED}${object}`);
250
+ }
251
+ } else if (!named && !target && e.k === 'var') {
252
+ bump(`${DECLARED}${e.name}`);
127
253
  }
128
- exprChildren(e).forEach((c) => callsInExpr(c, into));
254
+ const partOfThis = e.k === 'field' && fieldSpellsDot(e);
255
+ exprChildren(e).forEach((c) =>
256
+ effectsInExpr(c, into, ctx, partOfThis && c === e.base, access && c === e.base ? true : named && !access, false),
257
+ );
129
258
  }
130
259
 
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
- };
260
+ /** A dot member's own base chain, past the element or member it selects from: the address of the
261
+ * one access the member spells. */
262
+ function dotSelected(e: Extract<Expr, { k: 'index' } | { k: 'field' }>): Expr {
263
+ if (e.k === 'field' && fieldSpellsDot(e) && (e.base.k === 'index' || e.base.k === 'field')) {
264
+ return dotSelected(e.base);
265
+ }
266
+ return e.base;
267
+ }
268
+
269
+ /** The executions on the paths through a statement list, split by how each path LEAVES it: falling off
270
+ * the end, or by a `return`, `break` or `continue`. Null when no path leaves that way. Each is the
271
+ * per-name maximum over the paths of that kind. */
272
+ interface PathCounts {
273
+ through: EffectCounts | null;
274
+ ret: EffectCounts | null;
275
+ brk: EffectCounts | null;
276
+ cont: EffectCounts | null;
277
+ }
278
+
279
+ const sum = (a: EffectCounts, b: EffectCounts): EffectCounts => combine(a, b, (x, y) => x + y);
280
+ const most = (...xs: (EffectCounts | null)[]): EffectCounts | null =>
281
+ xs.reduce<EffectCounts | null>((acc, x) => (x === null ? acc : acc === null ? x : combine(acc, x, Math.max)), null);
282
+ /** `c` run ahead of every path of `p` */
283
+ const after = (c: EffectCounts, p: PathCounts): PathCounts => ({
284
+ through: p.through && sum(c, p.through),
285
+ ret: p.ret && sum(c, p.ret),
286
+ brk: p.brk && sum(c, p.brk),
287
+ cont: p.cont && sum(c, p.cont),
288
+ });
289
+ /** `a` then `b`: only a path that falls off the end of `a` reaches `b` */
290
+ const seq = (a: PathCounts, b: PathCounts): PathCounts =>
291
+ a.through === null
292
+ ? a
293
+ : {
294
+ through: b.through && sum(a.through, b.through),
295
+ ret: most(a.ret, b.ret && sum(a.through, b.ret)),
296
+ brk: most(a.brk, b.brk && sum(a.through, b.brk)),
297
+ cont: most(a.cont, b.cont && sum(a.through, b.cont)),
298
+ };
299
+ /** whichever of the alternatives runs */
300
+ const either = (...ps: PathCounts[]): PathCounts => ({
301
+ through: most(...ps.map((p) => p.through)),
302
+ ret: most(...ps.map((p) => p.ret)),
303
+ brk: most(...ps.map((p) => p.brk)),
304
+ cont: most(...ps.map((p) => p.cont)),
305
+ });
306
+ const falls = (c: EffectCounts): PathCounts => ({ through: c, ret: null, brk: null, cont: null });
307
+
308
+ /** `total` = every occurrence in the tree; `paths` = the most any single syntactic path executes,
309
+ * by how it leaves `stmts`. A path that leaves by `return` runs nothing after it, so two sequenced
310
+ * `if (…) { f(); return; }` arms are one call on any path, not two. `unreached` collects every
311
+ * statement that follows one no path falls past: the path count skips it, so the caller refuses it
312
+ * rather than let a duplicated region hide there. */
313
+ function countEffects(
314
+ stmts: Stmt[],
315
+ ctx: TreeContext,
316
+ unreached: Stmt[] = [],
317
+ ): { total: EffectCounts; paths: PathCounts } {
318
+ let total: EffectCounts = new Map();
319
+ let paths = falls(new Map());
139
320
  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);
321
+ if (paths.through === null) {
322
+ unreached.push(s);
323
+ }
324
+ const own: EffectCounts = new Map();
325
+ stmtExprs(s).forEach((e) => effectsInExpr(e, own, ctx, false, false, s.k === 'store' && e === s.lval));
326
+ total = sum(total, own);
327
+ let here: PathCounts;
143
328
  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
- );
329
+ const t = countEffects(s.then, ctx, unreached);
330
+ const e = countEffects(s.else, ctx, unreached);
331
+ total = sum(total, sum(t.total, e.total));
332
+ here = after(own, either(t.paths, e.paths));
151
333
  } else if (s.k === 'switch') {
152
- const arms = s.cases.map((c) => countCalls(c.body));
153
- const dflt = countCalls(s.default ?? []);
334
+ const arms = s.cases.map((c) => countEffects(c.body, ctx, unreached));
335
+ const dflt = countEffects(s.default ?? [], ctx, unreached);
336
+ total = arms.reduce((acc, a) => sum(acc, a.total), sum(total, dflt.total));
154
337
  // A fall-through arm continues into the NEXT one emitted (the last into `default`), so a
155
338
  // 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);
339
+ // CRITICAL took. Built from the end; `chain[i]` is that arm's paths.
340
+ const chain: PathCounts[] = new Array(arms.length);
158
341
  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;
342
+ const next = i + 1 < arms.length ? chain[i + 1] : dflt.paths;
343
+ chain[i] = s.cases[i].fallsThrough ? seq(arms[i].paths, next) : arms[i].paths;
161
344
  }
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);
345
+ // No `default` is one more alternative: no arm runs. A `break` leaves the switch, so it
346
+ // continues after it as a fall-off-the-end does.
347
+ const inner = either(...chain, s.default ? dflt.paths : falls(new Map()));
348
+ here = after(own, { through: most(inner.through, inner.brk), ret: inner.ret, brk: null, cont: inner.cont });
349
+ } else if (stmtChildren(s).length > 0) {
350
+ // A loop: its sequenced children (a body, a `for`'s init/inc) counted ONCE — a loop's dynamic
351
+ // trip count is not a syntactic occurrence, and the IR side is static too. A `break` or
352
+ // `continue` in the body lands after the loop or at its test, and the test may exit without
353
+ // running the body.
354
+ const body = countEffects(stmtChildren(s), ctx, unreached);
355
+ total = sum(total, body.total);
356
+ const p = body.paths;
357
+ here = after(own, { through: most(p.through, p.brk, p.cont, new Map()), ret: p.ret, brk: null, cont: null });
358
+ } else if (s.k === 'return') {
359
+ here = { through: null, ret: own, brk: null, cont: null };
360
+ } else if (s.k === 'break') {
361
+ here = { through: null, ret: null, brk: own, cont: null };
362
+ } else if (s.k === 'continue') {
363
+ here = { through: null, ret: null, brk: null, cont: own };
165
364
  } 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
- }
365
+ here = falls(own);
171
366
  }
367
+ paths = seq(paths, here);
172
368
  }
173
- return { total, path };
369
+ return { total, paths };
174
370
  }
175
371
 
176
372
  /**
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.
373
+ * Post structuring: every call and pinned access the asm makes is emitted, and none of those or of
374
+ * its declared volatile reads is emitted more times than the asm makes it on any one path. See the
375
+ * note above for what this deliberately does not cover.
179
376
  */
180
377
  export function assertEffectsPreserved(fn: Fn, sfn: SFn): void {
181
378
  // Reachable blocks only: an unreachable block's call is legitimately never emitted.
182
379
  const seen = reachableBlocks(fn);
183
- const irCalls: CallCounts = new Map();
380
+ const irCalls: EffectCounts = new Map();
381
+ const irDevices: EffectCounts = new Map();
382
+ // Pinned accesses through a named global: in `irDevices` too, and exempt from the dropped rule.
383
+ const namedDevices = new Map<string, number>();
384
+ // …and the reads among them by the object they name, whose renders may be counted as its reads.
385
+ const pinnedReadsOf: EffectCounts = new Map();
386
+ const irDeclared: EffectCounts = new Map();
387
+ // Objects some unplaced read reads too, whose renders the tree side cannot tell from a declared one.
388
+ const plainlyRead = new Set<string>();
389
+ // Objects a declared store writes, and those some unplaced store writes too.
390
+ const declaredWritten = new Set<string>();
391
+ const plainlyWritten = new Set<string>();
184
392
  // Unmodelled instructions, by the mnemonic the frontend stamped. Same "never dropped" property as
185
393
  // a call, and it needs its own tally because an `opaque` carries no `target`.
186
394
  const irOpaques = new Set<string>();
395
+ const defs = new Map<Value, Op>();
396
+ for (const b of fn.blocks) {
397
+ for (const op of b.ops) {
398
+ op.results.forEach((r) => defs.set(r, op));
399
+ }
400
+ }
401
+ const bump = (m: EffectCounts, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
402
+ // Of the unplaced qualified accesses, those whose address may be a stack object the frame audit
403
+ // qualified, so a refusal names that object rather than a device register.
404
+ const stackUnplaced: Record<Direction, StackUnplaced> = { r: { may: 0, only: 0 }, w: { may: 0, only: 0 } };
405
+ const reachOf = addressReach(fn, defs);
187
406
  for (const b of seen) {
188
407
  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));
408
+ const reads = op.opcode === 'load' || op.opcode === 'aload';
409
+ const placement = placedAt(op);
410
+ switch (placement) {
411
+ case 'call':
412
+ bump(irCalls, calleeName(op) ?? INDIRECT_CALL);
413
+ break;
414
+ case 'helper':
415
+ // Placed, not counted: the value it computes is pure.
416
+ break;
417
+ case 'device': {
418
+ const plain = op.opcode === 'load' || op.opcode === 'store';
419
+ const dir = reads ? 'r' : 'w';
420
+ const key = deviceKey(dir, plain ? constAddressOf(defs, op.operands[0], op.attrs.off as number) : null);
421
+ bump(irDevices, key);
422
+ const reach = key === deviceKey(dir, null) ? reachOf(op.operands[0]) : null;
423
+ if (reach?.stack === true) {
424
+ stackUnplaced[dir].may++;
425
+ if (!reach.other) {
426
+ stackUnplaced[dir].only++;
427
+ }
428
+ }
429
+ const object = globalBaseOf(defs, op.operands[0]);
430
+ if (object !== null) {
431
+ bump(namedDevices, key);
432
+ if (dir === 'r') {
433
+ bump(pinnedReadsOf, `${DECLARED}${object}`);
434
+ }
435
+ }
436
+ break;
437
+ }
438
+ case 'declared': {
439
+ const object = globalBaseOf(defs, op.operands[0]);
440
+ if (object !== null && reads) {
441
+ bump(irDeclared, `${DECLARED}${object}`);
442
+ } else if (object !== null) {
443
+ declaredWritten.add(object);
444
+ }
445
+ break;
446
+ }
447
+ case null:
448
+ if (op.opcode === 'opaque') {
449
+ irOpaques.add(gapReasonFor(op.attrs));
450
+ } else if (MEM_BASE_OPS.has(op.opcode)) {
451
+ const object = globalBaseOf(defs, op.operands[0]);
452
+ if (object !== null) {
453
+ (reads ? plainlyRead : plainlyWritten).add(object);
454
+ }
455
+ }
456
+ break;
457
+ default: {
458
+ // A new placement is keyed here before it is counted, or answered as not counted.
459
+ const unkeyed: never = placement;
460
+ throw new ContractError(`the effect contract keys no '${unkeyed as string}' placement`);
461
+ }
194
462
  }
195
463
  }
196
464
  }
465
+ for (const object of plainlyRead) {
466
+ irDeclared.delete(`${DECLARED}${object}`);
467
+ }
468
+ for (const object of plainlyWritten) {
469
+ declaredWritten.delete(object);
470
+ }
197
471
  // DROPPED only, not the RE-RUN half: a gap rendered twice is a diagnostic printed twice, which
198
472
  // costs nothing because nothing recompiles it, and structuring legitimately duplicates a shared
199
473
  // arm — so a per-path count here would fire on correct output.
@@ -216,21 +490,234 @@ export function assertEffectsPreserved(fn: Fn, sfn: SFn): void {
216
490
  }
217
491
  }
218
492
  }
219
- if (!irCalls.size) {
493
+ if (!irCalls.size && !irDevices.size && !irDeclared.size && !declaredWritten.size) {
220
494
  return;
221
495
  }
222
- const { total, path } = countCalls(sfn.body);
496
+ const unreached: Stmt[] = [];
497
+ const vt = declaredTypes(sfn);
498
+ const ctx: TreeContext = {
499
+ declared: new Set([...irDeclared.keys()].map((k) => k.slice(DECLARED.length))),
500
+ written: declaredWritten,
501
+ typeOf: (e) => exprCType(e, vt),
502
+ };
503
+ const { total, paths } = countEffects(sfn.body, ctx, unreached);
504
+ if (unreached.length) {
505
+ throw new ContractError(
506
+ `structuring emitted ${unreached.length} statement(s) no path reaches in '${sfn.name}', after one every path leaves`,
507
+ );
508
+ }
509
+ const path = most(paths.through, paths.ret, paths.brk, paths.cont) ?? new Map<string, number>();
223
510
  for (const [name, n] of irCalls) {
224
- if (!(total.get(name) ?? 0)) {
511
+ if (!(total.get(`call:${name}`) ?? 0)) {
225
512
  throw new ContractError(`structuring dropped the call to '${name}' in '${sfn.name}' — its effect is lost`);
226
513
  }
227
- const p = path.get(name) ?? 0;
514
+ const p = path.get(`call:${name}`) ?? 0;
228
515
  if (p > n) {
229
516
  throw new ContractError(
230
517
  `structuring emitted ${p} calls to '${name}' on one path in '${sfn.name}', where the asm makes ${n}`,
231
518
  );
232
519
  }
233
520
  }
521
+ if (irDevices.size) {
522
+ devicesPreserved(sfn.name, irDevices, namedDevices, total, path, stackUnplaced);
523
+ }
524
+ for (const object of declaredWritten) {
525
+ if (total.has(`${STRIPPED_WRITE}${object}`)) {
526
+ throw new ContractError(
527
+ `structuring spelled a write to the volatile object '${object}' in '${sfn.name}' through a cast that ` +
528
+ 'drops its qualifier',
529
+ );
530
+ }
531
+ }
532
+ for (const [key, n] of irDeclared) {
533
+ const object = key.slice(DECLARED.length);
534
+ if (total.has(`${STRIPPED}${object}`)) {
535
+ throw new ContractError(
536
+ `structuring spelled a read of the volatile object '${object}' in '${sfn.name}' through a cast that ` +
537
+ 'drops its qualifier',
538
+ );
539
+ }
540
+ const p = path.get(key) ?? 0;
541
+ const made = n + (pinnedReadsOf.get(key) ?? 0);
542
+ if (p > made) {
543
+ throw new ContractError(
544
+ `structuring emitted ${p} reads of the volatile object '${object}' on one path in '${sfn.name}', ` +
545
+ `where the asm makes ${made}`,
546
+ );
547
+ }
548
+ }
549
+ }
550
+
551
+ /** One direction of a device count map: the count at each address it reads off, and at `?`. */
552
+ function oneDirection(m: EffectCounts, dir: Direction): { at: Map<string, number>; unplaced: number; all: number } {
553
+ const prefix = `device:${dir}:`;
554
+ const at = new Map<string, number>();
555
+ let unplaced = 0;
556
+ let all = 0;
557
+ for (const [k, v] of m) {
558
+ if (k.startsWith(prefix)) {
559
+ all += v;
560
+ if (k === `${prefix}?`) {
561
+ unplaced = v;
562
+ } else {
563
+ at.set(k.slice(prefix.length), v);
564
+ }
565
+ }
566
+ }
567
+ return { at, unplaced, all };
568
+ }
569
+
570
+ /** What an address may be: a stack object, something else, or either. */
571
+ interface AddressReach {
572
+ stack: boolean;
573
+ other: boolean;
574
+ }
575
+
576
+ /** What each address may be: a stack object (a `laddr`, moved by `add`/`sub`), anything but a named
577
+ * global, or — through a phi whose incoming values differ — either. A fixpoint, so a loop phi
578
+ * stepped from a `laddr` is a stack object only. */
579
+ function addressReach(fn: Fn, defs: ReadonlyMap<Value, Op>): (addr: Value) => AddressReach {
580
+ const incoming = new Map<Value, Value[]>();
581
+ for (const b of fn.blocks) {
582
+ for (const op of b.ops) {
583
+ for (const s of op.successors ?? []) {
584
+ s.args.forEach((arg, i) => {
585
+ const param = s.block.params[i];
586
+ if (param !== undefined) {
587
+ (incoming.get(param) ?? incoming.set(param, []).get(param)!).push(arg);
588
+ }
589
+ });
590
+ }
591
+ }
592
+ }
593
+ const NONE: AddressReach = { stack: false, other: false };
594
+ const known = new Map<Value, AddressReach>();
595
+ const at = (v: Value) => known.get(v) ?? NONE;
596
+ // A value not yet reached stays NONE, so an `add` waiting on one does not call itself `other`.
597
+ const step = (v: Value): AddressReach => {
598
+ const d = defs.get(v);
599
+ if (d === undefined) {
600
+ const from = incoming.get(v) ?? [];
601
+ return from.length === 0
602
+ ? { stack: false, other: true }
603
+ : { stack: from.some((a) => at(a).stack), other: from.some((a) => at(a).other) };
604
+ }
605
+ if (d.opcode === 'laddr') {
606
+ return { stack: true, other: false };
607
+ }
608
+ // An access through a named global is exempt from the dropped rule, so a phi side that names
609
+ // one is not what a refusal names.
610
+ if (d.opcode === 'gaddr') {
611
+ return NONE;
612
+ }
613
+ if (d.opcode !== 'add' && d.opcode !== 'sub') {
614
+ return { stack: false, other: true };
615
+ }
616
+ const roots = (d.opcode === 'add' ? d.operands : d.operands.slice(0, 1)).map(at);
617
+ const bases = roots.filter((r) => r.stack);
618
+ if (bases.length > 0) {
619
+ return { stack: true, other: bases.some((r) => r.other) };
620
+ }
621
+ return roots.some((r) => !r.other) ? NONE : { stack: false, other: true };
622
+ };
623
+ const values = [...defs.keys(), ...incoming.keys()];
624
+ for (let changed = true; changed;) {
625
+ changed = false;
626
+ for (const v of values) {
627
+ const was = at(v);
628
+ const now = step(v);
629
+ const joined = { stack: was.stack || now.stack, other: was.other || now.other };
630
+ if (joined.stack !== was.stack || joined.other !== was.other) {
631
+ known.set(v, joined);
632
+ changed = true;
633
+ }
634
+ }
635
+ }
636
+ return (addr) => (known.has(addr) ? at(addr) : { stack: false, other: true });
637
+ }
638
+
639
+ /** How many unplaced qualified accesses may be a stack object, and how many can be nothing else. */
640
+ interface StackUnplaced {
641
+ may: number;
642
+ only: number;
643
+ }
644
+
645
+ /** What an unplaced qualified access may be, given how many of `unplaced` may or must be a stack
646
+ * object. */
647
+ function unplacedNoun(stack: StackUnplaced, unplaced: number): { one: string; many: string } {
648
+ if (stack.may === 0) {
649
+ return { one: 'a device register', many: 'device registers' };
650
+ }
651
+ if (stack.only >= unplaced) {
652
+ return { one: 'an address-taken stack local', many: 'address-taken stack locals' };
653
+ }
654
+ return { one: 'a device register or a stack local', many: 'device registers or a stack local' };
655
+ }
656
+
657
+ const NOUNS: Readonly<Record<Direction, { one: string; many: string }>> = {
658
+ r: { one: 'read of', many: 'reads of' },
659
+ w: { one: 'write to', many: 'writes to' },
660
+ };
661
+
662
+ /** The pinned-access half of {@link assertEffectsPreserved}, over the counts keyed by `deviceKey`:
663
+ * first that no access is dropped, then that none is re-run, each direction on its own. */
664
+ function devicesPreserved(
665
+ name: string,
666
+ ir: EffectCounts,
667
+ named: EffectCounts,
668
+ total: EffectCounts,
669
+ path: EffectCounts,
670
+ stackUnplaced: Readonly<Record<Direction, StackUnplaced>>,
671
+ ): void {
672
+ const directions = ['r', 'w'] as const;
673
+ for (const dir of directions) {
674
+ const made = oneDirection(ir, dir);
675
+ const exempt = oneDirection(named, dir);
676
+ const renders = oneDirection(total, dir);
677
+ // An access through a named global needs no render of its own.
678
+ const owed = (a: string) => Math.max(0, (made.at.get(a) ?? 0) - (exempt.at.get(a) ?? 0));
679
+ const short = [...made.at.keys()].filter((a) => owed(a) > (renders.at.get(a) ?? 0));
680
+ const deficit = short.reduce((n, a) => n + owed(a) - (renders.at.get(a) ?? 0), 0);
681
+ if (deficit > renders.unplaced) {
682
+ throw new ContractError(
683
+ short.length === 1
684
+ ? `structuring dropped the ${NOUNS[dir].one} the device register at ${short[0]} in '${name}' — ` +
685
+ 'the machine made it and the source does not'
686
+ : `structuring dropped ${NOUNS[dir].many} the device registers at ${short.join(', ')} in '${name}' — ` +
687
+ 'the machine made them and the source does not',
688
+ );
689
+ }
690
+ const owedAll = [...made.at.keys()].reduce((n, a) => n + owed(a), 0) + Math.max(0, made.unplaced - exempt.unplaced);
691
+ if (owedAll > renders.all) {
692
+ throw new ContractError(
693
+ `structuring dropped a ${NOUNS[dir].one} ${unplacedNoun(stackUnplaced[dir], made.unplaced).one} in ` +
694
+ `'${name}' — the machine made it and the source does not`,
695
+ );
696
+ }
697
+ }
698
+ for (const dir of directions) {
699
+ const made = oneDirection(ir, dir);
700
+ const onPath = oneDirection(path, dir);
701
+ let excess = 0;
702
+ for (const [a, p] of onPath.at) {
703
+ const n = made.at.get(a) ?? 0;
704
+ if (p > n + made.unplaced) {
705
+ throw new ContractError(
706
+ `structuring emitted ${p} ${NOUNS[dir].many} the device register at ${a} on one path in '${name}', ` +
707
+ `where the asm makes ${n + made.unplaced}`,
708
+ );
709
+ }
710
+ excess += Math.max(0, p - n);
711
+ }
712
+ if (excess > made.unplaced || onPath.all > made.all) {
713
+ // past the unplaced count, the excess is at the addresses, every one a device register
714
+ const what = excess > made.unplaced ? 'device registers' : unplacedNoun(stackUnplaced[dir], made.unplaced).many;
715
+ throw new ContractError(
716
+ `structuring emitted ${onPath.all} ${NOUNS[dir].many} ${what} on one path in '${name}', where the ` +
717
+ `asm makes ${made.all}`,
718
+ );
719
+ }
720
+ }
234
721
  }
235
722
 
236
723
  /** Post structuring: a local the body READS must be WRITTEN somewhere in it. A materialized value
@@ -259,6 +746,11 @@ export function assertLocalsWritten(sfn: SFn): void {
259
746
  if ((e.k === 'var' || e.k === 'addr') && suspect.has(e.name)) {
260
747
  (e.k === 'addr' ? written : read).add(e.name);
261
748
  }
749
+ // `v++` is a read and a write, so it answers both sides.
750
+ if (e.k === 'postincr' && suspect.has(e.name)) {
751
+ read.add(e.name);
752
+ written.add(e.name);
753
+ }
262
754
  exprChildren(e).forEach(walkExpr);
263
755
  };
264
756
  const walkStmt = (st: Stmt): void => {
@@ -278,6 +770,46 @@ export function assertLocalsWritten(sfn: SFn): void {
278
770
  }
279
771
  }
280
772
 
773
+ /** Post structuring, and after every respell variation: a `postincr` is the ONLY mention of its
774
+ * local inside the full expression holding it.
775
+ *
776
+ * C89 leaves a program undefined when an object is modified and also read elsewhere between two
777
+ * sequence points, so `v1++ <= 9 && v1 != 0` is not a spelling asmlift may emit — the compiler
778
+ * picks either value, and the candidate still compiles and scores. The producer establishes the
779
+ * property on the tree it builds (structure.ts's `emitDoWhile`, judged by `PREUPDATE_COND_GATES`);
780
+ * what this catches is a later pass bringing a SECOND mention into that expression — `l3/coalesce.ts`
781
+ * renames one local onto another over a whole body, and asks nothing about this.
782
+ *
783
+ * PER FULL EXPRESSION, not per function: a statement boundary is a sequence point, so the loop
784
+ * body reading the same name is well-defined, and a pass that duplicates a whole expression owes
785
+ * the evaluation-trace argument to `exprHasEffect` rather than to this contract. */
786
+ export function assertPostIncrUnshared(sfn: SFn): void {
787
+ const overUpdated = (e: Expr): string | undefined => {
788
+ const mentions = new Map<string, number>();
789
+ const updated = new Set<string>();
790
+ const walk = (x: Expr): void => {
791
+ if (x.k === 'var' || x.k === 'addr' || x.k === 'postincr') {
792
+ mentions.set(x.name, (mentions.get(x.name) ?? 0) + 1);
793
+ }
794
+ if (x.k === 'postincr') {
795
+ updated.add(x.name);
796
+ }
797
+ exprChildren(x).forEach(walk);
798
+ };
799
+ walk(e);
800
+ return [...updated].find((n) => mentions.get(n)! > 1);
801
+ };
802
+ const bad = (s: Stmt): string | undefined =>
803
+ stmtExprs(s).map(overUpdated).find(Boolean) ?? stmtChildren(s).map(bad).find(Boolean);
804
+ const name = sfn.body.map(bad).find(Boolean);
805
+ if (name !== undefined) {
806
+ throw new ContractError(
807
+ `'${name}' is updated by a post-increment and read again in the same expression in '${sfn.name}' — ` +
808
+ `C89 leaves which value the other read sees undefined`,
809
+ );
810
+ }
811
+ }
812
+
281
813
  /** After a respell variation: a local a pass DELETED from the declaration list is named nowhere in the tree it
282
814
  * produced.
283
815
  *
@@ -358,9 +890,10 @@ export function assertHoistsDominate(sfn: SFn, minted: ReadonlySet<string>): voi
358
890
  const readUndominated = (e: Expr, live: ReadonlySet<string>): string | null => {
359
891
  // `&p` COUNTS, the same mention the placing passes query on (l3/hoist.ts): the address is what
360
892
  // 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;
893
+ // read. `mentionedName` is the vocabulary rather than a pair of arms here for that reason.
894
+ const n = mentionedName(e);
895
+ if (n !== undefined && minted.has(n) && !live.has(n)) {
896
+ return n;
364
897
  }
365
898
  let bad: string | null = null;
366
899
  mapExprChildren(e, (c) => {
@@ -460,9 +993,12 @@ export function assertDerefsTyped(sfn: SFn): void {
460
993
  const NO_PTR_OPS = new Set<BinOp>(['&', '|', '^', '<<', '>>', '>>>', '*', '/', '/u', '%', '%u']);
461
994
  // The comparison operators — where a bare `&SYM` operand is SIGN-ambiguous, not ill-formed.
462
995
  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.)
996
+ // 1/2/4 only. `s64` IS in the typedef vocabulary now (target.ts C_TYPEDEFS), and the 8-byte
997
+ // ACCESS still is not: nothing in this pipeline recognises a pair of word loads as one 64-bit
998
+ // read, so a width-8 index here would be a claim no pass makes. The rule pre-empts the
999
+ // three-stages-later failure either way. (If f64 loads ever land they are floats, not a scalar
1000
+ // width here.) Adding 8 would also silently under-report in `l3/typing.ts` `exprIntWidth`, whose
1001
+ // soundness rests on memory not being a way a 64-bit value enters a rendered expression.
466
1002
  const SCALAR_WIDTHS = new Set([1, 2, 4]);
467
1003
  // Dot-form field bases (struct-array elements) carry the struct STRIDE as their width — any
468
1004
  // stride matching the element size is legal there (the tree-level struct cast governs the
@@ -526,10 +1062,10 @@ export function assertDerefsTyped(sfn: SFn): void {
526
1062
  if (bt) {
527
1063
  // type-check against the same dot-vs-arrow spelling the printer will use (shared rule)
528
1064
  const st = fieldSpellsDot(e) ? bt : bt.kind === 'ptr' ? bt.to : undefined;
529
- if (!st || st.kind !== 'struct') {
1065
+ if (!st || (st.kind !== 'struct' && st.kind !== 'union')) {
530
1066
  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}'`);
1067
+ } else if (memberOf(st, e.name) === undefined) {
1068
+ bad.push(`member access '${e.name}' not declared on '${typeToString(st)}'`);
533
1069
  }
534
1070
  }
535
1071
  }