Skip to content

Narrow variable types after a raise guard - #1354

Open
apiology wants to merge 8 commits into
castwide:masterfrom
apiology:fix-exclude-conformance
Open

apiology wants to merge 8 commits into
castwide:masterfrom
apiology:fix-exclude-conformance

Conversation

@apiology

@apiology apiology commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

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

Problem: A raise guard never narrows the type of the variable it tests, so typecheck --level strong reports a mismatch against a type the guard has already ruled out.

module Pred
  # @param x [Symbol, Array<Symbol, Array>]
  # @return [Symbol, String]
  def self.as_simple_pred(x)
    raise 'no' if x.is_a?(Array)
    x
  end
end
# Declared return type ::Symbol, ::String does not match inferred type
# ::Symbol, ::Array<::Symbol, ::Array>

This affects every raise guard, and it fails silently: raise is a plain :send node rather than a node type of its own, so FlowSensitiveTyping#always_leaves_compound_statement? tested for a :raise node the parser gem never produces and the narrowing fact was never attached at all.

Solution: Recognize a bare, receiverless raise call by name in always_leaves_compound_statement?.

Then make #exclude conformance-based, since the plain array subtraction it used matched only by == and so never removed a parameterized member such as Array<Symbol, Array> when the guard excluded Array. The direction is one-way on purpose: excluding Array<Integer> leaves a plain, unparameterized Array member in place.

…onformance-based

`raise` is not its own AST node type in the parser gem - it parses as a
plain method call (:send), unlike return/next/redo/retry which are real
node types. FlowSensitiveTyping#always_leaves_compound_statement? checked
for a :raise node type that never occurs, so a guard like
`raise 'no' if x.is_a?(Array)` never narrowed x's type for the rest of the
method: exclude_return_type stayed nil and ComplexType#exclude was never
invoked with real data.

Separately, ComplexType#exclude and UniqueType#exclude used plain array
subtraction (items - exclude_types.items), which only removes members
equal by ==/hash to something in the exclude list. That misses excluding
a parameterized member (Array<Symbol, Array>) via its plain form (Array),
the same conformance gap intersect_with already avoids by matching via
conforms_to? instead of equality. Fixed both #exclude implementations to
reject a member when it conforms to one of the excluded types - narrower
members are excluded by a broader exclude type, but not the reverse
(excluding Array<Integer> leaves a plain Array member alone).
ComplexType::UniqueType#exclude was rewritten to match by
conformance rather than equality, but had no direct test - only
ComplexType#exclude (spec/complex_type/exclude_spec.rb) was covered.
Compress four over-budget comments this PR added down to the
1-3 line docstring/inline budget, keeping only the non-obvious
why.
undercover flags line 115 (return self if exclude_types.nil?) as
untested on this branch. The two conformance cases already have specs
here; only the nil short-circuit was missing.
The three markers in Solargraph.assert_or_log each read "flow sensitive
typing needs to handle 'raise if'" - which is what this branch now does.
With the fix in place the strong typecheck reports all three as
"Unneeded @sg-ignore comment", so CI went from 527 problems on master to
532 rather than staying level.

Verified on a warm cache: lib/solargraph.rb reports 0 problems after the
deletion, against three before it.
Position.normalize and NodeMethods.pack_name each guard on every member
of their declared type, so the conformance-based #exclude added on this
branch leaves nothing behind and falls back to UNDEFINED. Both sites are
unreachable at runtime, making "Unresolved call to class" and
"Unresolved call to nil?" false positives.

castwide#1277 returns UniqueType::BOT for an exhausted
exclusion, which resolves both. Marking them here rather than carrying
that change too, so the two branches do not both edit #exclude.

Also drops the catalogued count for "flow sensitive typing needs to
handle 'raise if'" from 5 to 2, covering the three markers the previous
commit removed. The catalogue was already one high before this branch:
master states 5 and carries 4.
Position.from_offset carried an ignore reading "flow sensitive typing
needs to handle 'raise if'". This branch handles that, and the error the
marker suppresses now has no nil in it at all:

  character expected Integer, received Integer, Float, Rational, Complex

A probe differing only in the subtrahend type isolates the cause: no
Integer#- overload accepts nil, so none is selected and all four return
types union. The nil is newline_index, un-narrowed inside the while
condition that assigns it - the gap the ignore two lines above already
names. So this one belongs to the catalogued "if foo = bar" reason, not
to raise-if.

Bookkeeping in rules.rb follows: the raise-if entry goes away with its
last marker, the "if foo = bar" entry goes 2 to 3 and moves up to stay
in descending order, and its 3 now matches the three markers that carry
it. The entry deleted here also restores the sort, which the previous
commit broke by decrementing 5 to 2 in place.
@apiology
apiology marked this pull request as ready for review September 9, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant