Claude: drafted while investigating an unrelated PR; posting on the user's behalf via their GitHub credentials.
Summary
When a value's declared type is a union of two instantiations of the same @generic class (e.g. Box<Integer>, Box<String>), calling a method whose return type is generic<T> always resolves T against whichever instantiation is listed first in the union - regardless of which one the value would actually be at runtime, and regardless of any argument that might otherwise disambiguate it. Swapping the declared order of the union members changes the inferred type, which shouldn't be possible for a real union.
Reproduction
# @generic T
class Box
# @return [generic<T>]
def get; end
end
class Repro
# @param b [Box<Integer>, Box<String>]
# @return [void]
def process(b)
# @type [Integer]
x = b.get
end
end
solargraph typecheck --level strong on this: clean, b.get infers Integer.
Now swap the union's declared order, changing nothing else:
# @param b [Box<String>, Box<Integer>]
# @return [void]
def process(b)
# @type [Integer]
x = b.get
end
Declared type Integer does not match inferred type String for variable x
b.get now infers String - the only thing that changed was which member is listed first.
Root cause
Chain::Call#inferred_pins computes self_type once for the whole call and uses it to resolve the class's generic parameters:
self_type = if head?
name_pin.context
else
name_pin.binder # the *whole* union/intersection type
end
...
type = with_params(new_return_type.self_to_type(self_type), self_type).qualify(api_map, *p.gates)
self_type here is name_pin.binder - the complete union type (Box<Integer>, Box<String>), not a specific member. Whatever generic-resolution logic binds Box's T against that combined self_type ends up matching structurally against the first member it finds, so generic<T> always resolves to the first union member's argument, independent of which member is actually relevant to the call.
Scope
This reproduces with a plain union (,), no intersection types (&) or any other new syntax involved - it's independent of castwide/solargraph#1231 (which adds intersection types) and #1266 (structural RBS interface checks). It also affects intersection-typed receivers the same way, since intersections and unions both flatten to the same per-member dispatch code in Chain::Call, but a plain union is sufficient to reproduce it on its own.
Environment
Reproduced on castwide/solargraph@8fda63384 (current master).
Claude: drafted while investigating an unrelated PR; posting on the user's behalf via their GitHub credentials.
Summary
When a value's declared type is a union of two instantiations of the same
@genericclass (e.g.Box<Integer>, Box<String>), calling a method whose return type isgeneric<T>always resolvesTagainst whichever instantiation is listed first in the union - regardless of which one the value would actually be at runtime, and regardless of any argument that might otherwise disambiguate it. Swapping the declared order of the union members changes the inferred type, which shouldn't be possible for a real union.Reproduction
solargraph typecheck --level strongon this: clean,b.getinfersInteger.Now swap the union's declared order, changing nothing else:
b.getnow infersString- the only thing that changed was which member is listed first.Root cause
Chain::Call#inferred_pinscomputesself_typeonce for the whole call and uses it to resolve the class's generic parameters:self_typehere isname_pin.binder- the complete union type (Box<Integer>, Box<String>), not a specific member. Whatever generic-resolution logic bindsBox'sTagainst that combinedself_typeends up matching structurally against the first member it finds, sogeneric<T>always resolves to the first union member's argument, independent of which member is actually relevant to the call.Scope
This reproduces with a plain union (
,), no intersection types (&) or any other new syntax involved - it's independent of castwide/solargraph#1231 (which adds intersection types) and #1266 (structural RBS interface checks). It also affects intersection-typed receivers the same way, since intersections and unions both flatten to the same per-member dispatch code inChain::Call, but a plain union is sufficient to reproduce it on its own.Environment
Reproduced on
castwide/solargraph@8fda63384(currentmaster).