Narrow literal-equality (==/!=) and respond_to? guards in flow-sensitive typing - #1297
Draft
apiology wants to merge 6 commits into
Draft
Narrow literal-equality (==/!=) and respond_to? guards in flow-sensitive typing#1297apiology wants to merge 6 commits into
apiology wants to merge 6 commits into
Conversation
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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
FlowSensitiveTypingonly recognizedis_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:
A
respond_to?guard narrowed nothing, so the guarded call itself still failed even though the guard proves the receiver provides it:Solution
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 wayis_a?already does, viaPin::BaseVariable#downcast. Falls back safely (ComplexType.try_parse) on literals that can't be represented as type tags.process_respond_to, asserting a#methodduck-type fact on the true path ofx.respond_to?(:literal_sym)for an lvar/ivar receiver. It reuses the existing fact plumbing, so it composes with&&/||throughprocess_and/process_or. No false-path fact is asserted ��� a falserespond_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_withnow treats a duck-type candidate as a membership test (viaduck_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 likeObject, where the duck type is all we know). SoHash{String => Integer}, Array<Integer>narrowed by#key?becomesHash{String => Integer}��� not#key?, which would break other calls on the guarded path. Plain conformance can't express this:UniqueType#conforms_to?'s inferred-sideduck_type?short-circuit answers the wrong direction. (When #1231 renamesintersect_withtonarrow_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.) duringComplexType#qualify, per the existing, intentionalliteral?short-circuit inunique_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)solargraph typecheck --level strongproblems 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