Skip to content

Formally prove the correctness of Sqrt.sol and Cbrt.sol#511

Open
duncancmt wants to merge 325 commits intodcmt/newton-raphson-optimizationfrom
dcmt/codex-prove-sqrt-cbrt
Open

Formally prove the correctness of Sqrt.sol and Cbrt.sol#511
duncancmt wants to merge 325 commits intodcmt/newton-raphson-optimizationfrom
dcmt/codex-prove-sqrt-cbrt

Conversation

@duncancmt
Copy link
Collaborator

No description provided.

@immunefi-magnus
Copy link

🛡️ Immunefi PR Reviews

We noticed that your project isn't set up for automatic code reviews. If you'd like this PR reviewed by the Immunefi team, you can request it manually using the link below:

🔗 Send this PR in for review

Once submitted, we'll take care of assigning a reviewer and follow up here.

@duncancmt duncancmt requested a review from e1Ru1o February 27, 2026 22:40
@duncancmt duncancmt self-assigned this Feb 27, 2026
duncancmt and others added 24 commits March 1, 2026 11:08
Extract _innerSqrt, _karatsubaQuotient, _sqrtCorrection from _sqrt in
512Math.sol. Extend yul_to_lean.py to support multi-return functions
(tuple types, __component_N projections). The pipeline now generates 4
separate Lean models instead of one monolithic ~30-binding term, making
each sorry sub-lemma target ~2-10 let-bindings.

All private sub-functions are inlined by solc → identical bytecode.
Fuzz test (1000 runs) confirms the regenerated Lean model matches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prove model_innerSqrt_evm_correct by factoring through norm model:
- model_innerSqrt_snd_def: residue = x - fst^2 (by rfl)
- model_innerSqrt_snd_eq_residue: norm residue = x - natSqrt(x)^2
- model_innerSqrt_evm_correct: both components correct (uses evm_eq_norm)

Add helper theorems natSqrt_lt_2_128 and natSqrt_ge_2_127 for bounds.

Remaining sorry: model_innerSqrt_evm_eq_norm (EVM ops = norm ops on
bounded inputs, ~10 let-bindings via evm_bstep_eq chain).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract `_bstep` as a separate Solidity function so the Lean model
generator produces `model_bstep_evm` calls instead of inlining 6
copies of `evmShr(1, evmAdd(r, evmDiv(x, r)))`. This makes each
Babylonian step independently provable via `model_bstep_evm_eq_bstep`.

Gas unchanged (solc inlines private pure functions): μ 3549→3549.

Prove `model_innerSqrt_evm_eq_norm` by:
- Chaining 6 `model_bstep_evm_eq_bstep` applications (each gives
  equality + [2^127, 2^129) bounds for the next step)
- Showing the correction `evmSub z6 (evmLt (evmDiv x z6) z6)` matches
  `normSub z6 (normLt (normDiv x z6) z6)` via `correction_correct`
- Showing the residue `evmSub x (evmMul r r)` matches `normSub x
  (normMul r r)` since r = natSqrt(x) < 2^128 so r^2 < 2^256

Remaining sorry's: 3 (karatsubaQuotient, sqrtCorrection, composition)
plus 1 pre-existing normalization proof broken by Lean v4.28 API changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewrite the normalization proof to handle Lean 4.28 breaking changes:
- Nat.add_mul_mod_self → Nat.add_mul_mod_self_right
- Nat.mul_div_mul_left pattern matching changed
- Nat.mul_lt_mul → Nat.mul_lt_mul_of_le_of_lt
- ring tactic unavailable (Mathlib-only)
- set tactic unavailable (Mathlib-only)

