Skip to content

Structurally verify RBS interface-typed expectations - #1266

Open
apiology wants to merge 19 commits into
castwide:masterfrom
apiology:structural-rbs-interface-conformance
Open

Structurally verify RBS interface-typed expectations#1266
apiology wants to merge 19 commits into
castwide:masterfrom
apiology:structural-rbs-interface-conformance

Conversation

@apiology

@apiology apiology commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🤖 Filed by Claude, not Vince — acting on his behalf via his GitHub credentials.

Problem

RBS 4.1 broke hash lookups in Solargraph.

# @param registry [Hash{Symbol => Array<String>}]
# @return [Integer]
def count(registry)
  # #count return type could not be inferred
  registry.fetch(:x).length
end

4.1 loosened Hash's key lookups from the class parameter K to the structural interface Hash::_Key. Solargraph has rough workarounds for a few existing interfaces and no general way to satisfy one, so Symbol fails to match the parameter, every #[] and #fetch overload is rejected as a non-match, and the caller is left with no usable type.

class Hash[unchecked out K, unchecked out V]
  # rbs 4.0.3
  def fetch: (K arg0) -> V

  # rbs 4.1.3 — no K anywhere in the signature
  def fetch: (_Key key) -> V

  interface _Key
    def hash: () -> Integer
    def eql?: (untyped rhs) -> boolish
  end
end

Solution

Match interfaces structurally: an inferred type conforms to an interface-typed expectation if its own method stack has every method the interface declares. Symbol has #hash and #eql?, so it satisfies Hash::_Key without anyone declaring an include.

:allow_unmatched_interface now applies only where the expected interface has no methods Solargraph can see, leaving nothing to check.

Limitation: generic interfaces (_Each[Elem]) are checked by method name only, not parameter or return types, since they get turned into YARD #duck_types.

Fixes #1232

ComplexType::Conformance#ignore_interface? blanket-allowed any
argument or return value against an RBS interface-typed expectation
(Hash::_Key, _ToAry, etc.) whenever :allow_unmatched_interface was in
the rule set, even when the candidate type clearly didn't implement
the interface (e.g. an Integer against _ToAry).

RBS interface declarations become Pin::Namespace pins with their
required methods attached, so real duck-type verification is
possible: an inferred type now conforms to an interface-typed
expectation if its method stack has every method the interface
itself declares. :allow_unmatched_interface remains as a fallback for
cases the structural check can't resolve (the interface pin isn't
found) and for the reverse direction, where the inferred type is
itself an abstract interface.

Fixes castwide#1232
The structural conformance check added in the previous commit only
verifies that a same-named method exists; it doesn't check the
method's return type or parameters. Add two pending specs that
demonstrate the gap concretely (a `to_ary` that returns a String, an
`eql?` with the wrong arity) so the follow-up work has a target to
un-pend.

