@asmlift/core 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/src/backend/c.ts +1 -0
  3. package/src/backend/cfamily.ts +33 -12
  4. package/src/backend/cpp.ts +1 -0
  5. package/src/backend/lines.ts +10 -0
  6. package/src/backend/pascal.ts +17 -5
  7. package/src/cdecl/parse.ts +21 -0
  8. package/src/contracts.ts +73 -20
  9. package/src/frontend/arm-body.ts +155 -0
  10. package/src/frontend/asmdata.ts +2 -1
  11. package/src/frontend/call-plan.ts +197 -45
  12. package/src/frontend/callee-body.ts +370 -0
  13. package/src/frontend/device-pins.ts +18 -8
  14. package/src/frontend/frame-objects.ts +504 -61
  15. package/src/frontend/frontend.ts +20 -0
  16. package/src/frontend/jump-table.ts +35 -20
  17. package/src/frontend/mips.ts +226 -8
  18. package/src/frontend/ppc.ts +26 -2
  19. package/src/frontend/register-struct-call.ts +1 -86
  20. package/src/frontend/ssa.ts +263 -1
  21. package/src/frontend/stackargs.ts +95 -17
  22. package/src/frontend/thumb-entry.ts +340 -0
  23. package/src/frontend/thumb.ts +410 -61
  24. package/src/ir/alias.ts +29 -8
  25. package/src/ir/bits.ts +1081 -66
  26. package/src/ir/core.ts +154 -4
  27. package/src/ir/entry.ts +3 -5
  28. package/src/ir/opcodes.ts +28 -0
  29. package/src/ir/parse.ts +1 -0
  30. package/src/ir/simplify.ts +61 -10
  31. package/src/ir/verify.ts +16 -0
  32. package/src/l3/argbase.ts +2 -0
  33. package/src/l3/ast.ts +294 -2
  34. package/src/l3/basecse.ts +35 -10
  35. package/src/l3/caseendgoto.ts +223 -0
  36. package/src/l3/coalesce.ts +42 -14
  37. package/src/l3/constinit.ts +34 -6
  38. package/src/l3/dce.ts +52 -8
  39. package/src/l3/ptrcell.ts +2 -0
  40. package/src/l3/regspell.ts +2 -0
  41. package/src/l3/reindex.ts +13 -4
  42. package/src/l3/scopebase.ts +2 -0
  43. package/src/l3/tailmerge.ts +2 -0
  44. package/src/pipeline.ts +507 -23
  45. package/src/proto-context.ts +135 -1
  46. package/src/raise/const.ts +25 -0
  47. package/src/raise/declared-volatile.ts +1 -1
  48. package/src/raise/divpow2.ts +192 -0
  49. package/src/raise/offsetnames.ts +412 -10
  50. package/src/raise/pre-recovery.ts +8 -5
  51. package/src/raise/pre-structure.ts +24 -5
  52. package/src/raise/reroll.ts +4 -24
  53. package/src/raise/shortcircuit.ts +192 -23
  54. package/src/raise/unpre.ts +594 -0
  55. package/src/rank-variations.ts +97 -4
  56. package/src/rank.ts +258 -43
  57. package/src/structure/analysis.ts +56 -10
  58. package/src/structure/cross-jumped-tail.ts +216 -0
  59. package/src/structure/globalaccess.ts +46 -1
  60. package/src/structure/structure.ts +4221 -368
  61. package/src/structure/switch-recover.ts +345 -26
  62. package/src/structure/unfolded-branch-constant.ts +90 -0
  63. package/src/symbols.ts +52 -15
  64. package/src/target.ts +269 -8
  65. package/src/trace.ts +28 -14
  66. package/src/variation-definitions.ts +207 -6
  67. package/src/variation-gates.ts +2 -0
  68. package/src/variation-tokens.ts +63 -2
@@ -28,6 +28,7 @@ import {
28
28
  defOpMap,
29
29
  dominators,
30
30
  mergeClasses,
31
+ opShape,
31
32
  predecessors,
32
33
  successorsOf,
33
34
  } from '../ir/core';
