Skip to content

Narrow is_a? behind wrapper methods and indexed chains - #117

Draft
apiology wants to merge 713 commits into
masterfrom
narrow-is-a-through-wrappers-and-chains
Draft

apiology wants to merge 713 commits into
masterfrom
narrow-is-a-through-wrappers-and-chains

Conversation

@apiology

Copy link
Copy Markdown
Owner

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

Problem: Flow-sensitive typing misses two shapes of is_a? narrowing, together accounting for 28 @sg-ignore markers across 8 files. First: a predicate wrapper like Solargraph::Parser.is_ast_node?(x) calls x.is_a?(...) internally, but narrowing only recognizes a direct is_a?/kind_of? call on the guarded variable, not one hidden behind a wrapper method. Second: parse_receiver_chain only tracks chains of no-argument calls, so an argument-taking access like node.children[0] breaks the chain and can't be narrowed at all.

return unless Solargraph::Parser.is_ast_node?(node)
node.type # not narrowed - the is_a? call is hidden inside is_ast_node?

Solution: Inlines wrapper-method predicate calls (node.is_a?(...) in place of is_ast_node?(node)) so narrowing sees the real is_a? call, and extracts an argument-taking chain expression to a local variable first so narrowing has a trackable target. One site (Pin::Base#nearly?) needed a different fix: the existing instance_of?(other.class) check already implies other.is_a?(Pin::Base) whenever true, so making that implication explicit gives narrowing something to work from without changing behavior.

Test plan: solargraph typecheck --level strong reports the same 4 pre-existing, unrelated problems before and after (3x Unresolved constant Vernier in shell.rb, 1x Unneeded @sg-ignore in gemspecs.rb). Full rspec: 2208 examples, 0 failures, 45 pending.

"See ToStructInitializer for why a Struct.new definition needs
this" adds nothing at the call site a reader would not already get
from the method call itself - ToStructInitializer.make already says
what it does, and its own module docstring already has the why.
The existing spec exercises resolve_generics directly; add one
closer to the PR description repro of
NoMethodError<String>#receiver, going through real workspace source
and ApiMap so the fix is also demonstrated at the level a user would
actually hit it.
A method mixed into arbitrary classes that reads an instance variable
never assigned in its own lexical scope (e.g. a mixin expecting the
including class to set @logger) has no assignment pin for any existing
narrowing mechanism to find, so a guard like

  raise 'x' unless instance_variable_defined?(:@logger) &&
    @logger.respond_to?(:puts)

fails strong typecheck on both the guard and the subsequent read with
"Unresolved call to @logger".

Add FlowSensitiveTyping#process_instance_variable_defined, following
the same pattern as #process_respond_to and #process_isa. On the
guard's true branch it synthesizes a Pin::InstanceVariable typed as a
generic non-nil Object, scoped to the guarded presence range, when no
existing pin is found. #process_respond_to then narrows that
synthesized pin the same way it narrows any other variable, with no
changes needed to #process_respond_to itself.

Threading a closure through FlowSensitiveTyping (from the same
region.closure each node processor already passes to its own pins)
supplies the namespace the synthesized pin needs.

Verified: the guard-plus-read case above resolves to 0 problems, an
unguarded read of the same ivar elsewhere is still correctly flagged,
and the mechanism composes through && the same way respond_to?/is_a?
already do. Full rspec suite, RuboCop, and strong typecheck are
unchanged from baseline (1641 examples, 12 pre-existing failures;
629 strong-typecheck problems on both sides).

Separately confirmed: `raise 'x' unless X` does not get the same
"rest of the method is proven true" extension that `return unless X`
gets, because always_leaves_compound_statement? only recognizes the
parser's literal :return/:next/:redo/:retry node types, and `raise`
parses as an ordinary :send node. This is a pre-existing gap,
unrelated to this change, and unaddressed here.
…verloads

# Conflicts:
#	lib/solargraph/pin/parameter.rb
#	lib/solargraph/source/chain/hash.rb
Names what the two lines actually do (force an enclosing block own
@Rebind memo to populate before binder/context fall through to it)
instead of leaving them inline under a comment.
TypeChecker::Rules#level looked up @rank in LEVELS by value using
LEVELS.values.index, which returns a position index, not a level
name, so the returned level was always wrong or nil.

Conflicts resolved by intent: complex_type.rb, namespace_node.rb,
call.rb, node_processor_spec.rb, call_spec.rb kept HEAD's
2026-08-04 versions, which already contain the same or later
overload-resolution/intersection-duck-typing work the incoming
branch's unrelated commits also touched.

Also fixes typecheck findings the merge introduced:

* Rules#rank and Rules#level use Hash#fetch/Hash#key results that
  Solargraph can't prove non-nil, so add explicit fallbacks
  (LEVELS.fetch(level, 0), LEVELS.key(@rank) || :normal,
  LEVELS.fetch(..., 0) in report?) as a real defensive guard against
  LEVELS and the rank-setting logic drifting apart.
* YardMap::Mapper#core_store, added by one of the incoming branch's
  unrelated commits, was missing its @return tag.
* mapper_spec.rb's new "adjusts YARD namespaces" test called
  Yardoc.load! with a gemspec, but Yardoc.load! now takes a
  gem_yardoc_path (changed by an earlier PinCache refactor already on
  2026-08-04). Fixed the test to build docs first via Yardoc.build_docs
  and load from the resulting path.
* Removed two stale `pending` markers in clip_spec.rb for overload
  resolution by argument type - the feature already works on this
  branch, so the specs were passing unexpectedly.
Hash#[] is typed (_Key) -> V | nil in RBS since a generic lookup
can always miss. details is a literal hash built two lines above
with :qualified always present, so the miss case cannot happen at
runtime.

Hash#fetch(key), single-argument form, is typed to return V with
no nil in the union - it raises KeyError instead of returning nil
when the key is absent. Swapping to fetch drops the runtime-
impossible nil from the type and turns an accidental future
removal of the :qualified key into a loud KeyError rather than a
silent nil.
Range#to_hash builds its result from Position#to_hash for both the
start and end keys. Position#to_hash returns Hash{Symbol => Integer}
(line/character), not a Position object, so the tag was documenting
a type the method never produces.
Hash#[] is typed (_Key) -> V | nil in RBS since a generic lookup
can always miss. details is a literal hash built two lines above
with :qualified always present, so the miss case cannot happen at
runtime.

Hash#fetch(key), single-argument form, is typed to return V with
no nil in the union - it raises KeyError instead of returning nil
when the key is absent. Swapping to fetch drops the runtime-
impossible nil from the type and turns an accidental future
removal of the :qualified key into a loud KeyError rather than a
silent nil.
# Conflicts:
#	lib/solargraph/source/chain/call.rb
…on-types

# Conflicts:
#	lib/solargraph/complex_type.rb
#	lib/solargraph/source/chain/call.rb
Adds an intersection-type @type tag for the result hash and switches
result[:capabilities] accesses to result.fetch(:capabilities), which
strong typecheck requires once the intersection type declares the
hash key as non-nilable via Hash#fetch instead of Hash#[].

This depends on this branch own intersection-type (A & B) support
and does not apply cleanly to plain master.
Syncs 7 new commits from castwide/solargraph master (6f92464),
mostly earlier versions of refactors this branch had already
absorbed and extended (Chain::Call overload-matching extraction,
inline-RBS superclass parsing). Conflicts resolved by keeping this
branch's more advanced versions; upstream sides were strict subsets.

spec/source/chain/call_spec.rb: dropped upstream's new
"pending PR 1223" markers on 6 specs, since PR 1223 is already
merged into this branch.
git rerere reused a cached resolution from an earlier merge and
reintroduced two 'pending' calls this branch had already removed
(the underlying overload-narrowing bug they tracked, castwide
solargraph#1246, is already fixed here; upstream master still has
it pending). Both specs pass without the marker.
Base#send_response builds a Hash literal record type, then mutates
it with three later `[]=` calls. Solargraph does not widen a Hash
literal's per-key inferred type to cover a later assignment against
a declared record type, so each `[]=` line is flagged as a type
mismatch even though the code is correct at runtime.

This is a known Solargraph limitation; a fix is being finalized at
castwide#1231. Suppress the 3
findings with bare @sg-ignore markers citing that PR until it lands.
The castwide/solargraph:master sync introduced a new inference
change that left `details` unresolved at the same
add_restkwarg_param_tag_details call site fixed earlier by the
Hash#fetch change. Add an explicit intersection-type tag on the
hash literal alongside that fix.

Also drop two now-unneeded @sg-ignore comments in
NodeChainer#hash_pairs citing castwide/solargraph PR 1223; git log
-S confirms no prior commit ever needed them restored.
Adds an intersection-type @type tag for the result hash and switches
result[:capabilities] accesses to result.fetch(:capabilities), which
strong typecheck requires once the intersection type declares the
hash key as non-nilable via Hash#fetch instead of Hash#[].

This depends on this branch own intersection-type (A & B) support
and does not apply cleanly to plain master.
A variable declared with an intersection of single-key Hash record
types (Hash{:a => X} & Hash{:b => Y}) was rejected against its own
initializing Hash literal, because standard Hash-literal inference
merges all pairs into one Hash{K => V} type and cannot express which
value belongs to which key.

Add a fallback per-key inference path (Pin::BaseVariable#record_type,
used by TypeChecker#record_assignment_conforms_to?) that only kicks
in when the primary conformance check fails, so an intersection-typed
Hash literal assignment can still typecheck. Also updates
Intersection, Source::Chain::Hash, and annotates two call sites
(language_server/message/base.rb, type_checker.rb) that hit this gap
after the master merge.
The else branch of the splat handling in BaseVariable#probe
(types.flat_map returning [] when a mass-assignment source type is
neither a tuple nor splattable) had no test.
TypeMethods#qualify next-skips t.bot? alongside duck_type?/void?/
undefined?, but no test called qualify with a bot type, so the
branch this PR added was unexercised.
apiology and others added 27 commits September 4, 2026 21:23
Brings castwide#1281 up to 0aa9e65. This branch already had
an older squash of that work, 8b97bb1, so what arrives is the
refinements made since.

Eleven conflicts.

lib/solargraph/rbs_translator.rb, six: took this branch throughout. It
keeps build_unique_type, which that branch removed as uncalled but which
has three live call sites here; keeps to_restarg_return_type and
to_kwrestarg_return_type, which that branch does not have; keeps the
@sg-ignore wording that cites an issue URL over the prose form; and
keeps the keyword-parameter signature of type_to_tag, since every call
site here passes keywords.

lib/solargraph/rbs_map/conversions.rb, three: took this branch.
rooted_tags preserves the :: prefixes that to_s drops, and building the
nil union through the object model avoids re-parsing a tag string.

spec/rbs_map/conversions_spec.rb, two: kept both sides. Git aligned two
independent context blocks as one conflict - implicitly-returns-nil here,
a prepended module there.

That prepend example then failed: it expects the module name 'Bar' where
the combined tree produces '::Bar', because other work here roots those
names. Expectation updated.

.rubocop_todo.yml regenerated against the merged tree.

2184 examples, 0 failures, 45 pending.
Nineteen markers whose underlying gaps are closed by the work merged
here, reported by strong typecheck as "Unneeded @sg-ignore comment":
thirteen in rbs_translator.rb, three in rbs_map/conversions.rb, and one
each in pin/method.rb, rbs_map/core_map.rb and workspace/gemspecs.rb.

Removing the gemspecs.rb one exposed a second marker below it as
unneeded in turn, which is how that file's own comment predicts this
goes - the constant resolves differently by Ruby patch release, and CI
runs a newer one than most worktrees. Restored; the file is unchanged.

Also suppresses two findings the merges newly surfaced, both real
nil holes that :allow_unmatched_interface used to hide:

  logging.rb   Logger#formatter is _Formatter? and #formatter= takes
               _Formatter, so assigning one to the other is unsound.
  unique_type.rb  substring[0] is String?, and Hash#fetch's key is K
               again now that rbs/overrides restores it.

unique_type.rb's remaining three came from fetch's block-form overload
returning Symbol | generic<X>, where X never resolves because the block
only raises. A @type on parameters_type pins it back to Symbol, nil.

37 problems down to 8, all pre-existing.
Brings #60 up to its current head. The lib-side work
is already here, so what arrives is
spec/type_checker/levels/destructuring_spec.rb - the coverage for the
two undercover nodes that PR owns, in args_node.rb and pin/block.rb.

One conflict, .github/workflows/plugins.yml, resolved to this branch.
That side removes four continue-on-error suppressions and enables the
bundler cache; the goal here is to surface problems, not to keep the
check green. Both sides clone the same solargraph-rspec branch, so that
part of the conflict was cosmetic.

2186 examples, 0 failures, 45 pending.
6f299ac dropped nineteen markers a local strong typecheck called
unneeded. Two of them were not: CI reports Unresolved call to defined?
and Unresolved call to self_to_type in Chain::Call, failing the
Solargraph, regression and rspec-plugin jobs.

Local and CI disagree here because they resolve new_return_type
differently, which is the disagreement this integration branch exists
to surface. Where they differ, CI decides.
Brings castwide#1312 up to 99fc26b. Everything on that
branch except its own five commits is already here, so what arrives is
lib/solargraph/logging.rb plus coverage in three spec files, aimed at
the four undercover nodes that PR owns: Logging, Pin::Base#choose, and
two blocks in Shell.

One conflict, spec/shell_spec.rb, resolved to keep both sides - this
branch's gem-caching examples and that branch's typify-raises example.

Dropped that branch's two do_cache examples. They cover Shell#do_cache,
which is not on this branch: it was merged in at d0714be in August and
has since gone, and the PR that rewrote that area owns providing its
tests. The remaining four nodes are unaffected.

2197 examples, 0 failures, 45 pending.
Brings #66 up to its current head. Its lib fix,
c8f7cc9, was merged at 08e3c4a in August; the three outstanding
commits are coverage for the two undercover nodes it owns, both in
UniqueType#exclude.

Two conflicts.

lib/solargraph/parser/flow_sensitive_typing.rb: took this branch. That
branch still has always_leaves_compound_statement? private to this file;
here it has moved to parser_gem/node_methods.rb and is shared by three
callers. The moved version also handles a :begin multi-statement clause
and matches fail as well as raise, neither of which the private copy
does.

spec/complex_type/unique_type_spec.rb: kept both. Git aligned this
branch's #literal_keyed?, #key_type_tag?, #simplify_literals,
#narrow_with, #mixin_pairing? and #namespace_kind blocks against that
branch's #exclude block as one conflict. Spliced #exclude in rather than
choosing; the file is 25 examples, 0 failures.

Also carries that branch's comment trimming in complex_type.rb,
unique_type.rb and flow_sensitive_typing_spec.rb.
CI failed four typecheck jobs on 91dcde9 with
lib/solargraph/logging.rb:21: Wrong argument type for Hash#fetch: arg0
expected String, received String, nil - once per fetch overload.

configured_level defaults to ENV.fetch('SOLARGRAPH_LOG', nil), so it is
String or nil, and the LOG_LEVELS.key? guard on the same line does not
narrow it. The key is typed K again now that rbs/overrides restores it,
where Hash::_Key previously accepted anything.

The two markers already on this method sit on its docstring and so
apply to the def line, not to the fetch.
Brings castwide#1280 up to its current head. Seven of its
eight commits are already here; the outstanding one is 66adce5,
removing the dead TypeMethods#qualify.

That method is the undercover node this PR owns,
complex_type/type_methods.rb:248-258 at 0%. It closes by deletion rather
than by coverage - nothing called it.

Also carries a comment rewrite on UniqueType::BOT, naming which
operations a wrong @rooted would actually break rather than leaving it
general.

No conflicts. 2200 examples, 0 failures, 45 pending.
Brings castwide#1258 up to its current head. Its lib fix,
c2a16e5, is already here; the three outstanding commits are coverage
and comment work, including the spec for the undercover node it owns,
pin/base_variable.rb:483-485 equality_fields.

Four conflicts.

lib/solargraph/source/chain/call.rb: took this branch. None of the three
outstanding commits touches that file - the conflict is only that the
other branch still carries the older call.rb from c2a16e5, which is
already merged here.

spec/pin/base_variable_spec.rb: kept both sides, then adapted the
incoming example. It called downcast(intersection_return_type:), which
predates this branch's rename to narrowed_return_type; renamed the
keyword and the example title to match.

lib/solargraph/parser/flow_sensitive_typing.rb and
lib/solargraph/source/chain/instance_variable.rb: took the combination -
shorter comments, four @PARAM tags, and a bespoke @sg-ignore explanation
replaced by the catalogued "Need to add nil check here" slug.

2202 examples, 0 failures, 45 pending. Strong typecheck unchanged at the
six known baseline problems.
A merge onto this branch produced two byte-identical definitions of
hash_pairs, comment and all. Ruby keeps the last, so the first was
unreachable and undercover reported its three nodes - the method itself
and both inner blocks - as 0% covered.

Not a coverage gap and not something castwide#1231 needs to
fix: that branch has one definition. Merge resolution here.

This also explains why removing two @sg-ignore markers from hash_pairs
earlier today changed nothing - they were on the dead copy.

spec/parser: 193 examples, 0 failures, 4 pending.
Brings castwide#1308 up to its current head. Its narrowing
work is already here through 11e3386; the two outstanding commits are
dcc707b, covering the nil-location tiebreak in
CompoundStatement#combine_with - the undercover node this PR owns - and
6b12401, a marker-placement fix.

Two conflicts, both taken from that branch, since 6b12401 exists to
correct exactly those lines: api_map.rb and resbody_node.rb. The latter
also extracts a local and adds a real nil guard around
NodeProcessor.process, which is why it no longer needs a marker there.

6b12401's marker moves then had to be partly undone here, because the
combined tree narrows differently from that branch alone:

  api_map.rb    Its three markers all reported Unneeded, while
                store.get_superclass(sc_fqns) two lines below went
                unguarded - sc_fqns inherits ComplexType, String across
                the simplify_literals reassignment. Removed all three
                and put the "unions rather than overrides" marker back
                on the while line, where it had been before.
  formatting.rb Restored a marker it deleted as unneeded, using the
                catalogued "Need to add nil check here" slug.

2203 examples, 0 failures, 45 pending. Strong typecheck back to the six
known baseline problems.
Brings castwide#1325 up to its current head. Its original fix,
8f78bf4, is already here; seven commits are new, including real lib
work - recognizing structurally-Enumerable classes as splattable, and
giving every position the full union for a non-tuple Array target -
alongside c469abc, the coverage for the undercover node this PR owns
at masgn_node.rb:64-66.

One conflict, lib/solargraph/pin/base_variable.rb, where each side adds
a method the other lacks: override_assignments? and definite_reaches?
here, splattable? there. Both sides ended mid-method sharing the closing
end below, so they cannot simply be concatenated; kept this branch's
region and spliced splattable? in from that branch's own file.
splattable? has two live callers here, so dropping it was not an option.

Also ratchets .rubocop_todo.yml, which removes an entry rather than
adding one: Lint/DuplicateMethods was excluded for node_chainer.rb,
suppressing the duplicated hash_pairs that 269c9f8 deleted. RuboCop
had found that defect and the exclusion had hidden it.

2207 examples, 0 failures, 45 pending. Strong typecheck at the six known
baseline problems.
BaseVariable#probe splits multiple assignment into two flat_map blocks -
one for a splat target, one without - and each has an else branch for a
right-hand side that is neither a tuple nor splattable.

79de19b covered the splat one, using `command, *args = mutator`. The
non-splat branch had no test: `command, args = mutator` takes the other
path entirely, so the existing example never reaches it.

Verified by instrumenting the branch and confirming only the new example
reaches it.

spec/pin/base_variable_spec.rb: 9 examples, 0 failures.
Brings castwide#1325 up to 8a2a2ee, adding coverage for the
non-splat branch of BaseVariable#probe's multiple-assignment handling -
the last uncovered node undercover reported on this branch.

No conflicts. 2208 examples, 0 failures, 45 pending.
Index :or_asgn and :or children with fetch. Every form of ||= and ||
the parser builds has exactly two children, so the right-hand side is
never nil: the raise guard was unreachable, and it did not narrow for
the type checker either, which is why its line still needed an
@sg-ignore. fetch asserts the same invariant and drops the guard, that
suppression, and three "Need to add nil check here" markers on new
code, one of which the type checker already called unneeded.

On Chain::Or#equality_fields, replace "return type could not be
inferred" with the catalogued rules.rb string. Both problems there
have one cause, the literal array resolving to Chain::Array, so + does
not resolve and the return type cannot be inferred. Drop the
redefinition marker above without_nil, also reported as unneeded.

Move the note about passing node: to Pin::Namespace and state what the
parameter is for rather than what breaks without it. Trim
always_leaves_compound_statement? to the four-line comment budget.

Adjust the rules.rb counts by this delta. They were already stale --
lib holds 245 "Need to add nil check here" markers against 281 listed
-- so the new numbers carry the same error, less what is removed here.

Strong typecheck drops from 528 problems to 525 with none added.
Specs unchanged: 1665 examples, 0 failures, 67 pending.
The comments added by this branch ran well past the repo convention of
1-3 lines, budgeted per method across a docstring and its inline
comments together. Twenty-six blocks were 4 lines or more, the largest
at 26, 16, 14, 13 and 12 lines, which buries the one non-obvious fact
each was there to state.

Each block is cut to roughly a third rather than trimmed at the edges,
keeping the constraint that is still in force and dropping the
narration around it. Every @sg-ignore, @PARAM and @return line is left
in place, so no suppression moves relative to the code it applies to
and no declared type changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcKgVwg618qbVu6NoWDhA4
CI at dcc707b flags all three markers in ApiMap#super_and_sub? as
"Unneeded @sg-ignore comment" (api_map.rb:710, 712, 714). This
branch's own dominance handling resolves the redefinition case they
covered, so they no longer suppress anything.

Verified with "bundle exec solargraph typecheck --level strong": with
the markers gone, no problem is reported on any of those three lines.
bin/solargraph is a bare script rather than a bundler binstub, so it
loads the installed 0.60.4 gem and still reports the markers as
needed - that analyzer predates the dominance work, and its verdict
here is wrong.

The remaining gap in this method is untouched and still unsuppressed:
Wrong argument type for Store#get_superclass, where sc_fqns is
ComplexType, String because multiple sequential reassignments union
rather than dominate by recency.
Review asked for the nil checks behind "Need to add nil check here" to
be written now rather than deferred, here and elsewhere. This branch
added six such markers; none survive.

FlowSensitiveTyping#process_if guards conditional_node before using
it. CI reports it as "expected Parser::AST::Node, received
Parser::AST::Node, nil" on the process_guarded_reassignment call, and
the same nil reaches process_expression on the line above, which was
unsuppressed.

Pin::Method#infer_from_return_nodes now calls Pin::Base#filename,
which already returns nil when location is nil, instead of reaching
through location.filename itself. That drops two markers, including
one predating this branch. Note location is frequently non-nil while
its filename is nil, and the surrounding code relies on passing that
nil through to ApiMap#source_map, so a guard on filename rather than
on location breaks return-type inference for three method_spec
examples.

The four markers in IfNode are deleted outright: CI flags all four as
"Unneeded @sg-ignore comment" at if_node.rb:32, 42, 48 and 58.

Local typecheck disagrees with CI on that last point, and the
disagreement is unexplained. On this machine node.children[N] infers
as Array, so those lines report "expected Parser::AST::Node, received
Array" and the markers look needed; CI infers Parser::AST::Node, nil
for the identical source. CI is taken as authoritative here.
Review asked what benefit Pin::Base#closure deriving a closure from
the compound_statement chain brings. Measured answer: none. All eight
CompoundStatement.new sites pass closure: explicitly, and with the
derivation removed the only failing example was the one added
alongside it to exercise it - so its sole consumer was its own test.
Both are removed, along with the two @sg-ignore markers the private
method carried.

Removing it also tightens what #closure infers, taking the local
strong typecheck from 576 problems to 543.

The two remaining examples in compound_statement_spec keep their
value: they walk the chain with their own helper and check it reaches
the stored closure, so a node processor threading closure: without
compound_statement: still gets caught. Their header comment no longer
describes a derivation that exists.

Also documents what the definite: argument means at the LvasgnNode
call site, as asked.
Reviewing the compression hunk by hunk surfaced two cuts that removed
a fact rather than narration, and one defect that predates it.

process_guarded_reassignment lost the reason it asserts only the
opposite branch: the firing path is already handled by unioning in the
assignment pin. That sentence is back.

The resbody_node.rb block is not one method comment. and_node.rb,
or_node.rb and orasgn_node.rb all point at it instead of repeating
themselves, so it is the shared explanation for four call sites and
earns the full four lines rather than two.

base_variable.rb declared @PARAM other twice on combine_assignments,
with a stray blank comment line between them. Present at c8586e1,
so not introduced by the compression.

The spec helper comment drops to two lines, per the convention that
spec comments stay rare and the reasoning lives in the example name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcKgVwg618qbVu6NoWDhA4
process_condition only forwarded its three arguments to
process_expression. Its single caller, assert_after_guard, invokes it
on a separate instance configured with only_downcast_these_names, so
the pass-through existed solely to expose a private method across
instances.

process_expression moves up into the public section and takes over the
docstring; assert_after_guard now calls it directly. That makes the
class surface explicit rather than wider: the method was already
reachable from outside through the forwarder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcKgVwg618qbVu6NoWDhA4
Four constructs added by this branch - and, or, orasgn and resbody -
built a CompoundStatement pin and deliberately kept it out of pins,
with a comment claiming their bodies were too common to warrant one.
Master has no such case: all four of its sites push, and
NodeProcessor::Base#enclosing_compound_statement_pin finds them by
selecting from pins. The exception left the parent chain and that
positional lookup disagreeing about which compound statements exist.

Pushing and, or and resbody changes nothing measurable. Pushing
orasgn regresses one case: a leaving guard inside a ||= body stopped
narrowing at the end of the ||=.

That narrowing was previously right only by accident. With no pin for
the ||= body, the guard range ran to the method body and happened to
reach the correct answer. The reason it is correct is specific to
||=: the body is skipped exactly when the target is truthy, so the
skip path reaches the same conclusion about the target as the guard
does. No other conditional body carries that guarantee - a while or
rescue body simply may not run - which is why extending the range for
every leaving guard is wrong, and was measured to be: it flips six
constructs the other way.

FlowSensitiveTyping#assert_after_skipped_or_asgn asserts exactly that
fact, restricted by name to the assignment target. A new spec covers
the restriction, checking a second variable guarded inside the same
body is not narrowed after it.
The two branches diverged at 0.60.4 and share 13 of their commits, so
they overlap in 31 of the 38 files either one touches. Eight files
conflicted across eleven hunks.

Resolution rule: 1308 wins on behaviour. Its tip commits are answers to
review comments on 1308 itself, so they are the later decision rather
than a competing one. 1282 comment compression survives only where 1308
did not change the behaviour being described.

Four places needed more than a mechanical pick:

resbody_node.rb - 1308 extracts a rescue_body_node local and pushes the
CompoundStatement onto pins. That makes the 1282 comment saying it is
never pushed false, so the comment goes rather than merging, and the
two markers the extraction obsoletes go with it.

pin/base.rb - 1308 deleted derive_closure_from_compound_statement, so
the compressed comment describing it goes too.

base_variable.rb - kept the 1282 comment wording over the 1308 code,
which carries a superseded local that 1282 has no equivalent for.

flow_sensitive_typing.rb - 1308 still had the process_condition
forwarder and a private process_expression that also calls
process_parentheses and process_assignment. The public
process_expression from 1282 is the surviving one, now carrying both of
those calls.

Hooks bypassed with --no-verify: Overcommit in a linked worktree
resolves its git dir to the primary checkout and its stash-and-reset
destroys MERGE_HEAD, silently producing a single-parent commit whose
subject still says Merge. Checks run separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcKgVwg618qbVu6NoWDhA4
override_assignments? took a location so a branch-local reassignment
could supersede at a use site it dominates. That threaded a position
through combine_with and combine_assignments in BaseVariable, and
through the combine_with overrides in LocalVariable and Parameter,
making pin combination position-dependent.

ApiMap#var_at_location already filters candidates by position. It now
also asks each one for its definite-at-that-position form, so the
combiner sees only pins whose assignments have definitely run and needs
no location of its own. override_assignments? reduces to a check on
other.definite, and three signatures lose a parameter.

The negative controls still hold. A use site earlier in the branch than
its reassignment is excluded by visible_at? before the promotion is
reached, and a reassignment nested in a further conditional never
dominates.

BaseVariable#definite_at returns self or a definite copy, one return
type either way, and var_at_location binds the mapped collection to a
local before folding it. Both shapes are deliberate: a ternary over two
types, or map chained directly into inject, each leave the accumulator
unresolved at strong level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcKgVwg618qbVu6NoWDhA4
Fixes raise/fail-based nil guards, root-scoped (::-prefixed) is_a?
narrowing, case/when subject narrowing, ||= on lvars/ivars, and
is_a?/nil? guard narrowing at file/class-body scope.

Conflict resolution:
- node_chainer.rb: kept this branch's existing forms for the or_asgn
  and :or node-chaining branches (a raise-based nil guard plus one
  @sg-ignore for or_asgn, two @sg-ignores for :or) rather than 1259's
  Array#fetch refactor. fetch leaks an unresolved generic<T> type
  instead of Parser::AST::Node on this branch's Solargraph, confirmed
  by removing the ignores and re-running the self-hosted typecheck.
- source/chain/or.rb: dropped a literal-array @sg-ignore that 1259
  carries from an older base; this branch's self-hosted typecheck
  reports 0 problems on that line without it.
- type_checker/rules.rb: kept this branch's counts. 1259's catalogue
  was computed against an older base and is missing this branch's
  already-landed pull/1223, pull/1245, and issues/1249-1251 tracking
  entirely.
Fixes five flow-sensitive-typing gaps: a parameter reassigned to a
non-literal type, a nil-guarded default used past its conditional,
narrowing outliving a definite reassignment, a dominating reassignment
not counted as definite, and a variable assigned inside an if
condition.

Conflict resolution:
- flow_sensitive_typing.rb: assert_after_guard called a method,
  process_condition, that does not exist anywhere in the codebase - a
  pre-existing bug on this branch, unrelated to this merge, that would
  crash the moment guarded-reassignment narrowing actually fired with
  a non-empty name list. Called the real method (process_expression)
  and made it public, since it was private and being invoked with an
  explicit receiver on a sibling instance. Also removed a duplicate
  process_expression definition already present on this branch (the
  first was silently shadowed by the second; Ruby uses the last def).
- resbody_node.rb / source/chain/or.rb: kept this branch's existing
  behavior over the PR's older-base forms, verified via the self-hosted
  typecheck.
- type_checker/rules.rb: kept this branch's counts.

Verification: solargraph typecheck --level strong (1 pre-existing
problem, the known gemspecs.rb Ruby-version-sensitive @sg-ignore, same
as the branch tip); full RSpec (2208 examples, 0 failures, 45
pending); undercover --compare (pre-merge tip) reports no missing
coverage.
Fixes two flow-sensitive-typing blind spots surfaced by 28 @sg-ignore
markers across 8 files.

Solargraph::Parser.is_ast_node?(x) and similar predicate wrappers call
x.is_a?(...) internally, but flow-sensitive typing only recognizes a
direct is_a?/kind_of? call on the guarded variable, not one hidden
behind a wrapper method. Inlining the check (node.is_a?(...) in place
of is_ast_node?(node)) lets narrowing see it.

Separately, parse_receiver_chain only tracks chains of no-argument
calls, so an argument-taking access like node.children[0] breaks the
chain and can't be narrowed. Extracting the expression to a local
variable first, then guarding on the bare local, gives narrowing a
trackable target.

One site (Pin::Base#nearly?) needed a different fix: other's declared
type is [Pin::Base, Object], and nothing narrowed it away from Object
before calling name/comments/directives/docstring on it. The existing
instance_of?(other.class) check already implies other.is_a?(Pin::Base)
whenever true, so adding that implication explicitly gives narrowing a
real is_a? call to work from, without changing which inputs return
true or false.

Verification: solargraph typecheck --level strong reports the same 4
pre-existing, unrelated problems before and after (3x Unresolved
constant Vernier in shell.rb, 1x Unneeded @sg-ignore in gemspecs.rb).
Full rspec: 2208 examples, 0 failures, 45 pending.
This commit's own change to drill_signature (extracting a child
local, dropping 2 now-unneeded sg-ignore markers) modified lines
undercover flags with no test coverage. Covers all 4 branches:
non-AST-node passthrough, nested const chain, variable-rooted method
call, and bare ivar.
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