Skip to content

Keep a block's reassignment of an outer local visible after the block - #1319

Draft
apiology wants to merge 1 commit into
castwide:masterfrom
apiology:block-reassign-infer
Draft

Keep a block's reassignment of an outer local visible after the block#1319
apiology wants to merge 1 commit into
castwide:masterfrom
apiology:block-reassign-infer

Conversation

@apiology

Copy link
Copy Markdown
Contributor

A local assigned inside a block is invisible once the block ends, so code that
is correct at runtime reports an unresolved call:

# @return [Array, nil]
def collect(lines)
  chomped = nil
  lines.each { |line| chomped = [line.chomp] }
  chomped   # inferred `nil`, so `chomped.reject` is unresolved
end

The same assignment inside an if already infers the union nil, ::Array.
Only the block form loses it.

Cause. LvasgnNode#process set the pin's presence to
region.closure.location.range.ending. Inside a block that closure is the
Pin::Block, so presence stops at the block's closing token:

chomped @3:4  closure=Pin::Method  presence=3,4..8,5
chomped @5:6  closure=Pin::Block   presence=5,6..6,7   <- invisible at line 7

Fix. LvasgnNode walks outward for an enclosing closure that already has
that name in scope at that position, and attaches the pin there. A name the
block introduces itself ��� block parameter, shadow arg, or first assignment in
the block ��� keeps the block closure and still shadows correctly.

This deliberately does not silence downstream nil errors. Post-block inference
becomes nil, ::Array, not ::Array, because the block may run zero times;
chomped.reject on that union remains correctly unresolved.

Adds 5 examples in spec/pin/local_variable_spec.rb.

Measured against 8fda63384: rubocop 70 offences before and after, output
byte-identical; self-hosted typecheck --level strong 525 problems in 89 of
250 files before and after; rspec unchanged at 1624 examples / 1 pre-existing
failure / 60 pending.

This PR was written by Claude Code on behalf of @apiology.

An assignment inside a block wrote a LocalVariable pin whose closure was
the Pin::Block and whose presence stopped at the block's closing token, so
a read after the block saw only the pre-block pin. `x = nil; [1].each { x
= ['a'] }; x` inferred `nil`, and any call on x after the block was
unresolved.

The pin now belongs to whichever enclosing closure already has a variable
of that name in scope at that point, with presence running to that
closure's end - the same shape an assignment inside an `if` branch already
produces. The read after the block infers `nil, Array`: a block may run
zero times, so the pre-block value stays in the union.

A name the block introduces itself - a block parameter, a shadow arg, or a
first assignment inside the block - keeps the block closure and stays
invisible outside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXmnT5gSB1PheL9UbiGEVA
apiology added a commit to apiology/solargraph that referenced this pull request Aug 21, 2026
apiology added a commit to apiology/solargraph that referenced this pull request Aug 21, 2026
process_macro/process_directive's return values were assigned to
result, shadowing the outer pins.map block's own result= target.
castwide#1319's flow-sensitive reassignment tracking
picked up the inner block-local as though it were the outer one,
making the outer result unresolvable at its own use site (line 432)
and its downstream nil-guards report as unneeded.
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