@@ -1006,12 +1007,18 @@ function makeMemWriteBetween(deps: {
1006
1007
  if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(render.blk.ops, 0, render.idx)) {
1007
1008
  return true;
1008
1009
  }
1009
- for (const x of reachAvoiding(b, b)) {
1010
- if (x === render.blk && !reachAvoiding(render.blk, b).has(render.blk)) {
1011
- continue; // acyclic render block: head checked
1012
- }
1013
- if (x !== render.blk && !reachAvoiding(x, b).has(render.blk)) {
1014
- continue; // not on a def→render path
1010
+ // The between-blocks: every `x` the def's block reaches without re-crossing it from which the
1011
+ // render block is reached again in at least one step, by the same def-avoiding walk. The render
1012
+ // block itself counts only where it reaches itself so (its head is checked above); any other
1013
+ // block only where it lies on a def→render path.
1014
+ const forward = reachAvoiding(b, b);
1015
+ if (!forward.has(render.blk)) {
1016
+ return false; // every block on a def→render path is one the def's block reaches
1017
+ }
1018
+ const onPath = blocksReaching(forward, render.blk, b);
1019
+ for (const x of forward) {
1020
+ if (!successorsOf(x).some((s) => s !== b && onPath.has(s))) {
1021
+ continue; // not on a def→render path (or, for the render block, acyclic: head checked)
1015
1022
  }
1016
1023
  if (wDirty(x.ops, 0, x.ops.length)) {
1017
1024
  return true;
@@ -1021,6 +1028,49 @@ function makeMemWriteBetween(deps: {
1021
1028
  };
1022
1029
  }
1023
1030
 
1031
+ /** The blocks of `within` that reach `target` in zero or more steps without passing `avoid`,
1032
+ * `target` included: one backward walk over `within`'s own edges.
1033
+ *
1034
+ * `makeMemWriteBetween` asks, for each block `x` the def's block reaches, whether `target` is in
1035
+ * `reachAvoiding(x, avoid)`. That is a forward walk per `x`, quadratic in the function's size per
1036
+ * question, and the question is asked per op `analyze` names (`nameAccess`): Klonoa's
1037
+ * sub_0800D188 (1,783 blocks) spent most of its structuring in those walks. `target` is in
1038
+ * `reachAvoiding(x, avoid)` exactly where some successor of `x` other than `avoid` is in this set,
1039
+ * since every block on such a path after `x` is in `within` (`within` being everything the def's
1040
+ * block reaches without passing `avoid`, from which `x` was drawn). */
1041
+ function blocksReaching(within: ReadonlySet<Block>, target: Block, avoid: Block): Set<Block> {
1042
+ const into = new Map<Block, Block[]>();
1043
+ for (const y of within) {
1044
+ if (y === avoid) {
1045
+ continue;
1046
+ }
1047
+ for (const s of successorsOf(y)) {
1048
+ if (s !== avoid && within.has(s)) {
1049
+ const list = into.get(s);
1050
+ if (list === undefined) {
1051
+ into.set(s, [y]);
1052
+ } else {
1053
+ list.push(y);
1054
+ }
1055
+ }
1056
+ }
1057
+ }
1058
+ const out = new Set<Block>();
1059
+ if (target === avoid) {
1060
+ return out;
1061
+ }
1062
+ out.add(target);
1063
+ for (const work = [target]; work.length;) {
1064
+ for (const y of into.get(work.pop()!) ?? []) {
1065
+ if (!out.has(y)) {
1066
+ out.add(y);
1067
+ work.push(y);
1068
+ }
1069
+ }
1070
+ }
1071
+ return out;
1072
+ }
1073
+
1024
1074
  /** INTERDEPENDENT PARALLEL-COPY ARGS. A terminator's successor args are ONE parallel copy
1025
1075
  * (argAssigns), whose every read means the PRE-copy value. An arg whose def-tree reads a SIBLING
1026
1076
  * arg of the same edge cannot read it by name there — so the sibling's whole expression is
@@ -2284,10 +2334,6 @@ function isBarrier(rules: MaterializeRules, materialize: ReadonlySet<Op>, access
2284
2334
 
2285
2335
  const COMMUTATIVE_OPS: ReadonlySet<string> = new Set(['add', 'mul', 'and', 'or', 'xor']);
2286
2336
 
2287
- /** One op's identity apart from its operands: opcode, result count and attributes, keys sorted. */
2288
- const opShape = (op: Op): string =>
2289
- `${op.opcode}/${op.results.length}/${JSON.stringify(Object.entries(op.attrs).sort(([a], [b]) => a.localeCompare(b)))}`;
2290
-
2291
2337
  /** Do two values name the same address: one value, or two `gaddr`s of one symbol? */
2292
2338
  function sameAddress(defOf: Map<Value, Op>, a: Value, b: Value): boolean {
2293
2339
  if (a === b) {
@@ -0,0 +1,216 @@
1
+ // THE LAYOUT OF A CROSS-JUMPED RETURN TAIL: does the asm show that the compiler merged a return
2
+ // tail the source wrote into every arm, where the structurer took the join past early returns?
3
+ //
4
+ // A compiler that cross-jumps return tails (target.ts `crossJumpsReturnTails`, gcc 2.x jump.c)
5
+ // leaves the same CFG for `if (a) { x = 1; tail(); return; } ... tail();` and for `if (a) x = 1;
6
+ // ... tail();`: one copy of the tail, which every arm reaches. The two spellings differ in WHERE
7
+ // that copy sits and in WHICH arms became one block, because `do_cross_jump` keeps the copy of
8
+ // the LATER jumping arm and jumps the earlier one to a new label before it. That label stops
9
+ // further merging (`INSN_UID (JUMP_LABEL (insn)) < max_uid`). So the copies spelling leaves one of
10
+ // three marks in the object:
11
+ //
12
+ // (A) THE TAIL IS NOT LAST. The surviving copy ends in an unconditional jump (A1), and a block of
13
+ // the construct that never reaches the tail is laid out after it (A2). That is an arm that
14
+ // did not use the tail, left where the source wrote it. The join writes the tail after the
15
+ // whole construct, where it falls into what follows it. A bare return block does not count
16
+ // for A2: the epilogue an early `return` reaches is laid out last under either spelling.
17
+ // The copy can survive split over two blocks: an arm's own jump enters it part-way, so the
18
+ // join falls into the rest of the tail, and that rest ends in the jump. So A1 reads the end
19
+ // of the join's run of fall-throughs, not the join block alone.
20
+ // (B) IDENTICAL ARMS STAYED APART. Two arms that reach the tail and nothing else, each by an
21
+ // unconditional jump, have the same instructions. Under the join, each such arm jumps to the
22
+ // join's one original label, and jump2's search of the other jumps to that label merges
23
+ // identical bodies. Under the copies, the first cross-jump made a new label, and that search
24
+ // never ran. An arm that FALLS into the join is not one of them: it is tried only against
25
+ // the code before the label, which a call ending it defeats (flow.c's use-nop), so agbcc
26
+ // keeps a falling `g(2)` apart from a jumping `g(2)` under the join too
27
+ // (`probe-rettail-pd-join.c`).
28
+ // (C) A CALL CUT FROM ITS ARGUMENTS. The join starts with a call, and two predecessors that reach
29
+ // only the join each hold one same instruction after their own last call. `do_cross_jump`
30
+ // compares backward from each jump, one instruction at a time, and stops at the first pair
31
+ // that differs; arms that set the call's arguments in different orders, or where one arm
32
+ // finds an argument already in its register, differ just above the call. So the merge cuts
33
+ // the call from its argument setup, and leaves a copy of that setup in each arm. The join
34
+ // writes the setup once, after its label, and an instruction both arms hold in the same
35
+ // place would have been merged up into it.
36
+ //
37
+ // Bodies are compared as the frontend recorded them, the relocated symbol included
38
+ // (frontend/ssa.ts `recordAsmLayout`): in a relocatable MIPS object `gT = c` and `gW = c` are the
39
+ // same two instructions, and only the relocation names the global (`probe-rettail-pc-join.c`).
40
+ //
41
+ // A MARK IS EVIDENCE, NOT PROOF. jump2 cross-jumps any shared code, not only a return tail: two
42
+ // identical arms of a join-written `if`, or a call both arms end in, merge the same way, and leave
43
+ // the merged block ending in a jump with an early-return arm laid out after it (A). Both readings
44
+ // of such a site are verified readings of the asm, and on the hand probes neither matches. So a
45
+ // mark moves which reading is the DEFAULT, never what is lifted, and its worth is measured, not
46
+ // derived.
47
+ //
48
+ // MEASURED. Over the 620 real functions where the join re-spells the default (2026-11-05), each
49
+ // reading really compiled: the bound plus (A1 & A2) | B gives MATCH 44, against 35 for the copies
50
+ // everywhere and 40 for the size bound alone, with no match lost. A and B were chosen on those 620.
51
+ // The split tail of A1 and mark C were read off held-out sources instead: 160 generated functions
52
+ // written WITH the copies, early returns between the arms (2026-11-06). There the size bound with A
53
+ // and B alone loses 7 of the copies' matches on agbcc, 9 on gcc2.7.2kmc and 2 on gcc2.7.2; with the
54
+ // split tail and C it loses 2, 3 and 1, and gains as many as before (5, 5, 2). Over 300 paired
55
+ // functions, half written each way, the two lose no match; 3 join-written ones on each GCC 2.7.2
56
+ // lane move to the copies and score further. On the 620 they move 3 functions, all to the copies'
57
+ // own spelling: 2 closer, 1 further, MATCH still 44. What no layout read recovers: a tie the two
58
+ // spellings leave in the same layout, where only the register allocation differs, and a call merged
59
+ // across arms that pass it different arguments. The probe pairs in cross-jumped-tail.test.ts check
60
+ // each mark and each shape that is no mark.
61
+ //
62
+ // READ BY NO DEFAULT. Over held-out real functions (pokeemerald, 2026-11-06) the size bound with
63
+ // these marks still loses 9 of the copies' matches on agbcc, none of them marked; over the 160
64
+ // generated functions (2026-11-09) it still loses 3 on gcc2.7.2kmc and 1 on gcc2.7.2. So both
65
+ // compilers that cross-jump default to the copies outright (they leave out target.ts
66
+ // `joinPastReturnsByDefault`), and the probe pairs still pin each mark.
67
+ //
68
+ // Read only from the frontend's layout record (ir/core.ts `AsmLayout`). A block with no entry, such
69
+ // as one a raise pass made up, is no evidence either way. A function with no record (parsed IR)
70
+ // has no mark.
71
+ import type { AsmLayout, Block, Fn } from '../ir/core';
72
+ import type { JoinPastReturnsSite } from './structure';
73
+
74
+ const succsOf = (b: Block): Block[] => [...new Set(b.ops[b.ops.length - 1]?.successors.map((s) => s.block) ?? [])];
75
+
76
+ /** Every block reachable from `from`, `from` included, never entering `avoid`. */
77
+ function reach(from: Block, avoid?: Block): Set<Block> {
78
+ const seen = new Set<Block>();
79
+ const stack = [from];
80
+ while (stack.length) {
81
+ const x = stack.pop()!;
82
+ if (x === avoid || seen.has(x)) {
83
+ continue;
84
+ }
85
+ seen.add(x);
86
+ stack.push(...succsOf(x));
87
+ }
88
+ return seen;
89
+ }
90
+
91
+ const isBareReturn = (b: Block): boolean => b.ops.length === 1 && b.ops[0].opcode === 'ret';
92
+
93
+ /** Does `site` show either mark of a cross-jumped return tail in `fn`'s asm layout? */
94
+ export function crossJumpedTail(fn: Fn, site: JoinPastReturnsSite): boolean {
95
+ const layout: AsmLayout | undefined = fn.asmLayout;
96
+ const at = layout?.get(site.join);
97
+ if (layout === undefined || at === undefined) {
98
+ return false;
99
+ }
100
+ return (
101
+ (tailEndsInJump(layout, site.join) && armLaidOutAfter(fn, layout, site, at.index)) ||
102
+ identicalArms(fn, layout, site.join) ||
103
+ callCutFromArguments(fn, layout, site.join)
104
+ );
105
+ }
106
+
107
+ /** (A1): the tail from the join, followed through its fall-throughs, ends in an unconditional jump. */
108
+ function tailEndsInJump(layout: AsmLayout, join: Block): boolean {
109
+ const seen = new Set<Block>();
110
+ for (let b: Block = join; ;) {
111
+ const at = layout.get(b);
112
+ if (at === undefined || seen.has(b)) {
113
+ return false;
114
+ }
115
+ if (at.endsInJump) {
116
+ return true;
117
+ }
118
+ seen.add(b);
119
+ // a fall-through is the next block in the asm; anything else is no run of the tail
120
+ const next = succsOf(b);
121
+ if (next.length !== 1 || layout.get(next[0])?.index !== at.index + 1) {
122
+ return false;
123
+ }
124
+ b = next[0];
125
+ }
126
+ }
127
+
128
+ /** (C): the join's first instruction is a call, and two of its predecessors, each with the join as
129
+ * its only successor, hold a same instruction after their own last call.
130
+ *
131
+ * BLIND TO WHERE IN THE ARM THAT INSTRUCTION SITS, and to what it computes, on purpose. The text
132
+ * compared is the instruction as printed, so a shared statement is not needed: on MIPS the address
133
+ * half of a relocated global (`lui at,0x0 <gT>`) and a store from a register that holds a different
134
+ * value in each arm (`sw v0,0(at) <gT>`) read the same. So any two call-free arms that write one
135
+ * global fire it when the join opens with a call (`if (a == 0) { gV = b; gT = 1; } else if (a ==
136
+ * 1) { gV = c; gT = 2; } ... k(a);`, 26 lines off as copies, 0 as the join, on both MIPS GCC lanes),
137
+ * and it publishes the copies there, which do not match. Counting only
138
+ * an instruction that sets up a register the join reads (an argument of its call, or a saved
139
+ * register read past it) clears that case, and loses two matches the copies have: on
140
+ * `probe-rettail-pc-copies` the arms keep `gV = a` above the cut call, a store that sets up no
141
+ * register, and the join then scores 23 lines off on gcc2.7.2 and 30 on gcc2.7.2kmc, really
142
+ * compiled (2026-11-06). A mark that fires wrongly publishes the copies, the default before the
143
+ * join, and one that misses can lose a match the copies had; so C stays wide. */
144
+ function callCutFromArguments(fn: Fn, layout: AsmLayout, join: Block): boolean {
145
+ if (layout.get(join)?.calls[0] !== 0) {
146
+ return false;
147
+ }
148
+ const seen = new Set<string>();
149
+ for (const p of predecessors(fn).get(join) ?? []) {
150
+ const at = layout.get(p);
151
+ if (at === undefined || succsOf(p).length !== 1) {
152
+ continue;
153
+ }
154
+ const setup = new Set(at.body.slice((at.calls[at.calls.length - 1] ?? -1) + 1));
155
+ if ([...setup].some((ins) => seen.has(ins))) {
156
+ return true;
157
+ }
158
+ for (const ins of setup) {
159
+ seen.add(ins);
160
+ }
161
+ }
162
+ return false;
163
+ }
164
+
165
+ /** (A2): a block of the construct, reached from its branch short of the join, that never reaches
166
+ * the join and is laid out after it. A bare return does not count. */
167
+ function armLaidOutAfter(fn: Fn, layout: AsmLayout, site: JoinPastReturnsSite, joinIndex: number): boolean {
168
+ const fromJoin = reach(site.join);
169
+ const reachesJoin = new Set<Block>([site.join]);
170
+ const preds = predecessors(fn);
171
+ const stack = [site.join];
172
+ while (stack.length) {
173
+ for (const p of preds.get(stack.pop()!) ?? []) {
174
+ if (!reachesJoin.has(p)) {
175
+ reachesJoin.add(p);
176
+ stack.push(p);
177
+ }
178
+ }
179
+ }
180
+ for (const x of reach(site.branch, site.join)) {
181
+ const xi = layout.get(x)?.index;
182
+ if (xi !== undefined && xi > joinIndex && !fromJoin.has(x) && !reachesJoin.has(x) && !isBareReturn(x)) {
183
+ return true;
184
+ }
185
+ }
186
+ return false;
187
+ }
188
+
189
+ /** (B): two predecessors of the join, each with the join as its only successor and each ending in an
190
+ * unconditional jump to it, whose asm bodies are the same non-empty instructions. */
191
+ function identicalArms(fn: Fn, layout: AsmLayout, join: Block): boolean {
192
+ const seen = new Set<string>();
193
+ for (const p of predecessors(fn).get(join) ?? []) {
194
+ const at = layout.get(p);
195
+ if (at === undefined || !at.endsInJump || at.body.length === 0 || succsOf(p).length !== 1) {
196
+ continue;
197
+ }
198
+ const body = at.body;
199
+ const key = body.join('\n');
200
+ if (seen.has(key)) {
201
+ return true;
202
+ }
203
+ seen.add(key);
204
+ }
205
+ return false;
206
+ }
207
+
208
+ function predecessors(fn: Fn): Map<Block, Block[]> {
209
+ const preds = new Map<Block, Block[]>(fn.blocks.map((b) => [b, []]));
210
+ for (const b of fn.blocks) {
211
+ for (const s of succsOf(b)) {
212
+ preds.get(s)?.push(b);
213
+ }
214
+ }
215
+ return preds;
216
+ }
@@ -75,6 +75,42 @@ export function globalByteBase(e: Expr): { name: string; residual: Expr } | null
75
75
  return null;
76
76
  }
77
77
 
78
+ /** {@link globalOf} with the address any number of `+` down — FOR THE BARE-ARRAY SPELLING ONLY, the
79
+ * one consumer whose declaration says what the residual indexes. An interior address the lift
80
+ * rewrote as the object's plus a constant before the index was added (IDO's `addiu t, sp, 0x58`
81
+ * for the second of two copied arrays reaches the lift as `(u32)sp56 + 32 + v8`) has its object
82
+ * one `+` further down than globalByteBase looks. The sum's terms are flattened, exactly one of
83
+ * them an address, and the rest are the residual: its runtime terms in order and its constants
84
+ * summed into one trailing term (`v8 + 32`). Not for the cast forms, which keep the sum as written
85
+ * (`*(s8 *)((u32)sp0 + 3 + a0)`). `off` is the access's own displacement, summed into the same
86
+ * trailing constant. */
87
+ export function nestedGlobalOf(e: Expr, width: number, off: number): { name: string; idx: Expr } | null {
88
+ const terms: Expr[] = [];
89
+ const flatten = (x: Expr): void => {
90
+ if (x.k === 'bin' && x.op === '+') {
91
+ flatten(x.l);
92
+ flatten(x.r);
93
+ } else {
94
+ terms.push(x);
95
+ }
96
+ };
97
+ flatten(e);
98
+ const at = terms.filter((x) => addrIn(x) !== null);
99
+ if (at.length !== 1) {
100
+ return null;
101
+ }
102
+ const rest = terms.filter((x) => x !== at[0]);
103
+ const bias = rest.reduce((n, x) => (x.k === 'const' ? n + x.value : n), off);
104
+ const runtime = rest
105
+ .filter((x) => x.k !== 'const')
106
+ .reduce<Expr | null>((l, r) => (l === null ? r : { k: 'bin', op: '+', l, r }), null);
107
+ if (bias % width !== 0) {
108
+ return null;
109
+ }
110
+ const idx = runtime === null ? { k: 'const' as const, value: 0 } : elementIndex(runtime, width);
111
+ return idx === null ? null : { name: addrIn(at[0])!.name, idx: addOffset(idx, bias / width) };
112
+ }
113
+
78
114
  // THE one gate on the BARE-NAME array-global spelling (`gSym[i]` rather than `((T *)&gSym)[i]`),
79
115
  // shared by the constant-offset and variable-index access paths so the two cannot disagree.
80
116
  // Returns the `index` node's `lead` fragment when the bare form is spellable, or null to fall
@@ -93,10 +129,19 @@ export function globalByteBase(e: Expr): { name: string; residual: Expr } | null
93
129
  // inner extent) gets no bare form at all; `((T *)&gSym)[i]` is byte-identical and valid under ANY
94
130
  // declaration, which is why it is the safe fallback. See symbols.ts arrayInnerExtents for why an
95
131
  // ABSENT rank is read as 1 rather than as unknown.
96
- export function bareArrayLead(si: SymbolInfo, width: number, signed: boolean): { lead?: Expr[] } | null {
132
+ //
133
+ // …and a CONSTANT subscript must name an element of the declaration, where it states its extent: a
134
+ // subscript past a rank-1 array's end (or before its start) is not an element of it, so it keeps
135
+ // the cast form, which states only the address. A runtime subscript is the access's own business —
136
+ // for a frame part, the audit that split it bounded the reach (frame-objects.ts `provenParts`).
137
+ export function bareArrayLead(si: SymbolInfo, width: number, signed: boolean, idx?: Expr): { lead?: Expr[] } | null {
97
138
  if (!bareArrayElement(si, width, signed)) {
98
139
  return null;
99
140
  }
141
+ const extent = si.dims?.length === 1 ? si.dims[0] : undefined;
142
+ if (idx?.k === 'const' && (idx.value < 0 || (typeof extent === 'number' && idx.value >= extent))) {
143
+ return null;
144
+ }
100
145
  const inner = arrayInnerExtents(si);
101
146
  return inner === null ? null : inner.length === 0 ? {} : { lead: inner.map((): Expr => ({ k: 'const', value: 0 })) };
102
147
  }