Skip to content

bugc: sound per-instruction local-variable debug snapshot#261

Merged
gnidan merged 8 commits into
mainfrom
compiler-locals-emission
Jul 16, 2026
Merged

bugc: sound per-instruction local-variable debug snapshot#261
gnidan merged 8 commits into
mainfrom
compiler-locals-emission

Conversation

@gnidan

@gnidan gnidan commented Jul 16, 2026

Copy link
Copy Markdown
Member

This PR emits per-instruction variables debug info for local
variables — function parameters and let bindings.

Each instruction's variables lists the locals whose value is known
after 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.ts covers both cases.

Records are partial. type is always present, since it is always known
in BUG. pointer is present only where the value is actually located —
a frame-relative group for function locals, static memory for
main/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.ts
checks 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.

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.
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-16 21:30 UTC

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.
gnidan added 2 commits July 16, 2026 02:55
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.
@gnidan gnidan changed the title bugc: emit debug info for memory-homed local variables (O0) bugc: sound per-instruction local-variable debug snapshot Jul 16, 2026
gnidan added 4 commits July 16, 2026 03:12
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.
@gnidan
gnidan merged commit 885f962 into main Jul 16, 2026
4 checks passed
@gnidan
gnidan deleted the compiler-locals-emission branch July 16, 2026 21:25
gnidan added a commit that referenced this pull request Jul 16, 2026
* Revert "bugc: sound per-instruction local-variable debug snapshot (#261)"

This reverts commit 885f962.

* Revert "pointers: add scalar-first type-directed value decoder (#269)"

This reverts commit 4429ae8.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant