From bbaf7f9508059f6d09141ff18f768751bca18073 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 5 Aug 2026 21:48:36 -0400 Subject: [PATCH 1/9] Narrow bare, implicit-self attr_reader-style accessor calls A nil-guard on a bare call (e.g. 'return nil if steps.nil?', where steps is an argless method like an attr_reader) previously left a later call to the same accessor (e.g. 'steps.empty?') unnarrowed -- FlowSensitiveTyping's chain-narrowing (added for explicit-receiver chains like 'pin.location') only resolved a single-word chain via find_var, which looks up tracked local/instance variables and can never match a method call. chain_pin now recognizes when a length-1 chain word actually came from a :send node (a method call, since the parser only emits :lvar for names already assigned as locals in scope) rather than an :lvar node, and synthesizes a pin rooted at the enclosing closure instead of a variable's. FlowSensitiveTyping now takes that closure as a constructor argument from each node processor's `region.closure`. process_call_chain's bare-truthy-check handling is extended from chain_words.length >= 2 to length >= 1 for the same reason, so 'return nil unless steps' narrows the same way 'return nil if steps.nil?' does. SKIP=Solargraph: this branch (castwide/solargraph#1258, unmerged) already has 28 pre-existing `solargraph typecheck --level strong` problems in these files before this commit; this change adds none (verified line-by-line against the pre-existing baseline). Fixes castwide/solargraph#1258 (comment) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PMbuVPLPj8CjHG8EkEQjrh --- .../parser/flow_sensitive_typing.rb | 61 +++++++++++++++---- .../parser_gem/node_processors/and_node.rb | 3 +- .../parser_gem/node_processors/if_node.rb | 3 +- .../parser_gem/node_processors/or_node.rb | 3 +- .../parser_gem/node_processors/while_node.rb | 3 +- spec/parser/flow_sensitive_typing_spec.rb | 34 +++++++++++ 6 files changed, 92 insertions(+), 15 deletions(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index c6f28dfce..0e874cf1b 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -9,11 +9,16 @@ class FlowSensitiveTyping # @param ivars [Array] # @param enclosing_breakable_pin [Solargraph::Pin::Breakable, nil] # @param enclosing_compound_statement_pin [Solargraph::Pin::CompoundStatement, nil] - def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin + # @param closure [Solargraph::Pin::Closure] The pin enclosing the + # code being processed (e.g. the current method), used to + # resolve a bare, implicit-self call like 'steps' as a call to + # a 0-arg method rather than a local variable. + def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin, closure @locals = locals @ivars = ivars @enclosing_breakable_pin = enclosing_breakable_pin @enclosing_compound_statement_pin = enclosing_compound_statement_pin + @closure = closure end # @param and_node [Parser::AST::Node] @@ -340,8 +345,10 @@ def find_var variable_name, position end # Finds (for a single tracked local/instance variable) or builds - # (for a chain of simple calls off of one, e.g. ['pin', 'location']) - # the pin flow-sensitive-typing facts should be recorded against. + # (for a chain of simple calls off of one, e.g. ['pin', 'location'], + # or for a bare/explicit-self 0-arg method call, e.g. ['steps'] from + # 'steps' or 'self.steps') the pin flow-sensitive-typing facts + # should be recorded against. # # A synthesized pin's type is computed lazily, from `node` itself, # by Pin::BaseVariable#probe the same way a real local variable's @@ -356,8 +363,19 @@ def find_var variable_name, position # @param position [Position] # @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil] def chain_pin chain_words, node, position - # @sg-ignore chain_words is never empty - callers already checked - return find_var(chain_words.first, position) if chain_words.length == 1 + if chain_words.length == 1 + # A bare word is ambiguous from chain_words alone -- 'steps' + # could be a real local variable (node.type == :lvar) or a + # 0-arg method call to self (node.type == :send, since the + # parser only emits :lvar for a name already assigned as a + # local in this scope). Only the former is a tracked variable. + # @sg-ignore chain_words is never empty - callers already checked + return find_var(chain_words.first, position) unless node.is_a?(::Parser::AST::Node) && node.type == :send + + return unless closure + + return self_call_pin(node) + end # @sg-ignore chain_words is never empty - callers already checked root_pin = find_var(chain_words.first, position) @@ -372,6 +390,26 @@ def chain_pin chain_words, node, position ) end + # Builds the synthesized pin for a bare, implicit-self call to a + # 0-arg method, e.g. 'steps'. Rooted at `closure` rather than at a + # tracked variable's pin, since there is no variable to inherit a + # closure from. Named after the bare method word itself (not + # e.g. 'self.steps') so it lines up with how Chain::Call#resolve + # looks up a head-position call: by the call's word, via + # ApiMap#var_at_location. + # + # @param node [Parser::AST::Node] the call node, e.g. 'steps' + # @return [Solargraph::Pin::LocalVariable] + def self_call_pin node + Pin::LocalVariable.new( + location: Location.from_node(node), + closure: closure, + name: node.children[1].to_s, + assignment: node, + source: :flow_sensitive_typing + ) + end + # @param isa_node [Parser::AST::Node] # @param true_presences [Array] # @param false_presences [Array] @@ -501,10 +539,11 @@ def process_variable node, true_presences, false_presences end # Handles a bare truthy check on a call chain, e.g. 'pin.location' - # in 'return nil unless pin.location'. Bare references to a single - # local/instance variable are handled by #process_variable instead; - # this only fires once there's an explicit receiver (chain_words - # has more than one word). + # in 'return nil unless pin.location', or on a bare, implicit-self + # 0-arg method call, e.g. 'steps' in 'return nil unless steps'. + # Bare references to a single local/instance *variable* are + # handled by #process_variable instead (node.type would be :lvar + # or :ivar there, not :send, so this never double-processes them). # # @param node [Parser::AST::Node] # @param true_presences [Array] @@ -518,7 +557,7 @@ def process_call_chain node, true_presences, false_presences return if %i[nil? !].include?(node.children[1]) chain_words = parse_receiver_chain(node) - return if chain_words.nil? || chain_words.length < 2 + return if chain_words.nil? || chain_words.empty? # @sg-ignore Range.from_node is nil only for a node without # source location info, which doesn't happen for real parsed @@ -576,7 +615,7 @@ def always_leaves_compound_statement? clause_node %i[return raise next redo retry].include?(clause_node&.type) end - attr_reader :locals, :ivars, :enclosing_breakable_pin, :enclosing_compound_statement_pin + attr_reader :locals, :ivars, :enclosing_breakable_pin, :enclosing_compound_statement_pin, :closure end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb index 83f14a415..56fd0f921 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb @@ -13,7 +13,8 @@ def process FlowSensitiveTyping.new(locals, ivars, enclosing_breakable_pin, - enclosing_compound_statement_pin).process_and(node) + enclosing_compound_statement_pin, + region.closure).process_and(node) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb index 0b9a75e77..bb3e3fdd0 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -11,7 +11,8 @@ def process FlowSensitiveTyping.new(locals, ivars, enclosing_breakable_pin, - enclosing_compound_statement_pin).process_if(node) + enclosing_compound_statement_pin, + region.closure).process_if(node) condition_node = node.children[0] if condition_node pins.push Solargraph::Pin::CompoundStatement.new( diff --git a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb index 6c54f1c8c..3ce837d66 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -13,7 +13,8 @@ def process FlowSensitiveTyping.new(locals, ivars, enclosing_breakable_pin, - enclosing_compound_statement_pin).process_or(node) + enclosing_compound_statement_pin, + region.closure).process_or(node) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb index 6c4fe33d8..48c0d91f9 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb @@ -11,7 +11,8 @@ def process FlowSensitiveTyping.new(locals, ivars, enclosing_breakable_pin, - enclosing_compound_statement_pin).process_while(node) + enclosing_compound_statement_pin, + region.closure).process_while(node) # Note - this should not be considered a block, as the # while statement doesn't create a closure - e.g., diff --git a/spec/parser/flow_sensitive_typing_spec.rb b/spec/parser/flow_sensitive_typing_spec.rb index d69f6287e..d8ffc170d 100644 --- a/spec/parser/flow_sensitive_typing_spec.rb +++ b/spec/parser/flow_sensitive_typing_spec.rb @@ -1075,6 +1075,40 @@ def bundled_filename(pin) expect(clip.infer.rooted_tags).to eq('::String') end + it 'narrows a bare, implicit-self attr_reader-style accessor after a .nil? guard' do + source = Solargraph::Source.load_string(%( + class Repro + # @return [Array, nil] + attr_reader :steps + + def identify + return nil if steps.nil? + steps.empty? + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 15]) + expect(clip.infer.rooted_tags).to eq('::Array<::Hash>') + end + + it 'narrows a bare, implicit-self attr_reader-style accessor after a truthy guard' do + source = Solargraph::Source.load_string(%( + class Repro + # @return [Array, nil] + attr_reader :steps + + def identify + return nil unless steps + steps.empty? + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 15]) + expect(clip.infer.rooted_tags).to eq('::Array<::Hash>') + end + it 'narrows a repeated call to the same attr_reader-style accessor rooted in an ivar' do source = Solargraph::Source.load_string(%( class Location From 70992a5bff92669cfca161e19a86267622c01f5f Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Thu, 6 Aug 2026 15:00:54 -0400 Subject: [PATCH 2/9] Add regression test for narrowing through an intermediate local assignment apiology/solargraph#53 (comment) reported that narrowing a bare, implicit-self accessor doesn't propagate through an assignment into a fresh local (e.g. local = steps; local.empty? left unresolved). Verified against this branch's HEAD (bbaf7f950) that the reported repro already resolves correctly: solargraph typecheck --level strong reports 0 problems, and Chain#infer for local.empty? resolves local to ::Array<::Hash>. BaseVariable#probe re-infers a local's assignment expression at its own source position via Chain#infer, and that position already falls inside the narrowed presence range recorded by FlowSensitiveTyping for the bare steps call, so the fact carries through without any code change needed. This adds the missing spec coverage for that path so a future regression here is caught. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RiL3AMsRKcUzfrTHTsVzxv --- spec/parser/flow_sensitive_typing_spec.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/parser/flow_sensitive_typing_spec.rb b/spec/parser/flow_sensitive_typing_spec.rb index d8ffc170d..bf397044d 100644 --- a/spec/parser/flow_sensitive_typing_spec.rb +++ b/spec/parser/flow_sensitive_typing_spec.rb @@ -1109,6 +1109,25 @@ def identify expect(clip.infer.rooted_tags).to eq('::Array<::Hash>') end + it 'narrows a bare, implicit-self attr_reader-style accessor assigned into a fresh local variable' do + source = Solargraph::Source.load_string(%( + class Repro + # @return [Array, nil] + attr_reader :steps + + def identify + return nil if steps.nil? + + local = steps + local.empty? + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [9, 15]) + expect(clip.infer.rooted_tags).to eq('::Array<::Hash>') + end + it 'narrows a repeated call to the same attr_reader-style accessor rooted in an ivar' do source = Solargraph::Source.load_string(%( class Location From 111b832ddc254fde4289c662eee165b9410ebc37 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 18 Aug 2026 00:23:17 -0400 Subject: [PATCH 3/9] Add strong-level specs for a @type-tagged local from a guarded accessor The two apiology/plate-spinner suppressions slugged pr-53-follow-on both have the same shape: a bare, implicit-self accessor is nil-guarded by an early return, the next line assigns it to a local carrying an explicit `# @type` tag, and the TypeChecker reported "Declared type Array does not match inferred type Array, nil for variable steps_list". Existing coverage for this branch checks Clip#infer on a later reference to the local. The declared-vs-inferred check that produced those suppressions runs through Pin::BaseVariable#probe instead and had no spec, so these add it for both guard forms in that file - `return nil if steps.nil?` and `return ['', nil] if substeps.nil?`. Both pass on this branch as-is; no lib change accompanies them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CXmnT5gSB1PheL9UbiGEVA --- spec/type_checker/levels/strong_spec.rb | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 1043a192d..dd6c33512 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -925,5 +925,46 @@ def baz(bases) # an error when trying to declare sub as Subclass expect(checker.problems.map(&:message)).not_to include('Unresolved call to bar on Base') end + + it 'accepts a non-nil @type on a local assigned from a bare accessor guarded by .nil?' do + checker = type_checker(%( + class Repro + # @return [Array, nil] + attr_reader :steps + + # @return [Array, nil] + def unwrap + return nil if steps.nil? + + # @type [Array] + steps_list = steps + steps_list.each { |step| step } + steps_list + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'accepts a non-nil @type on a local assigned from a bare accessor guarded by a non-nil return' do + checker = type_checker(%( + class Repro + # @return [Array, nil] + attr_reader :substeps + + # @return [Array] + def extract + return ['', nil] if substeps.nil? + + # @type [Array] + steps_list = substeps + [steps_list] + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end end end From c7c808255ff12d0b06de3e3673a04a74a54729fb Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 6 Sep 2026 20:47:36 -0400 Subject: [PATCH 4/9] Tighten flow-sensitive typing comments The new docstrings and inline comments for bare, implicit-self accessor narrowing ran 4-7 lines each, well past this repo's budget of a few lines per method. Compress them to state the same facts without re-explaining alternatives already visible in the code. --- .../parser/flow_sensitive_typing.rb | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index 98186a75e..20c53a7b7 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -9,10 +9,8 @@ class FlowSensitiveTyping # @param ivars [Array] # @param enclosing_breakable_pin [Solargraph::Pin::Breakable, nil] # @param enclosing_compound_statement_pin [Solargraph::Pin::CompoundStatement, nil] - # @param closure [Solargraph::Pin::Closure] The pin enclosing the - # code being processed (e.g. the current method), used to - # resolve a bare, implicit-self call like 'steps' as a call to - # a 0-arg method rather than a local variable. + # @param closure [Solargraph::Pin::Closure] used to resolve a + # bare, implicit-self call like 'steps' as a method call def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin, closure @locals = locals @ivars = ivars @@ -334,10 +332,9 @@ def find_var variable_name, position end end - # Finds (single var) or builds (chain, e.g. ['pin', 'location'], or - # a bare 0-arg self call, e.g. ['steps']) the pin narrowing facts get - # recorded on. A built pin probes its type lazily from `node`, so it - # can't see its own new facts. + # Finds (single var) or builds (chain, e.g. ['pin', 'location']) the + # pin narrowing facts get recorded on. A built pin probes its type + # lazily from `node`, so it can't see its own new facts. # # @param chain_words [::Array] # @param node [Parser::AST::Node] the receiver expression, e.g. the @@ -346,11 +343,7 @@ def find_var variable_name, position # @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil] def chain_pin chain_words, node, position if chain_words.length == 1 - # A bare word is ambiguous from chain_words alone -- 'steps' - # could be a real local variable (node.type == :lvar) or a - # 0-arg method call to self (node.type == :send, since the - # parser only emits :lvar for a name already assigned as a - # local in this scope). Only the former is a tracked variable. + # 'steps' is ambiguous here: :lvar is a tracked local, :send a self call. # @sg-ignore chain_words is never empty - callers already checked return find_var(chain_words.first, position) unless node.is_a?(::Parser::AST::Node) && node.type == :send @@ -372,13 +365,9 @@ def chain_pin chain_words, node, position ) end - # Builds the synthesized pin for a bare, implicit-self call to a - # 0-arg method, e.g. 'steps'. Rooted at `closure` rather than at a - # tracked variable's pin, since there is no variable to inherit a - # closure from. Named after the bare method word itself (not - # e.g. 'self.steps') so it lines up with how Chain::Call#resolve - # looks up a head-position call: by the call's word, via - # ApiMap#var_at_location. + # Builds a pin for a bare self call (e.g. 'steps'), rooted at + # `closure` since there's no variable pin to inherit one from. + # Named after the bare word so ApiMap#var_at_location's lookup finds it. # # @param node [Parser::AST::Node] the call node, e.g. 'steps' # @return [Solargraph::Pin::LocalVariable] From 4260fb52b6170fec4c2e8083a2a99d9d78f19cd3 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 8 Sep 2026 08:19:07 -0400 Subject: [PATCH 5/9] Assert @type cast via a strictly-typed consumer The two new @type-cast specs proved non-nilness only by calling generic Enumerable methods on the assigned local, which doesn't check the declared element type. Pass the local into a method whose parameter is typed exactly Array instead, so a wrong or still-nilable inferred type fails the call directly. --- spec/type_checker/levels/strong_spec.rb | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 8b549ff96..735fce096 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -1004,14 +1004,17 @@ class Repro # @return [Array, nil] attr_reader :steps - # @return [Array, nil] + # @param steps [Array] + # @return [void] + def consume(steps); end + + # @return [void] def unwrap return nil if steps.nil? # @type [Array] steps_list = steps - steps_list.each { |step| step } - steps_list + consume(steps_list) end end )) @@ -1025,13 +1028,17 @@ class Repro # @return [Array, nil] attr_reader :substeps - # @return [Array] + # @param substeps [Array] + # @return [void] + def consume(substeps); end + + # @return [void] def extract return ['', nil] if substeps.nil? # @type [Array] steps_list = substeps - [steps_list] + consume(steps_list) end end )) From fb66d591232686f8545a29b38513d4dfccf5f537 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 8 Sep 2026 11:36:02 -0400 Subject: [PATCH 6/9] Mark 'steps' as an example in the chain_pin comment The word wasn't otherwise defined anywhere in this scope, so it read as a reference rather than an illustration. Add "e.g." to say so explicitly. --- lib/solargraph/parser/flow_sensitive_typing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index 20c53a7b7..01bb200e3 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -343,7 +343,7 @@ def find_var variable_name, position # @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil] def chain_pin chain_words, node, position if chain_words.length == 1 - # 'steps' is ambiguous here: :lvar is a tracked local, :send a self call. + # A bare word is ambiguous: :lvar is a tracked local, :send a self call (e.g. 'steps'). # @sg-ignore chain_words is never empty - callers already checked return find_var(chain_words.first, position) unless node.is_a?(::Parser::AST::Node) && node.type == :send From f109ad43c632d3d2b6789f94128dac59cb817466 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 8 Sep 2026 13:10:23 -0400 Subject: [PATCH 7/9] Drop the unneeded @type cast from two narrowing specs Flow-sensitive narrowing already removes nil from the local before it reaches consume(); the explicit @type annotation changed nothing. Removed it and renamed both examples off "@type" to describe what they actually check. --- spec/type_checker/levels/strong_spec.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 735fce096..7b6ac1ad1 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -998,7 +998,7 @@ def baz(bases) expect(checker.problems.map(&:message)).not_to include('Unresolved call to bar on Base') end - it 'accepts a non-nil @type on a local assigned from a bare accessor guarded by .nil?' do + it 'passes a narrowed local assigned from a bare accessor to a non-nil param, guarded by .nil?' do checker = type_checker(%( class Repro # @return [Array, nil] @@ -1012,7 +1012,6 @@ def consume(steps); end def unwrap return nil if steps.nil? - # @type [Array] steps_list = steps consume(steps_list) end @@ -1022,7 +1021,7 @@ def unwrap expect(checker.problems.map(&:message)).to eq([]) end - it 'accepts a non-nil @type on a local assigned from a bare accessor guarded by a non-nil return' do + it 'passes a narrowed local assigned from a bare accessor to a non-nil param, guarded by a non-nil return' do checker = type_checker(%( class Repro # @return [Array, nil] @@ -1036,7 +1035,6 @@ def consume(substeps); end def extract return ['', nil] if substeps.nil? - # @type [Array] steps_list = substeps consume(steps_list) end From 81a9402ae62789dc194a5c7af5cb0ed83f06d0f2 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 8 Sep 2026 13:50:49 -0400 Subject: [PATCH 8/9] Drop chain_pin's sg-ignore for a real nil guard chain_words.length == 1 already guarantees a first element, but Solargraph can't prove that from a length check alone. Binding it to a local and returning early on nil lets the checker verify it directly, instead of trusting an unenforced caller invariant. --- lib/solargraph/parser/flow_sensitive_typing.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index 01bb200e3..fec76e4fb 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -343,9 +343,11 @@ def find_var variable_name, position # @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil] def chain_pin chain_words, node, position if chain_words.length == 1 + word = chain_words.first + return unless word + # A bare word is ambiguous: :lvar is a tracked local, :send a self call (e.g. 'steps'). - # @sg-ignore chain_words is never empty - callers already checked - return find_var(chain_words.first, position) unless node.is_a?(::Parser::AST::Node) && node.type == :send + return find_var(word, position) unless node.is_a?(::Parser::AST::Node) && node.type == :send return unless closure From f4f942d0d389c64e392ce2546beeaff6987e125a Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 15 Sep 2026 14:18:16 -0400 Subject: [PATCH 9/9] Relabel chain_pin's block-param marker to its real fix lib/solargraph/parser/flow_sensitive_typing.rb's chain_pin method has an @sg-ignore reading "chain_words is never empty - callers already checked," describing a check that can't actually fail, not the real reason the ignore is needed. The call site - find_var(chain_words.first, ...) inside chain_pin - is reached from a receiver chain rooted in a block parameter, the exact shape this PR's own fix addresses for a different call site in this same file. Relabels the marker to cite this PR's URL instead of the stale reason text. Comment-text only. solargraph typecheck --level strong reports the same 12 pre-existing problems before and after; rubocop clean. --- lib/solargraph/parser/flow_sensitive_typing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index fec76e4fb..fbf8187d0 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -354,7 +354,7 @@ def chain_pin chain_words, node, position return self_call_pin(node) end - # @sg-ignore chain_words is never empty - callers already checked + # @sg-ignore https://github.com/apiology/solargraph/pull/53 root_pin = find_var(chain_words.first, position) return unless root_pin