From 5207130f5859f9ea0009383d6e01b6f8fa33d8be Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 11 Aug 2026 13:55:52 -0400 Subject: [PATCH 01/24] Update a parameter's flow-sensitive type after reassignment to a non-literal type A parameter's typify always returned its declared @param type once available, without ever consulting the types of its reassignments. Reassigning a parameter to the result of a call that narrows its type (e.g. a union normalized down to one member) was silently ignored, so later uses kept the stale declared type and got flagged against branches of the original union that could no longer occur. Track whether an assignment is guaranteed to have executed (definite) via a new Region#conditional flag, threaded through node processors for if/unless, while/until, when, rescue, block bodies, &&/||, and ||=. Pin::Parameter#typify now prefers the reassigned type over the declared type when the reassignment is definite, and continues to fall back to the declared type (as before) when it's only conditional, matching the existing union semantics for plain local variables. Fixes castwide/solargraph#1250 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VHyn8dc8oSqcQJrXFgDWUo --- lib/solargraph/api_map.rb | 2 +- lib/solargraph/complex_type.rb | 1 + .../message/text_document/formatting.rb | 1 + .../parser_gem/node_processors/and_node.rb | 6 +- .../parser_gem/node_processors/args_node.rb | 5 ++ .../parser_gem/node_processors/block_node.rb | 5 +- .../parser_gem/node_processors/if_node.rb | 6 +- .../parser_gem/node_processors/lvasgn_node.rb | 1 + .../parser_gem/node_processors/or_node.rb | 6 +- .../parser_gem/node_processors/orasgn_node.rb | 4 +- .../node_processors/resbody_node.rb | 2 +- .../parser_gem/node_processors/until_node.rb | 2 +- .../parser_gem/node_processors/when_node.rb | 2 +- .../parser_gem/node_processors/while_node.rb | 2 +- lib/solargraph/parser/region.rb | 17 +++++- lib/solargraph/pin/base_variable.rb | 20 ++++++- lib/solargraph/pin/parameter.rb | 9 +++ lib/solargraph/range.rb | 5 -- lib/solargraph/type_checker.rb | 1 + spec/type_checker/levels/strong_spec.rb | 57 +++++++++++++++++++ 20 files changed, 134 insertions(+), 20 deletions(-) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 26b42ddb47..29f03854d4 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -706,7 +706,6 @@ def super_and_sub? sup, sub # @todo If two literals are different values of the same type, it would # make more sense for super_and_sub? to return true, but there are a # few callers that currently expect this to be false. - # @sg-ignore flow-sensitive typing should be able to handle redefinition return false if sup.literal? && sub.literal? && sup.to_s != sub.to_s # @sg-ignore flow sensitive typing should be able to handle redefinition sup = sup.simplify_literals.to_s @@ -714,6 +713,7 @@ def super_and_sub? sup, sub sub = sub.simplify_literals.to_s return true if sup == sub sc_fqns = sub + # @sg-ignore flow sensitive typing unions rather than overrides types across multiple sequential reassignments while (sc = store.get_superclass(sc_fqns)) # @sg-ignore flow sensitive typing needs to handle "if foo = bar" sc_new = store.constants.dereference(sc) diff --git a/lib/solargraph/complex_type.rb b/lib/solargraph/complex_type.rb index 27d2ff08c0..7fe9eab4dc 100644 --- a/lib/solargraph/complex_type.rb +++ b/lib/solargraph/complex_type.rb @@ -224,6 +224,7 @@ def conforms_to? api_map, expected, situation, rules = [], variance: erased_variance(situation) + # @sg-ignore flow sensitive typing needs to handle a self-referential reassignment (x = x.foo) expected = expected.downcast_to_literal_if_possible inferred = downcast_to_literal_if_possible diff --git a/lib/solargraph/language_server/message/text_document/formatting.rb b/lib/solargraph/language_server/message/text_document/formatting.rb index c6cc3353a5..7212d677dc 100644 --- a/lib/solargraph/language_server/message/text_document/formatting.rb +++ b/lib/solargraph/language_server/message/text_document/formatting.rb @@ -48,6 +48,7 @@ def log_corrections corrections return if corrections&.empty? Solargraph.logger.info('Formatting result:') + # @sg-ignore flow sensitive typing should be able to handle redefinition corrections.each_line do |line| next if line.strip.empty? Solargraph.logger.info(line.strip) 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 83f14a4157..f6244a9b07 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb @@ -8,7 +8,11 @@ class AndNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - process_children + # the rhs of `a && b` only executes if `a` is truthy, so + # any assignment there isn't guaranteed to have executed + lhs, rhs = node.children + NodeProcessor.process(lhs, region, pins, locals, ivars) + NodeProcessor.process(rhs, region.update(conditional: true), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/args_node.rb b/lib/solargraph/parser/parser_gem/node_processors/args_node.rb index 9a22b8edd0..a45250b097 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/args_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/args_node.rb @@ -23,6 +23,11 @@ def process # @sg-ignore Need to add nil check here presence: callable.location.range, decl: get_decl(u), + # a default value expression is only assigned + # conditionally (when the caller omits the arg), + # so it shouldn't be treated as a guaranteed + # override of the declared @param type + definite: false, source: :parser ) callable.parameters.push locals.last diff --git a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb index 750bb99294..855e0419b6 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb @@ -28,7 +28,10 @@ def process source: :parser ) pins.push block_pin - process_children region.update(closure: block_pin) + # a block's body may execute zero or multiple times (e.g. + # Enumerable#each), so an assignment inside it is never + # guaranteed to have executed + process_children region.update(closure: block_pin, conditional: true) end private 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 0b9a75e774..0606f2970b 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -22,6 +22,8 @@ def process ) NodeProcessor.process(condition_node, region, pins, locals, ivars) end + conditional_region = region.update(conditional: true) + then_node = node.children[1] if then_node pins.push Solargraph::Pin::CompoundStatement.new( @@ -30,7 +32,7 @@ def process node: then_node, source: :parser ) - NodeProcessor.process(then_node, region, pins, locals, ivars) + NodeProcessor.process(then_node, conditional_region, pins, locals, ivars) end else_node = node.children[2] @@ -41,7 +43,7 @@ def process node: else_node, source: :parser ) - NodeProcessor.process(else_node, region, pins, locals, ivars) + NodeProcessor.process(else_node, conditional_region, pins, locals, ivars) end true diff --git a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb index 63e2c55dcd..84c2b97e40 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb @@ -19,6 +19,7 @@ def process assignment: node.children[1], comments: comments_for(node), presence: presence, + definite: !region.conditional, source: :parser ) process_children 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 6c54f1c8c1..8e847425e0 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -8,7 +8,11 @@ class OrNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - process_children + # the rhs of `a || b` only executes if `a` is falsy, so + # any assignment there isn't guaranteed to have executed + lhs, rhs = node.children + NodeProcessor.process(lhs, region, pins, locals, ivars) + NodeProcessor.process(rhs, region.update(conditional: true), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb index 17480adfb5..dfa69d42ef 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb @@ -8,7 +8,9 @@ class OrasgnNode < Parser::NodeProcessor::Base # @return [void] def process new_node = node.updated(node.children[0].type, node.children[0].children + [node.children[1]]) - NodeProcessor.process(new_node, region, pins, locals, ivars) + # `x ||= y` only assigns when x is falsy/undefined, so + # it's never a guaranteed override of x's prior type + NodeProcessor.process(new_node, region.update(conditional: true), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb index 24846748fb..7b014779f3 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb @@ -30,7 +30,7 @@ def process source: :parser ) end - NodeProcessor.process(node.children[2], region, pins, locals, ivars) + NodeProcessor.process(node.children[2], region.update(conditional: true), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb index 2e091f41d1..8edf6f4bce 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb @@ -20,7 +20,7 @@ def process comments: comments_for(node), source: :parser ) - process_children region + process_children region.update(conditional: true) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb index 915eb57e65..bcbf656f55 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb @@ -14,7 +14,7 @@ def process node: node, source: :parser ) - process_children + process_children region.update(conditional: true) 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 6c4fe33d86..986a791717 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb @@ -24,7 +24,7 @@ def process comments: comments_for(node), source: :parser ) - process_children region + process_children region.update(conditional: true) end end end diff --git a/lib/solargraph/parser/region.rb b/lib/solargraph/parser/region.rb index 8c4caf6acb..36222dbdd6 100644 --- a/lib/solargraph/parser/region.rb +++ b/lib/solargraph/parser/region.rb @@ -21,18 +21,27 @@ class Region # @return [Array] attr_reader :lvars + # True if the current position may be skipped at runtime (e.g., + # inside an if/while/until body), meaning an assignment made + # here isn't guaranteed to have executed at a later position. + # + # @return [Boolean] + attr_reader :conditional + # @param source [Source] # @param closure [Pin::Closure, nil] # @param scope [Symbol, nil] # @param visibility [Symbol] # @param lvars [Array] + # @param conditional [Boolean] def initialize source: Solargraph::Source.load_string(''), closure: nil, - scope: nil, visibility: :public, lvars: [] + scope: nil, visibility: :public, lvars: [], conditional: false @source = source @closure = closure || Pin::Namespace.new(name: '', location: source.location, source: :parser) @scope = scope @visibility = visibility @lvars = lvars + @conditional = conditional end # @return [String, nil] @@ -54,14 +63,16 @@ def namespace_pin # @param scope [Symbol, nil] # @param visibility [Symbol, nil] # @param lvars [Array, nil] + # @param conditional [Boolean, nil] # @return [Region] - def update closure: nil, scope: nil, visibility: nil, lvars: nil + def update closure: nil, scope: nil, visibility: nil, lvars: nil, conditional: nil Region.new( source: source, closure: closure || self.closure, scope: scope || self.scope, visibility: visibility || self.visibility, - lvars: lvars || self.lvars + lvars: lvars || self.lvars, + conditional: conditional.nil? ? self.conditional : conditional ) end diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index c7945e5998..ab97ccb47c 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -14,6 +14,16 @@ class BaseVariable < Base # @return [Range, nil] attr_reader :presence + # True if this pin's assignment(s) are guaranteed to have + # executed at (and after) its presence's start position, as + # opposed to being inside a conditional branch or loop that may + # not run. Used to decide whether a reassignment's type may + # safely override a variable's previously declared/inferred + # type instead of merely being unioned with it. + # + # @return [Boolean] + attr_reader :definite + # @param return_type [ComplexType, nil] # @param assignment [Parser::AST::Node, nil] First assignment # that was made to this variable @@ -45,10 +55,12 @@ class BaseVariable < Base # @see https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types # @see https://en.wikipedia.org/wiki/Intersection_type#TypeScript_example # @param presence [Range, nil] + # @param definite [Boolean] # @param [Hash{Symbol => Object}] splat def initialize assignment: nil, assignments: [], mass_assignment: nil, presence: nil, return_type: nil, intersection_return_type: nil, exclude_return_type: nil, + definite: true, **splat super(**splat) @assignments = (assignment.nil? ? [] : [assignment]) + assignments @@ -58,6 +70,7 @@ def initialize assignment: nil, assignments: [], mass_assignment: nil, @intersection_return_type = intersection_return_type @exclude_return_type = exclude_return_type @presence = presence + @definite = definite end # @param presence [Range] @@ -95,7 +108,12 @@ def combine_with other, attrs = {} return_type: combine_return_type(other), intersection_return_type: combine_types(other, :intersection_return_type), exclude_return_type: combine_types(other, :exclude_return_type), - presence: combine_presence(other) + presence: combine_presence(other), + # if either side had an assignment guaranteed to + # have executed, that assignment's type is + # eligible to override (not just be unioned + # with) the variable's other possible types + definite: definite || other.definite }) super(other, new_attrs) end diff --git a/lib/solargraph/pin/parameter.rb b/lib/solargraph/pin/parameter.rb index ba20976ec6..cbfe4ba84d 100644 --- a/lib/solargraph/pin/parameter.rb +++ b/lib/solargraph/pin/parameter.rb @@ -208,6 +208,15 @@ def index # @param api_map [ApiMap] def typify api_map + if definite + # flow sensitive typing: this parameter was reassigned by + # an assignment guaranteed to have executed, so prefer the + # type of the value it was reassigned to over its declared + # @param type + reassigned_type = probe(api_map) + return reassigned_type if reassigned_type.defined? + end + new_type = super return new_type if new_type.defined? diff --git a/lib/solargraph/range.rb b/lib/solargraph/range.rb index e1ed895921..e7f49e0344 100644 --- a/lib/solargraph/range.rb +++ b/lib/solargraph/range.rb @@ -46,11 +46,8 @@ def to_hash # @return [Boolean] def contain? position position = Position.normalize(position) - # @sg-ignore flow sensitive typing should be able to handle redefinition return false if position.line < start.line || position.line > ending.line - # @sg-ignore flow sensitive typing should be able to handle redefinition return false if position.line == start.line && position.character < start.character - # @sg-ignore flow sensitive typing should be able to handle redefinition return false if position.line == ending.line && position.character > ending.character true end @@ -58,11 +55,9 @@ def contain? position # True if the range contains the specified position and the position does not precede it. # # @param position [Position, Array(Integer, Integer)] - # @sg-ignore flow sensitive typing should be able to handle redefinition # @return [Boolean] def include? position position = Position.normalize(position) - # @sg-ignore flow sensitive typing should be able to handle redefinition contain?(position) && !(position.line == start.line && position.character == start.character) end diff --git a/lib/solargraph/type_checker.rb b/lib/solargraph/type_checker.rb index 2bd5d530ed..ed43ce5653 100644 --- a/lib/solargraph/type_checker.rb +++ b/lib/solargraph/type_checker.rb @@ -652,6 +652,7 @@ def add_to_param_details param_details, param_names, new_param_details # @return [Hash{String => Hash{Symbol => String, ComplexType}}] def param_details_from_stack signature, method_pin_stack signature_type = signature.typify(api_map) + # @sg-ignore flow sensitive typing should be able to handle redefinition signature = signature.proxy signature_type param_details = signature_param_details(signature) param_names = signature.parameter_names diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 1043a192dc..cbf94e7d9c 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -882,6 +882,63 @@ def maybe_bar? expect(checker.problems.map(&:message)).to eq([]) end + it 'updates a parameter type after reassignment to a different non-literal type' do + checker = type_checker(%( + class Position + # @return [Integer] + def line + 1 + end + end + + module PositionNormalizer + # @param position [Position, Array(Integer, Integer)] + # @return [Position] + def self.normalize(position) + Position.new + end + end + + # @param position [Position, Array(Integer, Integer)] + # @return [Integer] + def describe(position) + position = PositionNormalizer.normalize(position) + position.line + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not treat a parameter reassignment inside a block as guaranteed to have run' do + checker = type_checker(%( + class Position + # @return [Integer] + def line + 1 + end + end + + module PositionNormalizer + # @param position [Position, Array(Integer, Integer)] + # @return [Position] + def self.normalize(position) + Position.new + end + end + + # @param position [Position, Array(Integer, Integer)] + # @return [Integer] + def describe(position) + [1].each { position = PositionNormalizer.normalize(position) } + position.line + end + )) + expect(checker.problems.map(&:message)).to eq([ + '#describe return type could not be inferred', + 'Unresolved call to line on Position, Array(Integer, Integer)' + ]) + end + it 'supports !@x.nil && @x.y' do checker = type_checker(%( class Bar From d49cdabb20cac1646619e9b31cee8990fef50a62 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 11 Aug 2026 18:02:25 -0400 Subject: [PATCH 02/24] Exclude self-referential positions from a variable's own presence `x = x.length` (or `index += 1` desugared to `index = index + 1`) resolved the RHS's reference to `x` against the type of the value being derived on that same line, instead of `x`'s prior type - `x.length` was resolving as `Integer#length` instead of `String#length`, since var_at_location/visible_at? treated any position from the start of the reassignment onward (including positions inside its own RHS) as already reflecting the new value. BaseVariable#visible_at? now excludes positions that fall strictly inside one of the pin's own assignment value nodes, so a self-referential RHS resolves against the variable's other assignments instead of the not-yet-computed value being derived. Reported against castwide/solargraph#1282: https://github.com/castwide/solargraph/pull/1282#issuecomment-5257714611 --- lib/solargraph/pin/base_variable.rb | 31 +++++++++++++++++++++++++ spec/type_checker/levels/strong_spec.rb | 13 +++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index ab97ccb47c..1c28d6cbb9 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -301,6 +301,7 @@ def visible_at? other_closure, other_loc location.filename == other_loc.filename && # @sg-ignore flow sensitive typing needs to handle attrs (!presence || presence.include?(other_loc.range.start)) && + !within_own_assignment?(other_loc) && visible_in_closure?(other_closure) end @@ -313,6 +314,36 @@ def visible_at? other_closure, other_loc private + # True if `other_loc` falls inside the source range of one of this + # pin's own assignment value nodes - i.e., `other_loc` is + # resolving a reference that occurs *while* one of this + # variable's own assignments is still being evaluated, such as + # the receiver `x` in a self-referential reassignment (`x = + # x.length`, or `index += 1` desugared to `index = index + 1`). + # That reference must resolve against this variable's *other* + # assignments, not against the not-yet-assigned value being + # derived here, even though `other_loc` otherwise falls within + # this pin's presence. + # + # @param other_loc [Location] + # @return [Boolean] + def within_own_assignment? other_loc + return false unless location&.filename == other_loc.filename + + assignments.any? do |assignment_node| + next false unless assignment_node.respond_to?(:loc) + + rng = Range.from_node(assignment_node) + next false if rng.nil? + + # The position immediately at/after the assignment node's own + # end is where its new value becomes visible - only exclude + # positions strictly *inside* the node (i.e. still being + # evaluated), not that boundary itself. + rng.contain?(other_loc.range.start) && other_loc.range.start != rng.ending + end + end + # @param api_map [ApiMap] # @param raw_return_type [ComplexType, ComplexType::UniqueType] # diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index cbf94e7d9c..6da109b9a5 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -939,6 +939,19 @@ def describe(position) ]) end + it 'resolves a self-referential reassignment against the pre-assignment type' do + checker = type_checker(%( + class Repro + # @param x [String] + # @return [Integer] + def foo(x) + x = x.length + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'supports !@x.nil && @x.y' do checker = type_checker(%( class Bar From 12f0a156801ece10f3d5c34fcccef75d7cab6770 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 11 Aug 2026 20:16:52 -0400 Subject: [PATCH 03/24] Move definite's long-form doc to the initializer's @param tag The attr_reader carried the full explanation while initialize's own @param definite tag just said "[Boolean]" - move the explanation onto the @param tag it documents. --- lib/solargraph/pin/base_variable.rb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index 1c28d6cbb9..f91e203286 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -14,13 +14,6 @@ class BaseVariable < Base # @return [Range, nil] attr_reader :presence - # True if this pin's assignment(s) are guaranteed to have - # executed at (and after) its presence's start position, as - # opposed to being inside a conditional branch or loop that may - # not run. Used to decide whether a reassignment's type may - # safely override a variable's previously declared/inferred - # type instead of merely being unioned with it. - # # @return [Boolean] attr_reader :definite @@ -55,7 +48,13 @@ class BaseVariable < Base # @see https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types # @see https://en.wikipedia.org/wiki/Intersection_type#TypeScript_example # @param presence [Range, nil] - # @param definite [Boolean] + # @param definite [Boolean] True if this pin's assignment(s) are + # guaranteed to have executed at (and after) its presence's + # start position, as opposed to being inside a conditional + # branch or loop that may not run. Used to decide whether a + # reassignment's type may safely override a variable's + # previously declared/inferred type instead of merely being + # unioned with it. # @param [Hash{Symbol => Object}] splat def initialize assignment: nil, assignments: [], mass_assignment: nil, presence: nil, return_type: nil, From ce73329675c8d971a1eb32999bb36e107a24ebf5 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 12 Aug 2026 17:41:42 -0400 Subject: [PATCH 04/24] Extend definite-reassignment override to local and instance variables Pin::Parameter#typify already preferred a definite reassignment's type over the declared @param type, but plain local variables and instance variables kept unioning every assignment's type together instead, so `local = 5; local = 'hello'; local.upcase` (and the same pattern for an ivar reassigned within one method) still failed at strong: the combined pin's type came out as `Integer, String` instead of just `String`. BaseVariable#combine_assignments unconditionally unioned two pins' assignment nodes, and combine_with separately re-prepended the earlier pin's `assignment:` onto the merged list regardless. Make combine_assignments drop the earlier assignment(s) when the later pin's reassignment is definite (guaranteed to have executed) and in the same closure, and skip the redundant `assignment:` prepend in that case. Self-referential reassignments (`x = x.foo`, desugared `+=`, etc.) are excluded from the override: resolving their right-hand side needs the prior assignment(s) as a base case, so dropping them would leave nothing to resolve against. Un-pends three specs that were already asserting this behavior under 'sequential assignment support' and adds a spec for the reported local-variable case. The cross-method ivar case (assigned in `initialize`, reassigned in another method) is not addressed here - ivasgn_node.rb sets neither `presence:` nor `definite:`, so every ivar pin remains visible everywhere and `definite` defaults to true even inside conditionals. Addresses review feedback on #1282: https://github.com/castwide/solargraph/pull/1282#issuecomment-5272732583 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HKWGJjqfJuQFssuXEWLLMZ --- lib/solargraph/pin/base_variable.rb | 39 ++++++++++++++++++++++++- spec/source/chain_spec.rb | 2 -- spec/source_map/clip_spec.rb | 4 --- spec/type_checker/levels/strong_spec.rb | 28 ++++++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index f91e203286..9a74f47f86 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -101,7 +101,12 @@ def combine_with other, attrs = {} # tells you if the arg is optional or not. Prefer a # provided value if we have one here since we can't rely on # it from RBS so we can infer from it and typecheck on it. - assignment: choose(other, :assignment), + # + # When #combine_assignments supersedes rather than unions, + # skip this - the constructor prepends `assignment:` to + # `assignments:` unconditionally, which would re-introduce + # the dropped node. + assignment: override_assignments?(other) ? nil : choose(other, :assignment), assignments: new_assignments, mass_assignment: combine_mass_assignment(other), return_type: combine_return_type(other), @@ -135,6 +140,8 @@ def assignment # # @return [::Array] def combine_assignments other + return other.assignments.dup if override_assignments?(other) + (other.assignments + assignments).uniq end @@ -343,6 +350,36 @@ def within_own_assignment? other_loc end end + # True if `other`'s assignment(s) should supersede ours + # instead of merely being unioned with them: `other` reassigns + # the same variable, in the same scope, via an assignment + # guaranteed to have executed, so by the time `other`'s + # presence begins our value has definitely been overwritten. + # + # Excludes self-referential reassignments (`x = x.foo`, + # desugared `+=`, etc.) - resolving their right-hand side needs + # our assignment(s) as the base case, so dropping them would + # leave nothing to resolve against. + # + # @param other [self] + # @return [Boolean] + def override_assignments? other + other.definite && other.closure == closure && + other.assignments.none? { |node| references_name?(node) } + end + + # @param node [Parser::AST::Node, nil] + # @return [Boolean] + def references_name? node + return false unless node.is_a?(::AST::Node) + + # @sg-ignore flow sensitive typing doesn't narrow `node` past the guard above + return true if %i[lvar ivar].include?(node.type) && node.children[0].to_s == name + + # @sg-ignore flow sensitive typing doesn't narrow `node` past the guard above + node.children.any? { |child| references_name?(child) } + end + # @param api_map [ApiMap] # @param raw_return_type [ComplexType, ComplexType::UniqueType] # diff --git a/spec/source/chain_spec.rb b/spec/source/chain_spec.rb index a6b29686e9..3b50d4942c 100644 --- a/spec/source/chain_spec.rb +++ b/spec/source/chain_spec.rb @@ -363,8 +363,6 @@ class Bar; end end it 'infers instance variables from sequential assignments' do - pending('sequential assignment support') - source = Solargraph::Source.load_string(%( def foo @foo = nil diff --git a/spec/source_map/clip_spec.rb b/spec/source_map/clip_spec.rb index b30002967d..98a40dc5b9 100644 --- a/spec/source_map/clip_spec.rb +++ b/spec/source_map/clip_spec.rb @@ -2381,8 +2381,6 @@ def bar; end end it 'replaces nil with reassignments' do - pending 'sequential assignment support' - source = Solargraph::Source.load_string(%( bar = nil bar @@ -2398,8 +2396,6 @@ def bar; end end it 'replaces type with reassignments' do - pending 'sequential assignment support' - source = Solargraph::Source.load_string(%( bar = 'a' bar diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 6da109b9a5..5d815f39b8 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -939,6 +939,34 @@ def describe(position) ]) end + it 'updates a local variable type after reassignment to a different literal type' do + checker = type_checker(%( + # @return [void] + def run + local = 5 + local = 'hello' + local.upcase + nil + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'updates an instance variable type after reassignment in the same method' do + checker = type_checker(%( + class Foo + # @return [void] + def run + @ivar = 5 + @ivar = 'hello' + @ivar.upcase + nil + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'resolves a self-referential reassignment against the pre-assignment type' do checker = type_checker(%( class Repro From 7ffd5033d9ddd65d38ebf12b09672c139b6877af Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 12 Aug 2026 22:51:01 -0400 Subject: [PATCH 05/24] Fix definite-reassignment override picking a stale pin during flow-sensitive narrowing FlowSensitiveTyping#find_var used Array#find, returning the first local/ivar pin matching a variable name whose presence includes the query position. For `x = nil; x = 1; if x; ...`, both the original declaration and the reassignment have presences that include the `if` guard's position, so `find` always returned the stale `x = nil` pin instead of `x = 1`. That pin then got downcast and merged back into `locals` for narrowing, and because BaseVariable#override_assignments? (from the reassignment-override work) lets a later definite assignment supersede rather than union, the merge dropped the `x = 1` assignment and re-surfaced `nil` - regressing local variable inference to `undefined` at `y = x * 2`. find_var now picks the pin with the latest presence start among matches, and excludes any pin whose own assignment is still being evaluated at the query position (made BaseVariable#within_own_assignment? public so find_var can reuse the same check combine_with already relies on). This does not address the equivalent case for instance variables inside a conditional (e.g. `@x = nil; @x = 1; if @x; @x * 2; end`): ivar pins never get a `presence` range (ivasgn_node.rb doesn't set one, since an ivar stays visible across the whole class, so find_var's presence-based tie-break can't distinguish them, and the same stale-pin problem still surfaces via a separate path (Chain::InstanceVariable re-fetches raw ivar pins from the store rather than using FlowSensitiveTyping's narrowed list). That gap predates this fix and needs presence tracking for ivars to resolve; the regression reported in the PR comment was local-variable-only. Fixes castwide/solargraph#1282 (review comment) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KKhmGqQnKzRc89LEd8n7Ve EOF ) --- .../parser/flow_sensitive_typing.rb | 27 +++++++++++++++---- lib/solargraph/pin/base_variable.rb | 4 ++- spec/parser/flow_sensitive_typing_spec.rb | 16 +++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index 1606a32e06..8b900beb0b 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -298,13 +298,30 @@ def parse_isa isa_node # return type could not be inferred # @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil] def find_var variable_name, position - if variable_name.start_with?('@') - # @sg-ignore flow sensitive typing needs to handle attrs - ivars.find { |ivar| ivar.name == variable_name && (!ivar.presence || ivar.presence.include?(position)) } - else + pins = variable_name.start_with?('@') ? ivars : locals + # Prefer the pin whose presence starts latest - i.e., the + # most recent assignment reaching this position - rather + # than the first-declared pin for this name. Multiple pins + # can match (e.g. a variable's original declaration and a + # later reassignment both have presences that include this + # position), and picking the wrong one here would narrow the + # stale, superseded pin instead of the current one. + # + # Exclude pins whose own assignment is still being evaluated + # at this position (e.g. the receiver inside its own RHS, + # such as `baz ||= begin ... end`) - that pin's value isn't + # available yet, so its presence including this position + # would otherwise make it a false match ahead of the pin it's + # about to supersede. + matches = pins.select do |pin| + next false unless pin.name == variable_name # @sg-ignore flow sensitive typing needs to handle attrs - locals.find { |pin| pin.name == variable_name && (!pin.presence || pin.presence.include?(position)) } + next false unless !pin.presence || pin.presence.include?(position) + + other_loc = Location.new(pin.location&.filename, Range.new(position, position)) + !pin.within_own_assignment?(other_loc) end + matches.max_by { |pin| pin.presence&.start || Position.new(0, 0) } end # @param isa_node [Parser::AST::Node] diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index 9a74f47f86..03914c5b7c 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -318,7 +318,7 @@ def visible_at? other_closure, other_loc # @return [Range] attr_writer :presence - private + public # True if `other_loc` falls inside the source range of one of this # pin's own assignment value nodes - i.e., `other_loc` is @@ -350,6 +350,8 @@ def within_own_assignment? other_loc end end + private + # True if `other`'s assignment(s) should supersede ours # instead of merely being unioned with them: `other` reassigns # the same variable, in the same scope, via an assignment diff --git a/spec/parser/flow_sensitive_typing_spec.rb b/spec/parser/flow_sensitive_typing_spec.rb index 4c9034873b..147dd30e4c 100644 --- a/spec/parser/flow_sensitive_typing_spec.rb +++ b/spec/parser/flow_sensitive_typing_spec.rb @@ -314,6 +314,22 @@ def baz; end expect(clip.infer.to_s).to eq('Foo') end + it 'keeps a definite reassignment visible inside a subsequent if-guard' do + source = Solargraph::Source.load_string(%( + def m + x = nil + x = 1 + if x + y = x * 2 + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [5, 14]) + expect(clip.infer.rooted_tags).to eq('::Integer') + end + it 'skips is_a? without a receiver' do source = Solargraph::Source.load_string(%( if is_a? Object From a3ec7bab0d75a81389bd3a7ec943bd395d24bc76 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Thu, 13 Aug 2026 11:52:08 -0400 Subject: [PATCH 06/24] Fix return-type inference for bang-wrapped or-expressions with flow narrowing infer_from_return_nodes filtered candidate locals to only those visible at the return node's own end position before resolving its type chain. A flow-sensitive downcast (e.g. narrowing a nilable parameter across the rhs of val.nil? || val < 5) has a presence range scoped to that sub-expression, which ends before the end of an enclosing expression like !(...). The pre-filter dropped the narrowed local outright, even though chain resolution already re-checks each local's presence at its own precise sub-node location. Pass the full local set instead and let that per-node check do the filtering. Fixes the regression reported at https://github.com/castwide/solargraph/pull/1282#issuecomment-5281946636 Also drops two @sg-ignore comments that the fix's improved inference made unneeded (Cursor#end_of_word, SourceChainer#end_of_phrase). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01A6t6f1rQ26s9o6sP7QUFxE --- lib/solargraph/pin/method.rb | 13 +++++++------ lib/solargraph/source/cursor.rb | 1 - lib/solargraph/source/source_chainer.rb | 1 - spec/type_checker/levels/strong_spec.rb | 11 +++++++++++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/solargraph/pin/method.rb b/lib/solargraph/pin/method.rb index c1f8f88504..4480a1ab68 100644 --- a/lib/solargraph/pin/method.rb +++ b/lib/solargraph/pin/method.rb @@ -667,14 +667,15 @@ def infer_from_return_nodes api_map end rng = Range.from_node(n) next unless rng - clip = api_map.clip_at( - # @sg-ignore Need to add nil check here - location.filename, - rng.ending - ) + # A flow-sensitive downcast's presence can end before the return + # node's own end (e.g. inside `!(foo.nil? || foo < 5)`); chain + # resolution re-checks each local's presence at its own sub-node + # location, so pass the full local set rather than pre-filtering here. + # @sg-ignore Need to add nil check here + all_locals = api_map.source_map(location.filename).locals # @sg-ignore Need to add nil check here chain = Solargraph::Parser.chain(n, location.filename) - type = chain.infer(api_map, self, clip.locals) + type = chain.infer(api_map, self, all_locals) result.push type unless type.undefined? end result.push ComplexType::NIL if has_nil diff --git a/lib/solargraph/source/cursor.rb b/lib/solargraph/source/cursor.rb index 077364910a..147b03b6cb 100644 --- a/lib/solargraph/source/cursor.rb +++ b/lib/solargraph/source/cursor.rb @@ -54,7 +54,6 @@ def start_of_word # `foo.bar`, the end_of_word at position (0,6) is `r`. # # @return [String] - # @sg-ignore Need to add nil check here def end_of_word @end_of_word ||= begin match = source.code[offset..].to_s.match(end_word_pattern) diff --git a/lib/solargraph/source/source_chainer.rb b/lib/solargraph/source/source_chainer.rb index f96fa3319e..f8a778a57f 100644 --- a/lib/solargraph/source/source_chainer.rb +++ b/lib/solargraph/source/source_chainer.rb @@ -118,7 +118,6 @@ def fixed_position end # @return [String] - # @sg-ignore Need to add nil check here def end_of_phrase @end_of_phrase ||= begin match = phrase.match(/\s*(\.{1}|::)\s*$/) diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 5d815f39b8..935dbce71f 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -996,6 +996,17 @@ def foo? expect(checker.problems.map(&:message)).to eq([]) end + it 'infers a Boolean return from !!(x.nil? || x < n) on a nilable param' do + checker = type_checker(%( + # @param val [Integer, nil] + # @return [Boolean] + def check?(val) + !!(val.nil? || val < 5) + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'uses cast type instead of defined type' do checker = type_checker(%( # frozen_string_literal: true From 549b5411f9eec5de422d7c49d45beb77406b9cd6 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Fri, 14 Aug 2026 11:57:30 -0400 Subject: [PATCH 07/24] Override on conditional reassignment when the use site is dominated by it A reassignment inside an if/while/until/block/rescue/&&/||/||= body was never eligible to override an earlier assignment's type, even at a use site later in the same branch that the reassignment provably dominates. Only presence-inclusion was checked, not whether the branch that skips the reassignment could also have reached the use site. Region now tracks the source range of the nearest enclosing conditional construct's body (conditional_boundary) instead of a bare boolean, and BaseVariable pins carry that range as conditional_override_boundary. When resolving a variable at a specific location, a non-definite pin still overrides an earlier one if the location falls inside its conditional_override_boundary - i.e. the same branch, after the reassignment - while remaining merely unioned with the earlier type for any use site outside that boundary (e.g. after the branch merges back). Fixes the case reported in https://github.com/castwide/solargraph/pull/1282#issuecomment-5295201688 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YbhZvdCv7xdziXyKJiPuGk --- lib/solargraph/api_map.rb | 2 +- .../parser_gem/node_processors/and_node.rb | 2 +- .../parser_gem/node_processors/block_node.rb | 2 +- .../parser_gem/node_processors/if_node.rb | 8 +-- .../parser_gem/node_processors/lvasgn_node.rb | 3 +- .../parser_gem/node_processors/or_node.rb | 2 +- .../parser_gem/node_processors/orasgn_node.rb | 2 +- .../node_processors/resbody_node.rb | 3 +- .../parser_gem/node_processors/until_node.rb | 2 +- .../parser_gem/node_processors/when_node.rb | 2 +- .../parser_gem/node_processors/while_node.rb | 2 +- lib/solargraph/parser/region.rb | 26 ++++---- lib/solargraph/pin/base_variable.rb | 60 ++++++++++++++++--- lib/solargraph/pin/local_variable.rb | 4 +- lib/solargraph/pin/parameter.rb | 4 +- spec/type_checker/levels/strong_spec.rb | 17 ++++++ 16 files changed, 104 insertions(+), 37 deletions(-) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 29f03854d4..724fc443e3 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -416,7 +416,7 @@ def var_at_location candidates, name, closure, location !pin.visible_at?(closure, location) && !pin.starts_at?(location) end - vars_at_location.inject(&:combine_with) + vars_at_location.inject { |acc, pin| acc.combine_with(pin, location: location) } end # Get an array of class variable pins for a namespace. 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 f6244a9b07..633474a296 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb @@ -12,7 +12,7 @@ def process # any assignment there isn't guaranteed to have executed lhs, rhs = node.children NodeProcessor.process(lhs, region, pins, locals, ivars) - NodeProcessor.process(rhs, region.update(conditional: true), pins, locals, ivars) + NodeProcessor.process(rhs, region.update(conditional_boundary: Range.from_node(rhs)), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb index 855e0419b6..0e87cf935e 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb @@ -31,7 +31,7 @@ def process # a block's body may execute zero or multiple times (e.g. # Enumerable#each), so an assignment inside it is never # guaranteed to have executed - process_children region.update(closure: block_pin, conditional: true) + process_children region.update(closure: block_pin, conditional_boundary: Range.from_node(node)) end private 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 0606f2970b..0f3a4800ce 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -22,8 +22,6 @@ def process ) NodeProcessor.process(condition_node, region, pins, locals, ivars) end - conditional_region = region.update(conditional: true) - then_node = node.children[1] if then_node pins.push Solargraph::Pin::CompoundStatement.new( @@ -32,7 +30,8 @@ def process node: then_node, source: :parser ) - NodeProcessor.process(then_node, conditional_region, pins, locals, ivars) + # @sg-ignore Need to add nil check here + NodeProcessor.process(then_node, region.update(conditional_boundary: Range.from_node(then_node)), pins, locals, ivars) end else_node = node.children[2] @@ -43,7 +42,8 @@ def process node: else_node, source: :parser ) - NodeProcessor.process(else_node, conditional_region, pins, locals, ivars) + # @sg-ignore Need to add nil check here + NodeProcessor.process(else_node, region.update(conditional_boundary: Range.from_node(else_node)), pins, locals, ivars) end true diff --git a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb index 84c2b97e40..6d0f97f7f9 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb @@ -19,7 +19,8 @@ def process assignment: node.children[1], comments: comments_for(node), presence: presence, - definite: !region.conditional, + definite: region.conditional_boundary.nil?, + conditional_override_boundary: region.conditional_boundary, source: :parser ) process_children 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 8e847425e0..de85f87b48 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -12,7 +12,7 @@ def process # any assignment there isn't guaranteed to have executed lhs, rhs = node.children NodeProcessor.process(lhs, region, pins, locals, ivars) - NodeProcessor.process(rhs, region.update(conditional: true), pins, locals, ivars) + NodeProcessor.process(rhs, region.update(conditional_boundary: Range.from_node(rhs)), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb index dfa69d42ef..85f161bf4a 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb @@ -10,7 +10,7 @@ def process new_node = node.updated(node.children[0].type, node.children[0].children + [node.children[1]]) # `x ||= y` only assigns when x is falsy/undefined, so # it's never a guaranteed override of x's prior type - NodeProcessor.process(new_node, region.update(conditional: true), pins, locals, ivars) + NodeProcessor.process(new_node, region.update(conditional_boundary: Range.from_node(node)), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb index 7b014779f3..f88b2c7e4b 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb @@ -30,7 +30,8 @@ def process source: :parser ) end - NodeProcessor.process(node.children[2], region.update(conditional: true), pins, locals, ivars) + # @sg-ignore Need to add nil check here + NodeProcessor.process(node.children[2], region.update(conditional_boundary: Range.from_node(node.children[2])), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb index 8edf6f4bce..9a9d276bf3 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb @@ -20,7 +20,7 @@ def process comments: comments_for(node), source: :parser ) - process_children region.update(conditional: true) + process_children region.update(conditional_boundary: Range.from_node(node)) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb index bcbf656f55..60ddb5f180 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb @@ -14,7 +14,7 @@ def process node: node, source: :parser ) - process_children region.update(conditional: true) + process_children region.update(conditional_boundary: Range.from_node(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 986a791717..df78413325 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb @@ -24,7 +24,7 @@ def process comments: comments_for(node), source: :parser ) - process_children region.update(conditional: true) + process_children region.update(conditional_boundary: Range.from_node(node)) end end end diff --git a/lib/solargraph/parser/region.rb b/lib/solargraph/parser/region.rb index 36222dbdd6..0dd3d9334e 100644 --- a/lib/solargraph/parser/region.rb +++ b/lib/solargraph/parser/region.rb @@ -21,27 +21,31 @@ class Region # @return [Array] attr_reader :lvars - # True if the current position may be skipped at runtime (e.g., - # inside an if/while/until body), meaning an assignment made - # here isn't guaranteed to have executed at a later position. + # The source range of the nearest enclosing construct that may + # be skipped at runtime (e.g., an if/while/until body), meaning + # an assignment made at the current position isn't guaranteed + # to have executed at a later position - except at a position + # that itself falls within this same range, where the + # assignment is still guaranteed to dominate. nil if the + # current position isn't inside any such construct. # - # @return [Boolean] - attr_reader :conditional + # @return [Range, nil] + attr_reader :conditional_boundary # @param source [Source] # @param closure [Pin::Closure, nil] # @param scope [Symbol, nil] # @param visibility [Symbol] # @param lvars [Array] - # @param conditional [Boolean] + # @param conditional_boundary [Range, nil] def initialize source: Solargraph::Source.load_string(''), closure: nil, - scope: nil, visibility: :public, lvars: [], conditional: false + scope: nil, visibility: :public, lvars: [], conditional_boundary: nil @source = source @closure = closure || Pin::Namespace.new(name: '', location: source.location, source: :parser) @scope = scope @visibility = visibility @lvars = lvars - @conditional = conditional + @conditional_boundary = conditional_boundary end # @return [String, nil] @@ -63,16 +67,16 @@ def namespace_pin # @param scope [Symbol, nil] # @param visibility [Symbol, nil] # @param lvars [Array, nil] - # @param conditional [Boolean, nil] + # @param conditional_boundary [Range, nil] # @return [Region] - def update closure: nil, scope: nil, visibility: nil, lvars: nil, conditional: nil + def update closure: nil, scope: nil, visibility: nil, lvars: nil, conditional_boundary: nil Region.new( source: source, closure: closure || self.closure, scope: scope || self.scope, visibility: visibility || self.visibility, lvars: lvars || self.lvars, - conditional: conditional.nil? ? self.conditional : conditional + conditional_boundary: conditional_boundary || self.conditional_boundary ) end diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index 03914c5b7c..7ef5aa42cd 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -17,6 +17,9 @@ class BaseVariable < Base # @return [Boolean] attr_reader :definite + # @return [Range, nil] + attr_reader :conditional_override_boundary + # @param return_type [ComplexType, nil] # @param assignment [Parser::AST::Node, nil] First assignment # that was made to this variable @@ -55,11 +58,20 @@ class BaseVariable < Base # reassignment's type may safely override a variable's # previously declared/inferred type instead of merely being # unioned with it. + # @param conditional_override_boundary [Range, nil] When + # `definite` is false because this assignment is inside a + # conditional branch or loop, the source range of that + # construct's body - i.e., the extent within which this + # assignment, though not globally guaranteed, is still + # guaranteed to dominate any reference. A reference at a + # position inside this range may still treat the assignment + # as an override rather than merely unioning it with earlier + # possible types. # @param [Hash{Symbol => Object}] splat def initialize assignment: nil, assignments: [], mass_assignment: nil, presence: nil, return_type: nil, intersection_return_type: nil, exclude_return_type: nil, - definite: true, + definite: true, conditional_override_boundary: nil, **splat super(**splat) @assignments = (assignment.nil? ? [] : [assignment]) + assignments @@ -70,6 +82,7 @@ def initialize assignment: nil, assignments: [], mass_assignment: nil, @exclude_return_type = exclude_return_type @presence = presence @definite = definite + @conditional_override_boundary = conditional_override_boundary end # @param presence [Range] @@ -94,8 +107,14 @@ def reset_generated! super end - def combine_with other, attrs = {} - new_assignments = combine_assignments(other) + # @param other [self] + # @param attrs [Hash] + # @param location [Location, nil] The position being resolved, + # if known - used to decide whether a not-globally-definite + # `other` should still override us because the position falls + # within `other`'s conditional_override_boundary. + def combine_with other, attrs = {}, location: nil + new_assignments = combine_assignments(other, location) new_attrs = attrs.merge({ # default values don't exist in RBS parameters; it just # tells you if the arg is optional or not. Prefer a @@ -106,7 +125,7 @@ def combine_with other, attrs = {} # skip this - the constructor prepends `assignment:` to # `assignments:` unconditionally, which would re-introduce # the dropped node. - assignment: override_assignments?(other) ? nil : choose(other, :assignment), + assignment: override_assignments?(other, location) ? nil : choose(other, :assignment), assignments: new_assignments, mass_assignment: combine_mass_assignment(other), return_type: combine_return_type(other), @@ -138,9 +157,11 @@ def assignment # @param other [self] # + # @param other [self] + # @param location [Location, nil] # @return [::Array] - def combine_assignments other - return other.assignments.dup if override_assignments?(other) + def combine_assignments other, location = nil + return other.assignments.dup if override_assignments?(other, location) (other.assignments + assignments).uniq end @@ -364,12 +385,35 @@ def within_own_assignment? other_loc # leave nothing to resolve against. # # @param other [self] + # @param location [Location, nil] The position being resolved, + # if known - lets a conditional `other` still override us when + # `location` falls inside `other`'s conditional_override_boundary. # @return [Boolean] - def override_assignments? other - other.definite && other.closure == closure && + def override_assignments? other, location = nil + (other.definite || other.definite_reaches?(location)) && other.closure == closure && other.assignments.none? { |node| references_name?(node) } end + public + + # True if this pin's assignment, though not globally definite, + # is still guaranteed to dominate `location` - i.e., `location` + # falls inside the conditional construct's body that this + # assignment was made in, so no earlier branch exit could have + # skipped it by the time `location` is reached. + # + # @param location [Location, nil] + # @return [Boolean] + def definite_reaches? location + boundary = conditional_override_boundary + return false unless location && boundary + + location.filename == self.location&.filename && + boundary.contain?(location.range.start) + end + + private + # @param node [Parser::AST::Node, nil] # @return [Boolean] def references_name? node diff --git a/lib/solargraph/pin/local_variable.rb b/lib/solargraph/pin/local_variable.rb index 077da21be3..e73ba2588c 100644 --- a/lib/solargraph/pin/local_variable.rb +++ b/lib/solargraph/pin/local_variable.rb @@ -16,9 +16,9 @@ def probe api_map super end - def combine_with other, attrs = {} + def combine_with other, attrs = {}, location: nil # keep this as a parameter - return other.combine_with(self, attrs) if other.is_a?(Parameter) && !is_a?(Parameter) + return other.combine_with(self, attrs, location: location) if other.is_a?(Parameter) && !is_a?(Parameter) super end diff --git a/lib/solargraph/pin/parameter.rb b/lib/solargraph/pin/parameter.rb index cbfe4ba84d..a50bd1031a 100644 --- a/lib/solargraph/pin/parameter.rb +++ b/lib/solargraph/pin/parameter.rb @@ -30,7 +30,7 @@ def location super || closure&.type_location end - def combine_with other, attrs = {} + def combine_with other, attrs = {}, location: nil # Parameters can only be combined with local variables in the same closure return self unless other.closure == closure @@ -45,7 +45,7 @@ def combine_with other, attrs = {} asgn_code: asgn_code } end - super(other, new_attrs.merge(attrs)) + super(other, new_attrs.merge(attrs), location: location) end def combine_return_type other diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 935dbce71f..728b52d7ce 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -939,6 +939,23 @@ def describe(position) ]) end + it 'still treats a conditional reassignment as guaranteed to have run for a use site inside the same branch' do + checker = type_checker(%( + # @param str [String] + # @param num [Integer] + # @param flag [Boolean] + # @return [void] + def conditional_reassign(str, num, flag) + local = num + if flag + local = str + local.upcase + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'updates a local variable type after reassignment to a different literal type' do checker = type_checker(%( # @return [void] From 5eb82f3d405dcee61b0826be5021ac79e42cee7c Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Fri, 14 Aug 2026 13:37:21 -0400 Subject: [PATCH 08/24] Add a CompoundStatement parent chain; derive closure as a fallback Region now tracks compound_statement (the nearest enclosing CompoundStatement pin - an if/when/while/until/rescue/&&/||/||= body, a method/block body, or a namespace body), threaded through Region#update the same way closure already is. Every construct that creates a CompoundStatement-family pin, or previously only threaded conditional_boundary with no corresponding pin, now sets this pointer, giving every CompoundStatement pin a real link to its immediate parent instead of only the coarser closure chain (which already skips non-scope-forming branches like if-bodies). Pin::Base#closure becomes @closure || , kept strictly as a fallback behind the stored value - hand-built pins that pass closure: directly and have no derivable chain (send_node.rb's synthetic attr_reader/attr_writer pins, args_node.rb, etc.) are untouched. Every pin built through Region-threaded node processors still passes closure: explicitly today, so this is a no-behavior-change infra addition, verified by a new spec asserting the derived value agrees with the stored one across nested if/while/block structures. Pin::CompoundStatement also gains its own combine_with/ combine_compound_statement for incremental-reparse merging, mirroring BaseVariable#combine_closure's location-based tiebreak rather than reusing choose_pin_attr_with_same_name (unsuitable since bare CompoundStatement pins all share name == ''). BaseVariable also gains a compound_statement reader, threaded from lvasgn_node.rb, unused by any override logic yet - preparation for a follow-up that rewrites override_assignments?/definite_reaches? to walk this chain instead of comparing conditional_override_boundary Ranges, removing that duplicate bookkeeping. See the discussion on https://github.com/castwide/solargraph/pull/1282 for the fix this builds on and the design rationale for this follow-up. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YbhZvdCv7xdziXyKJiPuGk --- .../parser_gem/node_processors/and_node.rb | 10 ++- .../parser_gem/node_processors/block_node.rb | 3 +- .../parser_gem/node_processors/def_node.rb | 3 +- .../parser_gem/node_processors/defs_node.rb | 3 +- .../parser_gem/node_processors/if_node.rb | 15 +++- .../parser_gem/node_processors/lvasgn_node.rb | 1 + .../node_processors/namespace_node.rb | 3 +- .../parser_gem/node_processors/or_node.rb | 10 ++- .../parser_gem/node_processors/orasgn_node.rb | 11 ++- .../node_processors/resbody_node.rb | 14 +++- .../parser_gem/node_processors/until_node.rb | 6 +- .../parser_gem/node_processors/when_node.rb | 6 +- .../parser_gem/node_processors/while_node.rb | 6 +- lib/solargraph/parser/region.rb | 23 +++++- lib/solargraph/pin/base.rb | 20 +++++ lib/solargraph/pin/base_variable.rb | 15 ++++ lib/solargraph/pin/compound_statement.rb | 45 ++++++++++- spec/pin/compound_statement_spec.rb | 77 +++++++++++++++++++ 18 files changed, 249 insertions(+), 22 deletions(-) create mode 100644 spec/pin/compound_statement_spec.rb 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 633474a296..7e1da26b1e 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb @@ -12,7 +12,15 @@ def process # any assignment there isn't guaranteed to have executed lhs, rhs = node.children NodeProcessor.process(lhs, region, pins, locals, ivars) - NodeProcessor.process(rhs, region.update(conditional_boundary: Range.from_node(rhs)), pins, locals, ivars) + # not pushed onto `pins` - see resbody_node.rb for why + rhs_cs = Solargraph::Pin::CompoundStatement.new( + location: get_node_location(rhs), + closure: region.closure, + compound_statement: region.compound_statement, + node: rhs, + source: :parser + ) + NodeProcessor.process(rhs, region.update(conditional_boundary: Range.from_node(rhs), compound_statement: rhs_cs), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb index 0e87cf935e..232cf06821 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb @@ -20,6 +20,7 @@ def process block_pin = Solargraph::Pin::Block.new( location: location, closure: region.closure, + compound_statement: region.compound_statement, node: node, context: context, receiver: node.children[0], @@ -31,7 +32,7 @@ def process # a block's body may execute zero or multiple times (e.g. # Enumerable#each), so an assignment inside it is never # guaranteed to have executed - process_children region.update(closure: block_pin, conditional_boundary: Range.from_node(node)) + process_children region.update(closure: block_pin, conditional_boundary: Range.from_node(node), compound_statement: block_pin) end private diff --git a/lib/solargraph/parser/parser_gem/node_processors/def_node.rb b/lib/solargraph/parser/parser_gem/node_processors/def_node.rb index f45f5544df..b6e6137d68 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/def_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/def_node.rb @@ -15,6 +15,7 @@ def process methpin = Solargraph::Pin::Method.new( location: get_node_location(node), closure: region.closure, + compound_statement: region.compound_statement, name: name, context: method_context, comments: comments_for(node), @@ -51,7 +52,7 @@ def process else pins.push methpin end - process_children region.update(closure: methpin, scope: methpin.scope) + process_children region.update(closure: methpin, scope: methpin.scope, compound_statement: methpin) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb b/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb index 09679c7f76..9690fcf879 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb @@ -22,6 +22,7 @@ def process pins.push Solargraph::Pin::Method.new( location: loc, closure: closure, + compound_statement: region.compound_statement, name: node.children[1].to_s, comments: comments_for(node), scope: :class, @@ -29,7 +30,7 @@ def process node: node, source: :parser ) - process_children region.update(closure: pins.last, scope: :class) + process_children region.update(closure: pins.last, scope: :class, compound_statement: pins.last) 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 0f3a4800ce..db303d7337 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -17,6 +17,7 @@ def process pins.push Solargraph::Pin::CompoundStatement.new( location: get_node_location(condition_node), closure: region.closure, + compound_statement: region.compound_statement, node: condition_node, source: :parser ) @@ -24,26 +25,32 @@ def process end then_node = node.children[1] if then_node - pins.push Solargraph::Pin::CompoundStatement.new( + # @sg-ignore Need to add nil check here + then_cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(then_node), closure: region.closure, + compound_statement: region.compound_statement, node: then_node, source: :parser ) + pins.push then_cs # @sg-ignore Need to add nil check here - NodeProcessor.process(then_node, region.update(conditional_boundary: Range.from_node(then_node)), pins, locals, ivars) + NodeProcessor.process(then_node, region.update(conditional_boundary: Range.from_node(then_node), compound_statement: then_cs), pins, locals, ivars) end else_node = node.children[2] if else_node - pins.push Solargraph::Pin::CompoundStatement.new( + # @sg-ignore Need to add nil check here + else_cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(else_node), closure: region.closure, + compound_statement: region.compound_statement, node: else_node, source: :parser ) + pins.push else_cs # @sg-ignore Need to add nil check here - NodeProcessor.process(else_node, region.update(conditional_boundary: Range.from_node(else_node)), pins, locals, ivars) + NodeProcessor.process(else_node, region.update(conditional_boundary: Range.from_node(else_node), compound_statement: else_cs), pins, locals, ivars) end true diff --git a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb index 6d0f97f7f9..7887a8ce5e 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb @@ -21,6 +21,7 @@ def process presence: presence, definite: region.conditional_boundary.nil?, conditional_override_boundary: region.conditional_boundary, + compound_statement: region.compound_statement, source: :parser ) process_children diff --git a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb index 0acbf7ee01..24a1a35772 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb @@ -20,6 +20,7 @@ def process type: node.type, location: loc, closure: region.closure, + compound_statement: region.compound_statement, name: name, comments: comments, visibility: :public, @@ -36,7 +37,7 @@ def process source: :parser ) end - process_children region.update(closure: nspin, visibility: :public) + process_children region.update(closure: nspin, visibility: :public, compound_statement: nspin) end private 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 de85f87b48..c6a8ecdd28 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -12,7 +12,15 @@ def process # any assignment there isn't guaranteed to have executed lhs, rhs = node.children NodeProcessor.process(lhs, region, pins, locals, ivars) - NodeProcessor.process(rhs, region.update(conditional_boundary: Range.from_node(rhs)), pins, locals, ivars) + # not pushed onto `pins` - see resbody_node.rb for why + rhs_cs = Solargraph::Pin::CompoundStatement.new( + location: get_node_location(rhs), + closure: region.closure, + compound_statement: region.compound_statement, + node: rhs, + source: :parser + ) + NodeProcessor.process(rhs, region.update(conditional_boundary: Range.from_node(rhs), compound_statement: rhs_cs), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb index 85f161bf4a..fb31964022 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb @@ -10,7 +10,16 @@ def process new_node = node.updated(node.children[0].type, node.children[0].children + [node.children[1]]) # `x ||= y` only assigns when x is falsy/undefined, so # it's never a guaranteed override of x's prior type - NodeProcessor.process(new_node, region.update(conditional_boundary: Range.from_node(node)), pins, locals, ivars) + # + # not pushed onto `pins` - see resbody_node.rb for why + asgn_cs = Solargraph::Pin::CompoundStatement.new( + location: get_node_location(node), + closure: region.closure, + compound_statement: region.compound_statement, + node: node, + source: :parser + ) + NodeProcessor.process(new_node, region.update(conditional_boundary: Range.from_node(node), compound_statement: asgn_cs), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb index f88b2c7e4b..b9a07e3433 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb @@ -30,8 +30,20 @@ def process source: :parser ) end + # not pushed onto `pins` - and/or/orasgn/resbody bodies are + # too common to warrant a pin per occurrence, so only the + # pointer is needed for the compound_statement chain # @sg-ignore Need to add nil check here - NodeProcessor.process(node.children[2], region.update(conditional_boundary: Range.from_node(node.children[2])), pins, locals, ivars) + rescue_body_cs = Solargraph::Pin::CompoundStatement.new( + # @sg-ignore Need to add nil check here + location: node.children[2] ? get_node_location(node.children[2]) : nil, + closure: region.closure, + compound_statement: region.compound_statement, + node: node.children[2], + source: :parser + ) + # @sg-ignore Need to add nil check here + NodeProcessor.process(node.children[2], region.update(conditional_boundary: Range.from_node(node.children[2]), compound_statement: rescue_body_cs), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb index 9a9d276bf3..a431f81801 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb @@ -13,14 +13,16 @@ def process # until statement doesn't create a closure - e.g., # variables created inside can be seen from outside as # well - pins.push Solargraph::Pin::Until.new( + until_pin = Solargraph::Pin::Until.new( location: location, closure: region.closure, + compound_statement: region.compound_statement, node: node, comments: comments_for(node), source: :parser ) - process_children region.update(conditional_boundary: Range.from_node(node)) + pins.push until_pin + process_children region.update(conditional_boundary: Range.from_node(node), compound_statement: until_pin) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb index 60ddb5f180..d1090fca55 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb @@ -8,13 +8,15 @@ class WhenNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - pins.push Solargraph::Pin::CompoundStatement.new( + cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(node), closure: region.closure, + compound_statement: region.compound_statement, node: node, source: :parser ) - process_children region.update(conditional_boundary: Range.from_node(node)) + pins.push cs + process_children region.update(conditional_boundary: Range.from_node(node), compound_statement: cs) 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 df78413325..97eebe178c 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb @@ -17,14 +17,16 @@ def process # while statement doesn't create a closure - e.g., # variables created inside can be seen from outside as # well - pins.push Solargraph::Pin::While.new( + while_pin = Solargraph::Pin::While.new( location: location, closure: region.closure, + compound_statement: region.compound_statement, node: node, comments: comments_for(node), source: :parser ) - process_children region.update(conditional_boundary: Range.from_node(node)) + pins.push while_pin + process_children region.update(conditional_boundary: Range.from_node(node), compound_statement: while_pin) end end end diff --git a/lib/solargraph/parser/region.rb b/lib/solargraph/parser/region.rb index 0dd3d9334e..17fe727b32 100644 --- a/lib/solargraph/parser/region.rb +++ b/lib/solargraph/parser/region.rb @@ -32,16 +32,30 @@ class Region # @return [Range, nil] attr_reader :conditional_boundary + # The nearest enclosing CompoundStatement pin (an if/when/while/ + # rescue/&&/||/||= body, a method/block body, or a namespace + # body) - a series of statements/expressions where a later one + # executing implies the earlier ones in the same series + # executed too. Every Closure is also a CompoundStatement, so + # this is a superset of the `closure` chain: it additionally + # includes branch bodies that aren't scopes. + # + # @return [Pin::CompoundStatement] + attr_reader :compound_statement + # @param source [Source] # @param closure [Pin::Closure, nil] # @param scope [Symbol, nil] # @param visibility [Symbol] # @param lvars [Array] # @param conditional_boundary [Range, nil] + # @param compound_statement [Pin::CompoundStatement, nil] def initialize source: Solargraph::Source.load_string(''), closure: nil, - scope: nil, visibility: :public, lvars: [], conditional_boundary: nil + scope: nil, visibility: :public, lvars: [], conditional_boundary: nil, + compound_statement: nil @source = source @closure = closure || Pin::Namespace.new(name: '', location: source.location, source: :parser) + @compound_statement = compound_statement || @closure @scope = scope @visibility = visibility @lvars = lvars @@ -68,15 +82,18 @@ def namespace_pin # @param visibility [Symbol, nil] # @param lvars [Array, nil] # @param conditional_boundary [Range, nil] + # @param compound_statement [Pin::CompoundStatement, nil] # @return [Region] - def update closure: nil, scope: nil, visibility: nil, lvars: nil, conditional_boundary: nil + def update closure: nil, scope: nil, visibility: nil, lvars: nil, conditional_boundary: nil, + compound_statement: nil Region.new( source: source, closure: closure || self.closure, scope: scope || self.scope, visibility: visibility || self.visibility, lvars: lvars || self.lvars, - conditional_boundary: conditional_boundary || self.conditional_boundary + conditional_boundary: conditional_boundary || self.conditional_boundary, + compound_statement: compound_statement || self.compound_statement ) end diff --git a/lib/solargraph/pin/base.rb b/lib/solargraph/pin/base.rb index f7ae58d388..b4a611cf8f 100644 --- a/lib/solargraph/pin/base.rb +++ b/lib/solargraph/pin/base.rb @@ -75,6 +75,7 @@ def assert_location_provided # @return [Pin::Closure, nil] def closure + @closure ||= derive_closure_from_compound_statement unless @closure Solargraph.assert_or_log(:closure, "Closure not set on #{self.class} #{name.inspect} from #{source.inspect}") @@ -731,6 +732,25 @@ def equality_fields private + # Fallback for pins with no directly-assigned @closure: walk the + # CompoundStatement parent chain (present only on + # CompoundStatement-family pins - Closure, While, Until, etc.) + # until an ancestor is_a?(Closure). Every pin built through + # Region-threaded node processors already gets an explicit + # closure:, so this only matters for a pin constructed purely + # from a compound_statement chain with no closure: override. + # + # @return [Pin::Closure, nil] + def derive_closure_from_compound_statement + return nil unless is_a?(CompoundStatement) + + # @sg-ignore flow sensitive typing doesn't narrow self past an is_a? guard + cs = compound_statement + # @sg-ignore flow sensitive typing doesn't narrow self past an is_a? guard + cs = cs.compound_statement while cs && !cs.is_a?(Closure) + cs + end + # @return [void] def parse_comments # HACK: Avoid a NoMethodError on nil with empty overload tags diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index 7ef5aa42cd..3cfeac93ef 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -20,6 +20,16 @@ class BaseVariable < Base # @return [Range, nil] attr_reader :conditional_override_boundary + # The CompoundStatement pin this variable's (re)assignment was + # made within - i.e. Region#compound_statement at the point of + # assignment. Not yet consulted by any override logic (that's + # conditional_override_boundary's job today); threaded through + # now so a future chain-walk-based override check has the data + # already flowing. + # + # @return [Pin::CompoundStatement, nil] + attr_reader :compound_statement + # @param return_type [ComplexType, nil] # @param assignment [Parser::AST::Node, nil] First assignment # that was made to this variable @@ -67,11 +77,15 @@ class BaseVariable < Base # position inside this range may still treat the assignment # as an override rather than merely unioning it with earlier # possible types. + # @param compound_statement [Pin::CompoundStatement, nil] The + # CompoundStatement this variable's (re)assignment was made + # within. # @param [Hash{Symbol => Object}] splat def initialize assignment: nil, assignments: [], mass_assignment: nil, presence: nil, return_type: nil, intersection_return_type: nil, exclude_return_type: nil, definite: true, conditional_override_boundary: nil, + compound_statement: nil, **splat super(**splat) @assignments = (assignment.nil? ? [] : [assignment]) + assignments @@ -83,6 +97,7 @@ def initialize assignment: nil, assignments: [], mass_assignment: nil, @presence = presence @definite = definite @conditional_override_boundary = conditional_override_boundary + @compound_statement = compound_statement end # @param presence [Range] diff --git a/lib/solargraph/pin/compound_statement.rb b/lib/solargraph/pin/compound_statement.rb index 39d9cf2d5c..dec7ab9946 100644 --- a/lib/solargraph/pin/compound_statement.rb +++ b/lib/solargraph/pin/compound_statement.rb @@ -44,11 +44,54 @@ module Pin class CompoundStatement < Pin::Base attr_reader :node + # The immediately enclosing CompoundStatement, if any - nil only + # for the synthetic root Namespace Region creates for top-level + # code. Since Closure < CompoundStatement, walking this chain + # until an ancestor is_a?(Closure) is how Base#closure is + # derived when a pin has no directly-assigned @closure. + # + # @return [Pin::CompoundStatement, nil] + attr_reader :compound_statement + # @param node [Parser::AST::Node, nil] + # @param compound_statement [Pin::CompoundStatement, nil] # @param [Hash{Symbol => Object}] splat - def initialize node: nil, **splat + def initialize node: nil, compound_statement: nil, **splat super(**splat) @node = node + @compound_statement = compound_statement + end + + # @param other [self] + # @param attrs [Hash{Symbol => Object}] + # @return [self] + def combine_with other, attrs = {} + new_attrs = { compound_statement: combine_compound_statement(other) }.merge(attrs) + super(other, new_attrs) + end + + # Bare CompoundStatement pins (if/when/rescue/&&/||/||= bodies) + # all share name == '', so the same-name-assertion in + # Base#choose_pin_attr_with_same_name (used by #combine_closure) + # would be meaningless noise here - pick by location instead, + # mirroring BaseVariable#combine_closure. + # + # @param other [self] + # @return [Pin::CompoundStatement, nil] + def combine_compound_statement other + return compound_statement if compound_statement == other.compound_statement + return compound_statement || other.compound_statement if compound_statement.nil? || other.compound_statement.nil? + + # @sg-ignore flow sensitive typing needs to handle attrs + if compound_statement.location.nil? || other.compound_statement.location.nil? + # @sg-ignore flow sensitive typing needs to handle attrs + return compound_statement.location.nil? ? other.compound_statement : compound_statement + end + + # @sg-ignore flow sensitive typing needs to handle attrs + return compound_statement if compound_statement.location <= other.compound_statement.location + + other.compound_statement end end end diff --git a/spec/pin/compound_statement_spec.rb b/spec/pin/compound_statement_spec.rb new file mode 100644 index 0000000000..4cc9013a08 --- /dev/null +++ b/spec/pin/compound_statement_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +describe Solargraph::Pin::CompoundStatement do + # Every pin built through Region-threaded node processors still gets + # an explicit `closure:`, so `Pin::Base#closure` returns the stored + # value, not the derived one - the derivation only kicks in as a + # fallback. These specs check the two would agree anyway, so a + # future node processor that updates one threading (closure: or + # compound_statement:) without the other gets caught here instead + # of silently drifting. + def derive_closure pin + cs = pin.compound_statement + cs = cs.compound_statement while cs && !cs.is_a?(Solargraph::Pin::Closure) + cs + end + + it 'agrees with the stored closure for compound statements nested in a method, if, and while' do + source_map = Solargraph::SourceMap.load_string(%( + class Foo + def bar(flag) + if flag + while flag + local = 1 + end + end + end + end + )) + + compound_statement_pins = source_map.pins.select { |pin| pin.is_a?(described_class) } + expect(compound_statement_pins).not_to be_empty + + compound_statement_pins.each do |pin| + expect(derive_closure(pin)).to eq(pin.closure), "mismatch for #{pin.inspect}" + end + end + + it 'agrees with the stored closure for compound statements nested in a block' do + source_map = Solargraph::SourceMap.load_string(%( + class Foo + def bar + [1].each do |i| + if i + local = i + end + end + end + end + )) + + compound_statement_pins = source_map.pins.select { |pin| pin.is_a?(described_class) } + expect(compound_statement_pins).not_to be_empty + + compound_statement_pins.each do |pin| + expect(derive_closure(pin)).to eq(pin.closure), "mismatch for #{pin.inspect}" + end + end + + it 'derives the enclosing method as closure for a bare CompoundStatement built only with compound_statement:' do + source_map = Solargraph::SourceMap.load_string(%( + class Foo + def bar + 1 + end + end + )) + method_pin = source_map.pins.find { |pin| pin.is_a?(Solargraph::Pin::Method) && pin.name == 'bar' } + + bare_pin = described_class.new( + location: method_pin.location, + compound_statement: method_pin, + source: :parser + ) + + expect(bare_pin.closure).to eq(method_pin) + end +end From e32406566775b3b58fee2ba6aedb44c18ccec652 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Fri, 14 Aug 2026 14:35:37 -0400 Subject: [PATCH 09/24] Rewrite override eligibility to walk the compound_statement chain BaseVariable#definite_reaches? no longer compares a query Location against a separately-stored conditional_override_boundary Range. Instead it checks whether the location falls within this pin's own compound_statement's location range - the CompoundStatement pin already carries that range, and since a nested CompoundStatement's location is always a subrange of its parent's, this single containment check already accounts for arbitrarily nested branches without needing to walk the chain further. This removes the duplicate bookkeeping the original PR 1282 fix introduced: Region#conditional_boundary (a Range) and BaseVariable#conditional_override_boundary are gone, along with the Range.from_node(...) computation every conditional-construct node processor performed to populate them - that range is now read directly off the compound_statement pin instead of being computed a second time. lvasgn_node.rb's `definite` computation goes back to a plain Region#conditional boolean rather than `conditional_boundary.nil?` (and was briefly, incorrectly, tried as `compound_statement.is_a? (Closure)` during this rewrite - reverted because a block's body pin IS a Closure, for variable-scoping purposes, despite running zero or many times, which is exactly the case `conditional_boundary`/`conditional` exists to distinguish). Every closure-creating node processor (def_node.rb, defs_node.rb, namespace_node.rb) now explicitly resets `conditional: false` for its body, since entering a fresh method/namespace scope always runs its body top-to-bottom regardless of how the closure itself was reached, unlike a block. Added: - A loop-ordering regression test confirming a reassignment inside a while body doesn't affect a reference textually before it. - combine_with specs for Pin::CompoundStatement covering the location-based tiebreak and the nil-vs-non-nil case. Verified: full suite (1638 examples, 0 failures), typecheck self-check diffed against the pre-fix baseline (587 problems vs. 591 baseline - net fewer, since deleting the Range.from_node calls also removed several instances of the pre-existing nilable-AST-child pattern already tolerated throughout these files). Combines what were originally staged as two follow-up PRs into one - see https://github.com/castwide/solargraph/pull/1282 for the base fix and design discussion. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YbhZvdCv7xdziXyKJiPuGk --- .../parser_gem/node_processors/and_node.rb | 2 +- .../parser_gem/node_processors/block_node.rb | 2 +- .../parser_gem/node_processors/def_node.rb | 2 +- .../parser_gem/node_processors/defs_node.rb | 2 +- .../parser_gem/node_processors/if_node.rb | 4 +- .../parser_gem/node_processors/lvasgn_node.rb | 3 +- .../node_processors/namespace_node.rb | 2 +- .../parser_gem/node_processors/or_node.rb | 2 +- .../parser_gem/node_processors/orasgn_node.rb | 2 +- .../node_processors/resbody_node.rb | 2 +- .../parser_gem/node_processors/until_node.rb | 2 +- .../parser_gem/node_processors/when_node.rb | 2 +- .../parser_gem/node_processors/while_node.rb | 2 +- lib/solargraph/parser/region.rb | 42 ++++++++-------- lib/solargraph/pin/base_variable.rb | 50 ++++++++----------- spec/pin/compound_statement_spec.rb | 24 +++++++++ spec/type_checker/levels/strong_spec.rb | 17 +++++++ 17 files changed, 99 insertions(+), 63 deletions(-) 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 7e1da26b1e..40acf6354c 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb @@ -20,7 +20,7 @@ def process node: rhs, source: :parser ) - NodeProcessor.process(rhs, region.update(conditional_boundary: Range.from_node(rhs), compound_statement: rhs_cs), pins, locals, ivars) + NodeProcessor.process(rhs, region.update(compound_statement: rhs_cs, conditional: true), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb index 232cf06821..cf210cb5d5 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb @@ -32,7 +32,7 @@ def process # a block's body may execute zero or multiple times (e.g. # Enumerable#each), so an assignment inside it is never # guaranteed to have executed - process_children region.update(closure: block_pin, conditional_boundary: Range.from_node(node), compound_statement: block_pin) + process_children region.update(closure: block_pin, compound_statement: block_pin, conditional: true) end private diff --git a/lib/solargraph/parser/parser_gem/node_processors/def_node.rb b/lib/solargraph/parser/parser_gem/node_processors/def_node.rb index b6e6137d68..c93f0f80f1 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/def_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/def_node.rb @@ -52,7 +52,7 @@ def process else pins.push methpin end - process_children region.update(closure: methpin, scope: methpin.scope, compound_statement: methpin) + process_children region.update(closure: methpin, scope: methpin.scope, compound_statement: methpin, conditional: false) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb b/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb index 9690fcf879..70f058334e 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb @@ -30,7 +30,7 @@ def process node: node, source: :parser ) - process_children region.update(closure: pins.last, scope: :class, compound_statement: pins.last) + process_children region.update(closure: pins.last, scope: :class, compound_statement: pins.last, conditional: false) 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 db303d7337..6120a6ed63 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -35,7 +35,7 @@ def process ) pins.push then_cs # @sg-ignore Need to add nil check here - NodeProcessor.process(then_node, region.update(conditional_boundary: Range.from_node(then_node), compound_statement: then_cs), pins, locals, ivars) + NodeProcessor.process(then_node, region.update(compound_statement: then_cs, conditional: true), pins, locals, ivars) end else_node = node.children[2] @@ -50,7 +50,7 @@ def process ) pins.push else_cs # @sg-ignore Need to add nil check here - NodeProcessor.process(else_node, region.update(conditional_boundary: Range.from_node(else_node), compound_statement: else_cs), pins, locals, ivars) + NodeProcessor.process(else_node, region.update(compound_statement: else_cs, conditional: true), pins, locals, ivars) end true diff --git a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb index 7887a8ce5e..c3eb6dfaca 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb @@ -19,8 +19,7 @@ def process assignment: node.children[1], comments: comments_for(node), presence: presence, - definite: region.conditional_boundary.nil?, - conditional_override_boundary: region.conditional_boundary, + definite: !region.conditional, compound_statement: region.compound_statement, source: :parser ) diff --git a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb index 24a1a35772..a38762a577 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb @@ -37,7 +37,7 @@ def process source: :parser ) end - process_children region.update(closure: nspin, visibility: :public, compound_statement: nspin) + process_children region.update(closure: nspin, visibility: :public, compound_statement: nspin, conditional: false) end private 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 c6a8ecdd28..e64270045f 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -20,7 +20,7 @@ def process node: rhs, source: :parser ) - NodeProcessor.process(rhs, region.update(conditional_boundary: Range.from_node(rhs), compound_statement: rhs_cs), pins, locals, ivars) + NodeProcessor.process(rhs, region.update(compound_statement: rhs_cs, conditional: true), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb index fb31964022..2712866457 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb @@ -19,7 +19,7 @@ def process node: node, source: :parser ) - NodeProcessor.process(new_node, region.update(conditional_boundary: Range.from_node(node), compound_statement: asgn_cs), pins, locals, ivars) + NodeProcessor.process(new_node, region.update(compound_statement: asgn_cs, conditional: true), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb index b9a07e3433..c5f699e09c 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb @@ -43,7 +43,7 @@ def process source: :parser ) # @sg-ignore Need to add nil check here - NodeProcessor.process(node.children[2], region.update(conditional_boundary: Range.from_node(node.children[2]), compound_statement: rescue_body_cs), pins, locals, ivars) + NodeProcessor.process(node.children[2], region.update(compound_statement: rescue_body_cs, conditional: true), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb index a431f81801..47a2d35708 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb @@ -22,7 +22,7 @@ def process source: :parser ) pins.push until_pin - process_children region.update(conditional_boundary: Range.from_node(node), compound_statement: until_pin) + process_children region.update(compound_statement: until_pin, conditional: true) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb index d1090fca55..74a8877914 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb @@ -16,7 +16,7 @@ def process source: :parser ) pins.push cs - process_children region.update(conditional_boundary: Range.from_node(node), compound_statement: cs) + process_children region.update(compound_statement: cs, conditional: true) 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 97eebe178c..866ad03035 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb @@ -26,7 +26,7 @@ def process source: :parser ) pins.push while_pin - process_children region.update(conditional_boundary: Range.from_node(node), compound_statement: while_pin) + process_children region.update(compound_statement: while_pin, conditional: true) end end end diff --git a/lib/solargraph/parser/region.rb b/lib/solargraph/parser/region.rb index 17fe727b32..fbf34a069e 100644 --- a/lib/solargraph/parser/region.rb +++ b/lib/solargraph/parser/region.rb @@ -21,17 +21,6 @@ class Region # @return [Array] attr_reader :lvars - # The source range of the nearest enclosing construct that may - # be skipped at runtime (e.g., an if/while/until body), meaning - # an assignment made at the current position isn't guaranteed - # to have executed at a later position - except at a position - # that itself falls within this same range, where the - # assignment is still guaranteed to dominate. nil if the - # current position isn't inside any such construct. - # - # @return [Range, nil] - attr_reader :conditional_boundary - # The nearest enclosing CompoundStatement pin (an if/when/while/ # rescue/&&/||/||= body, a method/block body, or a namespace # body) - a series of statements/expressions where a later one @@ -43,23 +32,36 @@ class Region # @return [Pin::CompoundStatement] attr_reader :compound_statement + # True if the current position may be skipped, or run zero or + # multiple times, at runtime - e.g. inside an if/while/until/ + # rescue/&&/||/||= body, or inside a block body (which, despite + # its Block pin being a Closure like Method/Namespace, may run + # zero or many times depending on the method it's passed to, + # unlike a method/namespace body which always runs exactly once + # when reached). Not derivable from `compound_statement.is_a? + # (Closure)` alone for that reason - Block is the case where + # "is a Closure" and "unconditionally executes" diverge. + # + # @return [Boolean] + attr_reader :conditional + # @param source [Source] # @param closure [Pin::Closure, nil] # @param scope [Symbol, nil] # @param visibility [Symbol] # @param lvars [Array] - # @param conditional_boundary [Range, nil] # @param compound_statement [Pin::CompoundStatement, nil] + # @param conditional [Boolean] def initialize source: Solargraph::Source.load_string(''), closure: nil, - scope: nil, visibility: :public, lvars: [], conditional_boundary: nil, - compound_statement: nil + scope: nil, visibility: :public, lvars: [], + compound_statement: nil, conditional: false @source = source @closure = closure || Pin::Namespace.new(name: '', location: source.location, source: :parser) @compound_statement = compound_statement || @closure @scope = scope @visibility = visibility @lvars = lvars - @conditional_boundary = conditional_boundary + @conditional = conditional end # @return [String, nil] @@ -81,19 +83,19 @@ def namespace_pin # @param scope [Symbol, nil] # @param visibility [Symbol, nil] # @param lvars [Array, nil] - # @param conditional_boundary [Range, nil] # @param compound_statement [Pin::CompoundStatement, nil] + # @param conditional [Boolean, nil] # @return [Region] - def update closure: nil, scope: nil, visibility: nil, lvars: nil, conditional_boundary: nil, - compound_statement: nil + def update closure: nil, scope: nil, visibility: nil, lvars: nil, + compound_statement: nil, conditional: nil Region.new( source: source, closure: closure || self.closure, scope: scope || self.scope, visibility: visibility || self.visibility, lvars: lvars || self.lvars, - conditional_boundary: conditional_boundary || self.conditional_boundary, - compound_statement: compound_statement || self.compound_statement + compound_statement: compound_statement || self.compound_statement, + conditional: conditional.nil? ? self.conditional : conditional ) end diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index 3cfeac93ef..38d409b348 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -17,15 +17,10 @@ class BaseVariable < Base # @return [Boolean] attr_reader :definite - # @return [Range, nil] - attr_reader :conditional_override_boundary - # The CompoundStatement pin this variable's (re)assignment was # made within - i.e. Region#compound_statement at the point of - # assignment. Not yet consulted by any override logic (that's - # conditional_override_boundary's job today); threaded through - # now so a future chain-walk-based override check has the data - # already flowing. + # assignment. Used by #definite_reaches? to decide whether a + # non-definite assignment still dominates a given reference. # # @return [Pin::CompoundStatement, nil] attr_reader :compound_statement @@ -68,23 +63,17 @@ class BaseVariable < Base # reassignment's type may safely override a variable's # previously declared/inferred type instead of merely being # unioned with it. - # @param conditional_override_boundary [Range, nil] When - # `definite` is false because this assignment is inside a - # conditional branch or loop, the source range of that - # construct's body - i.e., the extent within which this - # assignment, though not globally guaranteed, is still - # guaranteed to dominate any reference. A reference at a - # position inside this range may still treat the assignment - # as an override rather than merely unioning it with earlier - # possible types. # @param compound_statement [Pin::CompoundStatement, nil] The # CompoundStatement this variable's (re)assignment was made - # within. + # within. When `definite` is false, a reference whose location + # falls within this pin's own range may still treat the + # assignment as an override rather than merely unioning it + # with earlier possible types - see #definite_reaches?. # @param [Hash{Symbol => Object}] splat def initialize assignment: nil, assignments: [], mass_assignment: nil, presence: nil, return_type: nil, intersection_return_type: nil, exclude_return_type: nil, - definite: true, conditional_override_boundary: nil, + definite: true, compound_statement: nil, **splat super(**splat) @@ -96,7 +85,6 @@ def initialize assignment: nil, assignments: [], mass_assignment: nil, @exclude_return_type = exclude_return_type @presence = presence @definite = definite - @conditional_override_boundary = conditional_override_boundary @compound_statement = compound_statement end @@ -127,7 +115,7 @@ def reset_generated! # @param location [Location, nil] The position being resolved, # if known - used to decide whether a not-globally-definite # `other` should still override us because the position falls - # within `other`'s conditional_override_boundary. + # within `other`'s compound_statement. def combine_with other, attrs = {}, location: nil new_assignments = combine_assignments(other, location) new_attrs = attrs.merge({ @@ -402,7 +390,7 @@ def within_own_assignment? other_loc # @param other [self] # @param location [Location, nil] The position being resolved, # if known - lets a conditional `other` still override us when - # `location` falls inside `other`'s conditional_override_boundary. + # `location` falls within `other`'s compound_statement. # @return [Boolean] def override_assignments? other, location = nil (other.definite || other.definite_reaches?(location)) && other.closure == closure && @@ -413,18 +401,24 @@ def override_assignments? other, location = nil # True if this pin's assignment, though not globally definite, # is still guaranteed to dominate `location` - i.e., `location` - # falls inside the conditional construct's body that this - # assignment was made in, so no earlier branch exit could have - # skipped it by the time `location` is reached. + # falls within the CompoundStatement body (an if/while/until/ + # rescue/&&/||/||= branch) this assignment was made in, so no + # earlier branch exit could have skipped it by the time + # `location` is reached. A nested CompoundStatement's location + # is always a subrange of its parent's, so this single + # containment check already accounts for arbitrarily nested + # branches without walking the compound_statement chain further. # # @param location [Location, nil] # @return [Boolean] def definite_reaches? location - boundary = conditional_override_boundary - return false unless location && boundary + cs = compound_statement + return false unless location && cs&.location&.range - location.filename == self.location&.filename && - boundary.contain?(location.range.start) + # @sg-ignore flow sensitive typing needs to handle attrs + cs.location.filename == location.filename && + # @sg-ignore flow sensitive typing needs to handle attrs + cs.location.range.contain?(location.range.start) end private diff --git a/spec/pin/compound_statement_spec.rb b/spec/pin/compound_statement_spec.rb index 4cc9013a08..5a6a1e0d99 100644 --- a/spec/pin/compound_statement_spec.rb +++ b/spec/pin/compound_statement_spec.rb @@ -74,4 +74,28 @@ def bar expect(bare_pin.closure).to eq(method_pin) end + + describe '#combine_with' do + let(:earlier_location) { Solargraph::Location.new('test.rb', Solargraph::Range.from_to(1, 0, 3, 0)) } + let(:later_location) { Solargraph::Location.new('test.rb', Solargraph::Range.from_to(5, 0, 7, 0)) } + + it 'prefers the compound_statement with the earlier location' do + earlier_cs = described_class.new(location: earlier_location, source: :parser) + later_cs = described_class.new(location: later_location, source: :parser) + pin1 = described_class.new(location: earlier_location, compound_statement: earlier_cs, source: :parser) + pin2 = described_class.new(location: later_location, compound_statement: later_cs, source: :parser) + + expect(pin1.combine_with(pin2).compound_statement).to eq(earlier_cs) + expect(pin2.combine_with(pin1).compound_statement).to eq(earlier_cs) + end + + it 'prefers a non-nil compound_statement over a nil one' do + cs = described_class.new(location: earlier_location, source: :parser) + pin1 = described_class.new(location: earlier_location, compound_statement: nil, source: :parser) + pin2 = described_class.new(location: earlier_location, compound_statement: cs, source: :parser) + + expect(pin1.combine_with(pin2).compound_statement).to eq(cs) + expect(pin2.combine_with(pin1).compound_statement).to eq(cs) + end + end end diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 728b52d7ce..195e862c11 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -956,6 +956,23 @@ def conditional_reassign(str, num, flag) expect(checker.problems.map(&:message)).to eq([]) end + it 'does not let a loop-body reassignment override a reference textually before it' do + checker = type_checker(%( + # @param str [String] + # @param num [Integer] + # @param flag [Boolean] + # @return [void] + def loop_reassign(str, num, flag) + local = num + while flag + local.abs + local = str + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'updates a local variable type after reassignment to a different literal type' do checker = type_checker(%( # @return [void] From 9fe7637c1f53d8adb5e9431485ba7e72181fca17 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Fri, 14 Aug 2026 15:37:04 -0400 Subject: [PATCH 10/24] Move conditional from Region to the CompoundStatement pin itself Region#conditional was a separate boolean threaded alongside compound_statement, requiring every node processor to pass both in lockstep (e.g. block_node.rb: compound_statement: block_pin, conditional: true). Keeping two parallel values in sync at every call site is exactly the kind of duplication this refactor set out to remove, and it's the shape of bug that broke Block handling mid-refactor (definite briefly, incorrectly, derived from compound_statement.is_a?(Closure), which is true for Block despite a block body running zero or many times). conditional is now a constructor attribute on Pin::CompoundStatement itself, set once where each construct is built (Pin::Block.new(..., conditional: true), Pin::Method.new(...) defaulting false), so there's only one thing to get right per site instead of two. It can't be a class-level constant: the bare Pin::CompoundStatement class is used both for an if's own condition (never conditional) and for then/else/rhs/rescue bodies (always conditional) - same class, different instances, different answers - so it stays an instance attribute, same as closure:/compound_statement: already are. lvasgn_node.rb's definite computation becomes a single-hop read: `!region.compound_statement.conditional`, no separate Region field. Pin::CompoundStatement#combine_with merges the new attribute via `choose`, since two versions of the same construct should already agree on it. Verified: full suite (1638 examples, 0 failures), typecheck self-check diffed clean against the prior baseline (587 problems, unchanged), rubocop clean on touched files. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YbhZvdCv7xdziXyKJiPuGk --- .../parser_gem/node_processors/and_node.rb | 3 ++- .../parser_gem/node_processors/block_node.rb | 9 ++++---- .../parser_gem/node_processors/def_node.rb | 2 +- .../parser_gem/node_processors/defs_node.rb | 2 +- .../parser_gem/node_processors/if_node.rb | 6 +++-- .../parser_gem/node_processors/lvasgn_node.rb | 2 +- .../node_processors/namespace_node.rb | 2 +- .../parser_gem/node_processors/or_node.rb | 3 ++- .../parser_gem/node_processors/orasgn_node.rb | 3 ++- .../node_processors/resbody_node.rb | 3 ++- .../parser_gem/node_processors/until_node.rb | 3 ++- .../parser_gem/node_processors/when_node.rb | 3 ++- .../parser_gem/node_processors/while_node.rb | 3 ++- lib/solargraph/parser/region.rb | 23 +++---------------- lib/solargraph/pin/compound_statement.rb | 21 +++++++++++++++-- 15 files changed, 49 insertions(+), 39 deletions(-) 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 40acf6354c..ae1ab31d71 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb @@ -17,10 +17,11 @@ def process location: get_node_location(rhs), closure: region.closure, compound_statement: region.compound_statement, + conditional: true, node: rhs, source: :parser ) - NodeProcessor.process(rhs, region.update(compound_statement: rhs_cs, conditional: true), pins, locals, ivars) + NodeProcessor.process(rhs, region.update(compound_statement: rhs_cs), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb index cf210cb5d5..08add69d9c 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb @@ -21,6 +21,10 @@ def process location: location, closure: region.closure, compound_statement: region.compound_statement, + # a block's body may execute zero or multiple times (e.g. + # Enumerable#each), so an assignment inside it is never + # guaranteed to have executed + conditional: true, node: node, context: context, receiver: node.children[0], @@ -29,10 +33,7 @@ def process source: :parser ) pins.push block_pin - # a block's body may execute zero or multiple times (e.g. - # Enumerable#each), so an assignment inside it is never - # guaranteed to have executed - process_children region.update(closure: block_pin, compound_statement: block_pin, conditional: true) + process_children region.update(closure: block_pin, compound_statement: block_pin) end private diff --git a/lib/solargraph/parser/parser_gem/node_processors/def_node.rb b/lib/solargraph/parser/parser_gem/node_processors/def_node.rb index c93f0f80f1..b6e6137d68 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/def_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/def_node.rb @@ -52,7 +52,7 @@ def process else pins.push methpin end - process_children region.update(closure: methpin, scope: methpin.scope, compound_statement: methpin, conditional: false) + process_children region.update(closure: methpin, scope: methpin.scope, compound_statement: methpin) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb b/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb index 70f058334e..9690fcf879 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb @@ -30,7 +30,7 @@ def process node: node, source: :parser ) - process_children region.update(closure: pins.last, scope: :class, compound_statement: pins.last, conditional: false) + process_children region.update(closure: pins.last, scope: :class, compound_statement: pins.last) 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 6120a6ed63..56bb2e63dd 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -30,12 +30,13 @@ def process location: get_node_location(then_node), closure: region.closure, compound_statement: region.compound_statement, + conditional: true, node: then_node, source: :parser ) pins.push then_cs # @sg-ignore Need to add nil check here - NodeProcessor.process(then_node, region.update(compound_statement: then_cs, conditional: true), pins, locals, ivars) + NodeProcessor.process(then_node, region.update(compound_statement: then_cs), pins, locals, ivars) end else_node = node.children[2] @@ -45,12 +46,13 @@ def process location: get_node_location(else_node), closure: region.closure, compound_statement: region.compound_statement, + conditional: true, node: else_node, source: :parser ) pins.push else_cs # @sg-ignore Need to add nil check here - NodeProcessor.process(else_node, region.update(compound_statement: else_cs, conditional: true), pins, locals, ivars) + NodeProcessor.process(else_node, region.update(compound_statement: else_cs), pins, locals, ivars) end true diff --git a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb index c3eb6dfaca..33c429a931 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb @@ -19,7 +19,7 @@ def process assignment: node.children[1], comments: comments_for(node), presence: presence, - definite: !region.conditional, + definite: !region.compound_statement.conditional, compound_statement: region.compound_statement, source: :parser ) diff --git a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb index a38762a577..24a1a35772 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb @@ -37,7 +37,7 @@ def process source: :parser ) end - process_children region.update(closure: nspin, visibility: :public, compound_statement: nspin, conditional: false) + process_children region.update(closure: nspin, visibility: :public, compound_statement: nspin) end private 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 e64270045f..b27c28806a 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -17,10 +17,11 @@ def process location: get_node_location(rhs), closure: region.closure, compound_statement: region.compound_statement, + conditional: true, node: rhs, source: :parser ) - NodeProcessor.process(rhs, region.update(compound_statement: rhs_cs, conditional: true), pins, locals, ivars) + NodeProcessor.process(rhs, region.update(compound_statement: rhs_cs), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, diff --git a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb index 2712866457..87b89505a7 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb @@ -16,10 +16,11 @@ def process location: get_node_location(node), closure: region.closure, compound_statement: region.compound_statement, + conditional: true, node: node, source: :parser ) - NodeProcessor.process(new_node, region.update(compound_statement: asgn_cs, conditional: true), pins, locals, ivars) + NodeProcessor.process(new_node, region.update(compound_statement: asgn_cs), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb index c5f699e09c..1d0e43d7b9 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb @@ -39,11 +39,12 @@ def process location: node.children[2] ? get_node_location(node.children[2]) : nil, closure: region.closure, compound_statement: region.compound_statement, + conditional: true, node: node.children[2], source: :parser ) # @sg-ignore Need to add nil check here - NodeProcessor.process(node.children[2], region.update(compound_statement: rescue_body_cs, conditional: true), pins, locals, ivars) + NodeProcessor.process(node.children[2], region.update(compound_statement: rescue_body_cs), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb index 47a2d35708..f345e00953 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb @@ -17,12 +17,13 @@ def process location: location, closure: region.closure, compound_statement: region.compound_statement, + conditional: true, node: node, comments: comments_for(node), source: :parser ) pins.push until_pin - process_children region.update(compound_statement: until_pin, conditional: true) + process_children region.update(compound_statement: until_pin) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb index 74a8877914..144220d48c 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb @@ -12,11 +12,12 @@ def process location: get_node_location(node), closure: region.closure, compound_statement: region.compound_statement, + conditional: true, node: node, source: :parser ) pins.push cs - process_children region.update(compound_statement: cs, conditional: true) + process_children region.update(compound_statement: cs) 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 866ad03035..44b30f84eb 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb @@ -21,12 +21,13 @@ def process location: location, closure: region.closure, compound_statement: region.compound_statement, + conditional: true, node: node, comments: comments_for(node), source: :parser ) pins.push while_pin - process_children region.update(compound_statement: while_pin, conditional: true) + process_children region.update(compound_statement: while_pin) end end end diff --git a/lib/solargraph/parser/region.rb b/lib/solargraph/parser/region.rb index fbf34a069e..d535a36840 100644 --- a/lib/solargraph/parser/region.rb +++ b/lib/solargraph/parser/region.rb @@ -32,36 +32,21 @@ class Region # @return [Pin::CompoundStatement] attr_reader :compound_statement - # True if the current position may be skipped, or run zero or - # multiple times, at runtime - e.g. inside an if/while/until/ - # rescue/&&/||/||= body, or inside a block body (which, despite - # its Block pin being a Closure like Method/Namespace, may run - # zero or many times depending on the method it's passed to, - # unlike a method/namespace body which always runs exactly once - # when reached). Not derivable from `compound_statement.is_a? - # (Closure)` alone for that reason - Block is the case where - # "is a Closure" and "unconditionally executes" diverge. - # - # @return [Boolean] - attr_reader :conditional - # @param source [Source] # @param closure [Pin::Closure, nil] # @param scope [Symbol, nil] # @param visibility [Symbol] # @param lvars [Array] # @param compound_statement [Pin::CompoundStatement, nil] - # @param conditional [Boolean] def initialize source: Solargraph::Source.load_string(''), closure: nil, scope: nil, visibility: :public, lvars: [], - compound_statement: nil, conditional: false + compound_statement: nil @source = source @closure = closure || Pin::Namespace.new(name: '', location: source.location, source: :parser) @compound_statement = compound_statement || @closure @scope = scope @visibility = visibility @lvars = lvars - @conditional = conditional end # @return [String, nil] @@ -84,18 +69,16 @@ def namespace_pin # @param visibility [Symbol, nil] # @param lvars [Array, nil] # @param compound_statement [Pin::CompoundStatement, nil] - # @param conditional [Boolean, nil] # @return [Region] def update closure: nil, scope: nil, visibility: nil, lvars: nil, - compound_statement: nil, conditional: nil + compound_statement: nil Region.new( source: source, closure: closure || self.closure, scope: scope || self.scope, visibility: visibility || self.visibility, lvars: lvars || self.lvars, - compound_statement: compound_statement || self.compound_statement, - conditional: conditional.nil? ? self.conditional : conditional + compound_statement: compound_statement || self.compound_statement ) end diff --git a/lib/solargraph/pin/compound_statement.rb b/lib/solargraph/pin/compound_statement.rb index dec7ab9946..c527d6928a 100644 --- a/lib/solargraph/pin/compound_statement.rb +++ b/lib/solargraph/pin/compound_statement.rb @@ -53,20 +53,37 @@ class CompoundStatement < Pin::Base # @return [Pin::CompoundStatement, nil] attr_reader :compound_statement + # True if this construct's body may be skipped, or run zero or + # multiple times, at runtime - e.g. an if/while/until/rescue/&&/ + # ||/||= body, or a block body (which, despite being a Closure + # like Method/Namespace, may run zero or many times depending on + # the method it's passed to, unlike a method/namespace body, + # which always runs exactly once when reached). Defaults false - + # true only where a node processor explicitly marks a construct + # as conditionally executed. + # + # @return [Boolean] + attr_reader :conditional + # @param node [Parser::AST::Node, nil] # @param compound_statement [Pin::CompoundStatement, nil] + # @param conditional [Boolean] # @param [Hash{Symbol => Object}] splat - def initialize node: nil, compound_statement: nil, **splat + def initialize node: nil, compound_statement: nil, conditional: false, **splat super(**splat) @node = node @compound_statement = compound_statement + @conditional = conditional end # @param other [self] # @param attrs [Hash{Symbol => Object}] # @return [self] def combine_with other, attrs = {} - new_attrs = { compound_statement: combine_compound_statement(other) }.merge(attrs) + new_attrs = { + compound_statement: combine_compound_statement(other), + conditional: choose(other, :conditional) + }.merge(attrs) super(other, new_attrs) end From e049739de3092ed2609dff06f3f1f4fcc514f441 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 15 Aug 2026 18:52:01 -0400 Subject: [PATCH 11/24] Narrow a nil-guarded default after the conditional The default-argument idiom - `tasks = ['a'] if tasks.nil?` followed by `tasks.each` - still reported `Unresolved call to each on Array, nil`. PR #1282 covered the dominance case (a use site inside the branch the reassignment dominates); here the use site is *after* the conditional, so what establishes the type on the path where the assignment did not run is the guard's condition, not dominance. At a merge point after an `if`, the incoming paths are (a) the clause ran and assigned a new value - already handled, that pin is unioned in - and (b) the clause did not run, leaving the original value, about which the condition tells us something. Path (b) was never asserted, so the original `Array, nil` was unioned in unnarrowed. FlowSensitiveTyping#process_if now also asserts the opposite branch's condition facts over the rest of the enclosing compound statement, for the variables the clause definitely reassigns. Reusing #process_expression for that gets `&&`/`||`/`!` handling for free, including `and`'s deliberate refusal to propagate false-facts. The restriction to definitely-reassigned variables is what keeps this sound. Facts are filtered by variable name in #add_downcast_var, driven by a second FlowSensitiveTyping built over the same locals/ivars arrays with `restricted_names:` set. Without it, `xs = [] if xs.nil? || ys.nil?` would also narrow `ys` after the conditional, even though only `xs` was replaced. Likewise, only unconditional `lvasgn`/`ivasgn` in the clause count: an assignment nested in another conditional, or an `||=`, may leave the previous value in play. Guards that test something other than the variable (`tasks = ['a'] if flag`) and nil guards that don't reassign (`puts 'hi' if tasks.nil?`) keep nil in the type, as they must; specs cover both, plus the non-modifier `if`, `unless`, and else-clause forms. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn --- .../parser/flow_sensitive_typing.rb | 108 +++++++++++++++++- spec/type_checker/levels/strong_spec.rb | 108 ++++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index 8b900beb0b..4b8dbf4f38 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -9,11 +9,30 @@ 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 restricted_names [Array, nil] If given, only + # assert facts about variables with these names, ignoring any + # other variable the analyzed condition happens to mention. + def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin, + restricted_names: nil @locals = locals @ivars = ivars @enclosing_breakable_pin = enclosing_breakable_pin @enclosing_compound_statement_pin = enclosing_compound_statement_pin + @restricted_names = restricted_names + end + + # Assert the facts implied by a condition being true/false over + # the given ranges. Public so that a differently-configured + # instance (see #initialize's restricted_names) can be handed a + # condition to analyze. + # + # @param conditional_node [Parser::AST::Node] + # @param true_ranges [Array] + # @param false_ranges [Array] + # + # @return [void] + def process_condition conditional_node, true_ranges, false_ranges + process_expression(conditional_node, true_ranges, false_ranges) end # @param and_node [Parser::AST::Node] @@ -153,6 +172,11 @@ def process_if if_node, true_ranges = [], false_ranges = [] end process_expression(conditional_node, true_ranges, false_ranges) + + # @sg-ignore the ast gem tags AST::Node#children as a bare + # [Array], so `if_node.children[0]` infers as `Array, nil` + # here - same gap the process_expression call above hits + process_guarded_reassignment(if_node, conditional_node, then_clause, else_clause) end # @param while_node [Parser::AST::Node] @@ -198,6 +222,83 @@ class << self private + # The standard default-argument idiom reassigns a variable in + # the branch where the guard on that same variable fired: + # + # tasks = ['a'] if tasks.nil? + # tasks.each { ... } + # + # At a use site *after* the conditional, the two incoming paths + # are (a) the guard fired and the clause assigned a new value, + # and (b) the guard did not fire, leaving the original value - + # which the condition tells us something about. Path (a) is + # already handled: the assignment's pin is unioned in. Path (b) + # is what's asserted here - the opposite branch's facts from the + # condition hold over the rest of the enclosing compound + # statement. + # + # The facts are restricted to the variables the clause + # definitely reassigns. Without that restriction a condition + # like `x.nil? || y.nil?` would wrongly narrow `y` after the + # conditional, since the clause only replaced `x`'s value. + # + # @param if_node [Parser::AST::Node] + # @param conditional_node [Parser::AST::Node] + # @param then_clause [Parser::AST::Node, nil] + # @param else_clause [Parser::AST::Node, nil] + # + # @return [void] + def process_guarded_reassignment if_node, conditional_node, then_clause, else_clause + compound_statement_node = enclosing_compound_statement_pin&.node + return if compound_statement_node.nil? + + rest_of_compound_statement = Range.new(get_node_end_position(if_node), + get_node_end_position(compound_statement_node)) + + # the then clause ran only when the condition was true, so the + # path that preserved the original value is the false one - + # and vice versa for the else clause + assert_after_guard(conditional_node, definitely_assigned_names(then_clause), + [], [rest_of_compound_statement]) + assert_after_guard(conditional_node, definitely_assigned_names(else_clause), + [rest_of_compound_statement], []) + end + + # @param conditional_node [Parser::AST::Node] + # @param names [Array] + # @param true_ranges [Array] + # @param false_ranges [Array] + # + # @return [void] + def assert_after_guard conditional_node, names, true_ranges, false_ranges + return if names.empty? + + FlowSensitiveTyping.new(locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin, + restricted_names: names) + .process_condition(conditional_node, true_ranges, false_ranges) + end + + # Names of the variables this clause assigns on every path + # through it. Only unconditional, plain assignments count - + # anything inside a nested conditional or loop may not run, and + # `||=`/`+=`-style assignments keep the previous value in play. + # + # @param clause_node [Parser::AST::Node, nil] + # + # @return [Array] + def definitely_assigned_names clause_node + return [] if clause_node.nil? + + case clause_node.type + when :lvasgn, :ivasgn + [clause_node.children[0].to_s] + when :begin, :kwbegin + clause_node.children.flat_map { |child| definitely_assigned_names(child) } + else + [] + end + end + # @param pin [Pin::BaseVariable] # @param presence [Range] # @param downcast_type [ComplexType, nil] @@ -205,6 +306,8 @@ class << self # # @return [void] def add_downcast_var pin, presence:, downcast_type:, downcast_not_type: + return if restricted_names && !restricted_names.include?(pin.name) + new_pin = pin.downcast(exclude_return_type: downcast_not_type, intersection_return_type: downcast_type, source: :flow_sensitive_typing, @@ -482,7 +585,8 @@ 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, + :restricted_names end end end diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 195e862c11..f9abce095c 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -956,6 +956,114 @@ def conditional_reassign(str, num, flag) expect(checker.problems.map(&:message)).to eq([]) end + it 'narrows a nil-guarded default after the modifier if' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def guarded_default(tasks) + tasks = ['a'] if tasks.nil? + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a nil-guarded default after a non-modifier if' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def guarded_default(tasks) + if tasks.nil? + tasks = ['a'] + end + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a guarded default assigned in an unless modifier' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def guarded_default(tasks) + tasks = ['a'] unless tasks + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a guarded default assigned in an else clause' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def guarded_default(tasks) + if !tasks.nil? + puts 'have tasks' + else + tasks = ['a'] + end + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows only the reassigned variable when an or-condition guards it' do + checker = type_checker(%( + # @param xs [Array, nil] + # @param ys [Array, nil] + # @return [void] + def or_guard(xs, ys) + xs = ['a'] if xs.nil? || ys.nil? + xs.each { |t| puts t } + ys.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to each on Array, nil']) + end + + it 'keeps nil in the type when the guard tests something other than the variable' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @param flag [Boolean] + # @return [void] + def unrelated_guard(tasks, flag) + tasks = ['a'] if flag + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to each on Array, nil']) + end + + it 'keeps nil in the type when the nil guard does not reassign the variable' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def no_reassignment(tasks) + puts 'hi' if tasks.nil? + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to each on Array, nil']) + end + + it 'keeps nil in the type when the guarded assignment is itself conditional' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @param flag [Boolean] + # @return [void] + def nested_conditional_assign(tasks, flag) + if tasks.nil? + tasks = ['a'] if flag + end + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to each on Array, nil']) + end + it 'does not let a loop-body reassignment override a reference textually before it' do checker = type_checker(%( # @param str [String] From cb9e7b020f21d2a499edcff709241074cdcf319d Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 15 Aug 2026 19:52:00 -0400 Subject: [PATCH 12/24] Use an existing ignore category on the guarded-reassignment call The ignore added with the fix carried a one-off description. rules.rb keeps a tally of @sg-ignore texts grouped into buckets, so a novel string creates a bucket of one instead of joining an existing count. Reuse the established "Need to add nil check here" wording, matching this file's three sibling ignores on Range.from_node results. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn --- lib/solargraph/parser/flow_sensitive_typing.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index 4b8dbf4f38..dba484f8c4 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -173,9 +173,7 @@ def process_if if_node, true_ranges = [], false_ranges = [] process_expression(conditional_node, true_ranges, false_ranges) - # @sg-ignore the ast gem tags AST::Node#children as a bare - # [Array], so `if_node.children[0]` infers as `Array, nil` - # here - same gap the process_expression call above hits + # @sg-ignore Need to add nil check here process_guarded_reassignment(if_node, conditional_node, then_clause, else_clause) end From f3e1b5402181fa1d8df0521cba934dd866971320 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 15 Aug 2026 22:37:29 -0400 Subject: [PATCH 13/24] Expire flow-sensitive narrowing at a definite reassignment A modifier-if guard stopped being applied once the variable it guards had been reassigned: got = lookup(name) return got.length if got # asserts got is nil/false below here got = lookup(name) got.length if got # Unresolved call to length on nil, Boolean The first guard's `return` leaves the method, so FlowSensitiveTyping asserts the false branch's facts - `got` is `nil, false` - over the rest of the compound statement, and that downcast pin's presence runs to the end of the method. The second `got = lookup(name)` overwrites the value the fact was about, but ApiMap#var_at_location still combined the stale pin in: Pin::BaseVariable#combine_with already let a definite reassignment supersede the earlier pin's *assignments*, yet unioned intersection_return_type and exclude_return_type unconditionally. The `nil, false` intersection survived and intersected the new value down to nothing. Narrowing recorded against a value expires when that value is definitely overwritten, so when #override_assignments? says `other` supersedes us, keep only `other`'s intersection/exclude types instead of unioning ours in. #references_name? then blocked the supersede in the shape this was actually observed in, `lib/solargraph/workspace/gemspecs.rb`: specish = all_gemspecs_from_bundle.find { |specish| specish.name == name } return to_gem_specification specish if specish The self-reference exclusion exists so `x = x.foo` keeps the assignment its own right-hand side resolves against, but a block parameter of the same name shadows the outer variable for the whole block - the mention inside the body is the parameter, not the variable being assigned. The walk now descends only into a shadowing block's receiver, which is still evaluated outside the block. Two @sg-ignore comments in gemspecs.rb are no longer needed and are removed. Facts stay in force up to the reassignment, and a reassignment that only runs in a nested branch still does not supersede; specs cover both, plus a guard on an unrelated variable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn --- lib/solargraph/pin/base_variable.rb | 40 ++++++++++++- lib/solargraph/workspace/gemspecs.rb | 2 - spec/type_checker/levels/strong_spec.rb | 79 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index 38d409b348..2af4f96bee 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -117,6 +117,7 @@ def reset_generated! # `other` should still override us because the position falls # within `other`'s compound_statement. def combine_with other, attrs = {}, location: nil + superseded = override_assignments?(other, location) new_assignments = combine_assignments(other, location) new_attrs = attrs.merge({ # default values don't exist in RBS parameters; it just @@ -128,12 +129,24 @@ def combine_with other, attrs = {}, location: nil # skip this - the constructor prepends `assignment:` to # `assignments:` unconditionally, which would re-introduce # the dropped node. - assignment: override_assignments?(other, location) ? nil : choose(other, :assignment), + assignment: superseded ? nil : choose(other, :assignment), assignments: new_assignments, mass_assignment: combine_mass_assignment(other), return_type: combine_return_type(other), - intersection_return_type: combine_types(other, :intersection_return_type), - exclude_return_type: combine_types(other, :exclude_return_type), + # Narrowing recorded against the old value expires + # when that value is definitely overwritten, so when + # `other`'s assignment supersedes ours, keep only the + # facts asserted about the new value. + intersection_return_type: if superseded + other.intersection_return_type + else + combine_types(other, :intersection_return_type) + end, + exclude_return_type: if superseded + other.exclude_return_type + else + combine_types(other, :exclude_return_type) + end, presence: combine_presence(other), # if either side had an assignment guaranteed to # have executed, that assignment's type is @@ -431,10 +444,31 @@ def references_name? node # @sg-ignore flow sensitive typing doesn't narrow `node` past the guard above return true if %i[lvar ivar].include?(node.type) && node.children[0].to_s == name + # A block parameter of the same name shadows us for the whole + # block, so any mention inside the body is the parameter, not + # this variable. The receiver (children[0]) is evaluated + # outside the block, so it still counts. + # @sg-ignore flow sensitive typing doesn't narrow `node` past the guard above + return references_name?(node.children[0]) if shadowed_by_block_parameter?(node) + # @sg-ignore flow sensitive typing doesn't narrow `node` past the guard above node.children.any? { |child| references_name?(child) } end + # @param node [::AST::Node] + # @return [Boolean] + def shadowed_by_block_parameter? node + return false unless node.type == :block + + args = node.children[1] + return false unless args.is_a?(::AST::Node) + + # @sg-ignore flow sensitive typing doesn't narrow `args` past the guard above + args.children.any? do |arg| + arg.is_a?(::AST::Node) && arg.children[0].to_s == name + end + end + # @param api_map [ApiMap] # @param raw_return_type [ComplexType, ComplexType::UniqueType] # diff --git a/lib/solargraph/workspace/gemspecs.rb b/lib/solargraph/workspace/gemspecs.rb index 2c29b948c6..756203a88b 100644 --- a/lib/solargraph/workspace/gemspecs.rb +++ b/lib/solargraph/workspace/gemspecs.rb @@ -63,7 +63,6 @@ def resolve_require require begin gemspec = Gem::Specification.find_by_name(gem_name) - # @sg-ignore flow sensitive typing should be able to handle redefinition return [gemspec_or_preference(gemspec)] if gemspec rescue Gem::MissingSpecError logger.debug do @@ -106,7 +105,6 @@ def find_gem name, version = nil, out: $stderr # @sg-ignore flow sensitive typing should be able to handle redefinition specish = all_gemspecs_from_bundle.find { |specish| specish.name == name } - # @sg-ignore flow sensitive typing needs to create separate ranges for postfix if return to_gem_specification specish if specish resolve_gem_ignoring_local_bundle name, version, out: out diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index f9abce095c..5e7a4cd367 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -956,6 +956,85 @@ def conditional_reassign(str, num, flag) expect(checker.problems.map(&:message)).to eq([]) end + it 'applies a modifier-if guard after the variable was reassigned' do + checker = type_checker(%( + # @param name [String] + # @return [Integer, nil] + def find(name) + got = lookup(name) + return got.length if got + + got = lookup(name) + got.length if got + end + + # @param name [String] + # @return [String, nil] + def lookup(name); name; end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'applies a modifier-if guard after a reassignment whose block shadows the name' do + checker = type_checker(%( + # @param name [String] + # @return [Integer, nil] + def find(name) + got = candidates.find { |got| got == name } + return got.length if got + + got = candidates.find { |got| got != name } + got.length if got + end + + # @return [Array] + def candidates; []; end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'keeps a guard fact in force until the variable is reassigned' do + checker = type_checker(%( + # @param name [String] + # @return [Integer, nil] + def find(name) + got = lookup(name) + return got.length if got + + got.length + end + + # @param name [String] + # @return [String, nil] + def lookup(name); name; end + )) + expect(checker.problems.map(&:message)) + .to eq(['Unresolved call to length on nil, Boolean']) + end + + it 'does not apply a guard fact past a reassignment that only runs in a branch' do + checker = type_checker(%( + # @param name [String] + # @param flag [Boolean] + # @return [Integer, nil] + def find(name, flag) + got = lookup(name) + return got.length if got + + if flag + got = lookup(name) + end + got.length + end + + # @param name [String] + # @return [String, nil] + def lookup(name); name; end + )) + expect(checker.problems.map(&:message)) + .to eq(['Unresolved call to length on nil, Boolean']) + end + it 'narrows a nil-guarded default after the modifier if' do checker = type_checker(%( # @param tasks [Array, nil] From 3747c781ddb4892ad623569fd5a241da3c8a6b9f Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 15 Aug 2026 22:51:10 -0400 Subject: [PATCH 14/24] Treat a dominating reassignment as definite at that use site A reassignment made inside a branch was ignored by a use site later in that same branch: def clean(items) # @param items [Array, nil] if items.nil? items = fetch_items items.reject! { |i| i.empty? } # Unresolved call to reject! on nil end end Pin::Parameter#typify prefers a reassignment's inferred type over the declared @param type only when the reassigning pin is `definite`, and an assignment inside an `if` body is not definite - it may never run. #override_assignments? already handles that distinction for a specific position via #definite_reaches?: the use site falls inside the CompoundStatement the assignment was made in, so on every path that reaches it the assignment ran. But that verdict only reached #combine_assignments; the combined pin still carried `definite: definite || other.definite`, which was false on both sides, so #typify fell back to the declared type and kept nil in the union. The combined pin is built for one resolved location, so when the supersede check passes there, the result is definite at that location. ApiMap#var_at_location is the only caller that passes a location, so locationless combines are unaffected: without one, #override_assignments? already requires `other.definite`. A reassignment nested in a further conditional, and a use site earlier in the branch than the reassignment, both still keep the original type; specs cover each. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn --- lib/solargraph/pin/base_variable.rb | 2 +- spec/type_checker/levels/strong_spec.rb | 54 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index 2af4f96bee..bf818f50a9 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -152,7 +152,7 @@ def combine_with other, attrs = {}, location: nil # have executed, that assignment's type is # eligible to override (not just be unioned # with) the variable's other possible types - definite: definite || other.definite + definite: definite || other.definite || superseded }) super(other, new_attrs) end diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 5e7a4cd367..33d19e983a 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -956,6 +956,60 @@ def conditional_reassign(str, num, flag) expect(checker.problems.map(&:message)).to eq([]) end + it 'uses a branch-local reassignment at a use site later in the same branch' do + checker = type_checker(%( + # @param items [Array, nil] + # @return [void] + def clean(items) + if items.nil? + items = fetch_items + items.reject! { |i| i.empty? } + end + end + + # @return [Array] + def fetch_items; ['x']; end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not use a reassignment made in a nested branch that may not run' do + checker = type_checker(%( + # @param items [Array, nil] + # @param flag [Boolean] + # @return [void] + def clean(items, flag) + if items.nil? + if flag + items = fetch_items + end + items.reject! { |i| i.empty? } + end + end + + # @return [Array] + def fetch_items; ['x']; end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to reject! on nil']) + end + + it 'does not use a branch-local reassignment at a use site before it' do + checker = type_checker(%( + # @param items [Array, nil] + # @return [void] + def clean(items) + if items.nil? + items.reject! { |i| i.empty? } + items = fetch_items + end + end + + # @return [Array] + def fetch_items; ['x']; end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to reject! on nil']) + end + it 'applies a modifier-if guard after the variable was reassigned' do checker = type_checker(%( # @param name [String] From 5215ce26e9249a53e57a5bbffde23dd1ebf6016f Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 15 Aug 2026 23:12:11 -0400 Subject: [PATCH 15/24] Narrow a variable assigned inside an if condition The assignment-as-condition idiom asserted nothing about the variable it assigns: if (md = name.match(/\[(.*)\]/)) md[1].to_i # Unresolved call to [] else 0 end Two things were missing. FlowSensitiveTyping#process_expression handled :send, :and, :or and bare variable references, but not the one-child :begin that parentheses produce, nor :lvasgn/:ivasgn - so the condition was walked past without a fact being recorded. An assignment used as a condition evaluates to the value assigned, so the branches say the same thing about the variable as a bare reference would: not nil where the condition held, `nil, false` where it did not. Adding those handlers alone changed nothing, because IfNode#process ran FlowSensitiveTyping *before* processing the condition node. The pin for `md` is created by that condition, so #find_var had nothing to look up and the facts were dropped. The FlowSensitiveTyping call now runs after the condition is processed; the then/else clauses are still processed after it, as before. `if (md = ...) || fallback` stays unnarrowed without further work: #process_or deliberately passes no true ranges down to its operands, since either side alone may be what made the disjunction true. In the else clause the variable is correctly narrowed to `nil, false` instead. Four @sg-ignore comments in position.rb are no longer needed and are removed. WhileNode#process has the same FlowSensitiveTyping-before-condition ordering, so `while (x = f.gets)` still misses this when `x` has no earlier assignment; left alone here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn --- .../parser/flow_sensitive_typing.rb | 53 ++++++++++++++++ .../parser_gem/node_processors/if_node.rb | 11 ++-- lib/solargraph/position.rb | 6 -- spec/type_checker/levels/strong_spec.rb | 61 +++++++++++++++++++ 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index dba484f8c4..ee6fff341d 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -350,9 +350,62 @@ def process_expression expression_node, true_ranges, false_ranges process_calls(expression_node, true_ranges, false_ranges) process_and(expression_node, true_ranges, false_ranges) process_or(expression_node, true_ranges, false_ranges) + process_parentheses(expression_node, true_ranges, false_ranges) + process_assignment(expression_node, true_ranges, false_ranges) process_variable(expression_node, true_ranges, false_ranges) end + # `(foo)` parses as a one-child :begin wrapping the expression, + # which is how an assignment used as a condition normally shows + # up: `if (md = foo.match(...))`. A multi-statement :begin + # takes its truthiness from the last statement, which isn't + # worth handling here. + # + # @param node [Parser::AST::Node] + # @param true_ranges [Array] + # @param false_ranges [Array] + # + # @return [void] + def process_parentheses node, true_ranges, false_ranges + return unless node.type == :begin && node.children.length == 1 + + child = node.children[0] + return unless child.is_a?(::Parser::AST::Node) + + # @sg-ignore flow sensitive typing doesn't narrow `child` past the guard above + process_expression(child, true_ranges, false_ranges) + end + + # An assignment used as a condition - `if (md = foo.match(...))` + # - evaluates to the value assigned, so the branches tell us the + # same thing about the variable that a bare reference to it + # would. + # + # @param node [Parser::AST::Node] + # @param true_presences [Array] + # @param false_presences [Array] + # + # @return [void] + def process_assignment node, true_presences, false_presences + return unless %i[lvasgn ivasgn].include?(node.type) + + variable_name = node.children[0]&.to_s + return if variable_name.nil? || variable_name.empty? + + # look the variable up at the end of its own assignment, where + # the new value has become visible + pin = find_var(variable_name, get_node_end_position(node)) + return unless pin + + # @type Hash{Pin::BaseVariable => Array ComplexType}>} + if_true = { pin => [{ not_type: ComplexType::NIL }] } + process_facts(if_true, true_presences) + + # @type Hash{Pin::BaseVariable => Array ComplexType}>} + if_false = { pin => [{ type: ComplexType.parse('nil, false') }] } + process_facts(if_false, false_presences) + end + # @param call_node [Parser::AST::Node] # @param method_name [Symbol] # @return [Array(String, String), nil] Tuple of rgument to 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 56bb2e63dd..64500c2186 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -8,10 +8,6 @@ class IfNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - FlowSensitiveTyping.new(locals, - ivars, - enclosing_breakable_pin, - enclosing_compound_statement_pin).process_if(node) condition_node = node.children[0] if condition_node pins.push Solargraph::Pin::CompoundStatement.new( @@ -23,6 +19,13 @@ def process ) NodeProcessor.process(condition_node, region, pins, locals, ivars) end + # after the condition, so that a variable the condition + # itself assigns (`if (md = foo.match(...))`) already has a + # pin to assert facts about + FlowSensitiveTyping.new(locals, + ivars, + enclosing_breakable_pin, + enclosing_compound_statement_pin).process_if(node) then_node = node.children[1] if then_node # @sg-ignore Need to add nil check here diff --git a/lib/solargraph/position.rb b/lib/solargraph/position.rb index 11d8eb8d5c..1a4dbcd78f 100644 --- a/lib/solargraph/position.rb +++ b/lib/solargraph/position.rb @@ -68,8 +68,6 @@ def self.to_offset text, position end last_line_index += 1 if position.line.positive? - # @sg-ignore `last_line_index` is always an Integer because `newline_index` - # is never nil inside the while block last_line_index + position.character end @@ -99,16 +97,12 @@ def self.from_offset text, offset character = offset newline_index = -1 - # @sg-ignore Typechecker thinks `newline_index` inside of the assignment - # can be nil while (newline_index = text.index("\n", newline_index + 1)) && newline_index < offset line += 1 - # @sg-ignore `newline_index` is always an Integer inside the while block character = offset - newline_index - 1 end character = 0 if character.nil? && (cursor - offset).between?(0, 1) raise InvalidOffsetError if character.nil? - # @sg-ignore flow sensitive typing needs to handle 'raise if' Position.new(line, character) end diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 33d19e983a..626b54d28a 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -956,6 +956,67 @@ def conditional_reassign(str, num, flag) expect(checker.problems.map(&:message)).to eq([]) end + it 'narrows a variable assigned in the if condition' do + checker = type_checker(%( + # @param name [String] + # @return [Integer] + def limit_of(name) + if (md = name.match(/\\[(.*)\\]/)) + md[1].to_i + else + 0 + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a variable assigned in the right side of an && condition' do + checker = type_checker(%( + # @param name [String, nil] + # @return [Integer] + def limit_of(name) + if !name.nil? && (md = name.match(/\\[(.*)\\]/)) + md[1].to_i + else + 0 + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not narrow a variable assigned in the left side of an || condition' do + checker = type_checker(%( + # @param name [String] + # @param fallback [Boolean] + # @return [Integer] + def limit_of(name, fallback) + if (md = name.match(/\\[(.*)\\]/)) || fallback + md[1].to_i + else + 0 + end + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to []']) + end + + it 'treats a variable assigned in the if condition as falsy in the else clause' do + checker = type_checker(%( + # @param name [String] + # @return [Integer] + def limit_of(name) + if (md = name.match(/\\[(.*)\\]/)) + 0 + else + md[1].to_i + end + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to [] on nil, Boolean']) + end + it 'uses a branch-local reassignment at a use site later in the same branch' do checker = type_checker(%( # @param items [Array, nil] From 2047ff56bca3ef55f3f46f10ae6a54d7087c0cf4 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 15 Aug 2026 23:39:02 -0400 Subject: [PATCH 16/24] Decouple guard-narrowing specs from falsy-type rendering The integration branch renders a falsy-only receiver as `nil, false` where this branch renders `nil, Boolean`, so three exact-message assertions passed on each branch and failed on the merge. The property under test is that exactly one problem remains and its receiver is narrowed to the falsy types - not which of the two spellings the formatter picks - so match either. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn --- spec/type_checker/levels/strong_spec.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 626b54d28a..3668a4b6c3 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -1014,7 +1014,10 @@ def limit_of(name) end end )) - expect(checker.problems.map(&:message)).to eq(['Unresolved call to [] on nil, Boolean']) + # the falsy-only receiver renders as either `nil, false` or + # `nil, Boolean` depending on literal handling; both mean narrowed + expect(checker.problems.map(&:message)) + .to contain_exactly(a_string_matching(/\AUnresolved call to \[\] on nil, (false|Boolean)\z/)) end it 'uses a branch-local reassignment at a use site later in the same branch' do @@ -1124,7 +1127,7 @@ def find(name) def lookup(name); name; end )) expect(checker.problems.map(&:message)) - .to eq(['Unresolved call to length on nil, Boolean']) + .to contain_exactly(a_string_matching(/\AUnresolved call to length on nil, (false|Boolean)\z/)) end it 'does not apply a guard fact past a reassignment that only runs in a branch' do @@ -1147,7 +1150,7 @@ def find(name, flag) def lookup(name); name; end )) expect(checker.problems.map(&:message)) - .to eq(['Unresolved call to length on nil, Boolean']) + .to contain_exactly(a_string_matching(/\AUnresolved call to length on nil, (false|Boolean)\z/)) end it 'narrows a nil-guarded default after the modifier if' do From 8f89ee7b3264a3e6130f1a3a4d7e6d4602ca9ecc Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 16 Aug 2026 11:08:40 -0400 Subject: [PATCH 17/24] Expire narrowing only when a different assignment overwrote it The supersede-expiry rule was too broad. #override_assignments? is true whenever `other`'s assignment is definite (or dominates the resolved location) and does not reference us - including when `other` is another flow-sensitive downcast of the *same* assignment. Those pins are not competing values; they are separate facts about one value, and dropping ours lost information: a = lookup(name) # String, Integer, nil a = 'd' if a.nil? || a.is_a?(Integer) a # String, nil - nil survived #process_or asserts the false branch of every operand, so the guard produces one downcast excluding nil and another excluding Integer, both derived from the `a = lookup(name)` pin. ApiMap#var_at_location folds them in order; the second supersede replaced the first pin's exclusions instead of adding to them, so only the last operand's fact reached the use site. Facts now expire only when `other`'s assignments are at different source positions than ours. Position, not structural node equality: `AST::Node#==` compares type and children, so two textually identical assignments on different lines compare equal - and telling exactly those apart is what the original fix is for (`got = lookup(name)` twice, with a guard between them, is its regression spec). Only the fact attributes use the narrower test. Assignment supersession is unchanged: when the sites match, `combine_assignments` replacing our assignments with an identical list was already a no-op. Two operands hid this - one fact, nothing to drop - so it surfaced only against a branch whose `==` handling contributes a second exclusion. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn --- lib/solargraph/pin/base_variable.rb | 26 +++++++++++++++++-- spec/type_checker/levels/strong_spec.rb | 34 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index bf818f50a9..f27322813b 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -118,6 +118,12 @@ def reset_generated! # within `other`'s compound_statement. def combine_with other, attrs = {}, location: nil superseded = override_assignments?(other, location) + # Facts expire only when a *different* assignment overwrote the + # value they describe. Two flow-sensitive downcasts of the same + # assignment - e.g. the one per operand that `a.nil? || + # a.empty? || a == 'x'` produces - are additional facts about + # one value, and must accumulate rather than replace each other. + facts_superseded = superseded && !same_assignment_sites?(other) new_assignments = combine_assignments(other, location) new_attrs = attrs.merge({ # default values don't exist in RBS parameters; it just @@ -137,12 +143,12 @@ def combine_with other, attrs = {}, location: nil # when that value is definitely overwritten, so when # `other`'s assignment supersedes ours, keep only the # facts asserted about the new value. - intersection_return_type: if superseded + intersection_return_type: if facts_superseded other.intersection_return_type else combine_types(other, :intersection_return_type) end, - exclude_return_type: if superseded + exclude_return_type: if facts_superseded other.exclude_return_type else combine_types(other, :exclude_return_type) @@ -166,6 +172,22 @@ def combine_mass_assignment other mass_assignment || other.mass_assignment end + # True when `other`'s assignments are the very same ones as ours, + # identified by source position. Structural node equality is not + # usable here - two textually identical assignments on different + # lines compare equal, and telling those apart is the whole point. + # + # @param other [self] + # @return [Boolean] + def same_assignment_sites? other + assignment_sites == other.assignment_sites + end + + # @return [::Array] + def assignment_sites + assignments.map { |node| Solargraph::Range.from_node(node) } + end + # @return [Parser::AST::Node, nil] def assignment @assignment ||= assignments.last diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 3668a4b6c3..d7a5060267 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -1074,6 +1074,40 @@ def fetch_items; ['x']; end expect(checker.problems.map(&:message)).to eq(['Unresolved call to reject! on nil']) end + it 'accumulates every fact an or-guard asserts about the same value' do + checker = type_checker(%( + # @param name [String, Integer, nil] + # @return [String] + def f(name) + a = lookup(name) + a = 'd' if a.nil? || a.is_a?(Integer) + a + end + + # @param name [String, Integer, nil] + # @return [String, Integer, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a nil-guarded default behind an or-guard with three operands' do + checker = type_checker(%( + # @param name [String, nil] + # @return [String] + def f(name) + a = lookup(name) + a = 'd' if a.nil? || a.empty? || a == 'x' + a + end + + # @param name [String, nil] + # @return [String, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'applies a modifier-if guard after the variable was reassigned' do checker = type_checker(%( # @param name [String] From 11e3386855f509f52d206ef0b278ac7d33f70cb1 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 16 Aug 2026 11:29:14 -0400 Subject: [PATCH 18/24] Cover four-operand or-guards and their soundness controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-operand regression this follows was invisible to the existing suite: two-operand or-guards were covered, and at two operands there is only one flow-sensitive fact to fold, so nothing can be wrongly dropped. Add the four-operand case, and two negative controls that were verified by hand but never asserted. The controls matter more than the positive case. `¬(x || y)` implies every operand is false, so the guard's false path may narrow any variable it tests - but its true path only reassigns one. Nothing may be concluded about a second variable the guard merely mentions, nor about a variable the guard never tests. Without these, a future over-narrowing change would pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn --- spec/type_checker/levels/strong_spec.rb | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index d7a5060267..3cc795ccc3 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -1108,6 +1108,65 @@ def lookup(name); end expect(checker.problems.map(&:message)).to eq([]) end + it 'narrows a nil-guarded default behind an or-guard with four operands' do + checker = type_checker(%( + # @param name [String, nil] + # @return [String] + def f(name) + a = lookup(name) + a = 'd' if a.nil? || a.empty? || a == 'x' || a == 'y' + a + end + + # @param name [String, nil] + # @return [String, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + # Soundness controls for the or-guard narrowing above. `¬(x || y)` implies + # every operand is false, so the guard's false path may narrow any variable + # it tests - but the guard's TRUE path only reassigns `a`, so nothing may be + # concluded about a second variable the guard happens to mention. + it 'does not narrow a second variable an or-guard tests but never reassigns' do + checker = type_checker(%( + # @param name [String, nil] + # @return [String] + def f(name) + a = lookup(name) + b = lookup(name) + a = 'd' if a.nil? || b.nil? + b + end + + # @param name [String, nil] + # @return [String, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)) + .to include(a_string_matching(/Declared return type ::String does not match/)) + end + + it 'does not narrow when the or-guard never tests the variable at all' do + checker = type_checker(%( + # @param name [String, nil] + # @param flag [Boolean] + # @return [String] + def f(name, flag) + a = lookup(name) + a = 'd' if flag || name.nil? + a + end + + # @param name [String, nil] + # @return [String, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)) + .to include(a_string_matching(/Declared return type ::String does not match/)) + end + it 'applies a modifier-if guard after the variable was reassigned' do checker = type_checker(%( # @param name [String] From 6b1240110811bbf4f7bfff62f57a2d0e349078b7 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 1 Sep 2026 12:27:31 -0400 Subject: [PATCH 19/24] Fix flow-sensitive-typing markers per review Remove an unneeded @sg-ignore in Formatting#log_corrections - no redefinition needing suppression exists there. Replace the two placeholder "Need to add nil check here" markers in ResbodyNode with a real local variable and nil guard around NodeProcessor.process. Reword Parameter#typify's reassignment comment so it no longer reads as a stray @param tag. Point the api_map.rb and type_checker.rb "should be able to handle redefinition" markers at PR 1282, which is open and fixes that gap; drop type_checker.rb's copy entirely since it turned out unneeded. Restore the "unions rather than overrides" marker in api_map.rb#super_and_sub? to the line it actually suppresses (above the sup.literal? check, not the while loop below). --- lib/solargraph/api_map.rb | 6 +++--- .../message/text_document/formatting.rb | 1 - .../parser/parser_gem/node_processors/resbody_node.rb | 10 ++++------ lib/solargraph/pin/parameter.rb | 6 ++---- lib/solargraph/type_checker.rb | 1 - 5 files changed, 9 insertions(+), 15 deletions(-) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 724fc443e3..c62b15daaf 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -706,14 +706,14 @@ def super_and_sub? sup, sub # @todo If two literals are different values of the same type, it would # make more sense for super_and_sub? to return true, but there are a # few callers that currently expect this to be false. + # @sg-ignore flow sensitive typing unions rather than overrides types across multiple sequential reassignments return false if sup.literal? && sub.literal? && sup.to_s != sub.to_s - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/pull/1282 sup = sup.simplify_literals.to_s - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/pull/1282 sub = sub.simplify_literals.to_s return true if sup == sub sc_fqns = sub - # @sg-ignore flow sensitive typing unions rather than overrides types across multiple sequential reassignments while (sc = store.get_superclass(sc_fqns)) # @sg-ignore flow sensitive typing needs to handle "if foo = bar" sc_new = store.constants.dereference(sc) diff --git a/lib/solargraph/language_server/message/text_document/formatting.rb b/lib/solargraph/language_server/message/text_document/formatting.rb index 7212d677dc..c6cc3353a5 100644 --- a/lib/solargraph/language_server/message/text_document/formatting.rb +++ b/lib/solargraph/language_server/message/text_document/formatting.rb @@ -48,7 +48,6 @@ def log_corrections corrections return if corrections&.empty? Solargraph.logger.info('Formatting result:') - # @sg-ignore flow sensitive typing should be able to handle redefinition corrections.each_line do |line| next if line.strip.empty? Solargraph.logger.info(line.strip) diff --git a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb index 1d0e43d7b9..5c8c89aefd 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb @@ -33,18 +33,16 @@ def process # not pushed onto `pins` - and/or/orasgn/resbody bodies are # too common to warrant a pin per occurrence, so only the # pointer is needed for the compound_statement chain - # @sg-ignore Need to add nil check here + rescue_body_node = node.children[2] rescue_body_cs = Solargraph::Pin::CompoundStatement.new( - # @sg-ignore Need to add nil check here - location: node.children[2] ? get_node_location(node.children[2]) : nil, + location: rescue_body_node ? get_node_location(rescue_body_node) : nil, closure: region.closure, compound_statement: region.compound_statement, conditional: true, - node: node.children[2], + node: rescue_body_node, source: :parser ) - # @sg-ignore Need to add nil check here - NodeProcessor.process(node.children[2], region.update(compound_statement: rescue_body_cs), pins, locals, ivars) + NodeProcessor.process(rescue_body_node, region.update(compound_statement: rescue_body_cs), pins, locals, ivars) if rescue_body_node end end end diff --git a/lib/solargraph/pin/parameter.rb b/lib/solargraph/pin/parameter.rb index a50bd1031a..39f49ce8df 100644 --- a/lib/solargraph/pin/parameter.rb +++ b/lib/solargraph/pin/parameter.rb @@ -209,10 +209,8 @@ def index # @param api_map [ApiMap] def typify api_map if definite - # flow sensitive typing: this parameter was reassigned by - # an assignment guaranteed to have executed, so prefer the - # type of the value it was reassigned to over its declared - # @param type + # Reassigned by an assignment guaranteed to have executed: + # prefer that value's type over the declared parameter type. reassigned_type = probe(api_map) return reassigned_type if reassigned_type.defined? end diff --git a/lib/solargraph/type_checker.rb b/lib/solargraph/type_checker.rb index ed43ce5653..2bd5d530ed 100644 --- a/lib/solargraph/type_checker.rb +++ b/lib/solargraph/type_checker.rb @@ -652,7 +652,6 @@ def add_to_param_details param_details, param_names, new_param_details # @return [Hash{String => Hash{Symbol => String, ComplexType}}] def param_details_from_stack signature, method_pin_stack signature_type = signature.typify(api_map) - # @sg-ignore flow sensitive typing should be able to handle redefinition signature = signature.proxy signature_type param_details = signature_param_details(signature) param_names = signature.parameter_names From dcc707b4c1887cc484e41dcf2f50ea98711849c4 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 1 Sep 2026 19:27:12 -0400 Subject: [PATCH 20/24] Cover the nil-location tiebreak in CompoundStatement#combine_with Only the identical-nil and both-non-nil-locations cases were tested. When exactly one side's compound_statement has no location (built without one, e.g. a bare if/while body), combine_with must still prefer the one that has a location over the one that doesn't - untested previously. Split from the 2026-08-04 integration branch's bundled undercover coverage commit 4aab8b2d7f94c8ed73151804cdcfcc289641ca3c. --- spec/pin/compound_statement_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/pin/compound_statement_spec.rb b/spec/pin/compound_statement_spec.rb index 5a6a1e0d99..1ffe4cda34 100644 --- a/spec/pin/compound_statement_spec.rb +++ b/spec/pin/compound_statement_spec.rb @@ -89,6 +89,16 @@ def bar expect(pin2.combine_with(pin1).compound_statement).to eq(earlier_cs) end + it 'prefers the compound_statement with a location when the other has none' do + cs_no_location = described_class.new(location: nil, source: :parser) + cs_with_location = described_class.new(location: later_location, source: :parser) + pin1 = described_class.new(location: earlier_location, compound_statement: cs_no_location, source: :parser) + pin2 = described_class.new(location: earlier_location, compound_statement: cs_with_location, source: :parser) + + expect(pin1.combine_with(pin2).compound_statement).to eq(cs_with_location) + expect(pin2.combine_with(pin1).compound_statement).to eq(cs_with_location) + end + it 'prefers a non-nil compound_statement over a nil one' do cs = described_class.new(location: earlier_location, source: :parser) pin1 = described_class.new(location: earlier_location, compound_statement: nil, source: :parser) From 21ab81a114b36dff70ef03dbb040c9d3380af911 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 5 Sep 2026 15:46:34 -0400 Subject: [PATCH 21/24] Drop three @sg-ignore markers now unneeded CI at dcc707b4c 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. --- lib/solargraph/api_map.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index c62b15daaf..f336772b23 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -706,11 +706,8 @@ def super_and_sub? sup, sub # @todo If two literals are different values of the same type, it would # make more sense for super_and_sub? to return true, but there are a # few callers that currently expect this to be false. - # @sg-ignore flow sensitive typing unions rather than overrides types across multiple sequential reassignments return false if sup.literal? && sub.literal? && sup.to_s != sub.to_s - # @sg-ignore https://github.com/castwide/solargraph/pull/1282 sup = sup.simplify_literals.to_s - # @sg-ignore https://github.com/castwide/solargraph/pull/1282 sub = sub.simplify_literals.to_s return true if sup == sub sc_fqns = sub From bfb10e793301be5c86060242a403f9ce85f28ce9 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 5 Sep 2026 18:47:40 -0400 Subject: [PATCH 22/24] Add the deferred nil checks and drop dead markers 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. --- lib/solargraph/parser/flow_sensitive_typing.rb | 3 ++- lib/solargraph/parser/parser_gem/node_processors/if_node.rb | 4 ---- lib/solargraph/pin/method.rb | 5 ++--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index ee6fff341d..bcd0054b6a 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -171,9 +171,10 @@ def process_if if_node, true_ranges = [], false_ranges = [] get_node_end_position(else_clause)) end + return if conditional_node.nil? + process_expression(conditional_node, true_ranges, false_ranges) - # @sg-ignore Need to add nil check here process_guarded_reassignment(if_node, conditional_node, then_clause, else_clause) 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 64500c2186..b9d357a816 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -28,7 +28,6 @@ def process enclosing_compound_statement_pin).process_if(node) then_node = node.children[1] if then_node - # @sg-ignore Need to add nil check here then_cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(then_node), closure: region.closure, @@ -38,13 +37,11 @@ def process source: :parser ) pins.push then_cs - # @sg-ignore Need to add nil check here NodeProcessor.process(then_node, region.update(compound_statement: then_cs), pins, locals, ivars) end else_node = node.children[2] if else_node - # @sg-ignore Need to add nil check here else_cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(else_node), closure: region.closure, @@ -54,7 +51,6 @@ def process source: :parser ) pins.push else_cs - # @sg-ignore Need to add nil check here NodeProcessor.process(else_node, region.update(compound_statement: else_cs), pins, locals, ivars) end diff --git a/lib/solargraph/pin/method.rb b/lib/solargraph/pin/method.rb index 4736e694e9..1e39b45203 100644 --- a/lib/solargraph/pin/method.rb +++ b/lib/solargraph/pin/method.rb @@ -673,9 +673,8 @@ def infer_from_return_nodes api_map # resolution re-checks each local's presence at its own sub-node # location, so pass the full local set rather than pre-filtering here. # @sg-ignore Need to add nil check here - all_locals = api_map.source_map(location.filename).locals - # @sg-ignore Need to add nil check here - chain = Solargraph::Parser.chain(n, location.filename) + all_locals = api_map.source_map(filename).locals + chain = Solargraph::Parser.chain(n, filename) type = chain.infer(api_map, self, all_locals) result.push type unless type.undefined? end From c1e692b881698342759c2c352ff0b0a0a9a12872 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 5 Sep 2026 21:23:14 -0400 Subject: [PATCH 23/24] Drop the unused closure fallback 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. --- .../parser_gem/node_processors/lvasgn_node.rb | 1 + lib/solargraph/pin/base.rb | 20 ------------- spec/pin/compound_statement_spec.rb | 30 +++---------------- 3 files changed, 5 insertions(+), 46 deletions(-) diff --git a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb index 33c429a931..8fd0212074 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb @@ -19,6 +19,7 @@ def process assignment: node.children[1], comments: comments_for(node), presence: presence, + # false inside an if/while/rescue body, which may not run definite: !region.compound_statement.conditional, compound_statement: region.compound_statement, source: :parser diff --git a/lib/solargraph/pin/base.rb b/lib/solargraph/pin/base.rb index b4a611cf8f..f7ae58d388 100644 --- a/lib/solargraph/pin/base.rb +++ b/lib/solargraph/pin/base.rb @@ -75,7 +75,6 @@ def assert_location_provided # @return [Pin::Closure, nil] def closure - @closure ||= derive_closure_from_compound_statement unless @closure Solargraph.assert_or_log(:closure, "Closure not set on #{self.class} #{name.inspect} from #{source.inspect}") @@ -732,25 +731,6 @@ def equality_fields private - # Fallback for pins with no directly-assigned @closure: walk the - # CompoundStatement parent chain (present only on - # CompoundStatement-family pins - Closure, While, Until, etc.) - # until an ancestor is_a?(Closure). Every pin built through - # Region-threaded node processors already gets an explicit - # closure:, so this only matters for a pin constructed purely - # from a compound_statement chain with no closure: override. - # - # @return [Pin::Closure, nil] - def derive_closure_from_compound_statement - return nil unless is_a?(CompoundStatement) - - # @sg-ignore flow sensitive typing doesn't narrow self past an is_a? guard - cs = compound_statement - # @sg-ignore flow sensitive typing doesn't narrow self past an is_a? guard - cs = cs.compound_statement while cs && !cs.is_a?(Closure) - cs - end - # @return [void] def parse_comments # HACK: Avoid a NoMethodError on nil with empty overload tags diff --git a/spec/pin/compound_statement_spec.rb b/spec/pin/compound_statement_spec.rb index 1ffe4cda34..d46b908a52 100644 --- a/spec/pin/compound_statement_spec.rb +++ b/spec/pin/compound_statement_spec.rb @@ -1,13 +1,10 @@ # frozen_string_literal: true describe Solargraph::Pin::CompoundStatement do - # Every pin built through Region-threaded node processors still gets - # an explicit `closure:`, so `Pin::Base#closure` returns the stored - # value, not the derived one - the derivation only kicks in as a - # fallback. These specs check the two would agree anyway, so a - # future node processor that updates one threading (closure: or - # compound_statement:) without the other gets caught here instead - # of silently drifting. + # Pins are threaded with both an explicit `closure:` and a + # `compound_statement:` chain. These specs walk the chain and check + # it reaches the same closure, catching a node processor that + # threads one without the other. def derive_closure pin cs = pin.compound_statement cs = cs.compound_statement while cs && !cs.is_a?(Solargraph::Pin::Closure) @@ -56,25 +53,6 @@ def bar end end - it 'derives the enclosing method as closure for a bare CompoundStatement built only with compound_statement:' do - source_map = Solargraph::SourceMap.load_string(%( - class Foo - def bar - 1 - end - end - )) - method_pin = source_map.pins.find { |pin| pin.is_a?(Solargraph::Pin::Method) && pin.name == 'bar' } - - bare_pin = described_class.new( - location: method_pin.location, - compound_statement: method_pin, - source: :parser - ) - - expect(bare_pin.closure).to eq(method_pin) - end - describe '#combine_with' do let(:earlier_location) { Solargraph::Location.new('test.rb', Solargraph::Range.from_to(1, 0, 3, 0)) } let(:later_location) { Solargraph::Location.new('test.rb', Solargraph::Range.from_to(5, 0, 7, 0)) } From 8e130a2ce096dfcf0ab0b33e85fdcd89d99739a7 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 5 Sep 2026 23:00:06 -0400 Subject: [PATCH 24/24] Push every CompoundStatement pin onto pins 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. --- .../parser/flow_sensitive_typing.rb | 39 +++++++++++++++++++ .../parser_gem/node_processors/and_node.rb | 2 +- .../parser_gem/node_processors/or_node.rb | 2 +- .../parser_gem/node_processors/orasgn_node.rb | 2 +- .../node_processors/resbody_node.rb | 4 +- spec/parser/flow_sensitive_typing_spec.rb | 24 ++++++++++++ 6 files changed, 67 insertions(+), 6 deletions(-) diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index bcd0054b6a..cd99b47e54 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -151,6 +151,8 @@ def process_if if_node, true_ranges = [], false_ranges = [] true_ranges << rest_of_returnable_body if always_leaves_compound_statement?(else_clause) end + assert_after_skipped_or_asgn(conditional_node, then_clause, else_clause) + unless then_clause.nil? # # If the condition is true we can assume things about the then clause @@ -263,6 +265,43 @@ def process_guarded_reassignment if_node, conditional_node, then_clause, else_cl [rest_of_compound_statement], []) end + # A leaving guard inside a `x ||= ...` body still dominates the + # code after the ||=, but only for x: the body is skipped + # exactly when x was truthy, so both paths reach the same + # conclusion about x. No other variable gets that guarantee, + # hence the restriction to x by name. + # + # @param conditional_node [Parser::AST::Node, nil] + # @param then_clause [Parser::AST::Node, nil] + # @param else_clause [Parser::AST::Node, nil] + # + # @return [void] + def assert_after_skipped_or_asgn conditional_node, then_clause, else_clause + return if conditional_node.nil? + + or_asgn_pin = enclosing_compound_statement_pin + return if or_asgn_pin.nil? + + or_asgn_node = or_asgn_pin.node + return if or_asgn_node.nil? + return unless or_asgn_node.type == :or_asgn + + parent = or_asgn_pin.compound_statement + return if parent.nil? + + parent_node = parent.node + return if parent_node.nil? + + lhs_node = or_asgn_node.children[0] + return if lhs_node.nil? + + name = lhs_node.children[0].to_s + rest = Range.new(get_node_end_position(or_asgn_node), get_node_end_position(parent_node)) + + assert_after_guard(conditional_node, [name], [], [rest]) if always_leaves_compound_statement?(then_clause) + assert_after_guard(conditional_node, [name], [rest], []) if always_leaves_compound_statement?(else_clause) + end + # @param conditional_node [Parser::AST::Node] # @param names [Array] # @param true_ranges [Array] 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 ae1ab31d71..35edda8384 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb @@ -12,7 +12,6 @@ def process # any assignment there isn't guaranteed to have executed lhs, rhs = node.children NodeProcessor.process(lhs, region, pins, locals, ivars) - # not pushed onto `pins` - see resbody_node.rb for why rhs_cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(rhs), closure: region.closure, @@ -21,6 +20,7 @@ def process node: rhs, source: :parser ) + pins.push rhs_cs NodeProcessor.process(rhs, region.update(compound_statement: rhs_cs), pins, locals, ivars) FlowSensitiveTyping.new(locals, 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 b27c28806a..7deaf54913 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -12,7 +12,6 @@ def process # any assignment there isn't guaranteed to have executed lhs, rhs = node.children NodeProcessor.process(lhs, region, pins, locals, ivars) - # not pushed onto `pins` - see resbody_node.rb for why rhs_cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(rhs), closure: region.closure, @@ -21,6 +20,7 @@ def process node: rhs, source: :parser ) + pins.push rhs_cs NodeProcessor.process(rhs, region.update(compound_statement: rhs_cs), pins, locals, ivars) FlowSensitiveTyping.new(locals, diff --git a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb index 87b89505a7..3326391c4a 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb @@ -11,7 +11,6 @@ def process # `x ||= y` only assigns when x is falsy/undefined, so # it's never a guaranteed override of x's prior type # - # not pushed onto `pins` - see resbody_node.rb for why asgn_cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(node), closure: region.closure, @@ -20,6 +19,7 @@ def process node: node, source: :parser ) + pins.push asgn_cs NodeProcessor.process(new_node, region.update(compound_statement: asgn_cs), pins, locals, ivars) end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb index 5c8c89aefd..35f0143159 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb @@ -30,9 +30,6 @@ def process source: :parser ) end - # not pushed onto `pins` - and/or/orasgn/resbody bodies are - # too common to warrant a pin per occurrence, so only the - # pointer is needed for the compound_statement chain rescue_body_node = node.children[2] rescue_body_cs = Solargraph::Pin::CompoundStatement.new( location: rescue_body_node ? get_node_location(rescue_body_node) : nil, @@ -42,6 +39,7 @@ def process node: rescue_body_node, source: :parser ) + pins.push rescue_body_cs NodeProcessor.process(rescue_body_node, region.update(compound_statement: rescue_body_cs), pins, locals, ivars) if rescue_body_node end end diff --git a/spec/parser/flow_sensitive_typing_spec.rb b/spec/parser/flow_sensitive_typing_spec.rb index 147dd30e4c..4e6701a456 100644 --- a/spec/parser/flow_sensitive_typing_spec.rb +++ b/spec/parser/flow_sensitive_typing_spec.rb @@ -915,6 +915,30 @@ def bar(baz: nil) expect(clip.infer.rooted_tags).to eq('::Boolean') end + it 'does not extend a ||= body guard to a variable other than the assignment target' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @param qux [::Boolean, nil] + # @return [void] + def bar(baz: nil, qux: nil) + baz ||= begin + return if qux.nil? + qux + end + qux + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + + # skipping the body means baz was truthy, which says nothing + # about qux, so the guard does not survive the ||= + clip = api_map.clip_at('test.rb', [10, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + end + it 'uses .nil? in a return if() in a try / rescue / ensure to refine types using nil checks' do source = Solargraph::Source.load_string(%( class Foo