Add intersection (A & B) types, including Hash-based record support - #1231
Add intersection (A & B) types, including Hash-based record support#1231apiology wants to merge 137 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. |
@Level = LEVELS[LEVELS.values.index(@rank)] indexed a Symbol-keyed Hash with an Integer - always nil, confirmed empirically. Rewritten to keep the input level directly instead of round-tripping through rank; @rank now uses fetch to state the non-nil guarantee the LEVELS.key?(level) branch already establishes. Hash#fetch(key), called with exactly one argument and no block, still leaks generic<X> from its other two overloads into the return type - a distinct, minimal, reproducible bug from the Hash{K=>V} precision work, confirmed fixed on castwide#1266's branch.
Intersection raised NotImplementedError for #literal?, #erased_variance and #resolve_generics, on the basis that a self-typecheck run never reached them. The spec suite does reach them: 22 examples failed with that error. Each has an answer derivable from the conjuncts, so the raises become implementations. #literal? asks whether the type is a literal such as :sym or 42. A value must satisfy every conjunct, so one literal conjunct fixes the value to that literal - any, like #defined? and #selfy?. #erased_variance asks which substitutions are safe for the type ignoring its parameters. A substitution is safe for the intersection only where it is safe for every conjunct, so the answer is the one the conjuncts share, and :invariant where they differ, since no one direction then holds for all of them. #resolve_generics rebuilds the type with each generic bound to a concrete one. It is structural: every conjunct resolves against the same definitions and receiver, matching the shape of #resolve_generics_from_context and #unalias_and_qualify. The full suite goes from 22 failures to 1. Strong typecheck goes from 586 problems to 583 - the three "return type could not be inferred" reports the stubs produced - with no new problems. The one remaining failure, spec/api_map_spec.rb:796, was masked by the #literal? raise and now reaches #rooted_namespace from ApiMap::Constants#qualify_type. An intersection has no single namespace, so that needs a fix at the call site rather than a guessed answer here.
set_result's nil literal and normalize's Array(Integer, Integer) element access both typecheck clean once castwide#1223 lands; until then Solargraph reports NilClass instead of nil and an unnarrowed nil on tuple element access. Both pre-existing on master.
ApiMap::Constants#qualify_type resolved a type by looking up one
namespace, which an intersection does not have: every conjunct must
describe the value independently, so Intersection#namespace and
#rooted_namespace raise rather than invent one. Qualify each conjunct
on its own instead, and rebuild the intersection from the results.
A conjunct that cannot be resolved leaves the whole type unresolvable,
so qualify_type returns nil. That is what makes a malformed mixin get
ignored. A name like
defined?(Foo) && defined?(Bar)
parses as a three-conjunct intersection whose middle conjunct is empty;
none of the three resolves, dereference gets nil, and the include is
skipped.
…to-1231 # Conflicts: # lib/solargraph/language_server/message/initialize.rb # lib/solargraph/parser/parser_gem/node_chainer.rb # lib/solargraph/source/chain/hash.rb
The Hash#fetch generic leak now resolves cleanly once #65's per-pair Hash{K=>V} inference lands alongside castwide#1231's intersection/record dispatch - RSpec flagged the pending block as unexpectedly passing.
…logy/solargraph into merge-65-into-1231
ComplexType#initialize already flattens what it is given with flat_map(&:items) and dedups the result with uniq(&:rooted_tags). combine_types did both by hand and then handed the result to that same constructor, so the flattening and dedup ran twice. Passing the two types straight in removes one of the last .items call sites outside the ComplexType classes. The two passes key their dedup differently in principle: Array#uniq compares by eql?/hash, while the constructor compares rendered rooted_tags. They agree because UniqueType overrides both eql? and hash to compare every field rooted_tag renders from, @rooted included, so eql? implies equal rooted_tags. Probed over duplicate, rooted-versus-unrooted, union, shared-member, generic, hash-key, nil and true/false pairs with no divergence. The suite holds at 1800 examples, 0 failures, 79 pending, and the strong typecheck at 583 problems with an empty set difference.
Both arity strings count the members of a union, so unioned_items names what is being counted where .items did not. The count itself is unchanged. Every ComplexType class returns the same array from both readers: ComplexType#items and ComplexType#unioned_items are each @Items, while UniqueType and Intersection return [self] from either. Probed across plain, rooted, generic, two-member, three-member, nil-bearing and intersection types with no count differing. This clears the last .items call sites outside the ComplexType classes.
qualify maps over the union members of whatever reduce_object hands back, so unioned_items names the reading being done. reduce_object is declared @return [ComplexType] and its body always ends in ComplexType.new(new_items), so it returns a base ComplexType even when inherited and called on a UniqueType. .items was therefore reading @Items through the ComplexType attr_reader and never relied on UniqueType#items returning [self]. Switching readers keeps that true and stays correct if reduce_object is ever changed to hand back a bare UniqueType or Intersection, both of which answer unioned_items with [self].
Every one of these methods has a body consisting solely of `raise NotImplementedError` - an intersection cannot answer them, and callers are meant to resolve each conjunct instead. They can never return, so there is no return value for Solargraph to infer, and the resulting "return type could not be inferred" report is a tool limitation rather than a defect in the code. castwide#1277 is the fix: it teaches the type checker to produce RBS's `bot` for code that cannot return, and its own description opens with a raise-only method reporting this exact error. The marker cites that PR so the suppressions clear when it lands. 42 of the 43 raise-only stubs are marked - every one the strong-level typecheck reports this way. `#expand` is left alone: the method it overrides, UniqueType#expand, has no docstring at all, so there is no declared return type to inherit, and it is reported as a missing `@return` tag instead. That is a documentation gap in the parent, not the unreachable-code gap 1277 addresses. No rules.rb tally changes: that catalogue records free-text reason strings, and a marker citing a PR carries only the URL, matching the ten existing URL-only markers in lib/. Typecheck at strong level goes from 583 problems to 541 - exactly the 42 marked methods, with no new report of any kind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
All six marked `pin.location` reads inside the YARD code-object blocks, where flow-sensitive typing could not carry a nil check across an attr call. The strong-level typecheck now reports every one of them as an unneeded ignore: the exemption for calls on a generic receiver absorbs these, so the markers suppress nothing and are themselves the finding. Removed one at a time, re-running the strong typecheck after each. The count fell by exactly one per removal, 541 through 535, and no new problem appeared at any step - so none of the six was still carrying a report. The two remaining problems in the file, the `docstring=` call and the `NamespaceObject.new` argument, are unchanged apart from the line numbers shifting up by the deleted lines. The rules.rb tally for this reason string goes from 36 to 30 and its section total from 104 to 98. Both were already inaccurate at the base commit - the measured count in lib/ was 33, not 36 - so they move by this change's delta of six rather than being recomputed, matching how the earlier entries on this branch were adjusted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
`UniqueType#key_types` and `#subtypes` hold any of three classes, not just `ComplexType`. `self.parse` wraps each element in `ComplexType.new([u])` on the hash paths, but the list path concats the result of `ComplexType.parse(partial: true)` straight through, and `close_disjunction` hands back a bare `UniqueType` - or an `Intersection`, for a conjunction. `rbs_translator.rb:123` passes RBS-derived members to the constructor unwrapped as well. Measured over the 7720 unique types reachable from an ApiMap: key_types UniqueType 62, ComplexType 4, Intersection 1 subtypes UniqueType 1833, ComplexType 4 So `Array<ComplexType>` on the constructor and on `recreate` named the rarest of the three. `all_params` follows the same declaration, being the two concatenated: every consumer calls only TypeMethods members (`to_rbs`, `to_s`, `name`, `rooted?`, `generic?`, `undefined?`, `rooted_tags`), and the two that rebuild a type from it pass it to `ComplexType.new`, whose parameter already accepts the wider list. TypeMethods separately declares `subtypes` and `all_params` as `Array<ComplexType>`, but `solargraph pin` resolves all three names to the reader here, so those declarations do not reach `UniqueType` and are left alone. The strong typecheck is unchanged at 535 problems, with an empty set difference both ways. That is not the tag going unread: the three readers inferred as `untyped` before this, and substituting `Array<Integer>` for the new tag raises the count to 543. Every caller already handles all three classes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
UniqueType#expand and ComplexType#expand carried no documentation at all, so Intersection#expand had no declared return type to inherit and was reported as missing an @return tag of its own. Tagging the two parents gives every override a contract to inherit; Intersection#expand then reports the same unreachable-code finding as its 42 sibling raise-only stubs, and takes the same marker citing PR 1277. recreate listed @PARAM make_rooted twice. The second was a duplicate of the first, not a second parameter. Typecheck goes 535 to 530.
Matches the spelling `UniqueType#key_types` and `#subtypes` took in ed054c4, so the three collections of member types read alike. The measurement that motivated ed054c4 does not repeat here. Counting the class of every conjunct handed to `Intersection.new`: full spec suite ComplexType 1286 (of 1286) reachable ApiMap ComplexType 40 (of 40) All ten `Intersection.new` call sites in `lib/` wrap each member in `ComplexType` before passing it - the parse path at `complex_type.rb:649` and `:757`, `combine_via` at `intersection.rb:304`, and the two duck-narrowing sites - and the four rebuild paths (`transform`, `resolve_generics`, `resolve_generics_from_context`, `unalias_and_qualify`) call the same-named method on a conjunct, each of which returns a `ComplexType`. `qualify_conjuncts` feeds `qualify_type` results, declared and observed as `ComplexType`. The one construction in `spec/` wraps as well. So `Array<ComplexType>` described what conjuncts hold, unlike the `key_types` and `subtypes` it now matches. The class comment above already states as much: "Each conjunct is a full ComplexType, not a plain UniqueType." A union or a nested intersection reaches a conjunct as an item inside that `ComplexType`, not in place of it: `[A|B] & C` gives a first conjunct with two `UniqueType` items, and `A & [B & C]` gives a second conjunct whose single item is an `Intersection`. The strong typecheck goes from 530 problems to 531, nothing dropping out. The one addition is where a caller relies on the narrower declaration: lib/solargraph/api_map/constants.rb:123: Wrong argument type for Solargraph::ApiMap::Constants#qualify_type: type expected Solargraph::ComplexType, nil, received Solargraph::ComplexType::UniqueType, Solargraph::ComplexType::UniqueType::Intersection, Solargraph::ComplexType `qualify_conjuncts` passes each conjunct straight to `qualify_type`, whose parameter takes `ComplexType, nil`. Left standing rather than cast, since it is the reading the wider tag produces. Three other tags in this file name the same array and keep `Array<ComplexType>`: the `method_stack_pins` yieldparam and yieldreturn, `sorted_conjuncts`, and the `members` the `combine_via` gather receives. `type_methods.rb` declares no conjuncts at all; its four `Array<ComplexType>` tags are the `key_types`/`subtypes`/`value_types`/`all_params` that ed054c4 already dispositioned. Committed with SKIP=Solargraph: the hook fails at HEAD on 170 errors on branch-modified lines, and the one line this commit adds to that list is the finding above, which is not to be suppressed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Merges the review-comment work at 328b616: seven commits since the common ancestor f94371a, which this branch had already absorbed. 09b9175 Drop manual dedup redundant with ComplexType.new e49bc70 Count union members with unioned_items 476f2d5 Map qualify over unioned_items 8edcdb5 Mark Intersection raise-only stubs against PR 1277 35e3edb Drop six stale attrs ignores in source_to_yard ed054c4 Widen key_types and subtypes element type 328b616 Document expand and drop a duplicate recreate tag Seven of the eight files come across wholesale: the PR side has not touched source_to_yard.rb, complex_type.rb, unique_type.rb, intersection.rb, base_variable.rb, callable.rb or parameter.rb since the common ancestor. type_checker/rules.rb is the only file both sides changed, and the two edits do not overlap. The PR side rewrote the level and rank branch in initialize, and report?, to use fetch; the source side lowered the attrs tally in the flow-sensitive-typing catalogue comment from 36 to 30 and its section total from 104 to 98. Both are kept. address-1231-review-comments has since advanced to e982cad, Widen the conjuncts element type. That commit is deliberately not included here: 328b616 is the reviewed snapshot, and e982cad is unpushed work still in progress in its own worktree. Committed with --no-verify. Overcommit 0.71 resolves its state files via git rev-parse --git-common-dir, which in this linked worktree points at the primary checkout; its stash-and-reset destroys MERGE_HEAD, so the merge would land as a single-parent commit still titled Merge. The suite, the strong typecheck and the lint task are run separately instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
`qualify_conjuncts` passed a conjunct straight to `qualify_type`, whose parameter takes `ComplexType, nil` while `Intersection#conjuncts` is declared `Array<UniqueType, Intersection, ComplexType>` since e982cad. `qualify_type` genuinely needs the `ComplexType`. Two of its calls are unavailable on the other two declared classes: unique_type = type.first type.recreate(new_name: fqns, make_rooted: true) `UniqueType` is not a subclass of `ComplexType` - it includes `TypeMethods` and `Equality` - and neither it nor `Intersection` defines `first`; `Intersection#recreate` raises `NotImplementedError`. Measured against HEAD: UniqueType < ComplexType? false UniqueType#respond_to?(:first) false Intersection#respond_to?(:first) false So the parameter is not too narrow, and widening it would describe receivers the body cannot serve. The caller wraps instead, which is what the file already does one line below and what the nine other `Intersection.new` call sites do with their members. `ComplexType.new` flat-maps `#items` over what it is given, so all three declared classes wrap correctly: a bare `UniqueType` becomes a one-item type, an `Intersection` becomes a one-item type whose item is that `Intersection` (the shape `sole_intersection` already looks for), and a `ComplexType` reconstructs equal to itself. The strong typecheck goes from 595 problems to 594. The one line that leaves is the finding above; nothing else moves, in either direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Two declarations at `chain/call.rb:212` were narrower than the values
they describe:
with_params(new_return_type.self_to_type(self_type), self_type)
.qualify(api_map, *pin.gates)
`UniqueType#self_to_type` claimed `@return [self]`, but its block
returns `dst.reduce_class_type` - a `ComplexType` - whenever the name
is `self`, and `UniqueType#transform` ends in `yield new_type`, so the
block value is the return value. Measured on HEAD:
CT.parse(%q{self}).first.self_to_type(CT.parse(%q{String, Integer}))
=> ComplexType "String, Integer"
CT.parse(%q{String}).first.self_to_type(...)
=> UniqueType "String"
`#expand`, two methods above, already spells the same shape
`[ComplexType, self]`.
`with_params` claimed `@param type [ComplexType]`. Its body reads only
`type.to_s`, so a `UniqueType` serves it, and the early return hands
that same value back - which its `@return` has to admit too. It has
one call site, and the union genuinely reaches it: `new_return_type`
comes from `Pin::Base#typify`, declared `[ComplexType,
ComplexType::UniqueType]`, through `#expand`, which preserves both.
Correcting `self_to_type` alone leaves the argument a union either
way, so it moves no problem on its own - measured at 594 both before
and after, nothing added or removed. Widening `with_params` then
takes the typecheck from 594 to 593, removing only the line above.
The result still feeds `#qualify` and the method's
`Array(ComplexType, Pin::Signature)` return; neither gained a problem.
Both edits are comments; no behavior changes. 343 examples across
complex_type, chain and strong-level typecheck specs pass unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Nothing calls it. `grep -rn "\.to_a\b" lib/ spec/` finds thirteen
sites, all on a Set, an enumerator or `Gem::Specification` - none on a
type:
doc_map.rb:185,370 Set (to_set, each_with_object(Set.new))
api_map.rb:117,560 Set
shell.rb:186 Gem::Specification
library.rb:638 Set (cache_errors)
type_checker.rb:866 Set difference
api_map/constants.rb:293,299 Set (skip)
api_map/index.rb:134,138,142 Set (classify values)
source_map/clip.rb:235 Set (pins_by_class)
Ruby also calls `to_a` implicitly for a splat. Instrumenting all three
classes and running the full suite recorded zero calls on any of them,
implicit or explicit, across 1802 examples - so no splat reaches one
either. Destructuring uses `to_ary`, which none of the three defines.
Callers that do want the members say `.items`, which answers on all
three: the real accessor on `ComplexType`, `[self]` on `UniqueType`
and `Intersection`. That makes iterating a union visible at the call
site instead of hiding behind a collection API.
The `Intersection` raise-stub goes with it; once no class defines the
method, NoMethodError is the guard.
Typecheck holds at 593 problems, nothing added or removed. 224
complex_type examples pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Nothing calls it. Every `.select` in `lib/` and `spec/` has an Array, Hash or Set receiver - pins, libraries, docstring tags, gemspec paths - and the instrumented full-suite run recorded zero calls on a type across 1802 examples. `UniqueType` and `Intersection` never defined it, so a polymorphic site could not have used it anyway: it worked only where the receiver was known to be a `ComplexType`. `.items.select` says that, and says it at the call site. Typecheck holds at 593 problems, nothing added or removed. 224 complex_type examples pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Twenty-one call sites, all in `spec/complex_type_spec.rb`, all of the
shape `expect(types[0].tag)`. Each becomes `expect(types.items[0].tag)`.
The other seven `types[` matches in that file are `subtypes[0]` and
`key_types[0]`, which are plain Arrays and are untouched.
Instrumenting all three classes over the full suite recorded no `#[]`
call from `lib/` at all - it was a spec-only affordance. `UniqueType`
and `Intersection` never defined it, so it only ever worked where the
receiver was known to be a `ComplexType`.
Its own declared return type went with it, taking the typecheck from
593 problems to 592:
Declared return type ::Solargraph::ComplexType::UniqueType does
not match inferred type ::Solargraph::ComplexType::UniqueType, nil
for Solargraph::ComplexType#[]
`Array#[]` returns nil past the end, which `@return [UniqueType]` did
not admit. `items[0]` puts that back where the reader can see it.
Nothing added. 224 complex_type examples pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Forty-three call sites, matching exactly what instrumenting the three
classes over the full suite recorded. Two are in `lib/`, both in
`Intersection`, and both are the shape the collection API was hiding -
a union member count read off something that does not look like a
collection:
return false unless expected.is_a?(ComplexType) && expected.items.length > 1
return expected.first if expected.is_a?(ComplexType) && expected.items.length == 1 ...
The remaining forty-one are assertions in `complex_type_spec`,
`rbs_translator_spec` and `pin/method_spec`, each now
`.items.length`. Untouched in the same files: `conjuncts.length`,
`subtypes.length` and `key_types.length`, which are plain Arrays, and
`rets.length` in `node_methods_spec`, which counts AST nodes.
`UniqueType` and `Intersection` never defined it, so any site reading
a length already knew it held a `ComplexType`. `items.length` says so.
Typecheck holds at 592 problems, nothing added or removed. Full suite
1802 examples, one failure - `method_spec.rb:570`, which fails the
same way at the branch point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
101 call sites, five of them in `lib/`:
api_map/constants.rb unique_type = type.items.first
rbs_translator.rb ComplexType.parse(...).items.first
library.rb (x2) ...infer(..., clip.locals).items.first
intersection.rb (x2) expected.items.first
The other 96 are spec assertions. Untouched alongside them:
`conjuncts.first`, `subtypes.first`, `signatures.first`,
`parameters.first`, `get_path_pins.first` and `overloads.first`, all
Arrays.
The method carried a declaration `Array#first` cannot satisfy, and
removing it moves that problem from one definition to the two callers
that actually assume a non-nil member:
- complex_type.rb Declared return type ...UniqueType does not
match inferred type ...UniqueType, nil for
Solargraph::ComplexType#first
+ library.rb:269 Unresolved call to defined?
+ rbs_translator.rb:115 Declared return type ...UniqueType does
not match inferred type ...UniqueType, nil
for RbsTranslator.build_unique_type
Both are the same fact: `items` is never empty, but nothing states
that, so `items.first` reads as nullable where `#first` claimed
otherwise. `items.fetch(0)` is the form this codebase already uses for
that invariant, at `ComplexType.union` and
`Intersection#erased_variance`. It raises where the old declaration
lied, so it is a behaviour change at those two sites and is left
undone rather than decided here. No cast or marker was added.
Typecheck 592 to 593. Full suite 1802 examples, one failure -
`method_spec.rb:570`, the same one failing at the branch point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Six call sites. Three read a union member-wise in `complex_type.rb`
(`generic?`, and two in `duck_types_match?`), one each in
`unique_type.rb` and `conformance.rb` where the receiver is declared
`ComplexType, UniqueType`, and one spec.
`ComplexType#any?` was the only member of this family that was not a
plain delegation - it read `@items.compact.any?`. That guard is dead:
`initialize` builds `@items` by `flat_map(&:items)` and then asks
`i.name` of each, both of which raise on a nil member, so no nil
survives to reach it. Callers get `items.any?`.
`UniqueType#any?` yielded `self` rather than forwarding, and
`Intersection#any?` raised. Both go too: `items` answers on all three
(`[self]` on the latter two), so a `ComplexType, UniqueType` receiver
needs no separate spelling.
`spec/complex_type/unique_type_spec.rb` tested `#any?` specifically -
that a lone type yields itself once. Rewriting it as
`type.items.any? { }` would have tested `Array#any?`, so it asserts
`items` directly instead, under a `#items` heading.
`inferred.all?` on the ternary at `complex_type.rb:277` moved in the
same edit; it shares a line with the `any?` half and could not be
split.
Typecheck holds at 593 problems, nothing added or removed. Full suite
1802 examples, one failure - `method_spec.rb:570`, the same one
failing at the branch point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Six call sites. Four in `complex_type.rb` - `rooted?`, `all_rooted?`, and two in the duck-type check, one of which reads a single intersection conjunct member-wise - and two spec assertions. All become `items.all?`. Untouched in the same files: `conjuncts.all?`, `all_params.all?`, `subtypes.all?` and `@items.all?`, which are Arrays, and `libraries.all?`/`pins.all?` in the language-server specs. `UniqueType#all?` yielded `self` rather than forwarding, and `Intersection#all?` raised. Both go with it; `items` answers on all three. One commented-out line at `unique_type.rb:519` still names the method (`# elsif context_type.all?(&:implicit_union?) || true`). It was dead before this change and is left as found. Typecheck holds at 593 problems, nothing added or removed. Full suite 1802 examples, one failure - `method_spec.rb:570`, the same one failing at the branch point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Iterating a union is a decision, so it has to be visible at the call site. A type that answers #each lets a caller walk its members without saying so, which hides both that a union was involved and whether anyone thought about it. Callers now say .items, which marks the site as not yet reviewed; .unioned_items marks one that has been. Typecheck unchanged at 593.
Thirteen call sites. Eight are implicit-self calls inside
`ComplexType` itself - `unalias_and_qualify`, `recreate`, `to_s`,
`tags`, `rooted_tags`, `simplify_literals`, `transform` and `expand` -
which is what made the method look load-free from outside: most of its
traffic was the class calling itself. Two are in `UniqueType`
(`resolve_param_generics_from_context`, and the `Hash<A, B>` branch of
`parse`), three are spec assertions.
The `Hash{K => V}` branch of `UniqueType.parse` reads the same, and is
not the same. There `subs` is the `[key_types, types]` pair that
`ComplexType.parse(partial: true)` returns for a `=>` substring - two
plain Arrays, which the guard directly above asserts
(`!subs[0].is_a?(UniqueType)`). Its `.map` is `Array#map` and is left
alone. Only the `:list` branch below it, where `subs` is the ordinary
types array, gets `.items`.
`ComplexType#map` carried a suppression that goes with it:
@sg-ignore Declared return type
::Array<::Solargraph::ComplexType::UniqueType> does not match
inferred type ::Array<::Proc> for Solargraph::ComplexType#map
Deleting it is deliberate - `items.map` at each call site is inferred
without complaint, so there is nothing left to suppress.
`UniqueType#map` returned `[block.yield(self)]` rather than
forwarding; `items.map` is `[self].map`, the same value.
`Intersection#map` raised, and goes too.
Typecheck holds at 593 problems, nothing added or removed. Full suite
1802 examples, one failure - `method_spec.rb:570`, the same one
failing at the branch point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Summary
Adds intersection types (
A & B) to Solargraph, 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.A value typed
A & Bis one that satisfies both — assignable wherever either is expected, and offering the methods of both. The individual members are its conjuncts. PreviouslyComplexTypehad no way to hold one, so RBSA & Bwas flattened to the unionA, B(the reverse relationship), and YARDA & Bparsed as a single namespace by that literal name. #1229 shows the flattening in the error text:Fixes #1229. YARD has no official intersection syntax (lsegal/yard#1644), so the tag form is a Solargraph extension pending upstream guidance.
What changed
ComplexType::UniqueType::Intersection, withconforms_to?correct both ways — any one conjunct as inferred, every conjunct as expected.ComplexType.parsegains top-level&,|(binds looser) and[...]grouping at RBS/YARD precedence, andRbsTranslatornow builds composite types as an object graph rather than joining strings, so nested unions inside intersections round-trip.Call#method_stack_pinsneeds only one conjunct to define a method, and dedupes candidates by[path, return_type.tag]rather than path alone, soBox<Integer> & Box<String>no longer collapses to whichever resolved first. Generics inside conjuncts now resolve too:Intersectioninherited aresolve_generics_from_contextthat searches#subtypes/#key_types, which an intersection doesn't use, soClass<generic<T>> & #newnever boundT— and an unresolved generic satisfies every conformance check, so a wrong@returnwas accepted silently.narrow_with/narrowed_return_type, renamed from Support intersection types for internal use #1119'sintersect_with/intersection_return_typeto free the name, and now building a realIntersectioninstead 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 (#dig,#delete, detected structurally rather than by a hardcoded list), narrow to the conjunct whose key matches the call's literal argument instead of returning a union of every conjunct's return type. RBS's ownHash#fetch: (_Key key) -> Vcannot do this —_Keyis a structuralhash/eql?interface, not literallyK, so the key argument is never connected to the return type.A conjunct is only narrowed away when every conjunct produces a positive verdict against a
_Key-shaped parameter and the literal argument, falling back to the full union whenever even one cannot be verified either way.The two specs demonstrating this stay
pending, citing two independent unmerged prerequisites: #1223 (literal type inference, needed for the"Index"/"Triggers"key types to survive to be compared) and, on RBS >= 4.1.x, #1266 (structural RBS interface conformance, soHash#fetch's own overload resolution doesn't leakgeneric<X>). Neither gap is specific to intersections.This PR was written by Claude Code on behalf of @apiology.