Skip to content

Fix equality for types whose name cannot be rooted - #133

Open
apiology wants to merge 1 commit into
masterfrom
fix-undefined-type-equality
Open

apiology wants to merge 1 commit into
masterfrom
fix-undefined-type-equality

Conversation

@apiology

@apiology apiology commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Claude: This PR was written by Claude Code on behalf of @apiology.

ComplexType::UniqueType#eql? compares the raw @rooted ivar on the left against the rooted? reader on the right, and rooted? is !can_root_name? || @rooted. For a name that cannot be rooted — undefined is the case that arises in practice — the reader returns true while the ivar is false, so that clause is permanently false and an undefined type is not equal even to itself. Anything relying on Ruby equality of types rather than tag-string equality silently treats two undefined types as distinct.

a = Solargraph::ComplexType.parse('undefined').items.first
a.eql?(a)                      # => false  (want true)

x = Solargraph::ComplexType::UniqueType.new('undefined', rooted: false)
y = Solargraph::ComplexType::UniqueType.new('undefined', rooted: false)
[x, y].uniq.length             # => 2      (want 1)

p = Solargraph::ComplexType::UniqueType::UNDEFINED
q = p.erase_parameters         # passes rooted: rooted?, storing the opposite flag
[p.eql?(q), q.eql?(p)]         # => [false, true]  asymmetric, and p.hash != q.hash

Compare rooted? on both sides rather than both raw ivars: where a name can be rooted the two spellings are identical, and where it cannot, rootedness carries no meaning, so two such types are the same type whichever flag built them. hash moves with it, since it read @rooted and would otherwise leave that erase_parameters pair equal but hashed differently.

UniqueType#eql? compared the raw @rooted ivar on the left against the
rooted? reader on the right. rooted? is "!can_root_name? || @rooted", so
for a name that cannot be rooted -- a lowercase name such as undefined
-- the reader returns true while the ivar is false. That comparison was
therefore permanently false, and no undefined type was equal to any
other, including to itself: a.eql?(a) returned false.

Anything relying on Ruby equality of types rather than tag-string
equality was affected. Two distinct undefined objects deduplicated to
two entries under uniq, missed each other under Set#include?, and
failed to find each other as Hash keys.

UNDEFINED and UNDEFINED.erase_parameters additionally broke the
eql?/hash contract outright. erase_parameters passes "rooted: rooted?",
so it stores @rooted = true where the constant stores false: eql? was
asymmetric between them, true in one direction and false in the other,
while their hashes differed.

Compare rooted? on both sides rather than both raw ivars. For a name
that can be rooted the two spellings are identical; for a name that
cannot, rootedness carries no meaning, so two such types are the same
type whichever flag they were constructed with. hash has to move with
eql? or that erase_parameters pair becomes equal with differing hashes,
so it now reads rooted? too.
@apiology
apiology marked this pull request as ready for review October 4, 2026 14:57
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.

1 participant