Key structural fix: avoid `intro` for let-bindings (which creates
opaque defs that rw/simp can't penetrate in Lean 4.28). Instead use
`show` to inline all let-bindings upfront, then `intro` only for
variables that need case-splitting.

Case-split on dbl_k = 0 (where evmShr 256 returns 0 since 256 ≥ 256)
vs dbl_k > 0 (where evmShr (256 - dbl_k) works normally).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… 2 sorrys

Close the Karatsuba quotient EVM bridge (sorry 2/4 → proved):
- Handle no-carry case (res < 2^128): n_evm = n_full directly
- Handle carry case (res >= 2^128): correction via WORD_MOD = d*qw + rw + 1
- Change hypothesis from res <= 2*natSqrt(...) to res <= 2*r_hi

Revise model_sqrtCorrection_evm_correct spec to raw EVM bridge form:
- Result = r_hi*2^128 + r_lo - cmp (where cmp is 257-bit comparison)
- Add hypotheses: r_lo <= 2^128, rem < 2*r_hi, hedge condition
- Scaffold EVM simplification (constant folding, all ops reduced to Nat)

Add helper lemmas:
- mul_mod_sq: (a*n) % (n*n) = (a%n)*n
- mul_pow128_mod_word: (a*2^128) % 2^256 = (a%2^128)*2^128
- div_of_mul_add / mod_of_mul_add: Euclidean division after recomposition

Remaining: 2 sorrys (sqrtCorrection comparison logic, composition proof)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4-way case split on rem/2^128 ∈ {0,1} × r_lo/2^128 ∈ {0,1}:
- (0,0): comparisons match directly, no EVM overflow
- (0,1): r_lo=2^128, cmp=1, handle evmAdd overflow via evmSub_evmAdd_eq_of_overflow
- (1,0): cmp=0, rem*2^128 ≥ 2^256 > r_lo^2
- (1,1): contradiction via hedge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Close the last sorry by proving the EVM model of 512-bit sqrt equals
the algebraic sqrt512 function. The proof decomposes into:

- evm_composition_eq_karatsubaFloor: composition of the three EVM
  sub-models (innerSqrt, karatsubaQuotient, sqrtCorrection) equals
  karatsubaFloor on normalized inputs. Uses the Karatsuba algebraic
  identity x + q² = r² + rem·H + x_lo_lo via correction_equiv.

- karatsubaFloor_lt_word: result fits in 256 bits, via
  karatsubaFloor_eq_natSqrt and natSqrt upper bound.

- Main theorem assembly: unfold model_sqrt512_evm, rewrite the
  composition to karatsubaFloor, then convert evmShr to division.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace all 21 native_decide calls with decide (using maxRecDepth for
deep convergence certificates and Fin 256 enumeration). Fix 6 unused
variable/simp warnings. Axiom set now minimal: propext, Classical.choice,
Quot.sound.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Solidity helper functions in 512Math.sol were renamed:
  _bstep → _sqrt_babylonianStep
  _innerSqrt → _sqrt_baseCase
  _karatsubaQuotient → _sqrt_karatsubaQuotient
  _sqrtCorrection → _sqrt_correction

Update generate_sqrt512_model.py to reference the new Solidity names
while preserving the Lean model names (model_bstep, model_innerSqrt,
etc.) so downstream proofs remain stable.

Fix two proofs in GeneratedSqrt512Spec.lean to accommodate the slightly
different generated model (double-AND shift wrapping and reversed
operand order in addition from the new compiler output).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tructure

- Add formal/sqrt/generate_sqrt_cert.py that generates FiniteCert.lean
  with SqrtCert (256 octaves) and Sqrt512Cert (fixed-seed certificates
  for octaves 254/255), replacing the hand-written FiniteCert.lean and
  inline certificate definitions in GeneratedSqrt512Spec.lean.

- Refactor model_innerSqrt_evm_eq_norm: extract shared bstep chain and
  correction logic into evm_innerSqrt_pair, eliminating ~100 lines of
  duplicated proof code across the .1/.2 components.

- Update CI: sqrt-formal.yml and sqrt512-formal.yml now generate the
  certificate before building, and sqrt512-formal.yml builds proofs
  (not just the model evaluator), matching sqrt/cbrt patterns.

- Consolidate formal READMEs into a single formal/README.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Solc 0.8.33 emits `and(and(1,255),255)` type-cleanup wrappers around
shift amounts and may reorder commutative operands in the Yul IR for
`_sqrt_babylonianStep`. Update the `model_bstep_eq_bstep` and
`model_bstep_evm_eq_bstep` proofs to constant-fold the nested AND back
to 1 and handle the `add(div(x,z),z)` vs `add(z,div(x,z))` reordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lake warns about a missing manifest when the SqrtProof dependency is
resolved during a fresh CI checkout. Track the file in git so it is
present at clone time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ry folding

Extend the Yul-to-Lean model generator to handle the public sqrt(uint512) and
osqrtUp(uint512, uint512) wrapper functions:

- Add mulmod opcode support (evmMulmod/normMulmod) needed by _mul inlining
- Add known_yul_names disambiguation in find_function to distinguish
  sqrt(uint512) from Sqrt.sqrt(uint256) by checking body references
- Add mstore/mload memory folding via lazy mstore_sink collection during
  inlining and _resolve_mloads post-processing in yul_function_to_model
- Add switch statement parsing (switch/case 0/default → ParsedIfBlock with
  else_body), with ConditionalBlock.else_assignments for Lean rendering
- Add conditional mstore detection as a hard error
- Suppress mstore inlining warnings when mstore_sink handles them
- Update Sqrt512Wrapper to use disjoint memory regions (tmp()=0 for result,
  x:=0x1080 for input) so mstore/mload pairs can be folded
- Refactor osqrtUp to move from() outside the conditional so memory writes
  are unconditional
- Update generate_sqrt512_model.py config for flat_sqrt512 and flat_osqrtUp

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reject all switch forms except exactly `case 0 { ... } default { ... }`:
- Duplicate case 0 or default branches
- default not in last position
- Missing case 0 or default (e.g. default-only, case-only)
- More than 2 branches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SqrtWrapperSpec.lean: model_sqrt512_wrapper_evm_correct (0 sorry)
  Proves the sqrt(uint512) wrapper computes natSqrt by case-splitting
  on x_hi=0 (bridges to SqrtProof's 256-bit proof via namespace
  compatibility lemmas) and x_hi>0 (reuses model_sqrt512_evm_correct).

- OsqrtUpSpec.lean: model_osqrtUp_evm_correct (1 sorry)
  x_hi=0 case fully proved (bridges to model_sqrt_up_evm_ceil_u256).
  Helper lemmas fully proved: mul512_high_word (2^256≡1 mod 2^256-1),
  gt512_correct (lexicographic 512-bit comparison), add_with_carry.
  x_hi>0 case remains sorry (requires matching generated model to helpers).

- GeneratedSqrt512Spec.lean: fix pre-existing model drift
  (normAnd(normAnd(1,255),255) folding, evmAdd operand reorder),
  un-privatize EVM simplification lemmas for reuse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The auto-generated model_osqrtUp_evm inlines the entire 256-bit sqrtUp
into the x_hi=0 branch, making unfold + ite_false produce a proof term
too deep for the kernel. The generator needs to emit branch-separated
definitions to unblock this proof.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
duncancmt and others added 30 commits March 11, 2026 21:02
…y writes (WS-I)

When `if` or `switch` conditions are constant at parse time:

- Constant-false `if`: parse the body (tolerating memory writes) but
  discard it entirely as dead code
- Constant-true `if`: flatten the body into the outer scope as
  straight-line code
- Constant-discriminant `switch`: parse all branches, flatten only the
  matching branch, discard dead branches

This prevents spurious "conditional memory write" errors when mstore
appears in provably-dead branches, and correctly folds constant-true
conditionals so their assignments become straight-line.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…, switch leave

1. find_exact_function: prefer top-level match before searching nested
   scopes, fixing regression where an unrelated nested homonym caused
   ambiguity (introduced by 9ffbc2b)

2. collect_all_functions: check both `functions` and `rejected` dicts
   for duplicate names, so a rejected-then-valid or valid-then-rejected
   duplicate pair is caught (pre-existing bug)

3. Nested rejected helpers shadow valid outer helpers: remove the outer
   name from helper_table when an inner scope rejects the same name,
   preventing silent fallback to the wrong definition (pre-existing bug)

4. Constant-false leave-bearing switch inlining: process the else-body
   (case-0 branch) as straight-line code instead of rewriting into a
   new ParsedIfBlock whose condition semantics are inverted, which
   caused the non-leave constant-fold path to skip the live branch
   entirely (pre-existing bug, exposed by commit 0bca759's incomplete
   constant-fold)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ambiguation

Two code-quality improvements, zero behavioral changes:

1. _TokenReader base class: _ReferenceScopeParser and YulParser had 7
   byte-for-byte identical methods (_at_end, _peek, _peek_kind, _pop,
   _expect, _expect_ident, _parse_expr). Extract them into a shared
   base class so a future expression-syntax change only needs one edit.

2. _disambiguate_by_references: extract the dense 3-level conditional
   block from find_function into a named helper with a docstring that
   explains the priority ordering for leaf selection (exclude_known=True),
   wrapper selection (exclude_known=False), and the partial-parse
   fallback. Add inline comments on each branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…olding

Duplicated decision logic for constant-folding if/switch conditions existed
independently in _inline_single_call (Site B) and yul_function_to_model
(Site C). Extract the shared classification into a single _IfFoldDecision
enum + _classify_if_fold function, and refactor both sites to use it.

Also adds the previously-missing constant-true handling in
yul_function_to_model's non-leave path (Site C), which now flattens
then-body as straight-line assignments instead of emitting a
ConditionalBlock with a constant-true condition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The THEN_LIVE case in Site C's non-leave path was flattening body
assignments into the outer scope using the outer var_map/subst, which
let block-local variables escape their scope. This caused three bugs:

1. Block-local variables (e.g. `let usr$tmp` inside `if 1 { ... }`)
   leaked into the outer scope, silently producing wrong models
2. Block-local pointer variables used by subsequent mstore went
   undetected instead of raising "non-constant address"
3. Block-local binders with names matching generated model functions
   could cause naming collisions in Lean emission

Fix by processing the body in branch-local copies of var_map/subst/
const_locals (same pattern as _process_conditional_branch), then
emitting only assignments to variables that existed in the outer scope.

Also add an out-of-scope variable check in _process_assignment_into:
after substitution and renaming, any Var not in emitted_ssa_names is
a reference to a variable from a closed scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the branch-copy-plus-filter approach with _lower_live_branch,
a dedicated helper that models two scopes during lowering:

  - Outer mutable state (var_map, SSA counters, emitted names, constant
    facts): outer writes go through the normal SSA machinery immediately,
    so reassigned variables get fresh binders (x_2, not a dropped x).
  - Block-local overlay: names introduced by `let` inside the branch
    are parked in var_map/subst for visibility to later statements in
    the same block, then cleaned up at block exit.

Scope survival is decided by source-level binding identity: a target
already in var_map or subst is an outer write; an unknown target is a
block-local declaration.

Fixes three bugs from the prior approach:
  1. Outer writes to SSA-renamed variables were dropped because the
     branch-local base name didn't match the outer SSA name.
  2. Constant facts from outer writes inside the branch were lost,
     breaking downstream mstore address resolution.
  3. ELSE_LIVE in the leave path used the same unsafe straight-line
     pattern; now shares _lower_live_branch with THEN_LIVE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The fundamental problem: _lower_live_branch inferred declaration-vs-
assignment from name visibility (s.target in outer_known), which breaks
when a `let` inside a taken branch shadows an outer binding with the
same Yul name.

Add `is_declaration: bool` to PlainAssignment and set it in the parser's
_parse_let method. Propagate through alpha-rename, inline_calls body
processing, and block substitution.

_lower_live_branch is now driven by is_declaration:
  - Declaration (True): always block-local. The processed expression
    goes into subst for visibility to later statements in the block,
    then is removed at block exit. Shadowed outer subst entries are
    saved and restored.
  - Reassignment (False): writes the outer binding via normal SSA.

Parser constant-fold flattening (Site A) also gains block scoping via
_flatten_scoped_block: when flattening constant-true if or constant-
switch live branches, declarations are substituted away and only
reassignments survive to the outer scope.

Fixes:
  - `let x` inside constant-true block no longer overwrites outer x
  - Block-local real vars available to later outer writes in same block
  - Constant-switch with shadowing let now respects block scope
  - switch-with-leave shadowing test now passes (bonus)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
is_declaration marks when a name enters the local scope, but the
binding's lifetime extends to block exit. A later `x := 2` after
`let x := 1` in the same block must update the local binding, not
escape to the outer scope.

Both _flatten_scoped_block (parser constant-fold) and _lower_live_branch
(model lowerer) now maintain a block_locals set that accumulates names
from declarations. Subsequent reassignments to names in that set update
the local binding instead of emitting to outer scope / outer SSA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…g paths

Replace ad-hoc scope heuristics with a uniform scope model used in all
four lowering paths: bare-block flattening, parser constant-fold
flattening, constant-live branch lowering, and ConditionalBlock
branch processing.

Core principle: is_declaration marks when a name enters a block scope.
All subsequent writes to that name within the block must target that
local binding. At block exit, the binding dies.

Structural changes:

1. _split_branch_scoped replaces _split_branch_assignments.
   Tracks per-branch lexical scope via branch_locals set: declarations
   create branch-local bindings, later writes to those names stay
   branch-local, only outer writes survive.

2. Parser bare-block handler uses sequential is_declaration tracking
   instead of block_let_vars. Real variable declarations (usr$*) are
   alpha-renamed to fresh internal names (_blk_N) and emitted, so the
   model lowerer can use SSA for point-in-time value capture. Compiler
   temps are substituted as before.

3. _process_conditional_branch uses same scope model: declarations
   create branch-local subst entries with const_locals tracking for
   mload address resolution. Only outer writes go through
   _process_assignment_into.

4. _stmt_targets excludes branch-local declarations from outer
   assign_counts, fixing temporary reuse across disjoint branches.

5. needs_zero_init detects conditional return writes that require
   explicit zero-initialization for ConditionalBlock else-outputs.

6. ConditionalBlock post-processing preserves const_locals facts when
   both branches agree on the same constant value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_gensym("blk") produces _blk_N names that are valid Yul identifiers.
User code can legitimately declare variables with the same pattern,
causing "assigned 2 times" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add an `avoid` parameter to _gensym that skips counter values whose
generated name appears in the given set. The bare-block handler passes
all source identifier tokens (lazily collected via _all_source_names)
so generated _blk_N names never collide with user-visible identifiers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…licit Ite/Project IR nodes

Multi-return projections and conditional expressions were encoded as
fake Call names (`__component_N_M(...)`, `__ite(cond, t, f)`) decoded
by regex at every consumption site. This made the IR fragile — a typo
in a regex or a new traversal forgetting to special-case these names
would silently miscompile.

Add two new Expr node types (Ite, Project) to the union alongside
IntLit, Var, Call. Update all 14 traversal functions, 3 creation sites,
5 consumption sites, and ~20 test locations. Semantics are identical;
all 263 tests pass unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nation

Track expression-statements per-branch (on ParsedIfBlock) instead of
per-function. When constant-folding proves a branch dead during inlining
or model generation, its expr_stmts are silently discarded. Live or
non-constant branches still reject expr_stmts as before.

This enables the transpiler to handle Solidity's `/ 3` pattern where the
compiler emits a divide-by-zero guard (`if iszero(y) { panic() }`) that
becomes dead code when the divisor is a known non-zero constant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three bugs in a7e98d6 where branch expr_stmts were silently lost:

1. Bare-block scoping dropped ParsedIfBlocks that carried
   body_expr_stmts but had no outer-scope assignments.
2. _flatten_scoped_block reconstructed ParsedIfBlock without
   propagating body_expr_stmts / else_body_expr_stmts.
3. Constant-true if and constant-switch parse-time paths did not
   isolate _expr_stmts, so dead-branch expr_stmts leaked to
   function level and live-branch nested expr_stmts were lost.

Root cause: the save/restore pattern for _expr_stmts was manually
duplicated at each call site, making it easy to miss new sites.

Fix: introduce _parse_scoped_body() that encapsulates the entire
_expect("{") + save/restore + _parse_assignment_loop + _expect("}")
pattern. All 6 branch-body parsing sites now use it. The bare-block
emit check also considers branch expr_stmts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add assert_never() for exhaustive isinstance checks on Expr/RawStatement
  unions (_alpha_rename, _validate_expr_shape, _check_calls, _wrap_u256_literals)
- Add assert narrowing for else_body before iteration in ELSE_LIVE paths
- Fix dict key type in _has_live/_has_dead helpers (int, not str)
- Rename shadowed variable (chosen_summary → switch_chosen, a → maybe_a)
- Annotate ParsedIfBlock switch normalization locals (if_body, else_body,
  parsed_condition) to avoid type narrowing mismatch
- Annotate rename_subst as dict[str, Expr] to satisfy substitute_expr variance
- Extract assertEqual arguments to typed locals in test file to avoid
  list[Any]/set[Any] under disallow_any_expr

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the two custom NoReturn helpers and use typing.assert_never at
all exhaustive isinstance dispatch sites. This gives mypy a proper
exhaustiveness check rather than a hand-rolled TypeError.

Also remove the sole type: ignore comment by building a typed list[int]
instead of filtering tuple[int | None, ...] in _try_const_eval, and add
the missing explicit Var early-return there so the union is fully covered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_inline_yul_function was the only sequential statement processor that
did not maintain a substitution map. When an intermediate variable was
assigned a constant (e.g. `let expr_1 := cleanup(3)`) and then passed
to a helper with branch expr_stmts (e.g. `wrapping_div(x, expr_1)`),
the helper saw `Var('expr_1')` instead of `IntLit(3)`, making the
branch condition non-constant and incorrectly rejecting the code.

Add a `const_subst` dict that tracks variables assigned to
compile-time-constant values:
- Before inline_calls: substitute known constants into expressions
- After inline_calls on PlainAssignment: record constant results,
  or remove the variable if the new value is non-constant
- After ParsedIfBlock: invalidate conditionally-assigned variables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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