From cd57da75161dafb400aff7b760e377931523ddd4 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Sat, 23 May 2026 09:14:32 +1000 Subject: [PATCH 1/2] Symmetric `common_descendent` for two generic class types (#10831) `x.is_a?(B) && x.is_a?(C)` returns `false` for a runtime value that is actually a `C` when `class C(T) < B(T)`. After the first filter, `x` is narrowed to `B(T)`. The chained `is_a?(C)` then asks `common_descendent(B, C)`, which walked the relationship in one direction only (`common_descendent_instance_and_generic(B, C)`) and couldn't reach `C` from `B`. The intersection collapsed and the filter evaluated to `false`. Try both directions so the relationship between generic class types flows whichever way the chain happens to be written. --- spec/compiler/semantic/is_a_spec.cr | 14 ++++++++++++++ src/compiler/crystal/semantic/type_intersect.cr | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/spec/compiler/semantic/is_a_spec.cr b/spec/compiler/semantic/is_a_spec.cr index f54126b70625..34df1e0302bc 100644 --- a/spec/compiler/semantic/is_a_spec.cr +++ b/spec/compiler/semantic/is_a_spec.cr @@ -258,4 +258,18 @@ describe "Semantic: is_a?" do end CRYSTAL end + + it "matches `is_a?(GenericA) && is_a?(GenericB)` when GenericB <= GenericA (#10831)" do + # Before the fix, `common_descendent(GenericClassType, GenericClassType)` + # only walked one direction, so the chained filter narrowed `x` to `B(T)` + # and `is_a?(C)` returned `false` for a runtime value that's actually a C. + run(<<-CRYSTAL).to_b.should be_true + class A; end + class B(T) < A; end + class C(T) < B(T); end + + x = C(Int32).new.as(A) + x.is_a?(B) && x.is_a?(C) + CRYSTAL + end end diff --git a/src/compiler/crystal/semantic/type_intersect.cr b/src/compiler/crystal/semantic/type_intersect.cr index 490503dbc4c1..851b46d0b5b3 100644 --- a/src/compiler/crystal/semantic/type_intersect.cr +++ b/src/compiler/crystal/semantic/type_intersect.cr @@ -214,7 +214,13 @@ module Crystal def self.common_descendent(type1 : GenericClassType, type2 : GenericClassType) return type1 if type1 == type2 - common_descendent_instance_and_generic(type1, type2) + # Try both directions: e.g. for `class C(T) < B(T)`, `common_descendent(B, C)` + # should resolve to `C` because every C is also a B. Without the symmetric + # check, chained type filters like `x.is_a?(B) && x.is_a?(C)` narrow `x` + # to `B(T)` and `is_a?(C)` returns `false` even when the runtime value is + # actually a C (#10831). + common_descendent_instance_and_generic(type1, type2) || + common_descendent_instance_and_generic(type2, type1) end def self.common_descendent(type1 : Type, type2 : AliasType) From 4f571b6f48386f875b01925e3d578076b46620a6 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Sun, 24 May 2026 00:41:38 +1000 Subject: [PATCH 2/2] Update spec/compiler/semantic/is_a_spec.cr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Müller --- spec/compiler/semantic/is_a_spec.cr | 3 --- 1 file changed, 3 deletions(-) diff --git a/spec/compiler/semantic/is_a_spec.cr b/spec/compiler/semantic/is_a_spec.cr index 34df1e0302bc..66df0965f1a7 100644 --- a/spec/compiler/semantic/is_a_spec.cr +++ b/spec/compiler/semantic/is_a_spec.cr @@ -260,9 +260,6 @@ describe "Semantic: is_a?" do end it "matches `is_a?(GenericA) && is_a?(GenericB)` when GenericB <= GenericA (#10831)" do - # Before the fix, `common_descendent(GenericClassType, GenericClassType)` - # only walked one direction, so the chained filter narrowed `x` to `B(T)` - # and `is_a?(C)` returned `false` for a runtime value that's actually a C. run(<<-CRYSTAL).to_b.should be_true class A; end class B(T) < A; end