From efa930f2a91068a07b5bb0f9e4a4d025a194808e Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 26 Aug 2026 14:26:08 -0400 Subject: [PATCH 1/8] Conform Hash to Enumerable of key/value pairs Hash is 2-arity (key_types, subtypes), but the Enumerable/_Each ancestor it satisfies structurally is 1-arity: Hash#each yields [key, value] pairs, so Hash includes Enumerable[Array[K, V]], not Enumerable[V]. Conformance previously compared a Hash raw key_types/subtypes directly against a 1-arity expectation once erased_type_conforms? established Hash <: Enumerable via inheritance, so URI.encode_www_form(token: abc) failed strong typecheck even though RBS declares encode_www_form(enum: Enumerable[[_ToS, _ToS]]) and a Hash is Enumerable at runtime. Reshape the Hash into the [key, value] tuple its ancestor actually sees before continuing the parameter comparison. --- lib/solargraph/complex_type/conformance.rb | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/solargraph/complex_type/conformance.rb b/lib/solargraph/complex_type/conformance.rb index c2a48b255..e295a6015 100644 --- a/lib/solargraph/complex_type/conformance.rb +++ b/lib/solargraph/complex_type/conformance.rb @@ -63,6 +63,15 @@ def conforms_to_unique_type? return false unless erased_type_conforms? + # Hash[K, V] is 2-arity (key_types, subtypes), but the + # Enumerable/_Each ancestor it satisfies structurally is + # 1-arity: Hash#each yields [key, value] pairs, so Hash + # includes Enumerable[Array[K, V]], not Enumerable[V]. Compare + # against that pair shape instead of Hash's raw key_types/ + # subtypes once erased_type_conforms? has already established + # Hash <: expected via inheritance. + return with_new_types(hash_as_pairs, expected).conforms_to_unique_type? if hash_viewed_as_pairs? + return true if inferred.all_params.empty? && rules.include?(:allow_empty_params) # at this point we know the erased type is fine - time to look at parameters @@ -127,6 +136,23 @@ def erased_type_conforms? true end + # @return [Boolean] true if `inferred` is a Hash being checked + # against a non-Hash-shaped expectation (e.g. Enumerable), so + # its key/value pair shape — not its raw key_types/subtypes — + # is what needs to conform + def hash_viewed_as_pairs? + inferred.name == 'Hash' && inferred.parameters_type == :hash && expected.parameters_type != :hash + end + + # @return [UniqueType] `inferred` (a Hash) reshaped as + # `expected`'s name parametrized with the [key, value] tuple + # Hash actually yields when enumerated + def hash_as_pairs + pair = UniqueType.new('Array', [], [ComplexType.new(inferred.key_types), ComplexType.new(inferred.subtypes)], + rooted: true, parameters_type: :fixed) + UniqueType.new(expected.name, [], [pair], rooted: inferred.rooted?, parameters_type: :list) + end + def key_types_conform? return true if expected.key_types.empty? From ce87999130828a7c5a9e8218af594ff011b6c2e9 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 26 Aug 2026 14:57:15 -0400 Subject: [PATCH 2/8] Generalize hash-to-pairs reshape beyond literal class Hash The pair-reshape in Conformance#hash_viewed_as_pairs? checked inferred.name == 'Hash' before comparing key_types/subtypes as a pair. That name check was tighter than the actual discriminator: parameters_type == :hash is set purely by the YARD `{K => V}` tag substring in UniqueType.parse, with no restriction to the class name Hash, so any class documented that way is 2-arity and gets the same reshape need against a lower-arity ancestor. RBS translation (rbs_translator.rb, rbs_map/conversions.rb) only ever sets parameters_type: :hash when base == 'Hash', so in practice, with stdlib/gem RBS types, Hash is the only type that reaches this path today. The check is written for the general case anyway, since YARD-documented classes are not limited that way. Renamed hash_viewed_as_pairs?/hash_as_pairs to pair_shaped_viewed_as_pairs?/pair_shaped_as_pairs to match. Added a conforms_to_spec.rb case with a non-Hash PairBag class including Enumerable, parsed via the {K => V} tag, to prove the structural check actually handles a type the old name check would have missed: with inferred.name == 'Hash' still in place, that case returns false; dropping it returns true and leaves the existing Hash{K => V} case unchanged. --- lib/solargraph/complex_type/conformance.rb | 35 ++++++++++++---------- spec/complex_type/conforms_to_spec.rb | 26 ++++++++++++++++ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/lib/solargraph/complex_type/conformance.rb b/lib/solargraph/complex_type/conformance.rb index e295a6015..bafe401aa 100644 --- a/lib/solargraph/complex_type/conformance.rb +++ b/lib/solargraph/complex_type/conformance.rb @@ -63,14 +63,16 @@ def conforms_to_unique_type? return false unless erased_type_conforms? - # Hash[K, V] is 2-arity (key_types, subtypes), but the - # Enumerable/_Each ancestor it satisfies structurally is - # 1-arity: Hash#each yields [key, value] pairs, so Hash - # includes Enumerable[Array[K, V]], not Enumerable[V]. Compare - # against that pair shape instead of Hash's raw key_types/ - # subtypes once erased_type_conforms? has already established - # Hash <: expected via inheritance. - return with_new_types(hash_as_pairs, expected).conforms_to_unique_type? if hash_viewed_as_pairs? + # A hash-shaped type (parameters_type == :hash; Hash[K, V] is + # the only stdlib/RBS example, but the YARD `{K => V}` tag + # syntax admits any class name) is 2-arity (key_types, + # subtypes), while an Enumerable/_Each ancestor it satisfies + # structurally can be 1-arity: #each yields [key, value] + # pairs, so the type includes Enumerable[Array[K, V]], not + # Enumerable[V]. Compare against that pair shape instead of + # the raw key_types/subtypes once erased_type_conforms? has + # already established inferred <: expected via inheritance. + return with_new_types(pair_shaped_as_pairs, expected).conforms_to_unique_type? if pair_shaped_viewed_as_pairs? return true if inferred.all_params.empty? && rules.include?(:allow_empty_params) @@ -136,18 +138,19 @@ def erased_type_conforms? true end - # @return [Boolean] true if `inferred` is a Hash being checked - # against a non-Hash-shaped expectation (e.g. Enumerable), so + # @return [Boolean] true if `inferred` is a 2-arity, hash-shaped + # type (key_types/subtypes, e.g. `Hash{K => V}`) being checked + # against a non-hash-shaped expectation (e.g. Enumerable), so # its key/value pair shape — not its raw key_types/subtypes — # is what needs to conform - def hash_viewed_as_pairs? - inferred.name == 'Hash' && inferred.parameters_type == :hash && expected.parameters_type != :hash + def pair_shaped_viewed_as_pairs? + inferred.parameters_type == :hash && expected.parameters_type != :hash end - # @return [UniqueType] `inferred` (a Hash) reshaped as - # `expected`'s name parametrized with the [key, value] tuple - # Hash actually yields when enumerated - def hash_as_pairs + # @return [UniqueType] `inferred` (a hash-shaped type) reshaped + # as `expected`'s name parametrized with the [key, value] + # tuple it actually yields when enumerated + def pair_shaped_as_pairs pair = UniqueType.new('Array', [], [ComplexType.new(inferred.key_types), ComplexType.new(inferred.subtypes)], rooted: true, parameters_type: :fixed) UniqueType.new(expected.name, [], [pair], rooted: inferred.rooted?, parameters_type: :list) diff --git a/spec/complex_type/conforms_to_spec.rb b/spec/complex_type/conforms_to_spec.rb index 27e9356af..aa3d52a09 100644 --- a/spec/complex_type/conforms_to_spec.rb +++ b/spec/complex_type/conforms_to_spec.rb @@ -127,6 +127,32 @@ class Sub < Sup; end expect(match).to be(true) end + it 'reshapes a Hash into key/value pairs to conform to a lower-arity Enumerable ancestor' do + exp = described_class.parse('Enumerable') + inf = described_class.parse('Hash{Symbol => String}') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'reshapes any hash-shaped (parameters_type == :hash) type into pairs, not just Hash' do + # `PairBag` proves the pair-reshaping in Conformance is a + # structural check on parameters_type, not a check on the literal + # class name 'Hash': the YARD `{K => V}` tag syntax produces + # parameters_type == :hash for any class name, so any 2-arity + # type that structurally includes a lower-arity Enumerable + # ancestor needs the same reshape Hash does. + source = Solargraph::Source.load_string(%( + class PairBag + include Enumerable + end + )) + api_map.map source + exp = described_class.parse('Enumerable') + inf = described_class.parse('PairBag{Symbol => String}') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + it 'matches multiple types' do exp = described_class.parse('String, Integer') inf = described_class.parse('String, Integer') From 273c7e6e70f97b794259c77c066963511b434900 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 26 Aug 2026 15:09:31 -0400 Subject: [PATCH 3/8] Reshape :list-generic tuples for Enumerable conformance too Conformance#pair_shaped_viewed_as_pairs? only reshaped :hash-parameterized types (Hash{K => V} and the YARD {K => V} tag) into a [key, value] tuple before comparing against a lower-arity Enumerable ancestor. A class declared with ordinary generic syntax (parameters_type == :list) that structurally includes Enumerable[[A, B]] hit the same raw-params-vs-tuple mismatch, but was never reshaped, so it failed to conform. Generalize the check to any inferred type with 2+ ordered params compared against a differently-sized expectation, and build the tuple from key_types/subtypes for :hash or from all_params for :list, in a single pair_shaped_as_pairs. --- lib/solargraph/complex_type/conformance.rb | 55 ++++++++------ spec/complex_type/conforms_to_spec.rb | 84 ++++++++++++++++++++++ 2 files changed, 119 insertions(+), 20 deletions(-) diff --git a/lib/solargraph/complex_type/conformance.rb b/lib/solargraph/complex_type/conformance.rb index bafe401aa..6558201d7 100644 --- a/lib/solargraph/complex_type/conformance.rb +++ b/lib/solargraph/complex_type/conformance.rb @@ -63,15 +63,19 @@ def conforms_to_unique_type? return false unless erased_type_conforms? - # A hash-shaped type (parameters_type == :hash; Hash[K, V] is - # the only stdlib/RBS example, but the YARD `{K => V}` tag - # syntax admits any class name) is 2-arity (key_types, - # subtypes), while an Enumerable/_Each ancestor it satisfies - # structurally can be 1-arity: #each yields [key, value] - # pairs, so the type includes Enumerable[Array[K, V]], not - # Enumerable[V]. Compare against that pair shape instead of - # the raw key_types/subtypes once erased_type_conforms? has - # already established inferred <: expected via inheritance. + # A type with 2+ ordered generic params - a hash-shaped type + # (parameters_type == :hash; Hash[K, V] is the only + # stdlib/RBS example, but the YARD `{K => V}` tag syntax + # admits any class name) always has 2 (key_types, subtypes), + # and an ordinary ``-generic type + # (parameters_type == :list) can have any number - may + # satisfy a lower-arity Enumerable/_Each ancestor + # structurally: #each yields all of its params together as + # a tuple, so the type includes Enumerable[Array[K, V]] (or + # Array[A, B, ...]), not Enumerable[V] (or Enumerable[A]). + # Compare against that tuple shape instead of the raw + # per-param types once erased_type_conforms? has already + # established inferred <: expected via inheritance. return with_new_types(pair_shaped_as_pairs, expected).conforms_to_unique_type? if pair_shaped_viewed_as_pairs? return true if inferred.all_params.empty? && rules.include?(:allow_empty_params) @@ -138,21 +142,32 @@ def erased_type_conforms? true end - # @return [Boolean] true if `inferred` is a 2-arity, hash-shaped - # type (key_types/subtypes, e.g. `Hash{K => V}`) being checked - # against a non-hash-shaped expectation (e.g. Enumerable), so - # its key/value pair shape — not its raw key_types/subtypes — - # is what needs to conform + # @return [Boolean] true if `inferred` has 2+ ordered generic + # params (a hash-shaped type's key/value types, e.g. + # `Hash{K => V}`, or a ``-generic type's ordered + # params) being checked against an expectation of a + # different arity (e.g. a 1-arity Enumerable), so its + # params need to be viewed as a single tuple - not compared + # one-for-one - to conform def pair_shaped_viewed_as_pairs? - inferred.parameters_type == :hash && expected.parameters_type != :hash + return false unless inferred.all_params.size >= 2 + + return expected.parameters_type != :hash if inferred.parameters_type == :hash + + inferred.parameters_type == :list && inferred.all_params.size != expected.all_params.size end - # @return [UniqueType] `inferred` (a hash-shaped type) reshaped - # as `expected`'s name parametrized with the [key, value] - # tuple it actually yields when enumerated + # @return [UniqueType] `inferred` reshaped as `expected`'s name + # parametrized with the tuple its params form when yielded + # together - [key, value] for a hash-shaped type, or its + # ordered params for a ``-generic type def pair_shaped_as_pairs - pair = UniqueType.new('Array', [], [ComplexType.new(inferred.key_types), ComplexType.new(inferred.subtypes)], - rooted: true, parameters_type: :fixed) + ordered_params = if inferred.parameters_type == :hash + [ComplexType.new(inferred.key_types), ComplexType.new(inferred.subtypes)] + else + inferred.all_params + end + pair = UniqueType.new('Array', [], ordered_params, rooted: true, parameters_type: :fixed) UniqueType.new(expected.name, [], [pair], rooted: inferred.rooted?, parameters_type: :list) end diff --git a/spec/complex_type/conforms_to_spec.rb b/spec/complex_type/conforms_to_spec.rb index aa3d52a09..96da1f7d0 100644 --- a/spec/complex_type/conforms_to_spec.rb +++ b/spec/complex_type/conforms_to_spec.rb @@ -153,6 +153,90 @@ class PairBag expect(match).to be(true) end + it 'reshapes a :list-parameterized (ordinary generic) 2-arity type into a tuple ' \ + 'to conform to a lower-arity Enumerable ancestor' do + # `Pair` proves the same reshape Hash/PairBag get for + # parameters_type == :hash is also needed for parameters_type + # == :list: a class documented with ordinary `` generic + # syntax (rather than YARD's `{K => V}` hash-tag syntax) that + # structurally includes a lower-arity Enumerable ancestor, + # because #each yields the two params together as a tuple. + source = Solargraph::Source.load_string(%( + # @generic A, B + class Pair + include Enumerable + + # @param a [generic] + # @param b [generic] + def initialize(a, b) + @a = a + @b = b + end + + # @yieldparam [Array(generic, generic)] + def each + yield [@a, @b] + end + end + )) + api_map.map source + exp = described_class.parse('Enumerable') + inf = described_class.parse('Pair') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'does not reshape a 2-arity :list type that does not include Enumerable' do + source = Solargraph::Source.load_string(%( + # @generic A, B + class NotEnumerablePair + # @param a [generic] + # @param b [generic] + def initialize(a, b) + @a = a + @b = b + end + end + )) + api_map.map source + exp = described_class.parse('Enumerable') + inf = described_class.parse('NotEnumerablePair') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + + it 'reshapes a 3-arity :list type into a 3-tuple to conform to a lower-arity Enumerable ancestor' do + source = Solargraph::Source.load_string(%( + # @generic A, B, C + class Triple + include Enumerable + + # @param a [generic] + # @param b [generic] + # @param c [generic] + def initialize(a, b, c) + @a = a + @b = b + @c = c + end + + # @yieldparam [Array(generic, generic, generic)] + def each + yield [@a, @b, @c] + end + end + )) + api_map.map source + exp = described_class.parse('Enumerable') + inf = described_class.parse('Triple') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + + # a 2-arity tuple expectation should not match a 3-arity inferred type + exp2 = described_class.parse('Enumerable') + expect(inf.conforms_to?(api_map, exp2, :method_call)).to be(false) + end + it 'matches multiple types' do exp = described_class.parse('String, Integer') inf = described_class.parse('String, Integer') From 86aab361c401482ccb7e300b47d096ef69d4d98b Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 26 Aug 2026 15:40:10 -0400 Subject: [PATCH 4/8] Restrict tuple reshape to Enumerable/_Each ancestors pair_shaped_viewed_as_pairs? fired on arity mismatch alone, for any ancestor erased_type_conforms? found via inheritance - not just Enumerable/_Each. A 2-arity type including some other 1-arity generic module (unrelated to the tuple semantics) could spuriously conform whenever that module happened to be parametrized with an Array type matching the wrapped tuple shape. Gate the reshape on expected.name being Enumerable or _Each, the two names the method's own comment already claims to cover, matching the precedent list in unique_type.rb's implicit_union?. The legitimate Pair/Triple specs use bare `include Enumerable` with no declared type binding, so there is no RBS include-generic-value data available to check the tuple binding directly for those cases - a name allowlist is the check that's actually available. One constructed case (a hash-shaped type against a 1-arity ancestor unrelated to its key/value types) is left `pending`: blocking the reshape there falls through to key_types_conform?/subtypes_conform?, which conflates a hash-shaped type's value type with a list-shaped ancestor's single param positionally. That fallback path is unchanged baseline code (git diff against the branch's merge-base shows the pair_shaped_* methods are the entire diff this branch makes to this file), so the false positive predates this branch and is out of scope here. --- lib/solargraph/complex_type/conformance.rb | 14 ++++ spec/complex_type/conforms_to_spec.rb | 95 ++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/lib/solargraph/complex_type/conformance.rb b/lib/solargraph/complex_type/conformance.rb index 6558201d7..fc86121dd 100644 --- a/lib/solargraph/complex_type/conformance.rb +++ b/lib/solargraph/complex_type/conformance.rb @@ -91,6 +91,19 @@ def conforms_to_unique_type? subtypes_conform? end + # Names of ancestors whose single generic param is known, by + # RBS/YARD convention, to mean "all of my own params yielded + # together as one tuple" - Hash's own RBS core signature + # declares `include Enumerable[[K, V]]`, and `_Each` is the + # duck-typed interface backing the same `#each` shape (see + # RbsMap::CoreFills::INCLUDES). Any other ancestor's generic + # param means whatever that ancestor declares it to mean, and + # erased_type_conforms? having already matched `expected.name` + # via inheritance says nothing about that meaning - it only + # says `inferred` is-a `expected`, not that `expected`'s own + # generic param is derived from `inferred`'s params at all. + TUPLE_YIELDING_ANCESTOR_NAMES = %w[Enumerable _Each].freeze + private def only_inferred_parameters? @@ -151,6 +164,7 @@ def erased_type_conforms? # one-for-one - to conform def pair_shaped_viewed_as_pairs? return false unless inferred.all_params.size >= 2 + return false unless TUPLE_YIELDING_ANCESTOR_NAMES.include?(expected.name) return expected.parameters_type != :hash if inferred.parameters_type == :hash diff --git a/spec/complex_type/conforms_to_spec.rb b/spec/complex_type/conforms_to_spec.rb index 96da1f7d0..ba3c18d0d 100644 --- a/spec/complex_type/conforms_to_spec.rb +++ b/spec/complex_type/conforms_to_spec.rb @@ -237,6 +237,101 @@ def each expect(inf.conforms_to?(api_map, exp2, :method_call)).to be(false) end + it 'does not reshape a :list type into a tuple for a lower-arity ancestor whose own ' \ + 'generic param is unrelated to the inferred type\'s params' do + # `Taggable`'s `X` has nothing to do with `Pair`'s `A`/`B` - the + # include doesn't parametrize Taggable with Pair's own generic + # params at all (unlike Enumerable, which RBS declares as + # `include Enumerable[[K, V]]` on Hash - bound to K and V + # directly). pair_shaped_viewed_as_pairs? triggers purely on + # arity mismatch (2 inferred params vs 1 expected), so it must + # not wrap [A, B] into a tuple and claim that tuple is what + # Taggable's X means. + source = Solargraph::Source.load_string(%( + # @generic X + module Taggable + end + + # @generic A, B + class Pair + include Taggable + + # @param a [generic] + # @param b [generic] + def initialize(a, b) + @a = a + @b = b + end + end + )) + api_map.map source + exp = described_class.parse('Taggable') + inf = described_class.parse('Pair') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + + it 'does not reshape a 3-arity :list type into a tuple for a 2-arity non-Enumerable ancestor' do + source = Solargraph::Source.load_string(%( + # @generic X, Y + module Labeled + end + + # @generic A, B, C + class Triple2 + include Labeled + + # @param a [generic] + # @param b [generic] + # @param c [generic] + def initialize(a, b, c) + @a = a + @b = b + @c = c + end + end + )) + api_map.map source + exp = described_class.parse('Labeled') + inf = described_class.parse('Triple2') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + + it 'does not reshape a hash-shaped type into a tuple for a lower-arity, non-Enumerable ' \ + 'ancestor whose own generic param is unrelated to the inferred type\'s key/value types' do + pending 'pre-existing conflation in key_types_conform?/subtypes_conform?, not introduced ' \ + 'by pair_shaped_viewed_as_pairs? - see comment below' + # Blocking the reshape here (expected.name isn't Enumerable/_Each) + # correctly stops pair_shaped_as_pairs from firing, but + # conforms_to_unique_type? then falls through to + # key_types_conform?/subtypes_conform?, which was comparing a + # hash-shaped inferred's value type against a list-shaped + # expected's single param positionally before this PR existed - + # `git diff ..273c7e6e7 -- conformance.rb` shows the + # pair_shaped_* methods are the *entire* diff this PR makes to + # this file, so that fallback path is unchanged baseline + # behavior. The same false positive is reproducible on the + # pre-PR fallback alone (Hash{Symbol => String} vs a 1-arity + # ancestor whose param happens to equal the value type), with + # no pair-shaping logic involved at all. Out of scope for this + # PR's correctness fix. + source = Solargraph::Source.load_string(%( + # @generic X + module Taggable + end + + class PairBag2 + include Taggable + end + )) + api_map.map source + exp = described_class.parse('Taggable') + inf = described_class.parse('PairBag2{Symbol => String}') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + it 'matches multiple types' do exp = described_class.parse('String, Integer') inf = described_class.parse('String, Integer') From 4cd39d692166ad2347388b8db37a53919dc09b90 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 1 Sep 2026 19:43:44 -0400 Subject: [PATCH 5/8] Trim comments to house style Comment blocks in this PR ran well past the 1-3 line budget per method/example, and several restated what the code or the it title already showed. Condense each to the non-obvious why, dropping restatement of the mechanism. The pending spec's explanatory comment also carried changelog content (a `git diff ..273c7e6e7` citation and "before this PR existed" / "out of scope for this PR" framing) confirming the fallback path in key_types_conform?/subtypes_conform? predates this PR's pair_shaped_* additions. That verification belongs here, not in the comment: this PR's only change to conformance.rb is the pair_shaped_* methods and their call site: the key_types_conform?/ subtypes_conform? fallback is unmodified baseline behavior. --- lib/solargraph/complex_type/conformance.rb | 47 ++++++---------------- spec/complex_type/conforms_to_spec.rb | 47 ++++++---------------- 2 files changed, 24 insertions(+), 70 deletions(-) diff --git a/lib/solargraph/complex_type/conformance.rb b/lib/solargraph/complex_type/conformance.rb index fc86121dd..6994f84e6 100644 --- a/lib/solargraph/complex_type/conformance.rb +++ b/lib/solargraph/complex_type/conformance.rb @@ -63,19 +63,9 @@ def conforms_to_unique_type? return false unless erased_type_conforms? - # A type with 2+ ordered generic params - a hash-shaped type - # (parameters_type == :hash; Hash[K, V] is the only - # stdlib/RBS example, but the YARD `{K => V}` tag syntax - # admits any class name) always has 2 (key_types, subtypes), - # and an ordinary ``-generic type - # (parameters_type == :list) can have any number - may - # satisfy a lower-arity Enumerable/_Each ancestor - # structurally: #each yields all of its params together as - # a tuple, so the type includes Enumerable[Array[K, V]] (or - # Array[A, B, ...]), not Enumerable[V] (or Enumerable[A]). - # Compare against that tuple shape instead of the raw - # per-param types once erased_type_conforms? has already - # established inferred <: expected via inheritance. + # Hash{K=>V} and -generic types yield their params together as + # one tuple via #each, not one at a time - compare against that + # tuple shape, not raw per-param types, for a lower-arity ancestor. return with_new_types(pair_shaped_as_pairs, expected).conforms_to_unique_type? if pair_shaped_viewed_as_pairs? return true if inferred.all_params.empty? && rules.include?(:allow_empty_params) @@ -91,17 +81,9 @@ def conforms_to_unique_type? subtypes_conform? end - # Names of ancestors whose single generic param is known, by - # RBS/YARD convention, to mean "all of my own params yielded - # together as one tuple" - Hash's own RBS core signature - # declares `include Enumerable[[K, V]]`, and `_Each` is the - # duck-typed interface backing the same `#each` shape (see - # RbsMap::CoreFills::INCLUDES). Any other ancestor's generic - # param means whatever that ancestor declares it to mean, and - # erased_type_conforms? having already matched `expected.name` - # via inheritance says nothing about that meaning - it only - # says `inferred` is-a `expected`, not that `expected`'s own - # generic param is derived from `inferred`'s params at all. + # Ancestors whose single generic param means "all params yielded as + # one tuple" (Hash's RBS: `include Enumerable[[K, V]]`; `_Each` backs + # the same #each shape). Any other ancestor's param means its own thing. TUPLE_YIELDING_ANCESTOR_NAMES = %w[Enumerable _Each].freeze private @@ -155,13 +137,9 @@ def erased_type_conforms? true end - # @return [Boolean] true if `inferred` has 2+ ordered generic - # params (a hash-shaped type's key/value types, e.g. - # `Hash{K => V}`, or a ``-generic type's ordered - # params) being checked against an expectation of a - # different arity (e.g. a 1-arity Enumerable), so its - # params need to be viewed as a single tuple - not compared - # one-for-one - to conform + # @return [Boolean] true if `inferred`'s 2+ ordered params need to be + # viewed as one tuple, not compared one-for-one, against a + # mismatched-arity Enumerable/_Each expectation def pair_shaped_viewed_as_pairs? return false unless inferred.all_params.size >= 2 return false unless TUPLE_YIELDING_ANCESTOR_NAMES.include?(expected.name) @@ -171,10 +149,9 @@ def pair_shaped_viewed_as_pairs? inferred.parameters_type == :list && inferred.all_params.size != expected.all_params.size end - # @return [UniqueType] `inferred` reshaped as `expected`'s name - # parametrized with the tuple its params form when yielded - # together - [key, value] for a hash-shaped type, or its - # ordered params for a ``-generic type + # @return [UniqueType] `inferred` reshaped as `expected`'s name, + # parametrized with a single tuple of its own params - [key, value] + # for hash-shaped, or its ordered params for a list-generic type def pair_shaped_as_pairs ordered_params = if inferred.parameters_type == :hash [ComplexType.new(inferred.key_types), ComplexType.new(inferred.subtypes)] diff --git a/spec/complex_type/conforms_to_spec.rb b/spec/complex_type/conforms_to_spec.rb index ba3c18d0d..7e032bc2e 100644 --- a/spec/complex_type/conforms_to_spec.rb +++ b/spec/complex_type/conforms_to_spec.rb @@ -135,12 +135,9 @@ class Sub < Sup; end end it 'reshapes any hash-shaped (parameters_type == :hash) type into pairs, not just Hash' do - # `PairBag` proves the pair-reshaping in Conformance is a - # structural check on parameters_type, not a check on the literal - # class name 'Hash': the YARD `{K => V}` tag syntax produces - # parameters_type == :hash for any class name, so any 2-arity - # type that structurally includes a lower-arity Enumerable - # ancestor needs the same reshape Hash does. + # YARD's `{K => V}` tag syntax produces parameters_type == :hash for + # any class name, not just Hash - PairBag proves the reshape is + # structural, not name-based. source = Solargraph::Source.load_string(%( class PairBag include Enumerable @@ -155,12 +152,9 @@ class PairBag it 'reshapes a :list-parameterized (ordinary generic) 2-arity type into a tuple ' \ 'to conform to a lower-arity Enumerable ancestor' do - # `Pair` proves the same reshape Hash/PairBag get for - # parameters_type == :hash is also needed for parameters_type - # == :list: a class documented with ordinary `` generic - # syntax (rather than YARD's `{K => V}` hash-tag syntax) that - # structurally includes a lower-arity Enumerable ancestor, - # because #each yields the two params together as a tuple. + # `Pair` uses ordinary `` generic syntax (parameters_type == + # :list), not YARD's `{K => V}` hash tag - the same reshape must + # apply to both param shapes. source = Solargraph::Source.load_string(%( # @generic A, B class Pair @@ -232,21 +226,15 @@ def each match = inf.conforms_to?(api_map, exp, :method_call) expect(match).to be(true) - # a 2-arity tuple expectation should not match a 3-arity inferred type exp2 = described_class.parse('Enumerable') expect(inf.conforms_to?(api_map, exp2, :method_call)).to be(false) end it 'does not reshape a :list type into a tuple for a lower-arity ancestor whose own ' \ 'generic param is unrelated to the inferred type\'s params' do - # `Taggable`'s `X` has nothing to do with `Pair`'s `A`/`B` - the - # include doesn't parametrize Taggable with Pair's own generic - # params at all (unlike Enumerable, which RBS declares as - # `include Enumerable[[K, V]]` on Hash - bound to K and V - # directly). pair_shaped_viewed_as_pairs? triggers purely on - # arity mismatch (2 inferred params vs 1 expected), so it must - # not wrap [A, B] into a tuple and claim that tuple is what - # Taggable's X means. + # Taggable's X has no relation to Pair's A/B (unlike Enumerable, RBS- + # bound to Hash's K/V via `include Enumerable[[K, V]]`) - an arity + # mismatch alone must not wrap [A, B] into a tuple and call it X. source = Solargraph::Source.load_string(%( # @generic X module Taggable @@ -302,20 +290,9 @@ def initialize(a, b, c) 'ancestor whose own generic param is unrelated to the inferred type\'s key/value types' do pending 'pre-existing conflation in key_types_conform?/subtypes_conform?, not introduced ' \ 'by pair_shaped_viewed_as_pairs? - see comment below' - # Blocking the reshape here (expected.name isn't Enumerable/_Each) - # correctly stops pair_shaped_as_pairs from firing, but - # conforms_to_unique_type? then falls through to - # key_types_conform?/subtypes_conform?, which was comparing a - # hash-shaped inferred's value type against a list-shaped - # expected's single param positionally before this PR existed - - # `git diff ..273c7e6e7 -- conformance.rb` shows the - # pair_shaped_* methods are the *entire* diff this PR makes to - # this file, so that fallback path is unchanged baseline - # behavior. The same false positive is reproducible on the - # pre-PR fallback alone (Hash{Symbol => String} vs a 1-arity - # ancestor whose param happens to equal the value type), with - # no pair-shaping logic involved at all. Out of scope for this - # PR's correctness fix. + # key_types_conform?/subtypes_conform? compares a hash-shaped inferred's + # value type against a list-shaped expected's param positionally - the + # same false positive reproduces with no pair-shaping logic involved. source = Solargraph::Source.load_string(%( # @generic X module Taggable From 1c9463b7d73fbb33d9341f7a97455f4da04870f4 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 9 Sep 2026 14:31:38 -0400 Subject: [PATCH 6/8] Derive tuple-yielding ancestors instead of naming them Conformance reshaped a Hash into key/value pairs only for ancestors listed in TUPLE_YIELDING_ANCESTOR_NAMES, a hard-coded %w[Enumerable _Each]. The list was standing in for a property the ancestor already declares, so it covered exactly two names and no user-defined module. Two derivations replace it. Where the ancestry declares its arguments, ApiMap#type_as_ancestor resolves them against the inferred type, so Hash's `include Enumerable[[K, V]]` turns Hash{String => Integer} into Enumerable with nothing guessed. Where it declares none, as a bare `include Enumerable` does, ApiMap#yields_type_parameter? asks whether the ancestor's single type parameter appears as a block parameter in one of its own signatures - true of Enumerable via #map, false of a module that yields nothing. Both read data RbsMap::Conversions already records; only the query was missing. _Each drops out of the code entirely: it is a RbsMap::CoreFills synthetic include, and Hash never had one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJfMsNRKVrBMkSfXvsEiT3 --- lib/solargraph/api_map.rb | 65 ++++++++++++++++++++++ lib/solargraph/complex_type/conformance.rb | 27 +++++++-- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 7186c4b83..09cf0d780 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -736,6 +736,29 @@ def type_include? host_ns, module_ns store.get_includes(host_ns).map { |inc_tag| inc_tag.type.name }.include?(module_ns) end + # @return [Hash{String => Boolean}] + def cached_yields_type_parameter + @cached_yields_type_parameter ||= {} + end + + # @param ancestor_name [String] + # @return [Boolean] + def uncached_yields_type_parameter? ancestor_name + namespace_pin = store.get_path_pins(ancestor_name).select { |pin| pin.is_a?(Pin::Namespace) }.first + return false unless namespace_pin.is_a?(Pin::Namespace) + return false unless namespace_pin.generics.length == 1 + + generic_tag = "generic<#{namespace_pin.generics.first}>" + store.get_methods(ancestor_name).any? do |method_pin| + method_pin.signatures.any? do |signature| + block = signature.block + next false if block.nil? + + block.parameters.any? { |param| param.return_type.to_s == generic_tag } + end + end + end + # @param pins [Enumerable] # @param visibility [Enumerable] # @return [Array] @@ -793,6 +816,48 @@ def inner_get_methods_from_reference fq_reference_tag, namespace_pin, type, scop methods end + # Express `type` as the ancestor named `ancestor_name`, resolving that + # ancestor's own type arguments from `type`'s parameters - given Hash's + # `include Enumerable[[K, V]]`, `Hash{String => Integer}` becomes + # `Enumerable`. + # + # A bare `include Enumerable` declares no arguments, so it resolves to a + # parameterless `Enumerable`: the ancestry says nothing about how the + # includer's params relate to the ancestor's. + # + # @param type [ComplexType::UniqueType] + # @param ancestor_name [String] unrooted name, as ComplexType#name reports + # @return [ComplexType::UniqueType, nil] nil unless an ancestor matches + def type_as_ancestor type, ancestor_name + namespace_pin = store.get_path_pins(type.name).select { |pin| pin.is_a?(Pin::Namespace) }.first + return nil if namespace_pin.nil? + + context = ComplexType.new([type]) + store.get_ancestor_references(type.name).each do |ref| + tag = store.constants.dereference(ref) + next if tag.nil? + resolved = ComplexType.parse(tag).force_rooted.resolve_generics(namespace_pin, context).first + return resolved if resolved.name == ancestor_name + end + nil + end + + # Whether `ancestor_name`'s single type parameter is bound to what its + # methods yield, as `Enumerable[E]` binds E through `map`'s block. Such a + # parameter describes one yielded element, so an includer conforms by the + # shape it yields rather than by matching parameters one for one. + # + # A module yielding nothing - `Taggable[X]` - says nothing about its + # includers' parameters, and gets no such treatment. + # + # @param ancestor_name [String] + # @return [Boolean] + def yields_type_parameter? ancestor_name + cached_yields_type_parameter.fetch(ancestor_name) do + cached_yields_type_parameter[ancestor_name] = uncached_yields_type_parameter?(ancestor_name) + end + end + # @param fq_sub_tag [String] # @return [String, nil] def qualify_superclass fq_sub_tag diff --git a/lib/solargraph/complex_type/conformance.rb b/lib/solargraph/complex_type/conformance.rb index 6994f84e6..388240406 100644 --- a/lib/solargraph/complex_type/conformance.rb +++ b/lib/solargraph/complex_type/conformance.rb @@ -63,6 +63,12 @@ def conforms_to_unique_type? return false unless erased_type_conforms? + # Where the ancestry declares how the ancestor's params derive from + # ours - Hash's `include Enumerable[[K, V]]` - resolve them and + # compare against that rather than guessing a shape. + declared = declared_ancestor_view + return with_new_types(declared, expected).conforms_to_unique_type? if declared + # Hash{K=>V} and -generic types yield their params together as # one tuple via #each, not one at a time - compare against that # tuple shape, not raw per-param types, for a lower-arity ancestor. @@ -81,13 +87,22 @@ def conforms_to_unique_type? subtypes_conform? end - # Ancestors whose single generic param means "all params yielded as - # one tuple" (Hash's RBS: `include Enumerable[[K, V]]`; `_Each` backs - # the same #each shape). Any other ancestor's param means its own thing. - TUPLE_YIELDING_ANCESTOR_NAMES = %w[Enumerable _Each].freeze - private + # @return [UniqueType, nil] `inferred` expressed as `expected`'s + # ancestor, when that ancestor is declared with arguments derived + # from `inferred`'s own params. nil when the ancestry declares none + # (a bare include), leaving nothing to resolve. + def declared_ancestor_view + return nil if inferred.name == expected.name + return nil if inferred.all_params.empty? || expected.all_params.empty? + + view = api_map.type_as_ancestor(inferred, expected.name) + return nil if view.nil? || view.all_params.empty? + + view + end + def only_inferred_parameters? !expected.parameters? && inferred.parameters? end @@ -142,7 +157,7 @@ def erased_type_conforms? # mismatched-arity Enumerable/_Each expectation def pair_shaped_viewed_as_pairs? return false unless inferred.all_params.size >= 2 - return false unless TUPLE_YIELDING_ANCESTOR_NAMES.include?(expected.name) + return false unless api_map.yields_type_parameter?(expected.name) return expected.parameters_type != :hash if inferred.parameters_type == :hash From 64b14e565f210875adb55f8b0fff1ca952bc8b0f Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 9 Sep 2026 15:07:01 -0400 Subject: [PATCH 7/8] Cover type_as_ancestor when no ancestor matches undercover reported the method at 88.89%: the trailing `nil`, reached when the ancestry names no match, had no test exercising it. The check runs under continue-on-error, so the job reported success anyway. The paired positive example also records what the derivation produces, which nothing else asserts directly - Hash{String => Integer} resolving through `include Enumerable[[K, V]]` to Enumerable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJfMsNRKVrBMkSfXvsEiT3 --- spec/api_map_spec.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/api_map_spec.rb b/spec/api_map_spec.rb index 6f367d229..08eee4d18 100755 --- a/spec/api_map_spec.rb +++ b/spec/api_map_spec.rb @@ -1005,4 +1005,18 @@ def self.property(name, default, type:, comment:) # @todo Undefined because the return tag expands to `type: String` expect(pins.map(&:return_type).map(&:tag)).to eq(%w[undefined]) end + + it 'resolves an ancestor\'s declared type arguments against the type' do + api_map = described_class.new + hash = Solargraph::ComplexType.parse('Hash{String => Integer}').first + # Hash's core signature declares `include Enumerable[[K, V]]`, so K and V + # bind to String and Integer. + expect(api_map.type_as_ancestor(hash, 'Enumerable').to_s).to eq('Enumerable') + end + + it 'returns nil for a namespace that is not an ancestor' do + api_map = described_class.new + hash = Solargraph::ComplexType.parse('Hash{String => Integer}').first + expect(api_map.type_as_ancestor(hash, 'Comparable')).to be_nil + end end From d151b45a272d892a72b8fc25b3fe7c7e3fb1e23b Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 9 Sep 2026 15:37:19 -0400 Subject: [PATCH 8/8] Give each spec generic its own @generic tag YARD reads a tag's name as its first whitespace-delimited token, and Pin::Closure#generics maps those names straight through, so `@generic A, B` produced generics ["A,"] - one entry, comma kept. `generic` then missed on the index lookup and `generic` was absent, leaving both unresolved with nothing reported. Six classes across these examples used the one-line form. Outcomes are unchanged, because they exercise the reshape through the type tag's all_params rather than through the namespace's generics - which is why nothing caught it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJfMsNRKVrBMkSfXvsEiT3 --- spec/complex_type/conforms_to_spec.rb | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/spec/complex_type/conforms_to_spec.rb b/spec/complex_type/conforms_to_spec.rb index 7e032bc2e..01d8e261a 100644 --- a/spec/complex_type/conforms_to_spec.rb +++ b/spec/complex_type/conforms_to_spec.rb @@ -156,7 +156,8 @@ class PairBag # :list), not YARD's `{K => V}` hash tag - the same reshape must # apply to both param shapes. source = Solargraph::Source.load_string(%( - # @generic A, B + # @generic A + # @generic B class Pair include Enumerable @@ -182,7 +183,8 @@ def each it 'does not reshape a 2-arity :list type that does not include Enumerable' do source = Solargraph::Source.load_string(%( - # @generic A, B + # @generic A + # @generic B class NotEnumerablePair # @param a [generic] # @param b [generic] @@ -201,7 +203,9 @@ def initialize(a, b) it 'reshapes a 3-arity :list type into a 3-tuple to conform to a lower-arity Enumerable ancestor' do source = Solargraph::Source.load_string(%( - # @generic A, B, C + # @generic A + # @generic B + # @generic C class Triple include Enumerable @@ -240,7 +244,8 @@ def each module Taggable end - # @generic A, B + # @generic A + # @generic B class Pair include Taggable @@ -261,11 +266,14 @@ def initialize(a, b) it 'does not reshape a 3-arity :list type into a tuple for a 2-arity non-Enumerable ancestor' do source = Solargraph::Source.load_string(%( - # @generic X, Y + # @generic X + # @generic Y module Labeled end - # @generic A, B, C + # @generic A + # @generic B + # @generic C class Triple2 include Labeled