Skip to content

Narrow literal-equality (==/!=) and respond_to? guards in flow-sensitive typing - #1297

Draft
apiology wants to merge 6 commits into
castwide:masterfrom
apiology:fix-1296-literal-equality-narrowing
Draft

Narrow literal-equality (==/!=) and respond_to? guards in flow-sensitive typing#1297
apiology wants to merge 6 commits into
castwide:masterfrom
apiology:fix-1296-literal-equality-narrowing

Conversation

@apiology

@apiology apiology commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Problem

FlowSensitiveTyping only recognized is_a?/nil?/! for narrowing a variable's type inside a conditional branch. Two common guard styles got no narrowing:

  • A bare literal-equality guard left the full declared union type intact, so calling a method that only some union members support still failed at strict typecheck levels even though the guard excludes the literal:

    # @param sections [Array<String>, :not_specified]
    def process(sections)
      return if sections == :not_specified
      sections.each { |s| ... }  # Unresolved call to each on Array<String>, :not_specified
    end
  • A respond_to? guard narrowed nothing, so the guarded call itself still failed even though the guard proves the receiver provides it:

    # @param data [Hash{Object => Object}, Array<Object>]
    def dig_into(data, subkey)
      return data[subkey] if data.respond_to?(:key?) && data.key?(subkey)
      # Unresolved call to key? on Hash{Object => Object}, Array<Object>
    end

Solution

  • Add process_eq/process_neq, parsing ==/!= comparisons against symbol/string/integer/boolean literals (on either side of the operator) and narrowing the guarded variable the same way is_a? already does, via Pin::BaseVariable#downcast. Falls back safely (ComplexType.try_parse) on literals that can't be represented as type tags.
  • Add process_respond_to, asserting a #method duck-type fact on the true path of x.respond_to?(:literal_sym) for an lvar/ivar receiver. It reuses the existing fact plumbing, so it composes with &&/|| through process_and/process_or. No false-path fact is asserted ��� a false respond_to? is not a sound class-level exclusion ��� and non-literal arguments assert nothing. How the duck-type fact is applied to the receiver's type is the policy decision described in the next section.

Reviewable policy: how a duck-type fact narrows

ComplexType#intersect_with now treats a duck-type candidate as a membership test (via duck_types_match?) rather than plain conformance: union arms that provide the method are kept, arms that don't are dropped, and only an arm-less result falls back to the bare duck type (opaque receivers like Object, where the duck type is all we know). So Hash{String => Integer}, Array<Integer> narrowed by #key? becomes Hash{String => Integer} ��� not #key?, which would break other calls on the guarded path. Plain conformance can't express this: UniqueType#conforms_to?'s inferred-side duck_type? short-circuit answers the wrong direction. (When #1231 renames intersect_with to narrow_with, that PR carries this branch through the rename.)

Note

Symbol/string/integer/boolean literals used in type positions get widened to their non-literal class (Symbol, String, etc.) during ComplexType#qualify, per the existing, intentional literal? short-circuit in unique_type.rb (#1201). So narrowing via == lands on the generic class rather than the exact literal - this is consistent with how the rest of the codebase already treats literal types, not something this PR changes. The primary case in the issue (!= excluding a literal member from a union) is unaffected by this and narrows precisely.

Fixes #1296

Test plan

  • bundle exec rspec spec/parser/flow_sensitive_typing_spec.rb spec/type_checker/levels/strong_spec.rb (new respond_to? specs fail without the two new commits)
  • bundle exec rspec spec/ (full suite; only pre-existing failures: 2 environment-dependent gem_pins examples, identical on base)
  • bundle exec rubocop (modified lines clean)
  • Confirmed no new solargraph typecheck --level strong problems introduced (pre-existing problems in this file untouched)

