bugc: sound per-instruction local-variable debug snapshot#261
Merged
Conversation
Emits `variables` debug contexts for local variables — function parameters and `let` bindings — that the memory planner spills to frame-relative memory. Each such local gets a `variables` entry (identifier, type, declaration) with a pointer to its runtime location, attached to the instructions across which it is live. The location is expressed with existing pointer vocabulary. A frame-homed local lives at mem[ mem[FRAME_POINTER] + delta ]; this is expressed as a `group` that names the frame-pointer machine region and a data region whose offset reads it (`$read`) and adds the local's static frame delta (`$sum`). Functions without a call frame (main/create) home locals at a static memory offset. Scope: O0 and memory-homed locals only. Stack-resident temps have no tracked position and are omitted; under optimization locations move or dissolve, so this is not applied at O1+; liveness is applied at block granularity. These are the honest limits of this first cut — broader coverage (stack locations, optimized code) follows.
Contributor
|
Adds runtime-correctness tests: each emitted local pointer is resolved against a real EVM trace's machine memory and must yield the variable's actual value. Covers both the frame-relative case (mem[ mem[FRAME_POINTER] + delta ]) and the static-absolute case (mem[offset], main/create locals) — confirming the static offset is a fixed absolute address, not a dynamic-base-relative one.
Rework the local-variable emission from a liveness-based, memory-homed
marking into a per-instruction guaranteed-known snapshot.
Each instruction's `variables` now lists the locals (params + `let`s)
whose value is guaranteed known after it executes. Availability is
computed by DOMINANCE, not liveness: a variable is reported at P only
if the def of its current SSA version dominates-or-equals P. This
fixes two soundness bugs in the previous block-granular emission:
- a variable is no longer listed before its defining store (which
previously resolved to uninitialized/garbage memory), and
- a reassigned variable resolves to the CURRENT version's slot, not
a stale one.
Records are partial: `type` is always emitted (always known in BUG);
`pointer` only where the value is located (memory-homed). An in-scope
but unlocated (stack-resident) variable is emitted type-only — sound
"in scope, no value", never a wrong value. This also makes coverage
complete over in-scope locals rather than memory-homed only.
Static (main/create) value regions are now named with the identifier.
Per-instruction snapshots are redundant (accepted for now). O0 only;
block-scope-exit / shadowing precision is bounded by dominance (never
reports before a def) — a scope-exit refinement is a follow-up.
Adds a shadowing soundness test and documents why the dominance snapshot's scope-exit over-reporting is precision-only, never a wrong value: (1) the frame allocator is a monotonic bump allocator — each SSA temp gets a unique, never-reused offset — so an over-reported out-of-scope local reads its OWN correct value; (2) BUG has no bare block statement, so all nested (shadowing) scopes are if/for bodies whose defs are on a conditional path and never dominate the post-block code, so dominance excludes an inner shadow there and the outer version is shown.
res.program is optional and not narrowed by res.compiled; guard on it directly so tsc (not just vitest) type-checks cleanly.
Split the two axes that decide a local's debug record. Lexical-scope membership (name + declared type) decides whether a variable is listed at an instruction; pointer availability (dominance) decides only whether that record carries a value or is type-only. Previously the two were conflated, so an in-scope variable vanished from the listing whenever its value was not currently located — including at call sites, where the call instruction is a block terminator and was not stamped at all. irgen now records each variable's enclosing scope extent: scopes carry their source range, declarations record their own loc, and each SSA variable gets a scopeEnd offset. evmgen builds per-name scope intervals from that and lists every lexically-in-scope variable at every instruction, terminators included, attaching a frame-relative or static pointer where the current version's def dominates and a type-only record otherwise. The invoke JUMP now carries the in-scope locals as well. Adds a soundness test for the SimpleFunctions case: a, b and c stay listed at and after the add(a, b) call rather than disappearing. Keeps the existing pre-def, reassignment, shadowing and one-per-PC guarantees.
This was referenced Jul 16, 2026
gnidan
added a commit
that referenced
this pull request
Jul 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR emits per-instruction
variablesdebug info for localvariables — function parameters and
letbindings.Each instruction's
variableslists the locals whose value is knownafter it executes. Availability is computed by dominance rather than
liveness: a variable is reported at an instruction only if the definition
of its current SSA version dominates it. That is what keeps the data
sound — a variable is never reported before its defining store (which
would resolve to garbage memory), and a reassigned variable always
resolves to its current version's slot rather than a stale one. Each
identifier appears at most once per instruction.
local-variables.soundness.test.tscovers both cases.Records are partial.
typeis always present, since it is always knownin BUG.
pointeris present only where the value is actually located —a frame-relative
groupfor function locals, static memory formain/create locals. An in-scope variable that is not located
(stack-resident) is emitted with its type only, so the set stays complete
over in-scope locals instead of just the memory-homed ones.
Pointers use the existing pointer vocabulary, so this needs no format
change. A frame-relative local resolves as
mem[mem[FRAME_POINTER] + delta];local-variables.resolve.test.tschecks the emitted pointers against a real EVM trace, confirming both the
frame-relative and static cases resolve to the variables' actual values.
This is O0 only — locations move or dissolve under optimization. The
per-instruction snapshots repeat information; deduplicating them through
program-level value tables is a separate step for later. Scope-exit and
shadowing precision are bounded by dominance for now; tightening scope
exit is a follow-up.