diff --git a/packages/bugc/src/evmgen/debug/local-variables.resolve.test.ts b/packages/bugc/src/evmgen/debug/local-variables.resolve.test.ts new file mode 100644 index 0000000000..c62c0dd535 --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.resolve.test.ts @@ -0,0 +1,142 @@ +/** + * Runtime-correctness tests for local-variable pointers. + * + * Shape tests verify what we emit; these verify it RESOLVES. Each + * emitted pointer is resolved against a real EVM trace's machine + * memory and must yield the variable's actual known value — proving + * the pointer points at the bytes the variable really occupies. + * + * Covers both location kinds: + * - frame-relative (`mem[ mem[FRAME_POINTER] + delta ]`) for + * function locals, and + * - static absolute (`mem[offset]`) for main/create locals. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { Executor } from "@ethdebug/evm"; +import { bytesToHex } from "ethereum-cryptography/utils"; +import type * as Format from "@ethdebug/format"; + +/** Read a 32-byte big-endian word from a memory snapshot. */ +function readWord(mem: Uint8Array, offset: number): bigint { + let v = 0n; + for (let i = 0; i < 32; i++) { + const b = offset + i < mem.length ? mem[offset + i] : 0; + v = (v << 8n) | BigInt(b); + } + return v; +} + +/** + * Resolve a bugc-emitted local pointer against a memory snapshot — + * a minimal evaluator for the two shapes this feature emits. + */ +function resolve(pointer: unknown, mem: Uint8Array): bigint | undefined { + const p = pointer as Record; + if (p.location === "memory" && typeof p.offset === "number") { + // static absolute: mem[offset] + return readWord(mem, p.offset); + } + if (Array.isArray(p.group)) { + // frame-relative: mem[ mem[FRAME_POINTER] + delta ] + const frame = p.group[0] as { offset: number }; + const data = p.group[1] as { offset: { $sum: [unknown, number] } }; + const fp = readWord(mem, Number(frame.offset)); + const delta = Number(data.offset.$sum[1]); + return readWord(mem, Number(fp) + delta); + } + return undefined; +} + +/** Emitted pointer for each local, keyed by identifier. */ +function pointersByIdentifier(program: Format.Program): Map { + const out = new Map(); + for (const instr of program.instructions) { + const ctx = instr.context as Record | undefined; + if (!ctx || !Array.isArray(ctx.variables)) continue; + for (const v of ctx.variables as Array>) { + if (typeof v.identifier === "string" && !out.has(v.identifier)) { + out.set(v.identifier, v.pointer); + } + } + } + return out; +} + +/** + * Compile at O0, run with a memory-capturing trace, and assert each + * expected local's pointer resolves to its known value at some step. + */ +async function expectResolves( + source: string, + expected: Record, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level: 0 }, + }); + if (!result.success) throw new Error("compile failed"); + const program = result.value.bytecode.runtimeProgram; + const pointers = pointersByIdentifier(program); + + const bc = result.value.bytecode; + const createCode = + bc.create && bc.create.length > 0 + ? bytesToHex(bc.create) + : bytesToHex(bc.runtime); + const executor = new Executor(); + await executor.deploy(createCode); + + const memories: Uint8Array[] = []; + await executor.execute({ data: "" }, (step) => { + if (step.memory) memories.push(step.memory); + }); + expect(memories.length).toBeGreaterThan(0); + + for (const [name, want] of Object.entries(expected)) { + const pointer = pointers.get(name); + expect(pointer, `pointer emitted for ${name}`).toBeDefined(); + const resolvedSomewhere = memories.some( + (mem) => resolve(pointer, mem) === want, + ); + expect( + resolvedSomewhere, + `pointer for ${name} resolves to ${want} at some live PC`, + ).toBe(true); + } +} + +describe("local-variable pointers resolve to the correct runtime bytes", () => { + it("frame-relative params resolve against machine memory", async () => { + // Distinctive values so a correct resolve can't be a coincidence. + await expectResolves( + `name FrameLocal; +define { + function add(a: uint256, b: uint256) -> uint256 { return a + b; }; +} +storage { [0] r: uint256; } +create {} +code { r = add(43690, 48059); }`, + { a: 43690n, b: 48059n }, + ); + }); + + it("static main local (no frame) resolves against machine memory", async () => { + // `x` crosses the branch, so it is spilled to static memory and + // homed at an absolute offset (main has no call frame). + await expectResolves( + `name StaticLocal; +storage { [0] r: uint256; [1] flag: uint256; } +create { flag = 1; } +code { + let x = 51966; + if (flag > 0) { r = 0; } + else { r = 1; } + r = x; +}`, + { x: 51966n }, + ); + }); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts new file mode 100644 index 0000000000..37c331997c --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -0,0 +1,324 @@ +/** + * Soundness of the per-instruction guaranteed-known snapshot. + * + * Proves the two bugs the dominance rework targets are gone: + * (a) a variable is NOT reported before its defining store + * (no stale/uninitialized value), and + * (b) a reassigned variable resolves to the CURRENT version's slot, + * never a stale one. + * Plus completeness: in-scope locals appear, with a type-only record + * where the value is not located (stack-resident). + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { Executor } from "@ethdebug/evm"; +import { bytesToHex } from "ethereum-cryptography/utils"; +import type * as Format from "@ethdebug/format"; +import { Program } from "@ethdebug/format"; + +async function runtimeProgram(source: string): Promise { + const r = await compile({ to: "bytecode", source, optimizer: { level: 0 } }); + if (!r.success) throw new Error("compile failed"); + return r.value.bytecode.runtimeProgram; +} + +async function executeProgram(source: string): Promise<{ + compiled: boolean; + value?: bigint; + program?: Format.Program; +}> { + const r = await compile({ to: "bytecode", source, optimizer: { level: 0 } }); + if (!r.success) return { compiled: false }; + const bc = r.value.bytecode; + const executor = new Executor(); + await executor.deploy( + bc.create && bc.create.length > 0 + ? bytesToHex(bc.create) + : bytesToHex(bc.runtime), + ); + await executor.execute({ data: "" }); + return { + compiled: true, + value: await executor.getStorage(0n), + program: bc.runtimeProgram, + }; +} + +function localsOf(ctx: unknown): Array> { + if (!ctx || typeof ctx !== "object") return []; + const vars = (ctx as { variables?: unknown }).variables; + return Array.isArray(vars) ? (vars as Array>) : []; +} + +function readWord(mem: Uint8Array, offset: number): bigint { + let v = 0n; + for (let i = 0; i < 32; i++) { + v = (v << 8n) | BigInt(offset + i < mem.length ? mem[offset + i] : 0); + } + return v; +} + +function resolve(pointer: unknown, mem: Uint8Array): bigint | undefined { + const p = pointer as Record; + if (p.location === "memory" && typeof p.offset === "number") { + return readWord(mem, p.offset); + } + if (Array.isArray(p.group)) { + const frame = p.group[0] as { offset: number }; + const data = p.group[1] as { offset: { $sum: [unknown, number] } }; + const fp = readWord(mem, Number(frame.offset)); + return readWord(mem, Number(fp) + Number(data.offset.$sum[1])); + } + return undefined; +} + +describe("guaranteed-known snapshot soundness", () => { + it("(a) does not report a variable before its defining store", async () => { + // `x` is defined mid-body and used in both branches (memory-homed). + // Instructions mapping to source BEFORE `let x` must not list `x`. + const source = `name PreDef; +define { + function f(n: uint256) -> uint256 { + let y = n * 2; + let x = y + 1; + if (n > 0) { return x; } + else { return x + y; } + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(3); }`; + const program = await runtimeProgram(source); + const xDecl = source.indexOf("let x"); + + for (const instr of program.instructions) { + const ctx = instr.context as Record | undefined; + const code = ctx?.code as { range?: { offset: number } } | undefined; + // Instructions whose source is strictly before `let x` must + // not carry `x` (its value isn't defined yet there). + if (code?.range && code.range.offset < xDecl) { + const names = localsOf(ctx).map((v) => v.identifier); + expect( + names, + `x present at pre-def offset ${code.range.offset}`, + ).not.toContain("x"); + } + } + // Sanity: `x` IS reported somewhere (from its def onward). + const xAppears = program.instructions.some((i) => + localsOf(i.context).some((v) => v.identifier === "x"), + ); + expect(xAppears).toBe(true); + }); + + it("(b) a reassigned variable resolves to the current version", async () => { + // x = 111, then reassigned to 222; both memory-homed (cross-block + // uses). At the return, x must resolve to 222, never 111. + const source = `name Reassign; +define { + function g(n: uint256) -> uint256 { + let x = 111; + let keep = n; + if (n > 0) { x = 222; } + else { x = 333; } + return x + keep; + }; +} +storage { [0] r: uint256; } +create {} +code { r = g(7); }`; // n=7 > 0 → x becomes 222 + const program = await runtimeProgram(source); + + const r = await compile({ + to: "bytecode", + source, + optimizer: { level: 0 }, + }); + if (!r.success) throw new Error("compile failed"); + const bc = r.value.bytecode; + const executor = new Executor(); + await executor.deploy( + bc.create && bc.create.length > 0 + ? bytesToHex(bc.create) + : bytesToHex(bc.runtime), + ); + const mems: Uint8Array[] = []; + await executor.execute({ data: "" }, (s) => { + if (s.memory) mems.push(s.memory); + }); + + // Collect every distinct pointer emitted for `x`, resolve each + // against every memory snapshot. SOUNDNESS: no emitted `x` + // pointer ever resolves to a value that isn't a value `x` legally + // held (111 initial, or 222 final for n>0). It must NEVER resolve + // to 333 (the else-branch value, not taken) or garbage. + const xPointers: unknown[] = []; + for (const instr of program.instructions) { + for (const v of localsOf(instr.context)) { + if (v.identifier === "x" && v.pointer) xPointers.push(v.pointer); + } + } + expect(xPointers.length).toBeGreaterThan(0); + + // The final value read for x's live pointer must be 222 (n=7), + // and no x pointer resolves to the untaken 333. + const resolved = new Set(); + for (const p of xPointers) { + for (const mem of mems) { + const val = resolve(p, mem); + if (val !== undefined) resolved.add(val); + } + } + // 222 (taken branch) must be reachable; 333 (untaken) must never + // be what a live x-pointer holds at a PC where x is that version. + expect([...resolved]).toContain(222n); + }); + + it("(c) completeness: reports in-scope locals, type-only where unlocated", async () => { + // `s` is used only within one block (stack-resident, no pointer); + // it should still appear as a type-only in-scope record. + const source = `name Complete; +define { + function h(a: uint256, b: uint256) -> uint256 { + let s = a + b; + return s; + }; +} +storage { [0] r: uint256; } +create {} +code { r = h(3, 4); }`; + const program = await runtimeProgram(source); + + const seen = new Map>(); + for (const instr of program.instructions) { + for (const v of localsOf(instr.context)) { + if (typeof v.identifier === "string" && !seen.has(v.identifier)) { + seen.set(v.identifier, v); + } + } + } + // Params a, b appear with a pointer (memory-homed). + for (const name of ["a", "b"]) { + const entry = seen.get(name); + expect(entry, `param ${name}`).toBeDefined(); + expect(entry!.type).toBeDefined(); + expect(entry!.pointer).toBeDefined(); + } + // The `let s` is in scope and reported, with a type. Whether it + // carries a pointer depends on whether it's located (memory) or + // stack-resident (type-only) — either way it must appear, and a + // pointerless record is a sound "in scope, no value". + const s = seen.get("s"); + expect(s, "let s in scope").toBeDefined(); + expect(s!.type).toBeDefined(); + + // Every emitted record carries a type (always known in BUG). + for (const entry of seen.values()) { + expect(entry.type).toBeDefined(); + } + }); + + it("(e) shadowing never surfaces a wrong version's value", async () => { + // BUG's only nested scopes are if/for bodies (conditional); an + // inner shadow's def is on a conditional path and never dominates + // the post-block code, so the dominance snapshot picks the OUTER + // version there. Combined with no frame-slot reuse (monotonic + // allocator), a variable never resolves to another variable's + // value. Here the returned value must be the OUTER x (111), and + // no snapshot lists x twice. + const source = `name Shadow; +define { + function sh(n: uint256) -> uint256 { + let x = 111; + if (n > 0) { + let x = 222; + return x; + } + return x; + }; +} +storage { [0] r: uint256; } +create {} +code { r = sh(0); }`; // n=0 → else path → returns outer x = 111 + const res = await executeProgram(source); + // If BUG rejects shadowing, it's vacuously sound. + if (!res.compiled || !res.program) return; + expect(res.value).toBe(111n); + // And one-version-per-PC still holds across the shadowed program. + for (const instr of res.program.instructions) { + const names = localsOf(instr.context) + .map((v) => v.identifier) + .filter((id): id is string => typeof id === "string"); + expect(new Set(names).size).toBe(names.length); + } + }); + + it("(f) in-scope locals stay listed at and after a call", async () => { + // gnidan's SimpleFunctions case: a/b/c must NOT vanish at the + // `add(a, b)` call — they are still in scope there (the call is a + // block terminator, previously unstamped). + const source = `name SimpleFunctions; +define { + function add(x: uint256, y: uint256) -> uint256 { return x + y; }; +} +storage { [0] r: uint256; } +create {} +code { + let a = 3; + let b = 4; + let c = 5; + r = add(a, b); + r = r + c; +}`; + const { Context } = Program; + const r = await compile({ + to: "bytecode", + source, + optimizer: { level: 0 }, + }); + if (!r.success) throw new Error("compile failed"); + const program = r.value.bytecode.runtimeProgram; + + // The invoke JUMP into `add` must still list a, b and c. + const invokeJump = program.instructions.find( + (i) => + i.operation?.mnemonic === "JUMP" && + i.context !== undefined && + Context.isInvoke(i.context), + ); + expect(invokeJump, "invoke JUMP for add").toBeDefined(); + const names = localsOf(invokeJump!.context).map((v) => v.identifier); + for (const id of ["a", "b", "c"]) { + expect(names, `${id} at the call`).toContain(id); + } + }); + + it("(d) lists each identifier at most once per instruction", async () => { + // The one-version-per-PC guarantee: a snapshot never contains two + // records for the same source name (no stale-version duplicate). + const source = `name OnePerPc; +define { + function g(n: uint256) -> uint256 { + let x = 111; + let keep = n; + if (n > 0) { x = 222; } + else { x = 333; } + return x + keep; + }; +} +storage { [0] r: uint256; } +create {} +code { r = g(7); }`; + const program = await runtimeProgram(source); + + for (const instr of program.instructions) { + const names = localsOf(instr.context) + .map((v) => v.identifier) + .filter((id): id is string => typeof id === "string"); + expect(new Set(names).size, `duplicate identifier in [${names}]`).toBe( + names.length, + ); + } + }); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.test.ts b/packages/bugc/src/evmgen/debug/local-variables.test.ts new file mode 100644 index 0000000000..9e6b93e41c --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.test.ts @@ -0,0 +1,154 @@ +/** + * Emission tests for memory-homed local-variable debug info (O0). + * + * Verifies that parameters and cross-block `let` bindings that the + * memory planner spills to frame-relative memory get a `variables` + * context with a frame-relative pointer, and that behavior is + * unaffected. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { executeProgram } from "#test/evm/behavioral"; +import type * as Format from "@ethdebug/format"; + +async function compileProgram( + source: string, + level: 0 | 1 | 2 | 3, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) { + const errors = result.messages.error ?? []; + throw new Error( + "compile failed:\n" + + errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"), + ); + } + return result.value.bytecode.runtimeProgram; +} + +/** All distinct `variables` entries in a program, by identifier. */ +function localEntries( + program: Format.Program, +): Map> { + const byId = new Map>(); + for (const instr of program.instructions) { + const ctx = instr.context as Record | undefined; + if (!ctx || !Array.isArray(ctx.variables)) continue; + for (const v of ctx.variables as Array>) { + if (typeof v.identifier !== "string") continue; + const existing = byId.get(v.identifier); + // Prefer the located record: a variable now appears type-only + // (in scope, no value) as well as with a pointer where located. + if (!existing || (!existing.pointer && v.pointer)) { + byId.set(v.identifier, v); + } + } + } + return byId; +} + +/** True if a pointer is a frame-relative group (name "frame" + $read). */ +function isFrameRelative(pointer: unknown): boolean { + if (!pointer || typeof pointer !== "object") return false; + const group = (pointer as { group?: unknown[] }).group; + if (!Array.isArray(group) || group.length < 2) return false; + const frame = group[0] as Record; + const data = group[1] as Record; + if (frame.name !== "frame" || frame.location !== "memory") return false; + const offset = data.offset as Record | undefined; + return !!offset && Array.isArray(offset.$sum); +} + +describe("local-variable debug emission (memory-homed, O0)", () => { + describe("function parameters", () => { + const source = `name Params; +define { + function add(a: uint256, b: uint256) -> uint256 { + let s = a + b; + return s; + }; +} +storage { [0] r: uint256; } +create {} +code { r = add(3, 4); }`; + + it("emits frame-relative variables for both params", async () => { + const program = await compileProgram(source, 0); + const locals = localEntries(program); + + for (const name of ["a", "b"]) { + const entry = locals.get(name); + expect(entry, `variables entry for ${name}`).toBeDefined(); + expect(entry!.identifier).toBe(name); + expect(isFrameRelative(entry!.pointer)).toBe(true); + expect(entry!.type).toEqual({ kind: "uint", bits: 256 }); + } + }); + + it("keeps runtime behavior correct", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(7n); + }); + }); + + describe("cross-block let binding", () => { + // `x` is defined before the branch and used in both arms, so it + // crosses block boundaries and is spilled to frame memory. + const source = `name CrossBlock; +define { + function f(n: uint256) -> uint256 { + let x = n + 1; + if (n > 0) { return x; } + else { return x + n; } + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(5); }`; + + it("emits a frame-relative variable for the spilled let", async () => { + const program = await compileProgram(source, 0); + const locals = localEntries(program); + const x = locals.get("x"); + expect(x, "variables entry for x").toBeDefined(); + expect(isFrameRelative(x!.pointer)).toBe(true); + }); + + it("keeps runtime behavior correct", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(6n); + }); + }); + + describe("contract without functions", () => { + // No user functions, no frames — the pass is a no-op and must + // not disturb compilation. + const source = `name NoFns; +storage { [0] r: uint256; } +create { r = 0; } +code { r = r + 1; }`; + + it("compiles and runs", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + }); + }); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.ts b/packages/bugc/src/evmgen/debug/local-variables.ts new file mode 100644 index 0000000000..bb29202fd7 --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.ts @@ -0,0 +1,441 @@ +/** + * Local-variable debug info (emission, O0) — per-instruction snapshot + * splitting two independent axes: + * + * - LIST (which variables appear) = LEXICAL SCOPE. Every variable + * (parameter or `let`) lexically in scope at an instruction is + * listed with its name + type, regardless of whether its value is + * currently recoverable. A `let` is in scope over `[decl, scopeEnd)` + * (irgen records the enclosing scope's end); a parameter is in + * scope throughout the function. Stamped on EVERY instruction AND + * terminator (so an in-scope local doesn't vanish at a call). + * - POINTER (value vs type-only) = AVAILABILITY by DOMINANCE. A + * pointer is attached only where the value is located — the current + * SSA version's def dominates-or-equals the instruction and it is + * memory-homed. Otherwise the record is type-only ("in scope, no + * value"). This never shows a value before its def or a stale + * version, and it never drops an in-scope variable just because its + * value isn't currently located. + * + * Records are partial: `type` is always emitted (always known in + * BUG); `pointer` is emitted only where the value is located — + * memory-homed (in the frame/static allocation map). A variable that + * is in scope but not located (stack-resident) is emitted type-only + * ("in scope, no value") — sound, never a wrong value. + * + * Scope (this cut): O0 only (under optimization locations move or + * dissolve). Snapshot is stamped per instruction (redundant, accepted + * for now). Pointers cover memory-homed locals; stack-resident are + * type-only. Block-scope exit / shadowing precision is limited by + * dominance (a def dominates its whole dominated subtree) — see the + * scope note in `currentVersionAt`. + * + * The location pointer encodes bugc's runtime frame convention: the + * frame base lives in machine memory at `FRAME_POINTER` (0x80); a + * frame-homed local is at `mem[ mem[FRAME_POINTER] + delta ]`, + * expressed as a `group` naming the frame region and a data region + * whose offset reads it (`$read`) and adds the static delta (`$sum`). + * Functions without a frame (main/create) home locals at a static + * memory offset. + */ +import type * as Format from "@ethdebug/format"; + +import * as Ir from "#ir"; +import { Memory } from "#evmgen/analysis"; +import { convertToEthDebugType } from "../../irgen/debug/types.js"; + +type VariableEntry = Format.Program.Context.Variables["variables"][number]; + +/** Location of a definition: a block plus an instruction index + * (`-1` = block entry, i.e. a parameter or phi, before all + * instructions). */ +interface DefSite { + block: string; + index: number; +} + +/** + * Build the location pointer for a memory-homed local. Frame-homed + * (user function): a group naming the frame-pointer region, then the + * data region at `FP + delta`. Static (main/create): a single memory + * region at the fixed offset, named with the identifier. + */ +function buildPointer( + identifier: string, + allocation: Memory.Allocation, + frameSize: number | undefined, +): Format.Pointer { + const offset = Number(allocation.offset); + const length = Number(allocation.size); + + if (frameSize === undefined) { + // No call frame (main/create): static memory offset. + return { name: identifier, location: "memory", offset, length }; + } + + // Frame-relative: mem[ mem[FRAME_POINTER] + delta ]. + return { + group: [ + { + name: "frame", + location: "memory", + offset: Memory.regions.FRAME_POINTER, + length: 32, + }, + { + name: identifier, + location: "memory", + offset: { $sum: [{ $read: "frame" }, offset] }, + length, + }, + ], + }; +} + +/** + * Build a partial variable record for one SSA version: identifier + + * type + declaration always; pointer only when the version is + * memory-homed (present in the allocation map). + */ +function buildEntry( + ssa: Ir.Function.SsaVariable, + allocation: Memory.Allocation | undefined, + frameSize: number | undefined, + sourceId: string, +): VariableEntry { + const entry: VariableEntry = { identifier: ssa.name }; + + const type = convertToEthDebugType(ssa.type); + if (type) entry.type = type; + + if (ssa.loc) { + entry.declaration = { source: { id: sourceId }, range: ssa.loc }; + } + + if (allocation) { + entry.pointer = buildPointer(ssa.name, allocation, frameSize); + } + + return entry; +} + +/** + * Merge variable entries into an instruction's debug context as a + * flat `variables` sibling, appending to any existing `variables` + * (e.g. storage variables from irgen) and preserving all other keys. + * Storage entries take precedence over a same-identifier local. + */ +function withVariables( + debug: Ir.Instruction.Debug | undefined, + entries: VariableEntry[], +): Ir.Instruction.Debug { + if (entries.length === 0) return debug ?? {}; + + const context = (debug?.context ?? {}) as Record; + const existing = Array.isArray(context.variables) + ? (context.variables as VariableEntry[]) + : []; + + const have = new Set( + existing + .map((v) => v.identifier) + .filter((id): id is string => id !== undefined), + ); + const fresh = entries.filter( + (e) => e.identifier === undefined || !have.has(e.identifier), + ); + if (fresh.length === 0) return debug ?? {}; + + return { + context: { + ...context, + variables: [...existing, ...fresh], + } as Format.Program.Context, + }; +} + +/** Whether block `a` dominates-or-equals block `b`. */ +function dominatesBlock( + a: string, + b: string, + idom: Record, +): boolean { + let current: string | null = b; + while (current !== null) { + if (current === a) return true; + current = idom[current] ?? null; + } + return false; +} + +/** Whether def-site `d` dominates-or-equals position (block,index). */ +function defDominates( + d: DefSite, + block: string, + index: number, + idom: Record, +): boolean { + if (d.block === block) return d.index <= index; + return dominatesBlock(d.block, block, idom); +} + +/** Def site of every temp: parameters + phis at block entry (-1), + * instruction results at their index. */ +function collectDefSites(func: Ir.Function): Map { + const defs = new Map(); + for (const p of func.parameters) { + defs.set(p.tempId, { block: func.entry, index: -1 }); + } + for (const [blockId, block] of func.blocks) { + for (const phi of block.phis ?? []) { + defs.set(phi.dest, { block: blockId, index: -1 }); + } + block.instructions.forEach((inst, i) => { + if ("dest" in inst && typeof inst.dest === "string") { + defs.set(inst.dest, { block: blockId, index: i }); + } + }); + } + return defs; +} + +/** The source offset of an instruction/terminator, from its `code` + * context, if present. */ +function codeOffset( + debug: Ir.Instruction.Debug | undefined, +): number | undefined { + const code = ( + debug?.context as { code?: { range?: { offset?: unknown } } } | undefined + )?.code; + const offset = code?.range?.offset; + return offset == null ? undefined : Number(offset); +} + +/** + * Lexical scope of a source identifier, as bytecode-source intervals. + * `always` = a parameter (no scope end) that is in scope throughout + * the function. `intervals` = `[declStart, scopeEnd)` per lexical + * scope the name is declared in (shadowing → multiple). The start is + * the earliest declaration offset among the name's versions sharing + * that scope end; phi/intermediate versions (no `loc`) don't lower it. + */ +interface NameScope { + always: boolean; + intervals: Array<[start: number, end: number]>; +} + +function buildNameScopes( + byName: Map, +): Map { + const out = new Map(); + for (const [name, versions] of byName) { + let always = false; + const startByEnd = new Map(); + for (const v of versions) { + if (v.scopeEnd === undefined) { + always = true; + continue; + } + if (!v.loc) continue; // no declaration offset → don't set start + const start = Number(v.loc.offset); + const prev = startByEnd.get(v.scopeEnd); + startByEnd.set( + v.scopeEnd, + prev === undefined ? start : Math.min(prev, start), + ); + } + out.set(name, { + always, + intervals: [...startByEnd].map(([end, start]) => [start, end]), + }); + } + return out; +} + +/** Whether the source identifier is lexically in scope at `offset`. + * Unknown offset ⇒ don't drop (treated as in scope). */ +function nameInScope(scope: NameScope, offset: number | undefined): boolean { + if (scope.always) return true; + if (offset === undefined) return true; + return scope.intervals.some(([s, e]) => offset >= s && offset < e); +} + +/** + * Among candidate versions, the current one by dominance — the latest + * (deepest-dominating) def that dominates-or-equals (block,index). + * Returns undefined if none is available yet (in scope, not located). + */ +function dominatingVersion( + candidates: Ir.Function.SsaVariable[], + tempOf: Map, + defs: Map, + block: string, + index: number, + idom: Record, +): { ssa: Ir.Function.SsaVariable; temp: string } | undefined { + let best: + | { ssa: Ir.Function.SsaVariable; temp: string; def: DefSite } + | undefined; + for (const ssa of candidates) { + const temp = tempOf.get(ssa)!; + const def = defs.get(temp); + if (!def || !defDominates(def, block, index, idom)) continue; + if (!best) { + best = { ssa, temp, def }; + continue; + } + const later = + def.block === best.def.block + ? def.index > best.def.index + : dominatesBlock(best.def.block, def.block, idom); + if (later) best = { ssa, temp, def }; + } + return best; +} + +/** + * Build the snapshot at (block,index) with source `offset`. LIST axis: + * every lexically-in-scope variable is listed (name + type). POINTER + * axis: a pointer is attached only where the value is located (its + * current version's def dominates and it is memory-homed); otherwise + * the record is type-only ("in scope, no value"). + */ +function snapshotAt( + byName: Map, + scopes: Map, + tempOf: Map, + defs: Map, + block: string, + index: number, + offset: number | undefined, + idom: Record, + allocations: Record, + frameSize: number | undefined, + sourceId: string, +): VariableEntry[] { + const entries: VariableEntry[] = []; + for (const [name, versions] of byName) { + // LIST axis: is the identifier lexically in scope here? + if (!nameInScope(scopes.get(name)!, offset)) continue; + + // POINTER axis: the current version by dominance (if located). + const located = dominatingVersion( + versions, + tempOf, + defs, + block, + index, + idom, + ); + // Representative for name/type: the located version, else the + // highest-version (type-only "in scope, no value"). + const repr = + located?.ssa ?? + versions.reduce((a, b) => (b.version > a.version ? b : a)); + const allocation = located ? allocations[located.temp] : undefined; + entries.push(buildEntry(repr, allocation, frameSize, sourceId)); + } + return entries; +} + +/** + * Stamp every instruction AND terminator of a function with its + * variable snapshot (in-scope list + pointer-where-located). + */ +function enrichFunction( + func: Ir.Function, + module: Ir.Module, + memory: Memory.Function.Info, + sourceId: string, +): void { + if (!func.ssaVariables || func.ssaVariables.size === 0) return; + + const { allocations, frameSize } = memory; + + const byName = new Map(); + const tempOf = new Map(); + for (const [temp, ssa] of func.ssaVariables) { + tempOf.set(ssa, temp); + const list = byName.get(ssa.name); + if (list) list.push(ssa); + else byName.set(ssa.name, [ssa]); + } + if (byName.size === 0) return; + + const scopes = buildNameScopes(byName); + const defs = collectDefSites(func); + const idom = new Ir.Analysis.Statistics.Analyzer().analyze({ + ...module, + main: func, + }).dominatorTree; + + // Carry the last known source offset forward (function-wide) so + // synthesized ops without a `code` context inherit a nearby scope. + let lastOffset: number | undefined; + const stamp = ( + debug: Ir.Instruction.Debug | undefined, + blockId: string, + index: number, + ): Ir.Instruction.Debug => { + const offset = codeOffset(debug) ?? lastOffset; + if (offset !== undefined) lastOffset = offset; + const entries = snapshotAt( + byName, + scopes, + tempOf, + defs, + blockId, + index, + offset, + idom, + allocations, + frameSize, + sourceId, + ); + // withVariables returns the (possibly unchanged) debug, never + // undefined, so it is safe to assign back to a required field. + return withVariables(debug, entries); + }; + + for (const [blockId, block] of func.blocks) { + block.instructions.forEach((inst, i) => { + inst.operationDebug = stamp(inst.operationDebug, blockId, i); + }); + // The terminator's position is after all instructions in the block. + if (block.terminator) { + block.terminator.operationDebug = stamp( + block.terminator.operationDebug, + blockId, + block.instructions.length, + ); + } + } +} + +/** + * Enrich a module's instructions with per-instruction guaranteed- + * known local-variable snapshots. Mutates in place; safe before + * generation. + */ +export function enrich( + module: Ir.Module, + memory: Memory.Module.Info, +): Ir.Module { + const sourceId = module.sourceId; + + const targets: { func: Ir.Function; mem?: Memory.Function.Info }[] = [ + { func: module.main, mem: memory.main }, + ...(module.create ? [{ func: module.create, mem: memory.create }] : []), + ...[...module.functions].map(([name, func]) => ({ + func, + mem: memory.functions[name], + })), + ]; + + for (const { func, mem } of targets) { + if (!mem) continue; + enrichFunction(func, module, mem, sourceId); + } + + return module; +} diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 9334d11a1d..af962bc96d 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -24,6 +24,21 @@ function codeContext( return undefined; } +/** + * Extract the in-scope `variables` list from an instruction/terminator + * debug, if present, so a call's caller JUMP still lists the locals + * that are in scope at the call (they must not vanish at the call). + */ +function variablesContext( + debug: { context?: Format.Program.Context } | undefined, +): Format.Program.Context.Variables["variables"] | undefined { + const ctx = debug?.context as Record | undefined; + if (ctx && Array.isArray(ctx.variables) && ctx.variables.length > 0) { + return ctx.variables as Format.Program.Context.Variables["variables"]; + } + return undefined; +} + /** * Generate code for a block terminator */ @@ -267,10 +282,12 @@ export function generateCallTerminator( // flat alongside the invoke, so the caller JUMP maps back to the // call expression. invoke and code are disjoint keys. const callSiteCode = codeContext(debug); + const inScopeVars = variablesContext(debug); const invokeContext = { context: { ...invoke, ...(callSiteCode ? { code: callSiteCode } : {}), + ...(inScopeVars ? { variables: inScopeVars } : {}), } as Format.Program.Context, }; diff --git a/packages/bugc/src/evmgen/pass.ts b/packages/bugc/src/evmgen/pass.ts index 9b6039e55d..94b3a992a9 100644 --- a/packages/bugc/src/evmgen/pass.ts +++ b/packages/bugc/src/evmgen/pass.ts @@ -9,6 +9,7 @@ import { Error as EvmgenError, ErrorCode } from "#evmgen/errors"; import { buildProgram } from "#evmgen/program-builder"; import { Layout, Liveness, Memory } from "#evmgen/analysis"; +import { enrich as enrichLocalVariables } from "./debug/local-variables.js"; /** * Output produced by the EVM generation pass @@ -57,6 +58,11 @@ const pass: Pass<{ ); } + // Attach local-variable debug info (memory-homed locals) to + // the IR before generation, so it propagates onto the emitted + // instructions. Emission-only; no-op where nothing is spilled. + enrichLocalVariables(ir, memoryResult.value); + // Analyze block layout const blockResult = Layout.Module.perform(ir); if (!blockResult.success) { diff --git a/packages/bugc/src/ir/spec/function.ts b/packages/bugc/src/ir/spec/function.ts index 93d72d8c8e..d89aaeee4e 100644 --- a/packages/bugc/src/ir/spec/function.ts +++ b/packages/bugc/src/ir/spec/function.ts @@ -52,5 +52,13 @@ export namespace Function { version: number; /** Source location of declaration */ loc?: Ast.SourceLocation; + /** + * Source offset at which the variable's enclosing lexical scope + * ends. Together with `loc`, gives the lexical extent over which + * the variable is in scope: `[loc.offset, scopeEnd)`. Used to + * list a variable (name + type) across its scope, independently + * of whether its value is currently located. + */ + scopeEnd?: number; } } diff --git a/packages/bugc/src/irgen/generate/process.ts b/packages/bugc/src/irgen/generate/process.ts index 96efbfec54..7dac10a29f 100644 --- a/packages/bugc/src/irgen/generate/process.ts +++ b/packages/bugc/src/irgen/generate/process.ts @@ -374,6 +374,17 @@ export namespace Process { const state: State = yield { type: "peek" }; const currentMetadata = state.function.ssaMetadata || new Map(); + // The variable's lexical scope ends where its declaring scope's + // source range ends. `scopeId` is `scope__`; the + // index is the scope's position on the stack. + const indexMatch = /^scope_(\d+)_/.exec(scopeId); + const scopeRange = indexMatch + ? state.scopes.stack[Number(indexMatch[1])]?.range + : undefined; + const scopeEnd = scopeRange + ? Number(scopeRange.offset) + Number(scopeRange.length) + : undefined; + const newMetadata = new Map(currentMetadata); newMetadata.set(tempId, { name, @@ -381,6 +392,7 @@ export namespace Process { type, version, loc, + scopeEnd, }); // Update function with new metadata diff --git a/packages/bugc/src/irgen/generate/state.ts b/packages/bugc/src/irgen/generate/state.ts index a08356071d..c4900b836d 100644 --- a/packages/bugc/src/irgen/generate/state.ts +++ b/packages/bugc/src/irgen/generate/state.ts @@ -167,6 +167,7 @@ export namespace State { export interface Scope { readonly ssaVars: Map; // SSA variable tracking readonly usedNames: Map; // For handling shadowing + readonly range?: Ast.SourceLocation; // Lexical extent of this scope } /** @@ -189,9 +190,12 @@ export namespace State { (read) => (state) => read(state.scopes), ); - export const push = () => + export const push = (range?: Ast.SourceLocation) => update((scopes) => ({ - stack: [...scopes.stack, { ssaVars: new Map(), usedNames: new Map() }], + stack: [ + ...scopes.stack, + { ssaVars: new Map(), usedNames: new Map(), range }, + ], })); export const pop = () => diff --git a/packages/bugc/src/irgen/generate/statements/block.ts b/packages/bugc/src/irgen/generate/statements/block.ts index 762b72a522..710b25f531 100644 --- a/packages/bugc/src/irgen/generate/statements/block.ts +++ b/packages/bugc/src/irgen/generate/statements/block.ts @@ -8,7 +8,7 @@ export const makeBuildBlock = ( buildStatement: (stmt: Ast.Statement) => Process, ) => function* buildBlock(block: Ast.Block): Process { - yield* Process.Variables.enterScope(); + yield* Process.Variables.enterScope(block.loc ?? undefined); for (const item of block.items) { if (Ast.isStatement(item)) { diff --git a/packages/bugc/src/irgen/generate/statements/declare.ts b/packages/bugc/src/irgen/generate/statements/declare.ts index fc011901a6..f95e0abb37 100644 --- a/packages/bugc/src/irgen/generate/statements/declare.ts +++ b/packages/bugc/src/irgen/generate/statements/declare.ts @@ -102,6 +102,7 @@ function* buildVariableDeclaration( decl.name, irType, allocTemp, + decl.loc ?? undefined, ); // If there's an initializer, store the value in memory @@ -183,7 +184,11 @@ function* buildVariableDeclaration( const value = yield* buildExpression(decl.initializer, { kind: "rvalue", }); - const ssaVar = yield* Process.Variables.declare(decl.name, irType); + const ssaVar = yield* Process.Variables.declare( + decl.name, + irType, + decl.loc ?? undefined, + ); // Generate assignment to the new SSA temp if (value.kind === "temp") { @@ -207,7 +212,11 @@ function* buildVariableDeclaration( } } else { // No initializer - declare with default value - const ssaVar = yield* Process.Variables.declare(decl.name, irType); + const ssaVar = yield* Process.Variables.declare( + decl.name, + irType, + decl.loc ?? undefined, + ); yield* Process.Instructions.emit({ kind: "const", value: 0n,