diff --git a/packages/programs-react/CHANGELOG.md b/packages/programs-react/CHANGELOG.md index 2124c451f3..15adee474b 100644 --- a/packages/programs-react/CHANGELOG.md +++ b/packages/programs-react/CHANGELOG.md @@ -7,6 +7,20 @@ 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. 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), 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]). + ## 0.1.0-preview.1 — 2026-10-03 Updated `@ethdebug/format` to `0.1.0-draft.1` and `@ethdebug/pointers` to @@ -38,3 +52,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..3d96301d57 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,158 @@ 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 }]); + }); + + 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("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)); + 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", () => { 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..9288ff884f 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -60,45 +60,132 @@ export function findInstructionAtPc( return program.instructions?.find((instr) => instr.offset === pc); } +/** + * A variable entry, as a `variables` context lists it. + */ +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. * - * 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. 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), 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. */ export function extractVariablesFromInstruction( instruction: Program.Instruction, -): Array<{ identifier?: string; type?: unknown; pointer?: unknown }> { +): VariableEntry[] { if (!instruction.context) { return []; } - return extractVariablesFromContext(instruction.context); + 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 joined.map(({ entry }) => entry); } -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; +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) + 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 + 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), + ); + 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 }); + } } - // Gather context (combines multiple contexts) - if ("gather" in context && Array.isArray(context.gather)) { - return context.gather.flatMap(extractVariablesFromContext); - } + return [...own, ...gathered, ...picked]; +} - // 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 vars; - } - } - } +/** 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 + ); +} - return []; +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], + ), + ) + ); } /**