���� This PR was written by Claude (Anthropic's AI assistant) on behalf of @apiology.

���� Generated with Claude Code

https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT

FlowSensitiveTyping only recognized is_a?/nil?/! for narrowing; a bare
`if x != :some_literal` guard left the full declared union type intact,
so calling a method that only some union members support (e.g. `.each`
on `Array<String>, :not_specified`) still triggered an unresolved-call
error at strict typecheck levels even though the guard excludes the
literal.

Fixes castwide#1296

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzVfx5Z9vZehNabQkgyvk6
apiology added a commit to apiology/solargraph that referenced this pull request Aug 13, 2026
…iteral union members

FlowSensitiveTyping only recognized is_a?/nil?/! for narrowing a variable's
type inside a conditional branch. A bare literal-equality guard
(if x != :some_literal) left the full declared union type intact, so
calling a method that only some union members support still triggered an
unresolved-call error at strict typecheck levels, even though the guard
excludes the literal.

Adds process_eq/process_neq to FlowSensitiveTyping, parsing ==/!=
comparisons against symbol/string/integer/boolean literals (on either
side of the operator) and narrowing the guarded variable the same way
is_a? already does, via Pin::BaseVariable#downcast. Falls back safely
when the literal can't be represented as a type tag.

Updated 3 of the source commit's own new spec expectations from the
generic widened class (e.g. 'Symbol') to the preserved literal tag (e.g.
':not_specified') - this branch already has literal-type inference merged
(castwide#1223), which keeps a narrowed-to-one-member union as its literal value
rather than widening to the class, unlike the plain castwide/master this
PR was authored against.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 13, 2026
…ide#1297

The ignore documented a flow-sensitive-typing gap ("needs to eliminate
literal from union with return if foo == :bar") that castwide#1297's process_eq
narrowing now resolves. Flagged by CI's Solargraph / strong gate
(Ruby 4.0 + fresh RBS collection); didn't surface locally on Ruby 3.2.6.

Leaves the 2 flow_sensitive_typing.rb @sg-ignore comments CI also flagged
in place - confirmed false positives (removing either introduces a real
Unresolved call to children error); tracked as a session TODO to
investigate separately rather than force a regression to satisfy the
checker's self-analysis.
apiology and others added 2 commits August 16, 2026 17:32
A duck-type narrowing fact (e.g. from a respond_to? guard) selects the
union arms that provide the method, via duck_types_match?; arms that
don't are excluded by the guard rather than replaced by the duck type.
UniqueType#conforms_to? can't express this test: its inferred-side
duck_type? short-circuit answers true for the wrong direction. When no
arm provides the method (opaque receivers like Object), the bare duck
type is kept so subsequent calls resolve against it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
process_respond_to asserts a #method duck-type fact on the true path of
a respond_to?(:literal_sym) guard against an lvar/ivar receiver,
reusing the existing fact plumbing (so it composes with && / || via
process_and and process_or for free). No false-path fact: a false
respond_to? is not a sound class-level exclusion. Non-literal
arguments assert nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
@apiology apiology changed the title Narrow literal-equality (==/!=) guards against literal union members Narrow literal-equality (==/!=) and respond_to? guards in flow-sensitive typing Aug 16, 2026
apiology and others added 3 commits August 17, 2026 22:45
An `instance_of?(T)` guard did not refine the guarded variable at all,
so `arg.to_h` after `return ... if arg.instance_of? Hash` stayed an
unresolved call on `Object`. `instance_of?` was simply not recognized:
FlowSensitiveTyping#parse_isa matches the literal method name `:is_a?`,
and nothing else asserted a fact for `instance_of?`.

A true `instance_of?(T)` proves the receiver is a T, which is the same
positive fact `is_a?` already asserts, so #process_instance_of reuses
the existing `type:` fact and narrow_with machinery.

The false path is deliberately not asserted. `!x.is_a?(T)` rules out T
and every subclass, but `!x.instance_of?(T)` is also satisfied by a
subclass instance, which is still a T for the declared type - so
`not_type: T` there would drop an arm that can still be present. This
mirrors #process_respond_to, which abstains from the false path for the
same reason.

Narrowing to exactly T (proving subclasses of T are absent) is a
separate problem and is not attempted; the narrowed type is a sound
upper bound that may still include a subclass arm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
A `kind_of?(T)` guard did not refine the guarded variable at all, so
`arg.upcase` after `return arg unless arg.kind_of? String` stayed an
unresolved call on the declared type. FlowSensitiveTyping#parse_isa
matched only the literal method name `:is_a?`.

`kind_of?` is an alias of `is_a?` with identical semantics on both
paths: a true result proves class membership, and a false result rules
out T and every subclass of T. It therefore folds into #parse_isa,
which already asserts `type:` on the true path and `not_type:` on the
false path, rather than into #process_instance_of, which cannot assert
the false path.

Also adds a spec contrasting the two false paths on a
`[ReproBase, String]` union: `instance_of?(ReproBase)` leaves both arms
(a Repro value fails the guard while still being a ReproBase), while
`is_a?`/`kind_of?` narrow to String.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXmnT5gSB1PheL9UbiGEVA
parse_call sets the type name via type_name(node.children[2]), which
returns nil for anything that is not a :const node. process_instance_of
guarded only the receiver, so a non-constant argument reached
ComplexType.parse(nil) and recorded a narrowing fact of undefined. The
receiver's declared type was discarded for the rest of the expression.

process_isa does not have this hole: parse_isa returns nil unless the
type name is truthy, so no fact is recorded and the declared type stands.
Extend the process_instance_of guard to do the same.

In solargraph's own lib/solargraph/pin/base.rb, compare_tags is written
as tag1.instance_of?(tag2.class). tag2.class is a :send node, so tag1
became undefined and the self-hosted strong typecheck reported five
problems at pin/base.rb:779-784 - a return type that could not be
inferred plus unresolved calls to tag_name, text, name and types. Those
five are gone; the run drops from 561 to 556 problems with no other
change to the problem set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXmnT5gSB1PheL9UbiGEVA
apiology added a commit to apiology/solargraph that referenced this pull request Aug 18, 2026
Two of this PR's four commits are already on this branch as equivalents:
e42dc4d landed as da39739 and a00779f as ac27529, both before
f85e823 renamed intersect_with to narrow_with. The new content is
146e944 (instance_of? guards) and 896c00e (kind_of? as an is_a? alias).

Conflict resolutions:

complex_type.rb narrow_with - three conflicts, all the intersect_with ->
narrow_with rename. Kept this branch's names and ported the PR's comment
explaining why UniqueType#conforms_to? cannot express the duck-type test.

flow_sensitive_typing.rb parse_isa - kept this branch's chain_words
receiver-chain support, took the PR's loop over ISA_METHOD_NAMES so both
is_a? and kind_of? are recognized.

process_instance_of merged without conflict but not correctly: the PR
wrote it against parse_call's older return shape, whose second element was
a variable name String, and this branch returns a receiver chain Array.
find_var would have been handed an Array and raised NoMethodError on
start_with? for every instance_of? guard. Rewritten to use chain_pin the
way process_isa does.

Verified: spec/parser/flow_sensitive_typing_spec.rb and
spec/complex_type_spec.rb, 213 examples, 0 failures, 9 pending.

Two further auto-merge defects the pre-commit hook caught, both
duplicate-definition rather than conflict:

process_respond_to was defined twice - this branch's chain_pin version at
699 and the PR's older lvar/ivar-only version appended after it. Ruby uses
the last definition, so the merge would have silently reverted respond_to?
narrowing to variables only, losing receiver-chain support. Removed the
PR's copy.

The same four respond_to? examples appeared twice in
spec/parser/flow_sensitive_typing_spec.rb. Removed the PR's copies.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 18, 2026
FlowSensitiveTyping#process_instance_of records a narrowing fact even
when type_name returns nil, which it does for any argument that is not a
static class name. Pin::Base#compare_tags guards on
`tag1.instance_of?(tag2.class)`, so tag1 becomes undefined for the rest
of the expression and its four subsequent calls no longer resolve. The
known YARD::Tags::Tag is discarded, not replaced by a wrong type.

Tagged tool-limitation:pr-1297-follow-on:instance-of-nil-type-name so
removal is a grep. The guard belongs on
fix-1296-literal-equality-narrowing, where it is being written now;
these five comments come out when that lands and is re-merged here.

Direct commit on the integration branch, authorized as a temporary
unblock rather than a fix.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 18, 2026
9231980 declines to narrow an instance_of? guard whose argument is not a
static class name, which is what made Pin::Base#compare_tags lose tag1s
type. The five tool-limitation:pr-1297-follow-on:instance-of-nil-type-name
comments and their block comment are removed; compare_tags is byte-for-byte
what it was before the first castwide#1297 merge.

Conflict was in process_instance_of, where both sides changed the guard
line: kept this branchs chain_words receiver resolution and took the PRs
instance_of_type_name.nil? check.
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.

Flow-sensitive typing doesn't narrow a literal-equality (==/!=) guard against a literal union member

1 participant