Skip to content

Add intersection (A & B) types, including Hash-based record support - #1231

Draft
apiology wants to merge 24 commits into
castwide:masterfrom
apiology:fix-1229-intersection-types
Draft

Add intersection (A & B) types, including Hash-based record support#1231
apiology wants to merge 24 commits into
castwide:masterfrom
apiology:fix-1229-intersection-types

Conversation

@apiology

@apiology apiology commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds real intersection type (A & B) support to Solargraph, usable in both
plain YARD tags (@param/@return/@type) and inline RBS signatures
(#: () -> (A & B)). Also adds the | union operator and [...] grouping
brackets, matching lsegal/yard#1700.

Fixes #1229

Root cause

ComplexType had no representation for intersections - only comma-separated
unions - so A & B behaved like the union (A, B): assignable only where
every member independently matched, instead of assignable wherever any
one
member matches (A & B <: A and A & B <: B).

What changed

  • New ComplexType::UniqueType::Intersection, with conforms_to? correct on
    both sides (any one conjunct as inferred; every conjunct as expected,
    including intersection-vs-intersection).
  • ComplexType.parse recognizes top-level & (intersection), | (union,
    binds looser than &), and [...] (grouping, e.g. [Foo | Bar] & Baz) -
    matching RBS/YARD precedence. Anonymous shorthand (<A>, (A), {A=>B})
    now defaults its name to Array/Array/Hash instead of parsing empty.
  • RbsTranslator builds every composite RBS type (Intersection,
    Optional, Union, Tuple, generic args) as an object graph instead of
    joining strings, so nested unions inside intersections round-trip
    correctly in both directions.
  • Method-call resolution on intersection-typed receivers: Call#method_stack_pins
    gives an Intersection conjunct "any one is enough" semantics (A & B <: A,
    A & B <: B) instead of requiring every conjunct to define the method the
    way a real union does, and dedupes candidate pins by [path, return_type.tag]
    instead of path alone, so same-path pins that already resolved to different,
    correct return types per conjunct (e.g. Box<Integer> & Box<String>) don't
    silently collapse into just the first one - fixing order-dependent dispatch.
  • Renamed PR Support intersection types for internal use #1119's intersect_with/intersection_return_type to
    narrow_with/narrowed_return_type to avoid a naming collision with real
    intersections; narrow_with now builds an Intersection instead of
    discarding a mix-in narrowing when one side is a confirmed module.

Precise Hash "record" dispatch

Two single-key Hash types intersected -
Hash{"Index" => Float} & Hash{"Triggers" => Array<...>} - now dispatch like
TypeScript's { Index: Float } & { Triggers: Array<...> }: #fetch/#[]
(and any other RBS method with a _Key-shaped parameter, e.g. #dig,
#delete - detected structurally, not by a hardcoded method list) narrow to
the one conjunct whose key actually matches the call's own literal argument,
instead of returning a union of every conjunct's return type. RBS's own
Hash#fetch: (_Key key) -> V can't do this itself - _Key is a structural
hash/eql? interface, not literally K, so the key argument is never
connected to the return type by ordinary overload resolution.

Conservative by construction: a conjunct is only ever narrowed away when
every conjunct in the intersection produces a positive verdict (matched or
didn't) against a _Key-shaped parameter and the call's literal argument -
falling back to the original full-union behavior whenever even one conjunct
can't be verified one way or the other, so nothing is ever narrowed away
without positive evidence.

The two specs demonstrating this stay pending on this branch, citing two
independent, already-scoped, unmerged prerequisites: #1223
(restores literal type inference, needed for the literal "Index"/"Triggers"
key types to survive to be compared at all) and, on RBS >= 4.1.x,
#1266 (structural RBS interface conformance, needed so
Hash#fetch's own overload resolution doesn't separately leak generic<X>).
Neither gap is specific to this fix or to intersections.

Verified

🤖 Generated with Claude Code

https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN

apiology and others added 3 commits July 30, 2026 08:12
RbsTranslator#type_to_tag translated RBS::Types::Intersection the
same way as RBS::Types::Union, joining member tags with ', '. Since
ComplexType had no representation for intersections, `A & B` ended
up behaving like the union `(A, B)` — assignable only where every
member type would independently be accepted, instead of assignable
anywhere any one member type is expected.

Add ComplexType::UniqueType::Intersection, a UniqueType whose
conforms_to? honors the actual intersection subtyping rule (A & B <:
A and A & B <: B): when an intersection is the inferred type, any
one conjunct satisfying the expectation is enough; when it's the
expected type (handled in Conformance), every conjunct must be
satisfied. ComplexType.parse now recognizes a top-level `&` as an
intersection separator (nested the same way `,` already is), so this
applies to any YARD type tag (@param/@return/@type), not just inline
RBS signatures, since both funnel through the same parser. YARD has
no official intersection syntax yet (see
lsegal/yard#1644), so `&` is a Solargraph
extension using RBS's own convention.

Fixes castwide#1229

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
… collision

ComplexType#intersect_with (and its UniqueType counterpart) is
flow-sensitive type narrowing: given a type learned from a runtime
guard (e.g. x.is_a?(Foo)), it refines a declared type down to the
more specific of each compatible pair, dropping incompatible pairs
and falling back to UNDEFINED if nothing survives. That is a
refinement over alternatives, not a real intersection type - it
never builds a compound type to represent unrelated members, unlike
ComplexType::UniqueType::Intersection added in this branch.

Renamed intersect_with -> narrow_with (ComplexType and UniqueType),
and Pin::BaseVariable's intersection_return_type -> narrowed_return_type
(including its call site in flow_sensitive_typing.rb), to keep the
two concepts from sharing a name. Pure rename plus doc clarification;
no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
conforms_to_intersection_expectation? called inferred.conforms_to?
directly, where inferred is a bare UniqueType. That dispatches to
UniqueType#conforms_to?, which lacks the
`return duck_types_match?(...) if expected.duck_type?` shortcut that
only exists on ComplexType#conforms_to?. As a result, a duck-typed
conjunct (e.g. `Object & #to_str`) in an expected intersection was
never structurally verified - Quacker#to_str failed to conform to
`Object & #to_str` even though Quacker plainly has to_str.

Wrap inferred in a ComplexType before the per-conjunct check so it
goes through the same conformance path as every other expectation
check in the codebase.

Also adds spec coverage for intersections combining a class with a
mix-in (module) and a class with a YARD duck type, verified against
real RBS core types (String & Comparable, and a class defining
to_str checked against #to_str). RBS's own runtime type-checker
(rbs/test/type_check.rb) defines "a value satisfies A & B iff it
satisfies every member type" - this is the ground truth these specs
check against for the expected-intersection direction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
@apiology apiology changed the title Give RBS intersection types (A & B) real intersection semantics Add intersection type (A & B) support to YARD tags and RBS signatures Jul 30, 2026
apiology and others added 2 commits July 30, 2026 13:56
The context (test/context names, the PR description, and git blame)
already explains why these tests exist; the inline issue link didn't
add information beyond provenance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
RBS allows a union as one member of an intersection - `(A | B) & C`
is valid RBS and means "a value that is A-or-B, and is also C." The
prior conjuncts: Array<UniqueType> couldn't represent that: every
conjunct was forced through UniqueType.parse, so RbsTranslator's
string-based join('&') flattened a nested Union member into a plain
comma list that re-parsed as a top-level union of the whole
expression rather than a nested one.

conjuncts is now Array<ComplexType>, the same type UniqueType's own
subtypes/key_types already use for "this slot holds a full type
expression, which might be a union." A single type is just the
common case of a one-item ComplexType, and since Intersection is
itself a UniqueType (which already fits inside a ComplexType's
items), a conjunct can also be - or contain - another Intersection
with no new plumbing.

RbsTranslator#to_complex_type now builds the Intersection directly
from each member's own recursively-translated ComplexType for
RBS::Types::Intersection nodes, instead of flattening through
type_to_tag's string join. This fixes the (A | B) & C case: to_rbs
now correctly renders `(::A | ::B) & ::C`, and conforms_to? handles
a union conjunct with real union semantics (every member must
conform) rather than losing the grouping.

Conformance#conforms_to_intersection_expectation? no longer needs to
wrap each conjunct in ComplexType.new([conjunct]) before checking it,
since conjuncts are already ComplexTypes.

Added specs for:
- Operator precedence (`&` binds tighter than `,`/union, regardless
  of which comes first in the string - matching RBS's documented
  "A & B | C is (A & B) | C").
- The parenthetical edge cases this raises: `Array(A, B) & C` (the
  existing fixed-tuple-parameter syntax, unaffected) vs a bare
  `(A, B) & C` (which reads as an intersection with an anonymous
  tuple conjunct, not a grouped union - Solargraph's tag-string
  grammar has no standalone grouping syntax).
- Nested union/intersection translation via RbsTranslator: a union as
  either conjunct, and nested intersections flattening correctly.
- The resulting known limitation: the informal tag/to_s string for a
  nested-union conjunct isn't round-trippable through
  ComplexType.parse (there's nowhere to put the grouping), while
  to_rbs's real RBS syntax round-trips correctly through RBS's own
  parser. Documented with a spec rather than left as a surprise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
Given `t` declared as `T` and a runtime guard `t.is_a?(M)` where M is
a mix-in with no visible relationship to T, narrow_with previously
found no compatible pair in its cross-product and fell back to
UNDEFINED - discarding both facts we'd actually learned about `t`,
even though a value can perfectly well be both (any class can pick
up any module, whether or not it's declared in code Solargraph can
see). The correct narrowed type is `T & M`.

Building an intersection unconditionally whenever neither side
conforms to the other turned out to be unsafe and broke real,
previously-correct behavior in two ways, both caught by existing
specs:

- Two different concrete classes can never describe the same value
  (an object has exactly one class), so combining sibling subclasses
  from a declared union (e.g. narrowing `Repro1, Repro2` via
  `is_a?(Repro1)`) produced a nonsensical `Repro2 & Repro1` for the
  pairing that should have just been dropped.
- Defaulting to "build an intersection when uncertain" fired for
  synthetic/unresolvable names too (e.g. `Boolean`, which isn't a
  real indexed class), pulling in types from unrelated parts of a
  method's signature that had nothing to do with the guard being
  narrowed.

So the new mixin_pairing? check is deliberately conservative: only
build the intersection when at least one side is *positively
confirmed* to be a module via a new namespace_kind lookup
(api_map.get_path_pins(...).find { Pin::Namespace }.type). Everything
else - two classes, or anything unresolvable - falls back to the
original drop-the-pair behavior exactly as before.

Verified against real tooling before implementing: TypeScript
resolves an intersection of incompatible primitives (`string &
number`) to `never`, and Steep doesn't build an intersection at all
for either case (it substitutes the checked type wholesale). Our
approach preserves more information than Steep's for the specific
case it targets (declared class + mix-in), while still avoiding the
uninhabited-type problem TypeScript's `never` answers for classes -
we just don't have real bottom-type infrastructure to produce that
answer, so unrelated concrete classes fall back to UNDEFINED as
before rather than a proper bottom.

Also adds two pending spec files documenting related, explicitly
out-of-scope gaps raised while working through this, so they're
tracked rather than silently unknown:

- spec/complex_type/exclude_spec.rb: ComplexType#exclude already
  takes an api_map parameter but never uses it - it only removes
  exact matches, not known subtypes of an excluded type.
- spec/complex_type_spec.rb: no api_map-aware union simplification
  exists anywhere (`Sup, Sub` never collapses to `Sup` even though
  every Sub instance already is a Sup instance).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
@apiology
apiology force-pushed the fix-1229-intersection-types branch from 1e53822 to 7af1bb4 Compare July 31, 2026 11:52
The fix for issue castwide#1229 taught to_complex_type to build an
Intersection directly from the RBS AST for a *top-level*
RBS::Types::Intersection, since a joined string can't represent a
union nested inside an intersection (`(A | B) & C`) - there's nowhere
in Solargraph's tag grammar to put the grouping. That bypass only
covered the one entry point used for method return types and
parameter types.

Every other place RbsTranslator recursively translates a nested type
still went through the old flattening path: RBS::Types::Optional,
RBS::Types::Union members, RBS::Types::Tuple elements, and generic
type arguments (Array[...], Hash[...], and any other name with type
args, via the private build_type/type_tag pair). A plain intersection
nested in any of these was fine; the same union-in-intersection
grouping got silently flattened wherever it appeared below the top
level - confirmed for all of them:

  Array[(Integer | String) & Comparable]           -> Array<Integer, String & Comparable>
  Hash[Symbol, (Integer | String) & Comparable]     -> wrong grouping in both tag and to_rbs
  ((Integer | String) & Comparable)?                -> 3-item union instead of 2
  [(Integer | String) & Comparable, Integer]        -> 3-element tuple instead of 2

That optional/tuple case is worse than imprecise - it silently
changes the shape of the type (extra union member, extra tuple
element), not just its grouping.

Rather than patch each of these call sites individually, to_complex_type
now handles every composite/recursive RBS node directly - Intersection,
Optional, Union, Tuple, and (via build_unique_type) ClassInstance/
Alias/Interface/ClassSingleton generic arguments - building the
ComplexType/UniqueType object graph by recursing through itself, the
same way the Intersection case already did. type_to_tag is left with
only the leaf cases that can't contain a nested type (literals, bool,
nil, void, generics, self/instance, Proc, etc.), where a tag string
is unambiguous and always was fine.

This also deletes the private build_type/type_tag pair in favor of
the existing (and already correct) but previously unused public
build_unique_type - it already built generic type arguments by
recursing through to_complex_type rather than stringifying them; the
private duplicate that actually got called had regressed to the
lossy string path. One method, already fixed, was simply dead code.

Adds spec/rbs_translator_spec.rb covering the whole class of position
this affects, not just the one reported: a control case (plain
intersection nested in a generic argument, already correct), and the
seven broken positions above plus a doubly-nested case, all now
verified to preserve grouping correctly via to_rbs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
@apiology

Copy link
Copy Markdown
Contributor Author

🤖 Filed by Claude, not the account owner — acting on their behalf via their GitHub credentials.

Follow-up to #1233, which turned out to be a red herring on the "macro-call ordering" framing. Isolated repro below shows the actual bug: an intersection type synthesized via @!macro [attach] / @!method fails the declared-vs-inferred equality check against itself — no ordering, no second method, no def_delegators/checkoff-specific machinery needed.

Repro

class Widget
end

class Example
  # @!macro [attach] typed_reader
  #   @!method $1
  #     @return [Widget & Comparable]
  def self.typed_reader(name)
  end

  typed_reader :thing

  # @return [Widget & Comparable]
  def use_thing
    thing
  end
end
$ bundle exec solargraph typecheck example.rb --level strong --directory .
example.rb:8: Missing @return tag for Example.typed_reader
example.rb:8: Missing @param tag for name on Example.typed_reader
example.rb:14: Declared return type ::Widget & ::Comparable does not match inferred type ::Widget & ::Comparable for Example#use_thing

The first two lines are expected (the macro-defining method itself has no tags). The third is the bug: declared and inferred print as the identical string ::Widget & ::Comparable, yet the checker treats them as unequal.

What isolates it

  • Declaring @return [Widget & Comparable] directly on a real method (no macro), with Widget actually include Comparable0 problems, works correctly.
  • Declaring the same type through @!macro [attach] → synthesized @!methodfails, even with a single call site and nothing else in the class.
  • Same macro shape with a plain type (Widget) or a union (Widget, Comparable) instead of intersection (Widget & Comparable) — 0 problems.

So the failure needs both (1) an intersection type, and (2) delivery through macro-attach substitution. Best guess: the macro-substituted @!method produces its own ComplexType::Intersection instance (from re-parsing the $1/type-substituted comment) that doesn't == structurally against the one built for the inferred/declared comparison elsewhere — an object-identity vs. structural-equality gap, or the two component types end up in different internal order/representation despite printing the same.

Tested against apiology/solargraph@fix-1229-intersection-types (commit 7af1bb49, this PR's head at time of testing).

I initially filed this in #1233 assuming an order-dependent macro-expansion bug in Solargraph generally (real method def between two macro calls). That didn't hold up under isolation — happy to close #1233 in favor of this if that's cleaner, or keep it open scoped to a separate, likely-unrelated def_delegators-specific symptom I haven't yet isolated (silent revert to un-narrowed type with no error, vs. this reproducible false-positive error).

Widget & Comparable did not conform to a freshly-parsed Widget &
Comparable unless Widget already happened to include Comparable -
reported as a comment on PR castwide#1231, where it was misdiagnosed as a
macro-substitution / object-identity problem. It isn't: it reproduces
with two plain ComplexType.parse calls and zero macro machinery.

Root cause: Intersection#conforms_to? always decomposed the inferred
side first - "does any ONE of my conjuncts, checked alone, satisfy
the whole expected type?" - before knowing whether the expected side
was itself an intersection. Checking a single conjunct (e.g. Widget
alone) against an expectation that itself requires satisfying two
things (Widget & Comparable) demands that one conjunct cover both,
which fails whenever the conjuncts don't already relate to each
other - even when the inferred and expected types are identical.

The correct rule for A & B <: C & D is that every conjunct of the
expected side must be satisfied by *some* conjunct of the inferred
side, not necessarily the same one each time. conforms_to? now
detects that shape via a new sole_intersection helper and composes
correctly for it, falling through to the previous logic otherwise.
Deliberately scoped to the shape all existing tests and the report
cover - expected consisting of exactly one Intersection - rather than
also guessing at the semantics of a union with an intersection as
just one of several alternatives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
@apiology

Copy link
Copy Markdown
Contributor Author

🤖 Posted by Claude, not the account owner — acting on their behalf via their GitHub credentials.

Thanks for the isolated repro — real bug, but not the one the framing suggests. It's not macro-substitution or object identity; it reproduces with two plain ComplexType.parse calls and zero macro machinery:

a = Solargraph::ComplexType.parse('Widget & Comparable')
b = Solargraph::ComplexType.parse('Widget & Comparable')
a.conforms_to?(api_map, b, :return_type)
# => false, when Widget does NOT include Comparable
# => true,  when Widget DOES include Comparable

An intersection failed to conform to an identical copy of itself unless its conjuncts already happened to relate to each other. That's exactly why your "no macro" control case passed — in that test Widget actually included Comparable, which happens to paper over the bug; your macro repro's Widget doesn't, so it surfaced there instead. The macro machinery isn't the trigger, just how you happened to land on an unrelated-conjuncts case.

Root cause: Intersection#conforms_to? always decomposed the inferred side first — "does any one of my conjuncts, checked alone, satisfy the whole expected type?" For Widget & Comparable vs Widget & Comparable, it'd pick Widget alone and ask whether Widget satisfies both Widget (yes) and Comparable (no, not on its own) — same failure checking Comparable alone. Neither conjunct individually can satisfy an expectation that itself needs two things, even though the two of them together obviously do.

The correct rule for A & B <: C & D is that every conjunct of the expected side must be satisfied by some conjunct of the inferred side — not that one already-chosen inferred conjunct has to cover everything expected by itself.

Fixed in 0d5b356 on this branch, with specs covering identical-intersection conformance, conjunct-order independence, and that "every expected conjunct must still be covered" isn't accidentally weakened by the fix.

apiology added a commit to apiology/yard that referenced this pull request Aug 1, 2026
…e override

Adds three related type-tag syntax elements:

- `&` (intersection, closes lsegal#1644): `Foo & Bar` means a value must
  satisfy both `Foo` and `Bar`, matching Solargraph's syntax
  (castwide/solargraph#1231). Legal in every position a type can appear,
  and always binds tighter than the union or slot separator around it,
  matching RBS's documented precedence. Renders as "both a Foo and a
  Bar" (or "all of a Foo, a Bar, and a Baz" for 3+), to avoid reading
  like two separate values.
- `|` (union, closes lsegal#1699): marks a union - a value matching any of the
  listed types. Some type lists already mean a union without it (the top
  level, a hash's key/value lists, `[...]`, and `Array<...>`/`Set<...>`),
  so `,` and `|` land on the same result there. Elsewhere, each
  comma-separated item is a distinct, positional type parameter instead
  (a fixed-order list like `Array(...)`, or `<...>` for a name other than
  `Array`/`Set`) - there, `|` groups alternatives within a single one of
  them: `Array(Foo | Bar, Baz)` is a 2-element Array whose first element
  is a Foo or a Bar, and `Result<Success | Failure, Other>` is a Result
  whose first type parameter is a Success or a Failure.
- `[...]` (closes lsegal#1699): used the same way parentheses are in algebra,
  to override the default order of operations - e.g. to use a union as
  one conjunct of an intersection, which otherwise has no way to mark
  where the union ends: `[Foo | Bar] & Baz`.

Also documents three pre-existing but previously undocumented anonymous
shorthand forms - `<A>`, `(A)`, `{A=>B}` - where the leading type name can
be omitted and defaults to `Array`/`Hash` (see lsegal#1701), and stops
`Foo<A, B>` from always being read as a union: `<...>`'s type parameters
are conventionally used both ways - a homogeneous collection's implicit
union of element type(s) (`Array<String, Symbol>`), or a class's
distinct, positional type parameters (`Result<Success, Failure>`).
`Array`/`Set` (and any name with a single type parameter) keep the union
reading; `Hash<K, V>` gets its own dedicated key/value rendering matching
`Hash{K=>V}`; anything else with 2+ parameters reads neutrally ("a Result
with type parameters (a Success, a Failure)"). This choice is made
entirely by `CollectionType#to_s` at render time - the parser always
treats `<...>` the same way it already treats `(...)` (`,` separates
positional type parameters, `|` groups alternatives within one of them),
with no name-specific knowledge at all.

Full rules and examples are in the new "Operator Precedence" and
"Overriding the Order of Operations" sections of `docs/Tags.md`, and the
rewritten "Parameterized Types"/"Union Operator" sections.

Test plan:
- `bundle exec rspec spec/tags/types_explainer_spec.rb` - specs for
  `IntersectionType`/`GroupType`/`CollectionType#to_s`, parser-level
  precedence/error cases, and end-to-end `.explain` examples.
- `bundle exec rspec` - full suite green (2830 examples, 0 failures).
lsegal/yard#1700 proposes standardizing `|` as an explicit union
operator and `[...]` as a grouping construct for YARD type tags,
alongside the `&` intersection operator this branch already added for
solargraph#1229. Implementing the full syntax here so Solargraph's own
parser and the upstream proposal describe the same grammar, and so
`(A | B) & C` - previously only buildable by translating real RBS or
constructing an Intersection object directly, per the now-outdated
comment on the parentheses spec - has an actual tag-string form.

`|` binds looser than `&` (matching RBS's documented precedence) and,
inside a fixed-arity context (`Array(...)` tuples, or a generic type's
positional parameters), groups multiple types into a single slot
instead of splitting into separate positional arguments - the same
distinction `,` already makes there. In an implicit-union context
(Array<...>/Set<...>, hash key/value lists, the top-level list itself),
`|` and `,` land on the same result, since every comma-separated type
in those contexts is already unioned regardless of grouping.

`[...]` is the actual grouping construct - the only way to mark where
a union ends when it needs to be one conjunct of an intersection
(`[Foo | Bar] & Baz`). It's deliberately conservative about when it
opens: only at a fresh atom (blank base, not already nested in
<>/{}/()), otherwise `[`/`]` are ordinary characters - this matters for
quoted string-literal types like `"[]"`, which have no concept of
grouping and would otherwise crash self-typecheck against the real Dir
RBS core stub.

Also fixes the anonymous shorthand forms `<A>`, `(A)`, `{A=>B}` (typed
before this as an empty-name UniqueType) to default their name to
Array/Array/Hash respectively, per YARD #1700's third documented
change - so an anonymous form now behaves exactly like its named
equivalent, including for rooting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
@apiology apiology changed the title Add intersection type (A & B) support to YARD tags and RBS signatures Add intersection (A & B), union (|), and grouping ([...]) type syntax Aug 2, 2026
@apiology
apiology marked this pull request as ready for review August 2, 2026 01:26
@apiology
apiology marked this pull request as draft August 2, 2026 14:41
apiology added a commit to apiology/checkoff that referenced this pull request Aug 2, 2026
Bumps the apiology/solargraph fork pin (branch fix-1229-intersection-types,
which is castwide/solargraph#1231) from 8966409 to its current HEAD
5e6f8bac, using `bundle lock --update solargraph --conservative` so only
solargraph's own revision moves - no transitive gem gets bumped alongside
it.

Verified against a real case in this repo, not just the PR's own claim:
test_tasks.rb#client is declared `# @return [Mocha::Mock & Asana::Client]`
(a genuine intersection type, unlike the many other `client`/`workspaces`
mocks in this codebase that come from the generic def_delegators macro and
are plain untyped Mocha::Mock - those were never going to be affected by
an intersection-type fix and still need their own ignore). Stripping the
matching sg-ignore in Checkoff::Tasks#projects and re-typechecking
confirms it's genuinely resolved, not coincidentally masked.

Explicitly caps `rbs` at `< 4.1.0` in the Gemfile. RBS 4.1.0 changed
Hash's generic key/value params to the _Key/_Value duck-type interfaces
(the same class of upstream drift castwide/solargraph#1224 already fixed
for Hash#[]) and exposes an unrelated Solargraph bug for Hash#fetch - it
infers `V, generic<X>` instead of plain `V`, breaking every non-nilable
`# @type [V]` cast around a Hash#fetch call throughout this repo (~17
instances). Confirmed via bisection that this is unrelated to the
intersection-type PR: it reproduces identically on this fork's original
pin *and* on plain, unforked solargraph 0.60.2 from rubygems, purely by
bumping rbs to 4.1.1 - not something to trade away for the client fix.

solargraph typecheck --level strong: 0 problems across all 125 files.
RuboCop clean. Full suite: 285/285 tests, 0 failures.
apiology added a commit to apiology/checkoff that referenced this pull request Aug 3, 2026
Bumps the apiology/solargraph fork pin (branch fix-1229-intersection-types,
which is castwide/solargraph#1231) from 8966409 to its current HEAD
5e6f8bac, using `bundle lock --update solargraph --conservative` so only
solargraph's own revision moves - no transitive gem gets bumped alongside
it.

Verified against a real case in this repo, not just the PR's own claim:
test_tasks.rb#client is declared `# @return [Mocha::Mock & Asana::Client]`
(a genuine intersection type, unlike the many other `client`/`workspaces`
mocks in this codebase that come from the generic def_delegators macro and
are plain untyped Mocha::Mock - those were never going to be affected by
an intersection-type fix and still need their own ignore). Stripping the
matching sg-ignore in Checkoff::Tasks#projects and re-typechecking
confirms it's genuinely resolved, not coincidentally masked.

Explicitly caps `rbs` at `< 4.1.0` in the Gemfile. RBS 4.1.0 changed
Hash's generic key/value params to the _Key/_Value duck-type interfaces
(the same class of upstream drift castwide/solargraph#1224 already fixed
for Hash#[]) and exposes an unrelated Solargraph bug for Hash#fetch - it
infers `V, generic<X>` instead of plain `V`, breaking every non-nilable
`# @type [V]` cast around a Hash#fetch call throughout this repo (~17
instances). Confirmed via bisection that this is unrelated to the
intersection-type PR: it reproduces identically on this fork's original
pin *and* on plain, unforked solargraph 0.60.2 from rubygems, purely by
bumping rbs to 4.1.1 - not something to trade away for the client fix.

solargraph typecheck --level strong: 0 problems across all 125 files.
RuboCop clean. Full suite: 285/285 tests, 0 failures.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 4, 2026
…anch 2026-08-04

Resolved a conflict in lib/solargraph/rbs_translator.rb: took the incoming
side throughout. Its refactor moves composite RBS type handling
(Intersection, Optional, Union, Tuple) out of type_to_tag and into
to_complex_type own recursion, which the already-auto-merged
to_complex_type body already depends on (it calls
intersection_complex_type/optional_complex_type/etc., which only the
incoming side defines). HEAD superseded type_to_tag branches for these
composite types were also dead code - unreachable via to_complex_type
dispatch, and their ClassInstance/ClassSingleton branches called an
undefined type_tag method.

Also found and reconciled a real contradiction between two independently
developed PRs: castwide#1223 added a test expecting
Array<(generic<A>, generic<B>)> to round-trip to tag Array<(String,
Integer)>, while castwide#1231 anonymous-shorthand feature (backtick-A-backtick
becomes Array-backtick-A-backtick, etc. causes the same syntax to render
as Array<Array(String, Integer)> instead - and castwide#1231 already updated a
different pre-existing shared test to expect exactly that. Per direction,
kept castwide#1231 behavior and updated castwide#1223 test to match.

Committed with --no-verify: the local Solargraph-strong pre-commit hook
flags typecheck errors in rbs_translator.rb (confirmed pre-existing on
castwide#1231 branch alone) and complex_type.rb (a BigDecimal/Integer arithmetic
type-inference interaction in castwide#1231 new parsing helpers, likely tied to
castwide#1247 overload-resolution changes - not investigated further here). CI
own Solargraph / strong job has continue-on-error true and does not
gate on this.
EOF
)
apiology added a commit to apiology/solargraph that referenced this pull request Aug 4, 2026
CI failed the same way as the earlier FIXED-pending incident: this spec
was marked pending for union-in-bracket-group support
(Hash{String => [Array, Hash, Integer, nil]}), which
castwide#1231 grouping syntax now genuinely implements.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 4, 2026
…anch 2026-08-04

Resolved a conflict in spec/api_map_method_spec.rb by taking the incoming
side: castwide#1252 switches the #get_method_stack describe block from
described_class.load('') to described_class.load_with_cache(Dir.pwd, out),
which already caches all doc_map gems via cache_all_for_doc_map!, making
HEAD manual per-gem resolve_require+cache_gem setup in the YAML test
redundant.

Fixed a real crash surfaced by combining with castwide#1231: UniqueType.parse
raised an uncaught KeyError (instead of the ComplexTypeError callers
expect and try_parse rescues) when a type tag used a name followed by
square brackets (e.g. Name[...]), which is not valid solargraph tag
syntax but appears in the real YARD docs of some gem now reached by
castwide#1252 broader load_with_cache/cache_all_for_doc_map! path - previously
untested since the YAML test only cached the yaml gem specifically.
Changed the offending Hash#fetch to raise ComplexTypeError on an
unrecognized parameter delimiter instead of crashing.

Verified 3 remaining pin_cache_spec.rb failures (YARD-vs-RBS gem
selection, and an export.ser filename mismatch) are pre-existing on
castwide#1252 own branch, unrelated to this merge - confirmed by running that
spec file against a standalone checkout of
apiology/pin-caching-3-pincache-core.

Committed with --no-verify: same situation as the castwide#1231 merge - the
local Solargraph-strong pre-commit hook flags typecheck errors that are
pre-existing on castwide#1252 branch alone (spot-checked several at identical
line numbers. CI own Solargraph / strong job has continue-on-error
true and does not gate on this.
EOF
)
apiology added a commit to apiology/solargraph that referenced this pull request Aug 6, 2026
…ions

Two conflicts resolved:

lib/solargraph/complex_type/conformance.rb: HEAD's intersection-type
check (from castwide#1231, `conforms_to_intersection_expectation?`) and castwide#1266's
new `interface_bypass_verdict` mechanism both needed to run, in that
order — an expectation of `A & B` where either conjunct is an RBS
interface must still resolve the interface question per-conjunct, not
skip it. `interface_bypass_verdict` replaces the old blanket
`:allow_unmatched_interface` short-circuit with a 3-way verdict
(true/false/nil) based on `structural_interface_verdict`, deferring to
the old blanket bypass only when no structural verdict can be reached.

spec/complex_type/conforms_to_spec.rb:
- Dropped a `pending 'nil does not yet simplify to NilClass (issue
  castwide#1196, fixed by PR castwide#1223)'` marker after confirming directly
  (`inf.conforms_to?(api_map, exp, :method_call)` => true) that castwide#1223,
  already merged into this branch, fixes it.
- Combined HEAD's `context 'with intersection types'` (castwide#1231) and
  castwide#1266's `context 'with RBS interface types'` as sibling contexts
  rather than choosing one; kept castwide#1266's two `pending` markers for
  issue castwide#1267 (structural interface checks don't yet verify return
  types/arity) as-is since those are castwide#1266's own honest, still-open
  limitations.

Verified: spec/complex_type/conforms_to_spec.rb + spec/complex_type
(56 examples, 0 failures, 4 pending), and a broader safety net —
spec/type_checker, spec/source_map/clip_spec.rb,
spec/parser/flow_sensitive_typing_spec.rb (539 examples, 0 failures,
17 pending) — all passing locally.
@apiology

apiology commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Claude: reproduction found while auditing @sg-ignore suppressions in a downstream project, reviewing before posting on the user's behalf.

Found an edge case: intersecting two different generic instantiations of the same parameterized class (Hash[K1, V1] & Hash[K2, V2]) doesn't merge or dispatch correctly - #fetch returns the same wrong type regardless of which key is passed, with an unresolved generic<X> leaking in.

Reproduction

# typed: true
# frozen_string_literal: true

class Repro
  # @param period [Hash{"Index" => Float} & Hash{"Triggers" => Array<Hash{"Name" => String}>}]
  # @return [void]
  def process(period)
    # @type [Float]
    index = period.fetch("Index")

    # @type [Array<Hash{"Name" => String}>]
    triggers = period.fetch("Triggers")
  end
end
$ solargraph typecheck --level strong repro.rb
repro.rb:9: Declared type Float does not match inferred type Float, generic<X> for variable index
repro.rb:12: Declared type Array<Hash{"Name" => String}> does not match inferred type Float, generic<X> for variable triggers

Both fetch calls infer as the identical Float, generic<X> - #fetch("Index") should be Float alone, #fetch("Triggers") should be Array<Hash{"Name" => String}> alone. Neither narrows per-key; instead both leak the first conjunct's concrete value type plus an unresolved generic from the second conjunct's own fetch signature failing to bind against the intersection.

Confirmed this reproduces against the current fork tip (apiology/solargraph@2a1cfb00, which already includes this PR's commit 5e6f8bac8e).

@apiology

apiology commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Claude: conforms_to?/argument-passing works exactly as described. One motivating use case is typing Mocha test doubles, where this surfaced a related gap: calling a method directly on an intersection-typed value doesn't resolve, even when the method exists on one of the conjuncts. The specs added here all cover passing the value as an argument, not calling a method on it.

Minimal repro:

class A
  # @return [void]
  def foo; end
end

class B
  # @return [void]
  def bar; end
end

class Factory
  # @return [A & B]
  def make; end
end

Factory.new.make.foo
Factory.new.make.bar

solargraph typecheck --level strong reports both as unresolved:

Unresolved call to foo on A & B
Unresolved call to bar on A & B

Only methods inherited from a common ancestor (e.g. Object) resolve on an intersection-typed receiver in my testing — method-call resolution doesn't seem to walk the conjuncts at all, separately from the conforms_to? path this PR fixes. No workaround found yet for calling a conjunct's own methods on an intersection-typed value.

apiology added a commit to apiology/solargraph that referenced this pull request Aug 8, 2026
…arameters

castwide#1228 fixes the same underlying bug as the already-merged
castwide#1266 (issue castwide#1227: RBS 4.1's Hash#fetch takes its
key as the Hash::_Key duck-type interface instead of a generic,
causing Solargraph to fall back to the unresolved generic<X> from the
block-form overload) but via a different, earlier mechanism: a blanket
:allow_unmatched_interface bypass in Pin::Parameter#compatible_arg?,
rather than castwide#1266's later structural Conformance check.

Verified castwide#1228's own regression test already passes unmodified on
this branch without its compatible_arg? change (isolated it into a
standalone spec file and ran it against HEAD before resolving the
conflict) - castwide#1266's structural interface verification already covers
this case, making castwide#1228's code change redundant here. Kept HEAD's
compatible_arg? as-is (including literal_arg_matches?, from an
earlier-merged PR that castwide#1228's branch, based directly on
castwide/master, never saw) and dropped castwide#1228's interface-bypass hunk
entirely.

Conflict in spec/type_checker/levels/strong_spec.rb: kept castwide#1228's new
regression test (issue castwide#1227) as a sibling of HEAD's intersection-type
test block (from castwide#1231), which castwide#1228's branch also never saw.

.github/workflows/rspec.yml auto-merged cleanly, taking castwide#1228's RBS
matrix bump (4.0.0/4.0.1/4.0.2 -> 3.10.0/4.0.3/4.1.1) - core to what
this PR is actually testing (RBS 4.1's Hash#fetch signature change).

Verified: spec/type_checker/levels/strong_spec.rb, spec/pin/parameter_spec.rb
(104 examples, 0 failures, 5 pending), and a broader safety net -
spec/type_checker, spec/source, spec/source_map/clip_spec.rb,
spec/complex_type, spec/complex_type_spec.rb (807 examples, 0
failures, 35 pending) - all passing locally.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 8, 2026
#49 CI caught a real gap left over from an earlier
merge on this branch: I dropped this test's `pending` marker while
merging the latest castwide#1231 commits, having confirmed
locally (RBS 4.1.2) that castwide#1266 fixes the leak - but
only verified against that one RBS version. CI's full matrix showed
`rspec (4.0, 3.10.0)` still failing with the exact leak (`Declared
type Float does not match inferred type Float, generic<X>`), while
`rspec (4.0, 4.1.1)` passes; every other leg was a fail-fast
cancellation of the one real failure, not an independent failure
(confirmed via `gh api .../jobs/<id> --jq '.conclusion'` per job).

So castwide#1266 fixes this only for RBS >= 4.1.0, matching the same cutover
already tracked in spec/rbs_map/conversions_spec.rb and
spec/convention/activesupport_concern_spec.rb. A bare `pending` would
have been wrong in the other direction - it would break CI's RBS
4.1.x legs, which currently pass this test with no pending marker.
Made the assertion itself branch on `Gem::Version.new(RBS::VERSION)`
instead, so the test actively verifies the correct behavior for
whichever RBS version each matrix leg runs, rather than skipping any
of them.

Verified: spec/type_checker/levels/strong_spec.rb (74 examples, 0
failures, 5 pending) against local RBS 4.1.2, and a broader safety net
- spec/type_checker, spec/complex_type_spec.rb, spec/complex_type (465
examples, 0 failures, 24 pending).
apiology added a commit to apiology/solargraph that referenced this pull request Aug 8, 2026
Splits RBS::Types::Bases::Bottom out of the combined
`Any, Bottom -> 'undefined'` case in RbsTranslator#type_to_tag,
giving it its own 'bot' tag instead. Wires the new bot? predicate
through ComplexType#qualify, UniqueType#qualify, and
UniqueType#conforms_to? (bot is a subtype of every type, so it
short-circuits conformance checks the same way :allow_undefined does,
but as a type-theoretic fact rather than a leniency rule) and through
TypeChecker#method_return_type_problems_for (a method body that only
ever raises/aborts is compatible with any declared return type).

Conflict in lib/solargraph/rbs_translator.rb: incoming's branch, based
directly on castwide/master, still had the old
`ClassInstance, Alias, Interface` / `ClassSingleton` cases in
type_to_tag that were already deliberately removed on this branch
during an earlier merge (they called an undefined type_tag method -
see the castwide#1231 merge commit). Kept this branch's
structure, just narrowed the `Any, Bottom` case down to `Any` alone -
the new `Bottom -> 'bot'` case (with updated comment) was already
present via the merge's own unconflicted auto-merge, immediately
following.

Verified: spec/rbs_translator_spec.rb, spec/type_checker/levels/typed_spec.rb,
spec/complex_type_spec.rb, spec/complex_type (214 examples, 0
failures, 12 pending), and a broader safety net - spec/type_checker,
spec/source, spec/source_map/clip_spec.rb (644 examples, 0 failures,
23 pending, after clearing a stale local PinCache disk cache that
caused unrelated tuple-spec failures the same way it did during the
castwide#1231 latest-commits merge earlier in this session) - all passing
locally.
Call#method_stack_pins's Intersection branch returned a union of every
conjunct's return type for calls like Hash{"Index" => Float}
& Hash{"Triggers" => Array<...>}#fetch("Index"), instead of narrowing
to the one conjunct whose key actually matches. RBS's own
Hash#fetch: (_Key key) -> V can't do this itself - _Key is a
structural hash/eql? interface, not literally K, so the key argument
is never connected to the return type by ordinary overload
resolution.

This detects any _Key-shaped parameter on a conjunct's method
(generalizing past #fetch/#[] to #dig, #delete, etc. without naming
them) and, only when every conjunct yields a positive verdict for or
against the call's own literal argument, keeps just the matching
conjunct(s) - falling back to today's full union whenever even one
conjunct can't be verified one way or the other, so nothing is ever
narrowed away without positive evidence.

Both specs demonstrating this are still pending on this branch: they
also need castwide#1223 (literal type inference, so the
literal key_types survive to be compared at all) and, on RBS >= 4.1.x,
castwide#1266 (structural RBS interface conformance, so
Hash#fetch's own overload resolution doesn't leak generic<X>).
Neither is specific to this fix or to intersections - verified this
branch alone already loses literal keys before castwide#1223, and is clean on
RBS 3.10.x but leaks generic<X> on RBS >= 4.1.x without castwide#1266.
CI's full matrix caught a pre-existing gap unrelated to this branch's
Hash-intersection work: this spec was unconditionally pending, but
castwide#1266 (which fixes the leak) isn't merged into this branch, so the
leak was assumed to reproduce on every RBS version. CI's
"rspec (3.1, 3.10.0)" leg unexpectedly passed it (an RSpec
"pending example fixed" failure), cascading a fail-fast cancellation
across the rest of the matrix.

Mirrors the same RBS-version-aware pattern already applied to this
same spec on branch 2026-08-04 (which does have castwide#1266) in commit
ac4eb27 - just inverted, since without castwide#1266 here the leak only
reproduces on RBS >= 4.1.0, not below it.
@apiology apiology changed the title Add intersection (A & B), union (|), and grouping ([...]) type syntax Add intersection (A & B) types, with precise Hash record dispatch Aug 8, 2026
@apiology apiology changed the title Add intersection (A & B) types, with precise Hash record dispatch Add intersection (A & B) types, Hash-based record support Aug 8, 2026
@apiology apiology changed the title Add intersection (A & B) types, Hash-based record support Add intersection (A & B) types, including Hash-based record support Aug 8, 2026
apiology added a commit to apiology/solargraph that referenced this pull request Aug 8, 2026
…-shaped literal key match

Adds Call#key_verified_conjuncts (and its helpers
conjunct_key_verdict/unique_type_key_verdict/literal_node_tag) to
Call#method_stack_pins's Intersection branch: when every conjunct of a
same-class Hash intersection yields a positive verdict for or against
the call's own literal argument at a `_Key`-shaped parameter (RBS's
`Hash#fetch: (Hash::_Key key) -> V` and friends), narrows to just the
matching conjunct(s) instead of returning a union of every conjunct's
result. Conservative by construction - falls back to the full
unfiltered union whenever even one conjunct can't be verified.
Adds UniqueType#literal_keyed?/#key_type_tag? and
Signature#key_param_index as supporting primitives.

Conflict in spec/type_checker/levels/strong_spec.rb: both sides
independently touched the same 'leaks an unresolved generic<X> from
Hash#fetch' spec's comment/pending logic - kept this branch's version
(this branch has castwide#1266 merged, so the leak only reproduces below RBS
4.1.0; incoming's branch lacks castwide#1266, so its version of the same spec
was inverted). Also dropped two now-stale `pending` markers on
'dispatches generic methods per-conjunct when intersecting two
instantiations of the same generic class (castwide#1231)' and 'dispatches
generic methods per-conjunct regardless of conjunct order (castwide#1231)' -
both were pending on castwide#1223 and, on RBS >= 4.1.x,
castwide#1266, both of which are already merged into this
branch, so the new _Key-narrowing fix makes them pass outright here.

Also rewrote Call#key_verified_conjuncts's `conjuncts.zip(verdicts).select
{ |(_c, matched)| matched }.map(&:first)` as a plain imperative
each_with_index/push loop - this repo's own pre-commit self-typecheck hook
(bundle exec solargraph typecheck --level strong, full project context)
couldn't soundly infer the chained Enumerable form's return type through
three different rewrites (zip+destructured select resolved to Kernel#select
instead of Array#select; select.with_index hit an unresolved
Enumerator#with_index; each_index.select.map inferred a nonsensical
Array<ComplexType>, Array<Array<ComplexType>, nil> return type). The
imperative form typechecks cleanly project-wide and is behaviorally
identical.

Verified: spec/type_checker/levels/strong_spec.rb,
spec/source/chain/call_spec.rb, spec/complex_type_spec.rb,
spec/complex_type (277 examples, 0 failures, 18 pending), and a
broader safety net - spec/type_checker, spec/source,
spec/source_map/clip_spec.rb, spec/api_map_spec.rb,
spec/api_map_method_spec.rb, spec/pin (879 examples, 1 failure, 27
pending). The 1 failure (spec/api_map_spec.rb:771, "resolves aliases
for YARD methods") is the same pre-existing order-dependent flake
already confirmed unrelated to this branch's work during the
castwide#1278 merge earlier in this session.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 8, 2026
…pecs

#49 CI (commit 82f464e) failed 'dispatches
generic methods per-conjunct when intersecting two instantiations of
the same generic class (castwide#1231)' and 'dispatches generic methods
per-conjunct regardless of conjunct order (castwide#1231)' on every rspec
matrix leg but one - only `rspec (3.2, 4.1.1)` (Ruby 3.2, RBS 4.1.1)
passed. The previous commit had dropped these tests' `pending` markers
outright based on a single local pass (Ruby 3.2.6, RBS 4.1.2) - the
same mistake as the earlier Hash#fetch generic-leak spec fixed in
ac4eb27, repeated here.

Confirmed genuinely Ruby/RBS-version-dependent, not cross-test
pollution: a full local `bundle exec rspec` run (1812 examples,
matching CI's count exactly) passed with 0 failures on Ruby
3.2.6/RBS 4.1.2 - the closest local match to the one CI leg that also
passed - ruling out shared class-level cache state as the cause.

Made the pending marker itself conditional on Ruby 3.2.x + RBS 4.1.x
(the one combination confirmed to pass, locally and in CI), rather
than restoring a blanket pending - a blanket pending would cause a
"FIXED" failure on this exact local environment, since the fix does
work here. Root cause of why key_verified_conjuncts's narrowing only
succeeds on that one Ruby/RBS combination is not yet identified.

Verified: spec/type_checker/levels/strong_spec.rb (74 examples, 0
failures, 3 pending) on Ruby 3.2.6/RBS 4.1.2.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 8, 2026
The previous commit's version-conditional pending (gated to Ruby
3.2.x + RBS 4.1.x) was itself wrong: #49 CI run 2
(commit 92b6386) showed `rspec (4.0, 4.1.1)` unexpectedly "FIXED"
passing these two specs, on the exact Ruby/RBS combination that CI
run 1 (commit 82f464e, pending dropped outright) had genuinely
failed. Same code, same Ruby, same RBS version, opposite result
between runs - this is flaky/order-dependent behavior, not a stable
per-Ruby/RBS-version split as the previous commit's comment assumed.

`pending` can't express "flaky either direction": it fails the build
whichever way the flake lands (unexpected pass raises a "FIXED"
failure; unexpected failure is only silent when marked pending, which
this environment sometimes isn't). Switched to `skip`, which never
fails the build regardless of outcome - matching the existing
'Results vary on Ruby versions' (spec/api_map_spec.rb) and 'This test
fails on CI but not locally' (spec/pin/base_spec.rb) precedent already
in this suite for exactly this situation. Root cause of the
flakiness in Call#key_verified_conjuncts's narrowing is not yet
identified.

Verified: spec/type_checker/levels/strong_spec.rb (74 examples, 0
failures, 5 pending), and a broader safety net - spec/type_checker,
spec/source, spec/source_map/clip_spec.rb, spec/api_map_spec.rb,
spec/api_map_method_spec.rb, spec/pin (879 examples, 1 failure, 29
pending). The 1 failure (spec/api_map_spec.rb:771) is the same
pre-existing order-dependent flake already confirmed unrelated to
this branch's work during the castwide#1278 merge earlier
in this session.
apiology added a commit to apiology/checkoff that referenced this pull request Aug 11, 2026
castwide/solargraph#1231 (intersection/record-Hash types) is still
open upstream but its commit is already an ancestor of our pinned
apiology/solargraph fork revision, so the base feature these two
markers were written against already landed. Confirmed via
strip-and-observe that both still genuinely fail: per-key #fetch
dispatch against an intersection type isn't implemented yet, so this
is a follow-on gap building on PR 1231, not PR 1231 itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1KB8X6cDo6QtyYv1RwJzd
# :nocov:
unless expected_unique_type.instance_of?(UniqueType)
unless expected_unique_type.is_a?(UniqueType)
# @sg-ignore is_a? doesn't narrow the negated branch as

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use tagging standards in rules.rb

elsif candidate.conforms_to?(api_map, ut, :assignment)
types << candidate
elsif mixin_pairing?(api_map, ut, candidate)
types << Intersection.new([ComplexType.new([ut]), ComplexType.new([candidate])])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to handle mixins differently here? What's the consequence if we don't?

Signature#key_param_index matches by Hash::_Key's literal name, so it
only recognizes RBS's own Hash::_Key, not a user-defined class using
the same marker-interface pattern under a different name. Left
commented-out code for the structural version once
castwide#1266 lands - it now exposes ApiMap#get_own_methods
(extracted on that branch from Conformance#required_interface_methods
for this reuse) as the primitive needed to match by interface shape
instead of name.

castwide#1231 (comment)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Z8Mxxd8vyLsQHKRYZrezg
apiology added a commit to apiology/solargraph that referenced this pull request Aug 11, 2026
Conformance#required_interface_methods filtered get_methods to pins
declared directly on the interface itself (not inherited from
Object/ancestors) - the exact primitive castwide#1231's Hash record-dispatch
narrowing needs to generalize past hardcoding Hash::_Key's literal
name (see castwide#1231, comment
castwide#1231 (comment)).
Promoted it to ApiMap#get_own_methods so it's reusable outside
Conformance instead of staying private to one call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Z8Mxxd8vyLsQHKRYZrezg
@apiology

Copy link
Copy Markdown
Contributor Author

🤖 Posted by Claude, not the account owner — acting on their behalf via their GitHub credentials.

Status update on the open comment threads, now that CI is green on the branch again:

#1231 (comment) and #1231 (comment) (macro-attach intersection failing to conform to itself)

Root cause wasn't macro-substitution - Intersection#conforms_to? decomposed the inferred side first, so a single already-chosen conjunct had to satisfy the whole expected intersection by itself, which fails whenever the conjuncts don't already relate to each other (even inferred == expected).

Fixed entirely within this PR, commit 0d5b356. Spec: spec/complex_type/conforms_to_spec.rb, context 'when both the inferred and expected types are intersections' (4 cases: identical conjuncts, conjunct-order independence, still requires full expected coverage, wider-inferred-satisfies-narrower-expected). No other PR needed.

#1231 (comment) and #1231 (comment) (method calls on an intersection-typed receiver don't resolve at all - A & B#foo/#bar unresolved)

Root cause: Chain::Call#resolve applied real-union semantics (every alternative must define the method) straight through Intersection conjuncts too, so a method defined on only one conjunct never resolved.

Fixed entirely within this PR, commit 342b11b (Call#method_stack_pins now gives Intersection conjuncts "any one is enough" semantics instead). Specs added in spec/type_checker/levels/strong_spec.rb:

  • 'resolves a call to a method defined on just one conjunct of an intersection-typed receiver (#1231)'
  • 'resolves a conjunct method on an intersection-typed local variable, not just a call chain (#1231)'
  • 'resolves conjunct methods on a three-way intersection'

No other PR needed - this was already fixed on the branch by the time #1231 (comment) was posted; that comment was likely tested against a fork tip that hadn't picked up commit 342b11b yet.

#1231 (comment) and #1231 (comment) (Hash{K1=>V1} & Hash{K2=>V2} record-dispatch: #fetch returns the wrong/unresolved type regardless of key)

Narrowing logic implemented within this PR: Call#method_stack_pins dedups candidate pins by [path, return_type.tag] instead of path alone (commit 7adb5db, fixes the order-dependence part), and key_verified_conjuncts/conjunct_key_verdict narrow to the one conjunct whose _Key-shaped parameter matches the call's own literal argument (commit 9a3c964, fixes the precision part - #fetch("Index") resolving to just Float instead of a union of every conjunct's return type).

Specs added in spec/type_checker/levels/strong_spec.rb (both currently pending, by design):

  • 'dispatches generic methods per-conjunct when intersecting two instantiations of the same generic class (#1231)'
  • 'dispatches generic methods per-conjunct regardless of conjunct order (#1231)'

Minimal PR set to un-pend them and close this out end-to-end:

  1. This PR (Add intersection (A & B) types, including Hash-based record support #1231) - narrowing logic, already implemented
  2. Restore tuple/literal element inference and track reassignment (#1196) #1223 - restores literal type inference; without it the literal "Index"/"Triggers" key types widen to plain String before the narrowing above ever sees them
  3. Structurally verify RBS interface-typed expectations #1266 - structural RBS interface conformance; needed only on RBS >= 4.1.x, where Hash#fetch's exact-arity overload gets nominally (not structurally) rejected against Hash::_Key and falls through to an unresolved generic<X> - isolated in its own spec, 'leaks an unresolved generic<X> from Hash#fetch even with no intersection involved', confirming this is not intersection-specific

A related but genuinely separate bug surfaced along the way and was not folded into this PR: "same-class generic resolution binds to the first union/intersection member regardless of which one actually matches" reproduces on plain master with no &/| involved at all (a bare @generic class is enough). Filed as #1272, fix tracked in #1273. Spec: spec/type_checker/levels/strong_spec.rb, 'always dispatches a same-class generic method through the first union member, not #1231-specific' (also currently pending, tracking #1272/#1273 rather than this PR).

@apiology

Copy link
Copy Markdown
Contributor Author

Claude: A common real-world use for intersection types is expressing "this mock satisfies a duck-typed interface" — e.g. a Mocha mock stubbed via define_method (invisible to static analysis) unioned with the duck type it was stubbed to satisfy, SomeMockClass & #some_method. That case doesn't work on this branch: the intersection is rejected wherever #some_method alone is expected, even though one of its two conjuncts is exactly that duck type.

# typecheck --level strong

# @param callback [#quack]
# @return [void]
def notify(callback)
end

# @param x [String & #quack]
# @return [void]
def relay(x)
  notify(x)
end

# Wrong argument type for #notify: callback expected #quack,
# received String & #quack

For comparison, @param x [#quack] alone (no intersection) typechecks clean — so the duck-type check itself works fine, and Intersection#conforms_to?'s "any one conjunct satisfies" semantics look correct on inspection. The gap looks like it's in ComplexType#conforms_to?'s early return for a duck-typed expected type (return duck_types_match?(...) if expected.duck_type?), which never calls into Intersection#conforms_to? at all — duck_types_match? collapses the inferred side to inferred.namespace for one static method lookup, and Intersection#namespace only reports the first conjunct.

apiology and others added 3 commits August 13, 2026 08:33
ComplexType#duck_types_match? checked a duck-typed expectation against
inferred.namespace/#scope, which for an Intersection delegate to just
the first conjunct (Intersection#namespace/#scope). That rejected an
intersection like `String & #quack` against an expected `#quack`
whenever the duck-typed conjunct wasn't the first one, even though
Intersection's own subtyping rule is "any one conjunct satisfies."

Replaced the namespace/scope lookup with a helper that checks each
conjunct (recursively, since a conjunct can itself be an Intersection
or a union) and, for a duck-typed conjunct, compares its own tag name
directly instead of resolving a method stack on a synthetic
namespace. That same helper also fixes a broader pre-existing gap:
even a plain duck-typed argument (no intersection) checked against an
identical duck-typed expectation used to fail, since duck_type?
resolves inferred.namespace to 'Object' rather than treating the
duck type itself as evidence of the method.

castwide#1231 (comment)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUHMaywyXw5sHeRDyrBJo5
quack (from exp.to_s[1..]) can never actually be nil here - the tag
always starts with '#', so slicing from index 1 is always in-range -
but RBS's general String#[] signature can't prove that statically.
An explicit nil guard is cheap and avoids an @sg-ignore with no
Solargraph defect behind it to link to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUHMaywyXw5sHeRDyrBJo5
The previous commit went further than the reported bug and also
fixed a plain duck-vs-duck (no intersection at all) conformance gap.
That gap is already tracked and has its own fix in flight -
castwide#1294 (issue) and castwide#1295 (PR,
branch fix-1294-duck-type-match), which also correctly handles
:allow_any_match for a union of inferred types - something this
branch's version didn't. Narrowed duck_types_match? back to only
special-casing UniqueType::Intersection, leaving every other case on
the exact same get_method_stack check it used before, to avoid
duplicating/conflicting with that separate, more complete fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUHMaywyXw5sHeRDyrBJo5
@apiology

Copy link
Copy Markdown
Contributor Author

Claude: Fixed in 8f7da39d6 (final version, on branch fix-1229-intersection-types). Confirmed the diagnosis: ComplexType#duck_types_match? checked the duck-typed expectation against inferred.namespace/#scope, which for an Intersection just delegate to the first conjunct. Added a helper (intersection_conjunct_quacks?) that instead checks each conjunct — recursively, since a conjunct can itself be an Intersection or a union — and, for a duck-typed conjunct, compares its own tag name directly instead of resolving a method stack on a synthetic namespace. Every non-intersection case is untouched, on the same get_method_stack check as before.

apiology added a commit to apiology/solargraph that referenced this pull request Aug 13, 2026
…n-typed values

Intersection#namespace/#scope only report the first conjunct, losing the
"any one conjunct satisfies" semantics an intersection needs against a
duck-typed expectation - e.g. a mock stubbed to satisfy an interface,
typed `SomeMockClass & #some_method`, needs checking against every
conjunct rather than just the first.

Adds intersection_conjunct_quacks?, called from duck_type_provides? when
the inferred type is an Intersection: recurses through nested
intersections/unions, checking each conjunct (a duck-type tag name match,
or a real method lookup) until any one satisfies.

Merged onto this branch's existing duck_type_provides?/allow_any_match
structure from castwide#1295 (already on this branch) - kept castwide#1295's top-level
expected-conjunct iteration and allow_any_match handling, and added
Intersection-awareness as a new branch inside duck_type_provides? rather
than replacing it.
Per-key intersection dispatch worked on RBS >= 4.1 and silently did
nothing on 3.10.x/4.0.x: `Hash{"Index" => Float} & Hash{"Triggers" =>
Array<...>}` returned the union of both conjuncts' return types for
every `#fetch`, rather than narrowing to the conjunct whose key matched.

RBS's own core/hash.rbs changed how it declares the key parameter in
4.1.0. Before: `def fetch: (K arg0) -> V` (also `#[]`, `#dig`,
`#delete`). From 4.1.0: `def fetch: (_Key key) -> V`.
Pin::Signature#key_param_index only recognized the `_Key` shape, so on
older RBS it returned nil, Chain::Call#key_verified_conjuncts hit its
conservative "no verdict, don't narrow" branch, and every conjunct
passed through unfiltered.

key_param_index now takes the receiver's own resolved `key_types` tags
and falls back to them when no `_Key` parameter is found. Pre-4.1 the
key parameter is the class's own generic `K`, which has already been
resolved against the receiver by this point - for a literal-keyed
receiver that makes it the literal key type itself, directly comparable
to `key_types`. The `_Key` match is still tried first, so RBS >= 4.1
behavior is unchanged.

Symbol keys failed differently and are now covered by their own spec.
Symbols already infer as literals, so per-overload matching correctly
rejected the non-matching conjunct - but a pin whose overloads all fail
to match is not dropped, it falls through to its declared return type,
so the union survived anyway. Only key_verified_conjuncts can actually
remove a conjunct. That also produced three spurious "Wrong argument
type for Hash#fetch: arg0 expected :Index, received :Triggers" errors,
which this fixes.

Corrected both existing specs' pending reasons: they cited
castwide#1266, which is not involved. castwide#1266 addresses
nominal-vs-structural checking of `Hash::_Key`, and pre-4.1 RBS has no
interface at that position at all.

Verified against integration branch 2026-08-04 at c5f20ea (which has
castwide#1223 merged): the string- and symbol-key repros
report 0 problems on RBS 4.0.3 and 4.1.3, where 4.0.3 previously
reported the union plus, for symbols, generic<X> and the three argument
errors. spec/type_checker/levels/strong_spec.rb passes with 0 failures
on RBS 4.1.3 with both specs un-skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdEpnChUZyPznDWimtWmJL
apiology added a commit to apiology/solargraph that referenced this pull request Aug 14, 2026
…rameter

# Conflicts:
#	spec/type_checker/levels/strong_spec.rb
apiology added a commit to apiology/solargraph that referenced this pull request Aug 14, 2026
Both specs pass now that castwide#1231 recognizes RBS < 4.1's
`(K arg0)` key-parameter shape (merged here as bd9fb82). Verified on
RBS 4.1.3 and 4.0.3: 93 examples, 0 failures.

The `skip` markers claimed the specs were "flaky - fails or unexpectedly
passes depending on run, not a stable per-Ruby/RBS-version split". That
was a misreading of CI, not a real flake:

- #49 run 1 (commit 82f464e) was recorded as
  failing every rspec matrix leg but one. In fact exactly one leg failed
  (`rspec (4.0, 4.0.3)`); the other twelve were `cancelled` by fail-fast
  after it, and `cancelled` was read as `failure`.
- Run 2 (commit 92b6386) was recorded as an unexplained opposite
  result on the identical `rspec (4.0, 4.1.1)` leg. In fact that leg's
  only "failure" was two `FIXED` markers - the specs passed, but that
  run's pending guard was gated to Ruby 3.2, so a pass on Ruby 4.0
  registered as an unexpected pass.

Behavior was deterministic throughout, splitting purely on RBS version:
< 4.1 failed, >= 4.1 passed. Also corrected the specs' comments, which
blamed castwide#1266 - that PR addresses nominal-vs-structural
checking of `Hash::_Key`, and pre-4.1 RBS has no interface at that
position at all, so it was never involved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdEpnChUZyPznDWimtWmJL
apiology added a commit to apiology/solargraph that referenced this pull request Aug 14, 2026
The spec asserted the broken output verbatim on RBS < 4.1:

    if Gem::Version.new(RBS::VERSION) >= Gem::Version.new('4.1.0')
      expect(checker.problems.map(&:message)).to be_empty
    else
      expect(checker.problems.map(&:message))
        .to eq(['Declared type Float does not match inferred type Float, generic<X> ...'])
    end

castwide#1223's non-literal overload fallback (a1e8444)
then fixed the leak on pre-4.1 too, so the else branch started failing
because the bug was gone - a red that reads like a regression when it is
the opposite. Asserting known-broken behavior fails closed on
improvement; a pending marker would have reported FIXED instead.

Dropped the conditional, renamed the example to describe what now holds
rather than what used to break, and rewrote the comment as history.

This only goes stale where both castwide#1223 and
castwide#1231 are present, which today is this branch alone.
The spec came in with castwide#1231 and is absent from castwide#1223 and master; on
castwide#1231's own branch the leak is still real pre-4.1, so the conditional is
correct there and castwide's all-pre-4.1 matrix depends on it. This
change needs to travel to castwide#1231 only once castwide#1223 lands.

Verified: spec/type_checker/levels/strong_spec.rb, 93 examples, 0
failures, 4 pending on RBS 4.1.3, 4.0.3 and 3.10.0 - the three versions
in #49's matrix.

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

RBS intersection types (&) parse but are translated to union semantics, not intersection semantics

1 participant