From 6f9fd645a2041e242bb94f75bc38136e15ac1759 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 15 Oct 2026 20:09:24 -0400 Subject: [PATCH 1/2] bugc: give a called function's entry its source range A called function's entry JUMPDEST has the function's declaration, the one its invoke names. The end of a body that has no return (the frame teardown and exit JUMP, or the STOP of code) has the function's range, and a block's JUMPDEST has the range of the instruction after it. Code generation is unchanged. --- packages/bugc/CHANGELOG.md | 11 ++ packages/bugc/src/evmgen/code-ranges.test.ts | 109 ++++++++++++++++++ .../debug/local-variables.soundness.test.ts | 19 ++- .../generation/control-flow/terminator.ts | 51 ++++++-- .../bugc/src/evmgen/generation/function.ts | 81 ++++++++++++- 5 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 packages/bugc/src/evmgen/code-ranges.test.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 7833d92624..2f9b3a9360 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -131,6 +131,16 @@ support. Changes to the specification itself are tracked in the root way. Before, these compiled to a copy of the slot's word, or wrote the memory address to the slot. An array literal assigned to a storage array variable (`items = [1, 2];`) still compiles ([#377]). +- Each instruction the compiler adds now has a source range. A called + function's entry JUMPDEST has the function's declaration, as its + `invoke` names. The end of a function body that has no `return` + (the frame teardown and its exit JUMP, or the `STOP` of `code`) has + the function's range. The instructions that return a value from + `code` have the `return`'s range. A block's JUMPDEST has the range + of the instruction after it. Before, these had none, so a debugger + stopped there showed no source. Two exceptions remain: the end of the + create program, which copies and returns the runtime code, and a + JUMPDEST just before an inlined call ([#400]). ## 0.1.0-preview.2 — 2026-10-07 @@ -596,3 +606,4 @@ First publication. [#395]: https://github.com/ethdebug/format/pull/395 [#397]: https://github.com/ethdebug/format/pull/397 [#398]: https://github.com/ethdebug/format/pull/398 +[#400]: https://github.com/ethdebug/format/pull/400 diff --git a/packages/bugc/src/evmgen/code-ranges.test.ts b/packages/bugc/src/evmgen/code-ranges.test.ts new file mode 100644 index 0000000000..9026ab717f --- /dev/null +++ b/packages/bugc/src/evmgen/code-ranges.test.ts @@ -0,0 +1,109 @@ +/** + * Every instruction has a source range, so a debugger stopped at any + * of them has something to show: the instructions the compiler adds + * (a called function's entry, a block's JUMPDEST, a return the source + * does not write) get the range of what they belong to. + */ +import { describe, it, expect } from "vitest"; +import type * as Format from "@ethdebug/format"; +import { Program } from "@ethdebug/format"; +import { compile } from "#compiler"; +import { exampleSources } from "../examples/index.js"; + +const { Context } = Program; + +/** Whether a context gives a source range, in a gather or a pick too */ +function hasCode(context: Format.Program.Context | undefined): boolean { + if (!context) return false; + if (Context.isCode(context)) return true; + if (Context.isGather(context)) return context.gather.some(hasCode); + if (Context.isPick(context)) return context.pick.some(hasCode); + return false; +} + +/** Whether a context starts a call, in a gather too */ +function hasInvoke(context: Format.Program.Context | undefined): boolean { + if (!context) return false; + if (Context.isInvoke(context)) return true; + return Context.isGather(context) && context.gather.some(hasInvoke); +} + +/** Why an instruction has no source range, if it may have none */ +function exception( + instructions: Format.Program.Instruction[], + index: number, +): string | undefined { + const { operation, context } = instructions[index]; + const remark = context && Context.isRemark(context) ? context.remark : ""; + // The source has no `code` block: the runtime is the compiler's own + if (remark === "initialize free memory pointer") return "no code block"; + // The create program's end: it returns the runtime code + if (remark.startsWith("deployment:")) return "deployment"; + // A block that starts with an inlined call: the JUMPDEST is at the + // call site, which the call's first instruction does not always give + if ( + operation?.mnemonic === "JUMPDEST" && + hasInvoke(instructions[index + 1]?.context) + ) { + return "before an inlined call"; + } + return undefined; +} + +describe("source ranges", () => { + it("are on every instruction of every example", async () => { + const missing: string[] = []; + for (const [name, source] of Object.entries(exampleSources)) { + for (const level of [0, 1, 2, 3] as const) { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) continue; + const { runtimeProgram, createProgram } = result.value.bytecode; + for (const program of [runtimeProgram, createProgram]) { + const { instructions } = program ?? { instructions: [] }; + instructions.forEach(({ offset, context }, index) => { + if (hasCode(context) || exception(instructions, index)) return; + missing.push(`${name} at O${level}, offset ${offset}`); + }); + } + } + } + expect(missing).toEqual([]); + }, 120_000); + + it("give a called function's entry its declaration", async () => { + const source = `name Entry; +define { + function f(n: uint256) -> uint256 { + if (n > 0) { return n; } + return f(n + 1); + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(0); }`; + for (const level of [0, 1, 2, 3] as const) { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) throw new Error("compilation failed"); + const entries = result.value.bytecode.runtimeProgram.instructions.filter( + ({ operation, context }) => + operation?.mnemonic === "JUMPDEST" && + context && + Context.isInvoke(context), + ); + expect(entries.length).toBeGreaterThan(0); + for (const { context } of entries) { + const { code, invoke } = context as Format.Program.Context.Code & + Format.Program.Context.Invoke; + expect(code).toEqual((invoke as { declaration?: unknown }).declaration); + } + } + }); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts index 1f1f9c9d83..ab514b279f 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -43,6 +43,17 @@ function codeOffset(context: unknown): number | undefined { ?.code?.range?.offset; } +/** The range of an instruction's `code`, if it has one */ +function codeRange( + context: unknown, +): { offset: number; length: number } | undefined { + return ( + context as + | { code?: { range?: { offset: number; length: number } } } + | undefined + )?.code?.range; +} + /** A struct local, a call, then the local again */ const acrossCall = `name AcrossCall; define { @@ -184,9 +195,11 @@ code { r = f(3); }`; const declared = source.indexOf("let x"); const rows = await listed(source); for (const { instruction, names } of rows) { - const offset = codeOffset(instruction.context); - if (offset !== undefined && offset < declared) { - expect(names, `x listed at offset ${offset}`).not.toContain("x"); + // code whose whole range is before the declaration (a range + // that holds it, as the function's, may list `x`) + const range = codeRange(instruction.context); + if (range && range.offset + range.length <= declared) { + expect(names, `x listed at offset ${range.offset}`).not.toContain("x"); } } expect(rows.some(({ names }) => names.includes("x"))).toBe(true); diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 6d10a33215..e4edc63d46 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -29,6 +29,31 @@ function codeContext( return undefined; } +/** Whether a context gives a source range, in a gather or a pick too */ +function hasCode(context: Format.Program.Context | undefined): boolean { + if (!context || typeof context !== "object") return false; + if ("code" in context) return true; + if ("gather" in context) return context.gather.some(hasCode); + if ("pick" in context) return context.pick.some(hasCode); + return false; +} + +/** + * A debug with the function's source range (its declaration), if it + * gives none of its own + */ +function withFunctionRange( + debug: Ir.Block.Debug, + func: Ir.Function | undefined, +): Ir.Block.Debug { + if (hasCode(debug.context) || !func?.sourceId || !func.loc) return debug; + const code = { source: { id: func.sourceId }, range: func.loc }; + return { + ...debug, + context: { ...debug.context, code } as Format.Program.Context, + }; +} + /** * Extract the in-scope `variables` list from an instruction/terminator * debug, if present, so a call's caller JUMP still lists the locals @@ -68,12 +93,14 @@ export function generateTerminator( // Note: For returns with a value, we assume the return value is already // on TOS from the previous instruction in the block. This avoids an // unnecessary DUP that would leave an extra value on the stack. + // A return the source does not write (the end of a body) has + // no source range of its own: it gets the function's + const debug = withFunctionRange(term.operationDebug, func); if (isUserFunction) { // Internal function return epilogue. // Uses the same imperative pattern as // generateCallTerminator — returns // Transition to erase output type. - const debug = term.operationDebug; return generateReturnEpilogue(term.value, debug, func); } @@ -89,23 +116,25 @@ export function generateTerminator( if (allocation === undefined) { const offset = state.memory.nextStaticOffset; return builder - .then(loadValue(value)) - .then(PUSHn(BigInt(offset)), { as: "offset" }) - .then(MSTORE()) - .then(PUSHn(32n), { as: "size" }) - .then(PUSHn(BigInt(offset)), { as: "offset" }) - .then(RETURN()); + .then(loadValue(value, { debug })) + .then(PUSHn(BigInt(offset), { debug }), { as: "offset" }) + .then(MSTORE({ debug })) + .then(PUSHn(32n, { debug }), { as: "size" }) + .then(PUSHn(BigInt(offset), { debug }), { as: "offset" }) + .then(RETURN({ debug })); } else { const offset = allocation.offset; return builder - .then(PUSHn(32n), { as: "size" }) - .then(PUSHn(BigInt(offset)), { as: "offset" }) - .then(RETURN()); + .then(PUSHn(32n, { debug }), { as: "size" }) + .then(PUSHn(BigInt(offset), { debug }), { as: "offset" }) + .then(RETURN({ debug })); } }) .done(); } else { - return isLastBlock ? (state) => state : pipe().then(STOP()).done(); + return isLastBlock + ? (state) => state + : pipe().then(STOP({ debug })).done(); } } diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 86dd2504f0..d3649734bc 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -61,9 +61,12 @@ function generatePrologue( // Until the prologue has stored the arguments in the frame, the // parameters are listed without a pointer (the invoke's // `arguments` point at them on the stack) + // The entry's source range is the function's declaration, the + // one its invoke names const variables = preludeVariables(func); const entryDebug = { context: { + ...(declaration ? { code: declaration } : {}), ...entryInvoke, ...(variables.length > 0 ? { variables } : {}), } as Format.Program.Context, @@ -364,7 +367,7 @@ export function generate( } } - return Block.generate( + const generated = Block.generate( block, predecessor, isLastBlock, @@ -373,6 +376,9 @@ export function generate( func, options.functions, )(entry); + return isFirstBlock + ? generated + : withLabelRange(generated, state.instructions.length, func); }, stateAfterPrologue, ); @@ -407,6 +413,79 @@ export function generate( }; } +/** + * A block's entry JUMPDEST, at `index`, with a source range when it has + * none of its own: that of what runs next, the instruction after it. + * Only when that instruction is in the same inlined bodies and starts + * and ends no call, so its range is the label's, too. A label with + * nothing after it (a block that holds only a return the code leaves + * out) gets the function's range, as that return would. + */ +function withLabelRange( + state: State, + index: number, + func: Ir.Function, +): State { + const label = state.instructions[index]; + if (label?.mnemonic !== "JUMPDEST") return state; + const context = (label.debug?.context ?? {}) as Format.Program.Context; + if ("code" in context || "gather" in context) return state; + + const next = state.instructions[index + 1]?.debug?.context; + const range = next + ? inlines(next) === inlines(context) + ? rangeOf(next) + : undefined + : func.sourceId && func.loc + ? { code: { source: { id: func.sourceId }, range: func.loc } } + : undefined; + if (!range) return state; + + const instructions = [...state.instructions]; + instructions[index] = { + ...label, + debug: { + ...label.debug, + context: { ...context, ...range } as Format.Program.Context, + }, + }; + return { ...state, instructions }; +} + +/** How many `inline` transforms a context has at its top */ +function inlines(context: Format.Program.Context): number { + return "transform" in context + ? context.transform.filter((id) => id === "inline").length + : 0; +} + +/** + * A context's source ranges, without the rest, when it has nothing + * but ranges, variables and transforms other than `inline` (below its + * top) + */ +function rangeOf( + context: Format.Program.Context, + top = true, +): Format.Program.Context.Code | Format.Program.Context.Gather | undefined { + const { + variables: _, + transform: __, + ...rest + } = context as Record; + if (!top && inlines(context) > 0) return undefined; + if (Format.Program.Context.isCode(rest) && Object.keys(rest).length === 1) { + return { code: rest.code }; + } + if (Format.Program.Context.isGather(rest) && Object.keys(rest).length === 1) { + const gather = rest.gather.map((c) => rangeOf(c, false)); + return gather.every((c) => c !== undefined) + ? { gather: gather as Format.Program.Context[] } + : undefined; + } + return undefined; +} + /** * Patch jump targets after all blocks have been generated */ From ee8422375f245fd83eb1d30e05c89569486d9d93 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 15 Oct 2026 20:26:02 -0400 Subject: [PATCH 2/2] bugc: test the source range rules, keep the strict locals check Check that a block's JUMPDEST has the range of the instruction after it, and that the exit of a body with no return has the function's. Exempt only a JUMPDEST before an inlined call. In the locals check, exempt only the function's range after the body, not the entry and the prologue. --- packages/bugc/src/evmgen/code-ranges.test.ts | 91 ++++++++++++++++--- .../debug/local-variables.soundness.test.ts | 43 +++++---- .../generation/control-flow/terminator.ts | 2 +- 3 files changed, 103 insertions(+), 33 deletions(-) diff --git a/packages/bugc/src/evmgen/code-ranges.test.ts b/packages/bugc/src/evmgen/code-ranges.test.ts index 9026ab717f..3aa30649ce 100644 --- a/packages/bugc/src/evmgen/code-ranges.test.ts +++ b/packages/bugc/src/evmgen/code-ranges.test.ts @@ -9,23 +9,30 @@ import type * as Format from "@ethdebug/format"; import { Program } from "@ethdebug/format"; import { compile } from "#compiler"; import { exampleSources } from "../examples/index.js"; +import { hasCode } from "./generation/control-flow/terminator.js"; const { Context } = Program; -/** Whether a context gives a source range, in a gather or a pick too */ -function hasCode(context: Format.Program.Context | undefined): boolean { - if (!context) return false; - if (Context.isCode(context)) return true; - if (Context.isGather(context)) return context.gather.some(hasCode); - if (Context.isPick(context)) return context.pick.some(hasCode); - return false; +/** Whether a context starts an inlined call */ +function invokesInline(context: Format.Program.Context | undefined): boolean { + return !!context && invokes(context) && inlined(context); } -/** Whether a context starts a call, in a gather too */ -function hasInvoke(context: Format.Program.Context | undefined): boolean { - if (!context) return false; - if (Context.isInvoke(context)) return true; - return Context.isGather(context) && context.gather.some(hasInvoke); +/** Whether a context starts a call with no jump target (in a gather + * too), as an inlined call's invoke has none */ +function invokes(context: Format.Program.Context): boolean { + return ( + (Context.isInvoke(context) && !("target" in context.invoke)) || + (Context.isGather(context) && context.gather.some(invokes)) + ); +} + +/** Whether a context is in an inlined body (in a gather too) */ +function inlined(context: Format.Program.Context): boolean { + return ( + (Context.isTransform(context) && context.transform.includes("inline")) || + (Context.isGather(context) && context.gather.some(inlined)) + ); } /** Why an instruction has no source range, if it may have none */ @@ -43,7 +50,7 @@ function exception( // call site, which the call's first instruction does not always give if ( operation?.mnemonic === "JUMPDEST" && - hasInvoke(instructions[index + 1]?.context) + invokesInline(instructions[index + 1]?.context) ) { return "before an inlined call"; } @@ -106,4 +113,62 @@ code { r = f(0); }`; } } }); + + it("give a block's JUMPDEST the range of what runs next", async () => { + const source = `name Labels; +storage { [0] r: uint256; } +create {} +code { + let n = r; + if (n > 3) { r = n - 1; } else { r = n + 2; } + for (let i = 0; i < n; i = i + 1) { r = r + i; } +}`; + const result = await compile({ + to: "bytecode", + source, + optimizer: { level: 0 }, + }); + if (!result.success) throw new Error("compilation failed"); + const { instructions } = result.value.bytecode.runtimeProgram; + const labels = instructions.flatMap(({ operation, context }, index) => + operation?.mnemonic === "JUMPDEST" ? [{ context, index }] : [], + ); + expect(labels.length).toBeGreaterThan(2); + for (const { context, index } of labels) { + const next = instructions[index + 1].context; + expect(hasCode(context)).toBe(true); + expect((context as Format.Program.Context.Code).code).toEqual( + (next as Format.Program.Context.Code).code, + ); + } + }); + + it("give the end of a body with no return the function's", async () => { + const source = `name End; +define { + function g(n: uint256) { r = n; }; +} +storage { [0] r: uint256; } +create {} +code { g(3); g(4); }`; + for (const level of [0, 1] as const) { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) throw new Error("compilation failed"); + const exits = result.value.bytecode.runtimeProgram.instructions.filter( + ({ operation, context }) => + operation?.mnemonic === "JUMP" && + context && + Context.isReturn(context), + ); + expect(exits.length).toBe(1); + const { code, return: info } = exits[0] + .context as Format.Program.Context.Code & Format.Program.Context.Return; + expect(info.declaration).toBeDefined(); + expect(code).toEqual(info.declaration); + } + }); }); diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts index ab514b279f..92742164a4 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -43,17 +43,6 @@ function codeOffset(context: unknown): number | undefined { ?.code?.range?.offset; } -/** The range of an instruction's `code`, if it has one */ -function codeRange( - context: unknown, -): { offset: number; length: number } | undefined { - return ( - context as - | { code?: { range?: { offset: number; length: number } } } - | undefined - )?.code?.range; -} - /** A struct local, a call, then the local again */ const acrossCall = `name AcrossCall; define { @@ -193,15 +182,31 @@ storage { [0] r: uint256; } create {} code { r = f(3); }`; const declared = source.indexOf("let x"); + const func = source.indexOf("function f"); const rows = await listed(source); - for (const { instruction, names } of rows) { - // code whose whole range is before the declaration (a range - // that holds it, as the function's, may list `x`) - const range = codeRange(instruction.context); - if (range && range.offset + range.length <= declared) { - expect(names, `x listed at offset ${range.offset}`).not.toContain("x"); - } - } + // The end of the body (a return the source does not write) has the + // function's range, which starts before `x` is declared; after the + // body's first instruction at or after the declaration, it may + // list `x`. The entry and the prologue, before it, may not. + const end = source.indexOf("};", func); + const body = rows.findIndex(({ instruction }) => { + const offset = codeOffset(instruction.context) ?? -1; + return offset >= declared && offset < end; + }); + expect(body).toBeGreaterThan(0); + // the entry and the prologue are checked + expect( + rows.some( + ({ instruction }, index) => + index < body && codeOffset(instruction.context) === func, + ), + ).toBe(true); + rows.forEach(({ instruction, names }, index) => { + const offset = codeOffset(instruction.context); + if (offset === undefined || offset >= declared) return; + if (offset === func && index > body) return; + expect(names, `x listed at offset ${offset}`).not.toContain("x"); + }); expect(rows.some(({ names }) => names.includes("x"))).toBe(true); }); diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index e4edc63d46..d0f244491b 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -30,7 +30,7 @@ function codeContext( } /** Whether a context gives a source range, in a gather or a pick too */ -function hasCode(context: Format.Program.Context | undefined): boolean { +export function hasCode(context: Format.Program.Context | undefined): boolean { if (!context || typeof context !== "object") return false; if ("code" in context) return true; if ("gather" in context) return context.gather.some(hasCode);