Add intersection (A & B), union (|), and grouping ([...]) type syntax - #1231
Add intersection (A & B), union (|), and grouping ([...]) type syntax#1231apiology wants to merge 17 commits into
Conversation
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
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
1e53822 to
7af1bb4
Compare
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
|
🤖 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 Reproclass 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
endThe 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 What isolates it
So the failure needs both (1) an intersection type, and (2) delivery through macro-attach substitution. Best guess: the macro-substituted Tested against 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
|
🤖 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 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 ComparableAn 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 Root cause: The correct rule for 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. |
…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
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.
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.
…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 )
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.
…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 )
…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.
|
Claude: reproduction found while auditing Found an edge case: intersecting two different generic instantiations of the same parameterized class ( 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
endBoth Confirmed this reproduces against the current fork tip ( |
|
Claude: 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
Only methods inherited from a common ancestor (e.g. |
Covers the two unfixed issues reported on the PR:
- generic dispatch through Hash{K1=>V1} & Hash{K2=>V2} leaks
generic<X> and returns the same wrong type regardless of which
key is fetched
(castwide#1231 (comment))
- calling a method defined on only one conjunct of an
intersection-typed receiver reports Unresolved call even though
the conjunct has it
(castwide#1231 (comment))
Marked pending since neither is fixed yet; they will flip to
failing-unexpectedly once someone lands the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZme4n9mb8hGU8mrw94NAV
…eview Adds positive and negative cases around the two bugs already covered, plus one broader scope finding surfaced while writing them: - Hash#fetch's generic<X> leak (issuecomment-5207523909) reproduces with no intersection at all, so it's not castwide#1231-specific; added as its own pending spec, outside the intersection context, so the fix here isn't expected to close it. - Conjunct order flip on the Hash intersection: dispatch always uses the first conjunct's fetch signature regardless of which key is passed, not just "some" wrong type. - Positive: a non-generic method shared by both conjuncts of a same-class intersection (Hash#size) already resolves correctly - isolates the leak to generic-parameter binding specifically. - Positive: a method inherited from a common ancestor (Object#to_s) already resolves on a different-class intersection receiver, matching what issuecomment-5207595119 described as already working. - Negative: the unresolved-conjunct-method gap (issuecomment-5207595119) also reproduces on a plain intersection-typed local variable, not just a method-return-value call chain, and extends to a three-way intersection. All still pending/passing as appropriate; 66 examples, 0 failures, 8 pending; rubocop clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZme4n9mb8hGU8mrw94NAV
Traced the generic<X> leak to Pin::Parameter#compatible_arg? checking Hash::_Key (an ad-hoc RBS interface) nominally instead of structurally against the String argument, rejecting the correct fetch overload. castwide#1266 already fixes this class of bug (structural RBS interface-typed expectations) on a different branch, but is not on master or this branch yet - leaving this pending rather than duplicating that work here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZme4n9mb8hGU8mrw94NAV
Confirmed by running the same repros against a branch with castwide#1266 already merged: - the two-conjunct Hash#fetch dispatch specs are blocked on castwide#1266 for the generic<X> leak, but will still fail afterward on a separate, unfixed first-conjunct-only dispatch bug - the three method-call-on-intersection-receiver specs reproduce identically with or without castwide#1266 - unrelated code path (Chain::Call#resolve, not Pin::Parameter#compatible_arg?) So merging castwide#1266 will not silently flip any of these to passing; each still needs its own dispatch/resolution fix. Still 66 examples, 0 failures, 8 pending; rubocop clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZme4n9mb8hGU8mrw94NAV
…1231) Chain::Call#resolve applied union call-semantics (every alternative must define the method, unless loose_unions) to every unique type produced by binder.each_unique_type - and that flattens straight through an Intersection conjunct-by-conjunct, so `A & B#foo` (foo on A only) required foo on *both* A and B and came back unresolved. Split the walk into two levels: method_pins_for_binder applies the existing strict union semantics across a ComplexType top-level (each alternative must resolve), while method_stack_pins handles a single unique type and gives Intersection conjuncts the opposite, correct rule - any one conjunct defining the method is enough (A & B <: A, A & B <: B) - recursing per conjunct since RBS allows a union inside an intersection member, e.g. (A | B) & C. Flips the 3 method-resolution specs added earlier from pending to passing; the 3 Hash#fetch dispatch specs (blocked on castwide#1266 and/or the separate first-conjunct-only bug) are untouched by this, as expected - this fix does not touch compatible_arg? or per-conjunct #fetch dispatch at all. Verified: full suite 1686 examples, 1 pre-existing unrelated failure (spec/pin/method_spec.rb:516, reproduces identically on unmodified HEAD), 0 regressions; rubocop clean (pre-existing offenses at line 170 untouched); self-typecheck --level strong on call.rb clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZme4n9mb8hGU8mrw94NAV
Found while verifying the intersection method-call-resolution fix (342b11b) did not regress real union semantics: loose_unions: false should deny a call when only one member of a plain two-class union defines it, but does not. The existing strict-mode spec only covers this rule via nil-stripping (nullable?/without_nil), never the general two-real-class case. Confirmed pre-existing - reproduces identically on unmodified HEAD, before 342b11b. Filed as its own GitHub issue for discussion: castwide#1270 covers a different, unrelated bug found along the way (Chain#nullable? nil leak); this union bug is tracked via task #2 for a pre-merge discussion, not yet filed separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZme4n9mb8hGU8mrw94NAV
A plain union of two Hash instantiations (Hash{...}, Hash{...}, no
& at all) shows the identical always-first-member dispatch bug as
the same-class intersection specs already here. Verified with a
minimal @Generic Box class (no Hash, no literal keys, no castwide#1266) that
this also reproduces byte-identically on unmodified
castwide/solargraph master (8fda633) - confirms the root cause is
Call#inferred_pins binding a class generic against the whole
union/intersection self_type instead of per-member, unrelated to
anything castwide#1231 or castwide#1266 introduced.
Intent: fix this as its own PR against master so the Hash
intersection specs inherit it regardless of merge order, rather
than stacking this branch on top of a dependency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZme4n9mb8hGU8mrw94NAV
|
Claude: drafted while auditing Confirming the distinct-methods-per-conjunct gap reported above still reproduces unchanged after pulling in #1273 (unrelated fix, doesn't touch this path): 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
class Caller
def go
Factory.new.make.foo
Factory.new.make.bar
end
endSame result before and after #1273 - method lookup isn't walking to the second conjunct at all when the two conjuncts define different methods. |
|
Claude: drafted while auditing A second, distinct sub-case from the one above: when both conjuncts are the same generic class with different type arguments, method lookup does find a pin - #1273 changed what happens next, but not to a correct result. Reproductionclass 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
endBefore #1273: After #1273: Both |
Applied the same fix as castwide#1273 (order-dependent generic resolution for same-class union receivers) to Call#method_stack_pins Intersection branch: both conjunct dedup points now key on [path, return_type.tag] instead of path alone, so a same-class intersection (e.g. Hash{K1=>V1} & Hash{K2=>V2}) no longer silently drops every conjunct but the first. This makes Hash#fetch dispatch order-independent and sound (returns the union of every conjunct plausible result), but not yet precise - true per-key narrowing needs the literal Hash key ("Index" vs "Triggers") to survive Pin::Parameter#typify, and UniqueType#qualify unconditionally widens literal types to their base class. Attempted gating that on a corrected #literal? check (the existing one is unconditionally disabled by castwide#1201, for an unrelated array/tuple-inference reason) but reverted it: the same code path is load-bearing for other tested behavior (RBS `NilClass#to_s: () -> ""` widening to String, true/false -> Boolean consolidation), which broke under the naive fix (spec/rbs_map/core_map_spec.rb:102,114 and spec/parser/flow_sensitive_typing_spec.rb:644). A real fix needs qualify/transform to distinguish a key_types position from a general return-type position, which is a larger change than this commit attempts. Updated the two affected pending specs to describe the current, accurate remaining gap (union-not-precise-narrowing + castwide#1266) instead of the now-fixed order-dependence. Verified: full suite 1688 examples, 1 pre-existing unrelated failure, 0 regressions; rubocop clean (pre-existing offenses untouched). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZme4n9mb8hGU8mrw94NAV
Pulls in 8 new upstream commits: a fix for method-call resolution on intersection-typed receivers (an Intersection conjunct only needs one conjunct to define the method, unlike a union where every alternative must), a fix for order-dependent Hash intersection dispatch, and several pending-spec/documentation commits (including two that document the Hash#fetch generic leak already fixed by castwide#1266 on this branch). Conflict in lib/solargraph/source/chain/call.rb, in two parts: - Chain::Call#resolve's inline union-only pin lookup (each_unique_type + get_method_stack) is replaced by the incoming branch's method_pins_for_binder, which generalizes it to also handle intersections (via a new private method_stack_pins helper) - took the incoming version entirely, since it's a strict superset. - The private-methods section had HEAD's match_overload_type (castwide#1247) and narrowed_call_pin (castwide#1258) on one side and the incoming method_pins_for_binder/method_stack_pins pair on the other; all four are independent and still called from unconflicted parts of the file, so kept all four as sibling private methods. Also dropped a `pending 'blocked on castwide#1266 ...'` marker on spec/type_checker/levels/strong_spec.rb's Hash#fetch generic-leak test: castwide#1266 (structural RBS interface-typed expectation checks), already merged into this branch, fixes exactly what the test's own comment predicted - confirmed via "Expected pending ... to fail. No error was raised." Investigated an apparent regression in spec/source_map/clip_spec.rb (11 tuple-related failures, all returning "undefined") surfaced by the post-merge broader safety-net run: traced it to ComplexType#qualify failing to resolve Solargraph::Fills::Tuple via api_map.qualify, root caused to a stale local PinCache disk cache left over from earlier in this session (PinCache.work_dir keys off Solargraph::VERSION's branch-derived dev string, which doesn't change within a branch, so a cache built before this merge can persist and mask/corrupt later results). Clearing ~/.cache/solargraph/ruby-3.2.6/rbs-4.1.2/solargraph-* made all 11 failures disappear - confirmed not a real regression by diffing behavior against a clean detached checkout of the pre-merge commit with the same (then also cleared) cache. Verified: spec/source/chain/call_spec.rb, spec/type_checker/levels/strong_spec.rb, spec/complex_type/conforms_to_spec.rb (159 examples, 0 failures, 10 pending), and a broader safety net - spec/type_checker, spec/source, spec/source_map/clip_spec.rb, spec/complex_type_spec.rb (799 examples, 0 failures, 33 pending) - all passing locally with a clean cache.
Summary
Adds real intersection type (
A & B) support to Solargraph, usable in bothplain YARD tags (
@param/@return/@type) and inline RBS signatures(
#: () -> (A & B)). Also adds the|union operator and[...]groupingbrackets, matching lsegal/yard#1700.
Fixes #1229
Root cause
ComplexTypehad no representation for intersections - only comma-separatedunions - so
A & Bbehaved like the union(A, B): assignable only whereevery member independently matched, instead of assignable wherever any
one member matches (
A & B <: AandA & B <: B).What changed
ComplexType::UniqueType::Intersection, withconforms_to?correct onboth sides (any one conjunct as inferred; every conjunct as expected,
including intersection-vs-intersection).
ComplexType.parserecognizes 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/Hashinstead of parsing empty.RbsTranslatorbuilds every composite RBS type (Intersection,Optional,Union,Tuple, generic args) as an object graph instead ofjoining strings, so nested unions inside intersections round-trip
correctly in both directions.
intersect_with/intersection_return_typetonarrow_with/narrowed_return_typeto avoid a naming collision with realintersections;
narrow_withnow builds anIntersectioninstead ofdiscarding a mix-in narrowing when one side is a confirmed module.
Verified
Mocha::Mock & Asana::Resources::Project) passessolargraph typecheck --level strong; a plainMocha::Mockstill fails.TypeScript/Steep narrowing behavior before implementing the mix-in fix.
rubocop,rbs validate, and self-typecheck(624 problems before and after - zero new) all clean; only the
pre-existing
spec/pin/method_spec.rb:516failure remains, whichreproduces identically on unmodified
master.🤖 Generated with Claude Code
https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN