From e3381a3887964014bf1e08682e8c439abc6cca8e Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 02:50:28 -0400 Subject: [PATCH 1/3] programs-react: read variables across a gather All of a context's keys apply, and so do all of a `gather`'s contexts, so the variables at an instruction are every `variables` list in its context: its own and each gathered context's. The extractor stopped at the context's own list, so at bugc's inlined code (the callee's locals beside a gather that held the storage variables) the storage variables disappeared. Entries for one variable (the same identifier and declaration, as in the gather schema's example) now compose into one. --- packages/programs-react/CHANGELOG.md | 10 ++++ .../src/utils/mockTrace.test.ts | 47 +++++++++++++++++++ .../programs-react/src/utils/mockTrace.ts | 40 +++++++++++----- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/packages/programs-react/CHANGELOG.md b/packages/programs-react/CHANGELOG.md index 2124c451f3..fde401a815 100644 --- a/packages/programs-react/CHANGELOG.md +++ b/packages/programs-react/CHANGELOG.md @@ -7,6 +7,15 @@ root [`CHANGELOG.md`](../../CHANGELOG.md). ## Unreleased +### Fixed + +- `extractVariablesFromInstruction` now reads every `variables` list in a + context: its own and those of each context in its `gather`. Before, a + context with its own `variables` hid those in its `gather`, so at + bugc's inlined code the storage variables disappeared. Entries for one + variable (the same identifier and declaration) compose into one + ([#342]). + ## 0.1.0-preview.1 — 2026-10-03 Updated `@ethdebug/format` to `0.1.0-draft.1` and `@ethdebug/pointers` to @@ -38,3 +47,4 @@ First publication. [#298]: https://github.com/ethdebug/format/pull/298 [#299]: https://github.com/ethdebug/format/pull/299 [#300]: https://github.com/ethdebug/format/pull/300 +[#342]: https://github.com/ethdebug/format/pull/342 diff --git a/packages/programs-react/src/utils/mockTrace.test.ts b/packages/programs-react/src/utils/mockTrace.test.ts index 1e5517b781..4eadd88999 100644 --- a/packages/programs-react/src/utils/mockTrace.test.ts +++ b/packages/programs-react/src/utils/mockTrace.test.ts @@ -17,6 +17,7 @@ import { extractTransformFromInstruction, extractCallInfoFromInstruction, extractCallEvents, + extractVariablesFromInstruction, buildCallStack, buildPcToInstructionMap, type TraceStep, @@ -32,6 +33,52 @@ function instr(offset: number, context: unknown): Program.Instruction { } as unknown as Program.Instruction; } +describe("extractVariablesFromInstruction", () => { + const total = { identifier: "total", pointer: { location: "storage" } }; + const x = { identifier: "x", pointer: { location: "memory" } }; + const names = (context: unknown) => + extractVariablesFromInstruction(instr(0, context)).map((v) => v.identifier); + + it("reads a flat variables list", () => { + expect(names({ variables: [total, x] })).toEqual(["total", "x"]); + }); + + it("reads variables from every gathered context", () => { + expect( + names({ + gather: [ + { code: {}, variables: [total] }, + { code: {}, variables: [x] }, + ], + }), + ).toEqual(["total", "x"]); + }); + + it("reads variables beside a gather and within it", () => { + // Inlined code: the callee's local beside a gather of the call + // site's and the callee's source ranges; all of them apply + expect( + names({ + variables: [x], + gather: [{ code: {} }, { code: {}, variables: [total] }], + }), + ).toEqual(["x", "total"]); + }); + + it("composes two entries for one variable", () => { + const declaration = { source: { id: 0 }, range: { offset: 1 } }; + const typed = { identifier: "x", declaration, type: { kind: "uint" } }; + const located = { identifier: "x", declaration, pointer: x.pointer }; + expect( + extractVariablesFromInstruction( + instr(0, { + gather: [{ variables: [typed] }, { variables: [located] }], + }), + ), + ).toEqual([{ ...typed, ...located }]); + }); +}); + describe("extractTransformFromInstruction", () => { it("returns identifiers from a direct transform context", () => { const i = instr(0, { transform: ["tailcall"] }); diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 670f212bb9..ecfc0e436b 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -60,32 +60,48 @@ export function findInstructionAtPc( return program.instructions?.find((instr) => instr.offset === pc); } +/** + * A variable entry, as a `variables` context lists it. + */ +type VariableEntry = { identifier?: string; type?: unknown; pointer?: unknown }; + /** * Extract variables that are in scope at a given instruction. * - * This walks the context and extracts variables from Variables contexts. + * All of a context's keys apply, and so do all of a `gather`'s + * contexts, so this reads every `variables` list: the context's own + * and each gathered context's. Entries for one variable (the same + * identifier and declaration) compose into one. */ export function extractVariablesFromInstruction( instruction: Program.Instruction, -): Array<{ identifier?: string; type?: unknown; pointer?: unknown }> { +): VariableEntry[] { if (!instruction.context) { return []; } - return extractVariablesFromContext(instruction.context); + const byKey = new Map(); + for (const entry of extractVariablesFromContext(instruction.context)) { + const { identifier, declaration } = entry as { + declaration?: unknown; + } & VariableEntry; + const key = JSON.stringify([identifier, declaration]); + byKey.set(key, { ...byKey.get(key), ...entry }); + } + return [...byKey.values()]; } function extractVariablesFromContext( context: Program.Context, -): Array<{ identifier?: string; type?: unknown; pointer?: unknown }> { - // Variables context - if ("variables" in context && Array.isArray(context.variables)) { - return context.variables; - } +): VariableEntry[] { + const own = + "variables" in context && Array.isArray(context.variables) + ? (context.variables as VariableEntry[]) + : []; - // Gather context (combines multiple contexts) + // Gather context (all of its contexts apply) if ("gather" in context && Array.isArray(context.gather)) { - return context.gather.flatMap(extractVariablesFromContext); + return [...own, ...context.gather.flatMap(extractVariablesFromContext)]; } // Pick context (picks from multiple contexts - take first with variables) @@ -93,12 +109,12 @@ function extractVariablesFromContext( for (const subContext of context.pick) { const vars = extractVariablesFromContext(subContext); if (vars.length > 0) { - return vars; + return [...own, ...vars]; } } } - return []; + return own; } /** From 1cafceea1866f19d715bb0d8418720480b3a6f12 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 03:33:32 -0400 Subject: [PATCH 2/3] programs-react: read a pick as only what its contexts agree on Only one of a `pick`'s contexts holds, so of a pick the extractor lists only the variables that all of its contexts list alike: the same identifier, declaration and pointer. Entries compose into one only for the same identifier and declaration (compared by source and range, whatever the order of keys) in the same frame; an entry without a declaration stays apart, as one did before. --- packages/programs-react/CHANGELOG.md | 8 +- .../src/utils/mockTrace.test.ts | 91 +++++++++++++ .../programs-react/src/utils/mockTrace.ts | 126 +++++++++++++----- 3 files changed, 193 insertions(+), 32 deletions(-) diff --git a/packages/programs-react/CHANGELOG.md b/packages/programs-react/CHANGELOG.md index fde401a815..3e09d9f0cc 100644 --- a/packages/programs-react/CHANGELOG.md +++ b/packages/programs-react/CHANGELOG.md @@ -12,8 +12,12 @@ root [`CHANGELOG.md`](../../CHANGELOG.md). - `extractVariablesFromInstruction` now reads every `variables` list in a context: its own and those of each context in its `gather`. Before, a context with its own `variables` hid those in its `gather`, so at - bugc's inlined code the storage variables disappeared. Entries for one - variable (the same identifier and declaration) compose into one + bugc's inlined code the storage variables disappeared. Of a `pick`, + only one of whose contexts holds, it now lists only the variables that + all of its contexts list alike (the same identifier, declaration and + pointer); before, it took the first context with variables. Entries + for one variable (the same identifier and declaration, in the same + `frame`) compose into one; an entry without a declaration stays apart ([#342]). ## 0.1.0-preview.1 — 2026-10-03 diff --git a/packages/programs-react/src/utils/mockTrace.test.ts b/packages/programs-react/src/utils/mockTrace.test.ts index 4eadd88999..819ea315a2 100644 --- a/packages/programs-react/src/utils/mockTrace.test.ts +++ b/packages/programs-react/src/utils/mockTrace.test.ts @@ -77,6 +77,97 @@ describe("extractVariablesFromInstruction", () => { ), ).toEqual([{ ...typed, ...located }]); }); + + const declared = (identifier: string, offset: number, pointer?: unknown) => ({ + identifier, + declaration: { source: { id: 0 }, range: { offset, length: 1 } }, + ...(pointer ? { pointer } : {}), + }); + const memory = (offset: number) => ({ location: "memory", offset }); + + it("lists only what every context of a pick agrees on", () => { + const a = declared("a", 1, memory(0)); + const b = declared("b", 2, memory(32)); + expect( + extractVariablesFromInstruction( + instr(0, { pick: [{ variables: [a, b] }, { variables: [b] }] }), + ), + ).toEqual([b]); + }); + + it("lists nothing of a pick whose contexts disagree on a pointer", () => { + expect( + names({ + pick: [ + { variables: [declared("a", 1, memory(0))] }, + { variables: [declared("a", 1, memory(32))] }, + ], + }), + ).toEqual([]); + }); + + it("reads a pick within a gather, and gathers within a pick", () => { + const a = declared("a", 1, memory(0)); + const b = declared("b", 2, memory(32)); + const c = declared("c", 3, memory(64)); + expect( + names({ + gather: [ + { variables: [c] }, + { + pick: [ + { gather: [{ variables: [a] }, { variables: [b] }] }, + { variables: [b] }, + ], + }, + ], + }), + ).toEqual(["c", "b"]); + }); + + it("keeps entries without a declaration apart", () => { + expect( + extractVariablesFromInstruction( + instr(0, { + gather: [ + { variables: [{ identifier: "x", type: { kind: "uint" } }] }, + { variables: [{ identifier: "x", pointer: x.pointer }] }, + ], + }), + ), + ).toHaveLength(2); + }); + + it("keeps entries from different frames apart", () => { + const typed = { ...declared("x", 1), type: { kind: "uint" } }; + const located = declared("x", 1, memory(0)); + expect( + extractVariablesFromInstruction( + instr(0, { + gather: [ + { frame: "source", variables: [typed] }, + { frame: "ir", variables: [located] }, + ], + }), + ), + ).toEqual([typed, located]); + }); + + it("matches declarations whatever the order of their keys", () => { + const typed = { ...declared("x", 1), type: { kind: "uint" } }; + const located = { + identifier: "x", + declaration: { range: { length: 1, offset: 1 }, source: { id: 0 } }, + pointer: memory(0), + }; + expect( + extractVariablesFromInstruction( + instr(0, { + gather: [{ variables: [typed] }, { variables: [located] }], + }), + ), + ).toHaveLength(1); + }); }); describe("extractTransformFromInstruction", () => { diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index ecfc0e436b..0438da1e88 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -63,15 +63,30 @@ export function findInstructionAtPc( /** * A variable entry, as a `variables` context lists it. */ -type VariableEntry = { identifier?: string; type?: unknown; pointer?: unknown }; +type VariableEntry = { + identifier?: string; + declaration?: unknown; + type?: unknown; + pointer?: unknown; +}; + +/** A variable entry, with the `frame` of the context that lists it */ +interface Listed { + entry: VariableEntry; + frame?: string; +} /** * Extract variables that are in scope at a given instruction. * * All of a context's keys apply, and so do all of a `gather`'s * contexts, so this reads every `variables` list: the context's own - * and each gathered context's. Entries for one variable (the same - * identifier and declaration) compose into one. + * and each gathered context's. Only one of a `pick`'s contexts holds, + * so of a pick it reads only the variables that every one of its + * contexts lists alike (the same identifier, declaration and + * pointer). Entries for one variable (the same identifier and + * declaration, in the same frame) compose into one; an entry without + * a declaration stays apart, as does an entry from another frame. */ export function extractVariablesFromInstruction( instruction: Program.Instruction, @@ -80,41 +95,92 @@ export function extractVariablesFromInstruction( return []; } - const byKey = new Map(); - for (const entry of extractVariablesFromContext(instruction.context)) { - const { identifier, declaration } = entry as { - declaration?: unknown; - } & VariableEntry; - const key = JSON.stringify([identifier, declaration]); - byKey.set(key, { ...byKey.get(key), ...entry }); + const joined: Listed[] = []; + for (const listed of listedIn(instruction.context)) { + const same = joined.find((other) => sameVariable(other, listed)); + if (same) { + same.entry = { ...same.entry, ...listed.entry }; + } else { + joined.push({ ...listed }); + } } - return [...byKey.values()]; + return joined.map(({ entry }) => entry); } -function extractVariablesFromContext( - context: Program.Context, -): VariableEntry[] { - const own = - "variables" in context && Array.isArray(context.variables) - ? (context.variables as VariableEntry[]) - : []; +function listedIn(context: Program.Context, frame?: string): Listed[] { + const ctx = context as unknown as Record; + const here = typeof ctx.frame === "string" ? ctx.frame : frame; + + const own = Array.isArray(ctx.variables) + ? (ctx.variables as VariableEntry[]).map((entry) => ({ + entry, + frame: here, + })) + : []; // Gather context (all of its contexts apply) - if ("gather" in context && Array.isArray(context.gather)) { - return [...own, ...context.gather.flatMap(extractVariablesFromContext)]; + const gathered = Array.isArray(ctx.gather) + ? (ctx.gather as Program.Context[]).flatMap((c) => listedIn(c, here)) + : []; + + // Pick context (one of its contexts applies): only what all agree on + let picked: Listed[] = []; + if (Array.isArray(ctx.pick) && ctx.pick.length > 0) { + const [first, ...others] = (ctx.pick as Program.Context[]).map((c) => + listedIn(c, here), + ); + picked = first.filter((listed) => + others.every((branch) => + branch.some( + (other) => + sameVariable(listed, other) && + same(listed.entry.pointer, other.entry.pointer), + ), + ), + ); } - // Pick context (picks from multiple contexts - take first with variables) - if ("pick" in context && Array.isArray(context.pick)) { - for (const subContext of context.pick) { - const vars = extractVariablesFromContext(subContext); - if (vars.length > 0) { - return [...own, ...vars]; - } - } - } + return [...own, ...gathered, ...picked]; +} - return own; +/** Whether two entries are for one variable: the same identifier and + * declaration (both given), in the same frame */ +function sameVariable(a: Listed, b: Listed): boolean { + const x = a.entry.declaration as Declaration | undefined; + const y = b.entry.declaration as Declaration | undefined; + return ( + a.frame === b.frame && + a.entry.identifier === b.entry.identifier && + x !== undefined && + y !== undefined && + x.source?.id === y.source?.id && + x.range?.offset === y.range?.offset && + x.range?.length === y.range?.length + ); +} + +type Declaration = { + source?: { id?: unknown }; + range?: { offset?: unknown; length?: unknown }; +}; + +/** Structural equality, whatever the order of keys */ +function same(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== "object" || typeof b !== "object" || !a || !b) { + return false; + } + if (Array.isArray(a) !== Array.isArray(b)) return false; + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every((key) => + same( + (a as Record)[key], + (b as Record)[key], + ), + ) + ); } /** From 7bd1ddb67103c59db386c53de88246bffbbcf98f Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 08:30:20 -0400 Subject: [PATCH 3/3] programs-react: drop a type the contexts of a pick differ on Of a pick, an entry the contexts agree on keeps its type only where they all give the same type: otherwise the type holds in only one of them. --- packages/programs-react/CHANGELOG.md | 3 ++- .../src/utils/mockTrace.test.ts | 15 +++++++++++++++ .../programs-react/src/utils/mockTrace.ts | 19 ++++++++++++------- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/programs-react/CHANGELOG.md b/packages/programs-react/CHANGELOG.md index 3e09d9f0cc..15adee474b 100644 --- a/packages/programs-react/CHANGELOG.md +++ b/packages/programs-react/CHANGELOG.md @@ -15,7 +15,8 @@ root [`CHANGELOG.md`](../../CHANGELOG.md). bugc's inlined code the storage variables disappeared. Of a `pick`, only one of whose contexts holds, it now lists only the variables that all of its contexts list alike (the same identifier, declaration and - pointer); before, it took the first context with variables. Entries + pointer), without a type they differ on; before, it took the first + context with variables. Entries for one variable (the same identifier and declaration, in the same `frame`) compose into one; an entry without a declaration stays apart ([#342]). diff --git a/packages/programs-react/src/utils/mockTrace.test.ts b/packages/programs-react/src/utils/mockTrace.test.ts index 819ea315a2..3d96301d57 100644 --- a/packages/programs-react/src/utils/mockTrace.test.ts +++ b/packages/programs-react/src/utils/mockTrace.test.ts @@ -106,6 +106,21 @@ describe("extractVariablesFromInstruction", () => { ).toEqual([]); }); + it("drops a type the contexts of a pick differ on", () => { + const pointer = { location: "stack", slot: 0 }; + const x = (type: unknown) => ({ ...declared("x", 1, pointer), type }); + expect( + extractVariablesFromInstruction( + instr(0, { + pick: [ + { variables: [x({ kind: "uint", bits: 8 })] }, + { variables: [x({ kind: "int", bits: 8 })] }, + ], + }), + ), + ).toEqual([declared("x", 1, pointer)]); + }); + it("reads a pick within a gather, and gathers within a pick", () => { const a = declared("a", 1, memory(0)); const b = declared("b", 2, memory(32)); diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 0438da1e88..9288ff884f 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -84,7 +84,7 @@ interface Listed { * and each gathered context's. Only one of a `pick`'s contexts holds, * so of a pick it reads only the variables that every one of its * contexts lists alike (the same identifier, declaration and - * pointer). Entries for one variable (the same identifier and + * pointer), without a type they differ on. Entries for one variable (the same identifier and * declaration, in the same frame) compose into one; an entry without * a declaration stays apart, as does an entry from another frame. */ @@ -124,20 +124,25 @@ function listedIn(context: Program.Context, frame?: string): Listed[] { : []; // Pick context (one of its contexts applies): only what all agree on - let picked: Listed[] = []; + const picked: Listed[] = []; if (Array.isArray(ctx.pick) && ctx.pick.length > 0) { const [first, ...others] = (ctx.pick as Program.Context[]).map((c) => listedIn(c, here), ); - picked = first.filter((listed) => - others.every((branch) => - branch.some( + for (const listed of first) { + const matches = others.map((branch) => + branch.find( (other) => sameVariable(listed, other) && same(listed.entry.pointer, other.entry.pointer), ), - ), - ); + ); + if (matches.some((match) => match === undefined)) continue; + // A type the contexts differ on holds in only some of them + const { type, ...rest } = listed.entry; + const typed = matches.every((match) => same(type, match!.entry.type)); + picked.push({ ...listed, entry: typed ? listed.entry : rest }); + } } return [...own, ...gathered, ...picked];