Skip to content

Stop dropping union members a module guard could match - #104

Draft
apiology wants to merge 2 commits into
masterfrom
fix-mixin-positive-narrowing
Draft

Stop dropping union members a module guard could match#104
apiology wants to merge 2 commits into
masterfrom
fix-mixin-positive-narrowing

Conversation

@apiology

Copy link
Copy Markdown
Owner

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

Problem: When a guard tests for a module, a union member not already known to include it is dropped — so the inferred type omits a value that can actually occur.

module M; end
class A; end
class B; end
class A_with_M < A; include M; end
class B_with_M < B; include M; end

# @param x [A, B_with_M]
# @return [nil]
def f(x)
  return x if x.is_a?(M)

  nil
end
# Declared return type nil does not match inferred type ::B_with_M, nil for #f

A_with_M satisfies the declared A, passes the guard, and is returned, yet the inferred type admits only B_with_M. The mirror case — a module member against a class guard — is quieter still: every member drops, the empty result falls back to undefined, and the file reports no problems at all.

Solution: Keep the guard's type when the two types are related in neither direction and either side is a module, since a subclass can always mix a module in. Two unrelated classes remain disjoint under single inheritance and are still dropped.

The exact answer is their intersection, which cannot be spelled yet; a pending spec asserts it against castwide#1231.

Test plan:

  • Strong self-typecheck against an origin/master baseline: 531 problems in 90 of 250 files on both sides, problem lists identical after normalizing paths and line numbers.

apiology and others added 2 commits September 9, 2026 21:54
ComplexType#intersect_with keeps a declared union member only when it
conforms to a guard type or the guard type conforms to it. A member
related to the guard in neither direction falls through both branches
of that test and is dropped, so narrowing 'A, B_with_M' by an
is_a?(M) guard yields 'B_with_M' alone. That is narrower than
reality: an A_with_M satisfies the declared A, passes the guard, and
is no longer admitted.

The first example asserts that soundness property rather than an
exact tag string, so it goes green under any correct answer. The
second pins the precise result and stays pending, because the sound
answer 'A & M, B_with_M' needs intersection types that master cannot
represent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EiauH8LL9NaN6hbx5EaNbB
ComplexType#intersect_with kept a declared union member only when it
conformed to the guard type or the guard conformed to it, and dropped
it otherwise. Dropping is correct for two classes, which single
inheritance proves cannot overlap, and narrowing a union by is_a?
depends on it. It is wrong when either side is a module, because a
subclass can mix the module in, so no such pair can be ruled out.

Narrowing [A, B_with_M] by is_a?(M) therefore inferred B_with_M
alone, excluding an A_with_M that satisfies the declared A, passes
the guard, and is returned. The mirror case -- a module member
against a class guard -- dropped every member and fell back to
undefined, silencing the check rather than narrowing it.

Both copies now keep the guard's type when neither side conforms and
either is a module. That is the closest sound answer expressible
here; the exact one is the two intersected, which has no
representation yet.

ApiMap#module? wraps the existing private get_namespace_type so the
conformance code does not reimplement it at the call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EiauH8LL9NaN6hbx5EaNbB
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