See castwide#1267
Moving the structural check into erased_type_conforms? let generic
interfaces (e.g. _Each[Elem], _ToAry[T]) fall through into the
subsequent subtype/parameter comparison, which isn't
generic-parameter-aware for interfaces (see issue castwide#1267) and could
wrongly reject a match the old blanket bypass would have allowed.

Move the check back to where the old ignore_interface? ran, at the
top of conforms_to_unique_type?, so once the interface question is
settled (now via the real structural verdict, with
:allow_unmatched_interface as fallback), nothing downstream runs -
matching the old bypass's shape exactly, just with a real verdict
behind it instead of a blind rule check.

Verified against castwide/solargraph's own downstream
solargraph-rspec integration suite (run_solargraph_rspec_specs
CI job): its 3 pre-existing failures (Array<Integer> => Array
generic-loss cases) reproduce identically on unmodified
castwide/master at the exact commit this branch forked from, so
they're unrelated to this PR either way - this commit is a
defensive correctness fix, not a regression fix.
This pending case already existed on master with a vague
"side of effect of inference changes" reason. It's the same
nil-doesn't-simplify-to-NilClass gap that's already tracked and
fixed (pending merge) in castwide#1223 and
#40. Make that traceable instead of leaving the
next reader to rediscover it.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 6, 2026
…ions

Two conflicts resolved:

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

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

Verified: spec/complex_type/conforms_to_spec.rb + spec/complex_type
(56 examples, 0 failures, 4 pending), and a broader safety net —
spec/type_checker, spec/source_map/clip_spec.rb,
spec/parser/flow_sensitive_typing_spec.rb (539 examples, 0 failures,
17 pending) — all passing locally.
@apiology
apiology marked this pull request as ready for review August 6, 2026 18:38
apiology added a commit to apiology/solargraph that referenced this pull request Aug 6, 2026
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
apiology added a commit to apiology/solargraph that referenced this pull request Aug 6, 2026
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
apiology added a commit to apiology/solargraph that referenced this pull request Aug 6, 2026
…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
apiology added a commit to apiology/solargraph that referenced this pull request Aug 6, 2026
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
apiology added a commit to apiology/solargraph that referenced this pull request Aug 6, 2026
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
apiology added a commit to apiology/solargraph that referenced this pull request Aug 6, 2026
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.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 8, 2026
…arameters

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

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

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

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

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

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

Verified: spec/type_checker/levels/strong_spec.rb (74 examples, 0
failures, 5 pending) against local RBS 4.1.2, and a broader safety net
- spec/type_checker, spec/complex_type_spec.rb, spec/complex_type (465
examples, 0 failures, 24 pending).
apiology added a commit to apiology/solargraph that referenced this pull request Aug 8, 2026
Call#method_stack_pins's Intersection branch returned a union of every
conjunct's return type for calls like Hash{"Index" => Float}
& Hash{"Triggers" => Array<...>}#fetch("Index"), instead of narrowing
to the one conjunct whose key actually matches. RBS's own
Hash#fetch: (_Key key) -> V can't do this itself - _Key is a
structural hash/eql? interface, not literally K, so the key argument
is never connected to the return type by ordinary overload
resolution.

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

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

Mirrors the same RBS-version-aware pattern already applied to this
same spec on branch 2026-08-04 (which does have castwide#1266) in commit
ac4eb27 - just inverted, since without castwide#1266 here the leak only
reproduces on RBS >= 4.1.0, not below it.
apiology added a commit to apiology/solargraph that referenced this pull request Aug 8, 2026
…-shaped literal key match

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

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

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

Verified: spec/type_checker/levels/strong_spec.rb,
spec/source/chain/call_spec.rb, spec/complex_type_spec.rb,
spec/complex_type (277 examples, 0 failures, 18 pending), and a
broader safety net - spec/type_checker, spec/source,
spec/source_map/clip_spec.rb, spec/api_map_spec.rb,
spec/api_map_method_spec.rb, spec/pin (879 examples, 1 failure, 27
pending). The 1 failure (spec/api_map_spec.rb:771, "resolves aliases
for YARD methods") is the same pre-existing order-dependent flake
already confirmed unrelated to this branch's work during the
castwide#1278 merge earlier in this session.
Conformance#required_interface_methods filtered get_methods to pins
declared directly on the interface itself (not inherited from
Object/ancestors) - the exact primitive castwide#1231's Hash record-dispatch
narrowing needs to generalize past hardcoding Hash::_Key's literal
name (see castwide#1231, comment
castwide#1231 (comment)).
Promoted it to ApiMap#get_own_methods so it's reusable outside
Conformance instead of staying private to one call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Z8Mxxd8vyLsQHKRYZrezg
apiology added a commit to apiology/solargraph that referenced this pull request Aug 11, 2026
Signature#key_param_index matches by Hash::_Key's literal name, so it
only recognizes RBS's own Hash::_Key, not a user-defined class using
the same marker-interface pattern under a different name. Left
commented-out code for the structural version once
castwide#1266 lands - it now exposes ApiMap#get_own_methods
(extracted on that branch from Conformance#required_interface_methods
for this reuse) as the primitive needed to match by interface shape
instead of name.

castwide#1231 (comment)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Z8Mxxd8vyLsQHKRYZrezg
apiology added a commit to apiology/solargraph that referenced this pull request Aug 11, 2026
apiology added a commit to apiology/solargraph that referenced this pull request Aug 14, 2026
Per-key intersection dispatch worked on RBS >= 4.1 and silently did
nothing on 3.10.x/4.0.x: `Hash{"Index" => Float} & Hash{"Triggers" =>
Array<...>}` returned the union of both conjuncts' return types for
every `#fetch`, rather than narrowing to the conjunct whose key matched.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdEpnChUZyPznDWimtWmJL
apiology added a commit to apiology/solargraph that referenced this pull request Aug 14, 2026
Both specs pass now that castwide#1231 recognizes RBS < 4.1's
`(K arg0)` key-parameter shape (merged here as bd9fb82). Verified on
RBS 4.1.3 and 4.0.3: 93 examples, 0 failures.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdEpnChUZyPznDWimtWmJL
apiology added a commit to apiology/solargraph that referenced this pull request Aug 17, 2026
Review feedback on castwide#1231:

- ComplexType::UniqueType#conforms_to?: the new @ sg-ignore did not use
  a reason string from the taxonomy in TypeChecker::Rules. It is no
  longer needed at all - the ignore existed only because
  `expected_unique_type.class` was interpolated into the raise message
  after `is_a?(UniqueType)` narrowed the negated branch to nothing.
  `expected.inspect` already reports the offending value, so the
  interpolation and the suppression both come out.

- ComplexType.close_disjunction: same treatment. `disjuncts.fetch(0)`
  types as non-nil where `disjuncts.first` did not, so its ignore
  marker (also off-taxonomy) is gone rather than reworded.

- Pin::Signature#key_param_index renamed to #hash_key_param_index -
  `_Key` is specific to hashtable datatypes. Its doc block is cut to
  roughly a third: the RBS 4.1.0 shape change stays, the inline code
  sketch for the post-castwide#1266 structural rewrite drops in favor of a
  one-line @todo pointing at the PR comment that holds it.

- Chain::Call doc blocks for #method_pins_for_binder,
  #method_stack_pins and #key_verified_conjuncts roughly halved.

- Comments describing what the code used to do, rather than what it
  does, removed from complex_type_spec, call_spec and the strong-level
  intersection specs. The history they carried:

  - duck_types_match? previously checked the duck-typed expectation
    against ComplexType#namespace/#scope, which for an Intersection
    delegates to the first conjunct, so an intersection was rejected
    whenever the duck-typed conjunct was not first.
  - #fetch on Hash{K1=>V1} & Hash{K2=>V2} previously always resolved
    through the first conjunct's signature; dedup keyed on pin path
    alone. Fixed the way castwide#1273 fixed it for real
    unions, then narrowed further by literal key.
  - Before Signature#hash_key_param_index learned RBS < 4.1's
    `(K arg0)` shape, the symbol-key spec failed on RBS 3.10.x/4.0.x
    with an unresolved generic<X> and three spurious "Wrong argument
    type for Hash#fetch" errors.
  - The strict-union call_spec gap was found while checking whether
    the intersection change regressed union semantics; it did not.

- Class-level and #conforms_to? doc blocks in
  UniqueType::Intersection, and the duplicated #mixin_pairing? doc in
  ComplexType::UniqueType, trimmed for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175gggvr8gZaKQsGFe3e6CT
apiology added a commit to apiology/solargraph that referenced this pull request Aug 17, 2026
CI on a cold pin cache showed the Hash::_Key stub taking effect, which
three specs were still asserting against. All three encoded the RBS
4.1.0 regression the stub reverses.

- conversions_spec and activesupport_concern_spec version-gated
  "finds superclass method pin parameter type", expecting
  `::Hash::_Key` on RBS >= 4.1.0 and `Symbol` below it. The stub makes
  the answer `Symbol` on every version, so the gate is gone.

- strong_spec's "leaks an unresolved generic<X> from Hash#fetch" was
  pending on RBS >= 4.1.0, blocked on castwide#1266. The
  leak came from Hash::_Key being checked nominally rather than
  structurally against a String argument; with the interface stubbed to
  K there is no interface left to check, so the example passes and the
  pending is removed. castwide#1266 is still needed for the general case of
  structurally verifying interface-typed expectations - just not for
  this one.

Also corrects two comments that explained the Hash-record narrowing by
saying `_Key` is not literally `K`. That was the reason before the
stub; the reason now is that overload resolution runs per conjunct and
both conjuncts produce a pin with the same path, so nothing downstream
can tell which one the argument selected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175gggvr8gZaKQsGFe3e6CT
@apiology
apiology marked this pull request as draft August 31, 2026 17:32
castwide#1266 fixes the same underlying bug as castwide#1228
(castwide#1228) via real structural
interface conformance instead of a blanket allow_unmatched_interface
flag, making castwide#1228 redundant. These two pieces of castwide#1228 are not
duplicated by castwide#1266 and are carried over before castwide#1228 is closed:

- CI matrix bump in rspec.yml (tests rbs 4.0.3/4.1.1 instead of the
  now-superseded 4.0.0-4.0.2 range)
- End-to-end regression spec reproducing castwide#1227's original repro at
  the typecheck level (structural-rbs-interface-conformance only had
  unit-level specs against ComplexType::Conformance directly)
Compress the interface_bypass_verdict, get_own_methods, and
structural_interface_verdict docstrings to their load-bearing facts,
and drop the narrated pending-block comments in conforms_to_spec.rb
in favor of a bare issue URL as the pending reason.
Cut interface_bypass_verdict's docstring to the requested 4 lines and
get_own_methods's to 2, and drop the last narrated pending comment in
favor of a bare issue/PR URL pair, matching the trim already applied
to the sibling methods and pending blocks in this file.
interface_bypass_verdict returned true whenever the inferred type was
itself an interface and :allow_unmatched_interface was set, before any
structural check ran. That made an interface conform to an unrelated
class: _ToAry satisfied Array.

Instrumented over a full suite run, the condition never once held --
5021 calls to the method, zero firings -- so no spec covered it. Suite
results are identical with and without: 2181 examples, 20 failures, 45
pending, same failing examples.
apiology added a commit to apiology/solargraph that referenced this pull request Sep 5, 2026
Resolve every conflict by where `ours` came from, not by comparing the
two sides on their merits. Overcommit 0.71.0 destroyed merge state on
this branch repeatedly, so several deliberate castwide#1231 changes present as
divergent local work.

Where `ours` is castwide#1231 code that castwide#1231 itself later changed, take theirs:
the `closure` parameter (derivable as `name_pin.closure`), the inlined
`parse_type_string`, `Signature#key_param_index`, the four
`*_complex_type` object-graph builders, and `key_verified_conjuncts`,
now `argument_verified_conjuncts`.

Where `ours` is independent work, port it onto castwide#1231's structure and
signatures rather than the reverse: `receiver_path`/`narrowed_call_pin`,
per-arm `self_binder` resolution, keyword-argument matching and
`require_literal`, the yielded-parameter signature preference, the
duck-type and bot branches, castwide#1310's `visibility_for`, and castwide#1255's RBS
type-alias expansion, which now threads through `type_to_tag`.

Auto-merge silently dropped `Intersection#intersection_tag` and
`ComplexType::QUOTE_CHARACTERS`; both are restored. castwide#1231's
`RBS_INTERFACE_TO_GENERIC` stub stays out - it stands in for structural
interface types, which castwide#1266 supplies here for real.
Its `pull/1223` pending guards go too, since castwide#1223 is merged on this
branch.
apiology added a commit to apiology/solargraph that referenced this pull request Sep 5, 2026
Brings castwide#1266 up to cc8cfb5: an inferred type
conforms to an interface-typed expectation when its own method stack has
every method the interface declares, rather than only when something
declares an include. cc8cfb5 itself drops the escape that returned
true whenever the inferred type was an interface and
:allow_unmatched_interface was set.

Three conflicts, all in specs.

spec/complex_type/conforms_to_spec.rb: that branch still pends two
examples on castwide#1196 and castwide#1223. Both are merged here, so the examples pass
and the pendings would fail as unexpectedly passing. Took this branch.

spec/type_checker/levels/strong_spec.rb, first hunk: the same example
carries a different title on each side. Kept this branch's issue link
and explanatory comment with that branch's more specific title, so
neither side's contribution is lost. Second hunk: 642 lines here against
nothing there; took this branch.

2183 examples, 0 failures, 45 pending.
The two description lines restated the method name and its one-line
body, duplicating what ApiMap#get_own_methods already documents about
which methods are excluded.

Keeps the @return tag, which strong typecheck requires.
"Verdict" was a coined term with no precedent in the codebase: the two
methods it named held the only two [Boolean, nil] returns in lib/, so
there was nothing for a reader to match it against. The class already
speaks of conformance (conforms_to?, erased_type_conforms?), so the
methods now use that word, and what nil means moved out of the name and
into the @return tag where it can be stated.

Adds the RBS interface-declaration URL, whose _Hashing example declares
the same hash and eql? pair Hash::_Key requires in the specs.
The rename grew it to seven lines, over the four-line budget the review
set. Both URLs stay; the prose around them goes, since the RBS link
already says what an interface is and the issue link already says what
is unverified.
The two methods returned Boolean or nil, the only such returns in lib/,
and neither name said how they differed. They are now plain predicates:
interface_declares_methods? asks whether there is an interface contract
to check, conforms_via_interface? checks it, and the caller carries the
allow_unmatched_interface fallback that the nil case used to encode.

required_interface_methods memoizes, since both predicates call it.
It ran the full deep method walk, including inherited methods, mixins
and conventions, then discarded everything whose closure path did not
match. Store#get_methods already returns just the method pins a
namespace declares itself, so both the walk and the filter were
redundant. Output verified equal for Hash::_Key, _ToAry, _Each and
Symbol.

The return type follows Store#get_methods to Enumerable, so the caller
asks any? rather than empty?.
The example depended on the issue and on the PR that fixes it. With
1223 open, the PR is the live reference and the issue adds nothing.
get_methods already takes deep: false, which returns exactly the same
pins in every case checked: Hash::_Key, _ToAry, _Each and Symbol. A
second public method for it added a name to learn and nothing else.

Inlines the interface gate back into conforms_to_unique_type? as well,
so the interface? test and the fallback rule read in one place.
They relied on Hash::_Key and _ToAry from whatever RBS version is
installed, and those are not present in every version. The context now
writes its own interfaces and classes to a tmpdir and indexes them, so
the examples test conformance rather than the shape of core RBS.
UniqueType#conforms_to? decomposes the expected type with expected.any?,
so each union member reaches Conformance on its own and the interface
branch fires only for the interface member. Nothing tested that, in
either direction or at either position in the union.
@apiology
apiology marked this pull request as ready for review September 6, 2026 02:18
apiology added a commit to apiology/solargraph that referenced this pull request Sep 7, 2026
castwide#1266 already fixes the Hash#fetch generic leak
this addition targeted, via real structural conformance instead of a
blanket allow. Mark the regression spec pending on that PR and let
compatible_arg? go back to its narrower rule set.
apiology added a commit to apiology/solargraph that referenced this pull request Sep 9, 2026
@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.
apiology added a commit to apiology/solargraph that referenced this pull request Sep 9, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Teach Solargraph to structurally verify RBS interface-typed parameters instead of blanket-allowing them

1 participant