Skip to content

Integration test: 2026-08-04 - #49

Draft
apiology wants to merge 263 commits into
masterfrom
2026-08-04
Draft

Integration test: 2026-08-04#49
apiology wants to merge 263 commits into
masterfrom
2026-08-04

Conversation

@apiology

@apiology apiology commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Integration branch merging in open PRs for combined CI testing.

PRs included

apiology and others added 30 commits July 30, 2026 13:57
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
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
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
Fred raised two additional examples on PR castwide#1223: a scalar
reassignment union that still shows the stale pre-+= literal, and a
plain Array's inferred element type not tracking a later #push.

Both reproduce identically on master, so neither is caused by this
PR. The first is the same general "sequential assignment"
flow-narrowing gap already tracked as pending since PR castwide#863 (see the
pre-existing "replaces type with reassignments" spec). The second is
the same "no mutation tracking" limitation already documented and
accepted for tuples/#unshift in this PR, generalized to plain arrays
via the separate literal-array inference path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCTZUQv2ijSU5Cqgw4CU1f
Pin::BaseVariable#probe unions the return types of every assignment
to a variable in scope. When one assignment's type is a literal
(e.g. `0` from `x = 0`) and another is that literal's own
non-literal base type (e.g. `Integer` from `x += 1`, which already
correctly widens away the literal per the earlier reassignment fix),
the literal adds no information the base type doesn't already carry
- keeping both just reads as if the literal value were still
reachable after a later, wider assignment. Drop such redundant
literal items so `x = 0; x += 1; x` infers as `Integer` instead of
`0, Integer`.

This does not touch the general "sequential assignment" narrowing
gap (unioning across *all* assignments regardless of position,
tracked since PR castwide#863) - it only removes items that were always
redundant given another item already in the same union.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCTZUQv2ijSU5Cqgw4CU1f
Array's mutating methods that can shift, replace, or reorder a
tuple's existing positions (unshift/prepend, insert, delete_if,
keep_if, reject!, select!/filter!, compact!, flatten!, uniq!, sort!,
sort_by!, reverse!, rotate!, shuffle!, replace, fill, clear,
collect!/map!) were inherited from core Array unmodified, so their
RBS-declared `-> self` return type kept the precise (and, after such
a call, wrong) Tuple type. #push/#<</#concat are deliberately
excluded - appending past the known arity can't invalidate an
already-known position.

Overriding these to return the widened, position-erased
`Array[union-of-all-elements]` type instead of `self` means
capturing the result via reassignment (`array = array.unshift(x)`)
now falls back to the safe union, reusing this PR's existing
reassignment-tracking machinery. It does nothing for the more common
bare-statement form (`array.unshift(x)`, no reassignment) - that's
still the documented castwide#1196 scenario-4 limitation, unaffected and
still covered by its own spec.

Note for reviewers: RBS's own maintainers hit this same wall and
retreated from it for the general core-type case - see
ruby/rbs@aae95840 ("Use monomorphic
versions of in-place modifying methods"), which reverted an earlier
attempt at a non-self, generically-typed #collect! because "it's not
possible to represent side-effects and the receiver type changing."
Our case differs in a way that keeps it sound: we're not
introducing a fresh polymorphic type var the way that attempt did,
just narrowing to a type that's already a strict superset of every
possible resulting position - the tuple's own declared union.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCTZUQv2ijSU5Cqgw4CU1f
TypeChecker#signature_argument_problems_for used to bail out on any
signature with a restarg parameter, skipping type checking entirely
for the rest of the call. That's why `y = [1]; y.push 'two'` (Fred's
second example on castwide#1223) went unflagged even though `push` expects an
Integer.

Restarg params are now checked argument-by-argument against the
restarg's declared type, resolved against the receiver's actual
generic parameters (e.g. `Integer` for an `Array<Integer>` receiver).
Trailing positional parameters and an implicit kwargs hash appended
to the call's arguments are excluded from the restarg's own checks.

This surfaced a real bug in RbsTranslator#to_parameter_pin: restarg
and kwrestarg parameters had their per-element type discarded and
hardcoded to bare `Array` / `Hash{Symbol => Object}`, so there was
never any element type to check against in the first place. Fixed to
preserve the real per-element type, falling back to the old bare
Array/Hash only when the element type is genuinely untyped (e.g. an
inline `#: (*bar) -> bool` annotation with no declared element type).
Two specs in spec/pin/method_spec.rb asserted the old erased-to-bare
behavior and are updated to reflect the now-tracked type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCTZUQv2ijSU5Cqgw4CU1f
CI runs the matrix against RBS 3.10.0 through 4.0.2, where
Array#push's restarg parameter is named differently (e.g. `obj`)
than in the RBS version used locally (`objects`). The parameter
name is an incidental detail of the core RBS declaration, not
something this PR's type-checking logic controls, so match on the
substance of the message instead of the exact name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCTZUQv2ijSU5Cqgw4CU1f
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
Part of re-enabling `solargraph typecheck --level strong` in CI
(currently `continue-on-error: true`, 496 pre-existing problems).

- Remove 42 `@sg-ignore` comments strong-mode now reports as
  unneeded (the underlying issue they suppressed no longer exists).
- lib/solargraph/source/chain/literal.rb: reword nested `@sg-ignore`
  mentions inside a commented-out illustrative code block so
  Solargraph's comment parser doesn't mistake them for live
  annotations (was causing a false "unneeded @sg-ignore" report with
  no matching live comment to remove).
- lib/solargraph/yardoc.rb: keep one @sg-ignore in place (reworded)
  for an Open3.capture2e overload-resolution edge case strong mode
  can't otherwise clear; removing it surfaced a real "Unresolved call
  to success?" report.

spec/pin/combine_with_spec.rb's 5 stale `pending` markers are
intentionally NOT removed here: they only start passing once
PR castwide#1238's Pin::Method#combine_same_type_arity_signatures fix is
present, and that fix is being kept in a separate, non-annotation PR.
Removing them on this branch (which doesn't have that fix) would turn
'pending' into a real failure.

Verified: typecheck strong (497 -> 454 problems, no new problems
introduced, no regressions).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit cb7bb61)
Continues re-enabling `solargraph typecheck --level strong` in CI.

Root-cause fixes (not suppression):
- lib/solargraph/rbs_translator.rb, lib/solargraph/rbs_map/conversions.rb:
  `RbsTranslator.to_complex_type`/`type_to_tag` were tagged
  `@param type [RBS::Types::Bases::Base]`, but RBS itself defines no
  such shared base type -- `RBS::Types::t` (RBS's own "any type"
  alias) is a flat union of ~20 concrete classes. Retagging both
  methods with that full union fixed 11 identical false-positive
  reports in one shot. Same fix applied to
  `RbsTranslator.to_parameter_pins` / `Conversions#extract_method_type_return_type`,
  which are genuinely called with either `RBS::MethodType` or
  `RBS::Types::Block` (both just need `.type`).
- lib/solargraph/rbs_map/conversions.rb: removed two entirely dead,
  shadowed method definitions (`build_type`, `parts_of_function`) --
  each had an earlier, unreachable definition still calling two
  methods (`method_type_to_type`, `other_type_to_type`) that don't
  exist anywhere in the codebase. Ruby silently uses the later
  definition, so this was always dead code, not a live bug, but it's
  why rubocop's Lint/DuplicateMethods was already flagging this file.
- rooted_name/fqns/build_type: replaced `Hash#fetch(key, default)`
  (whose two-arg overload Solargraph can't resolve generically here)
  with `Hash#[] || default`, which type-checks correctly.
- Real nil-safety fixes (guard rewritten so flow typing can see it,
  not suppressed): node_methods.rb's paren-scanning method-signature
  parser (String#[] with a Range is nilable even though the
  surrounding bounds checks make it unreachable in practice);
  location_decl_to_pin_location / RbsTranslator.to_sg_location's
  `location&.name.nil?` guards, rewritten as `location.nil? ||
  location.name.nil?` so Solargraph narrows `location` afterward.

Suppressions (matching this codebase's established @sg-ignore
conventions, used where the gap is in Solargraph's own flow-typing
engine, not a bug in this code -- see the categorized backlog in
lib/solargraph/type_checker/rules.rb):
- `Parser.is_ast_node?(x)`-style custom predicate wrappers don't
  narrow `x` for later calls (flow sensitive typing needs to narrow
  down type with an if is_a? check).
- Postfix `unless x.nil?` guards on a repeated subexpression don't
  narrow the repeated use (Translate to something flow sensitive
  typing understands).
- `if obj.attr` doesn't narrow a later `obj.attr` re-access
  (flow sensitive typing needs to handle attrs).
- `case type; when SomeClass; type.foo` doesn't narrow `type` per
  branch (flow sensitive typing should support case/when).
- A few pre-existing, unrelated-to-narrowing "Unresolved call"
  reports on RBS-derived types Solargraph can't otherwise resolve.

spec/pin/method_spec.rb: switch the batch-1 regression test to
`instance_double` (RSpec/VerifiedDoubles), fixing a rubocop failure
CI caught on the batch-1 push.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (454 -> 374 problems this batch; 497 -> 374
overall across both batches).

Note: CI's `run_solargraph_rspec_specs` job (solargraph-rspec's own
integration suite, run against this branch) shows 3 pre-existing
failures. Confirmed via local bisection (pointing solargraph-rspec's
Gemfile at a pristine, unmodified castwide/solargraph master
checkout) that these same failures reproduce on master itself, and
confirmed via `gh run list` that this job has already failed on a
master push independent of this PR. Not caused by, or fixable
within, this PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit e54709d)
Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/type_checker.rb: fully clean (27 -> 0 problems).
  Real fixes: `kwarg_problems_for` now returns early if
  `sig.parameters[idx]` is nil (was calling `.name`/`.decl`/
  `.asgn_code` on a possibly-nil param without a guard);
  `arity_problems_for` now falls back to `[]` if
  `pin.signatures.map { ... }.first` is nil (empty signatures list).
  The rest are `@sg-ignore`s matching this codebase's established
  flow-typing-gap conventions (postfix nil guards, attr re-access,
  Hash `||=` on a key, `Array#last` after an emptiness check).

- lib/solargraph/rbs_translator.rb: wrap the two `@param type [...]`
  union-type tags (added in batch 2) in
  `rubocop:disable/enable Layout/LineLength` -- CI's rubocop check
  caught these on the batch 2 push (511/513 chars vs the 224 limit).

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (374 -> 347 problems this batch; 497 -> 347
overall across three batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit d940789)
Continues re-enabling `solargraph typecheck --level strong` in CI.

lib/solargraph/pin/method.rb: fully clean (17 -> 0 problems).

Real fixes:
- `return_type_from_inline_rbs` / `signatures_from_inline_rbs`: guard
  against `RBS::Parser.parse_method_type` returning `nil` (its own
  RBS signature allows this independent of raising
  `RBS::ParsingError`, which is the only failure mode these methods
  previously handled).
- `dodgy_visibility_source?`: add a `@return [Boolean]` tag (was
  missing, same "return type could not be inferred" pattern already
  fixed for `splatted_hash?` in batch 2).

The rest are `@sg-ignore`s matching this codebase's established
flow-typing-gap conventions from lib/solargraph/type_checker/rules.rb
(String#[] with a Range being nilable despite surrounding bounds
checks, Array#first/#last after an emptiness check, attr re-access
after a truthy check, Hash `||=` on a key). One (`Macro.from_directive`
called with an already-built `Macro` instead of a raw
`YARD::Tags::Directive`) works at runtime only because `Macro`
duck-types `#tag` the same way -- confirmed by reading both classes
before suppressing rather than assuming.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (347 -> 330 problems this batch; 497 -> 330
overall across four batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 3262e75)
Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/parser/parser_gem/node_chainer.rb: fully clean
  (15 -> 0 problems).
- lib/solargraph/parser/parser_gem/node_processors/send_node.rb:
  fully clean (17 -> 0 problems).

Both files are almost entirely `node.children[N]` accesses feeding
into recursive chain-building calls (`NodeChainer.chain`,
`generate_links`) after guards Solargraph's flow typing doesn't
propagate (`is_a?` checks, truthiness checks, or just structural
guarantees from the parser's own AST shape) -- `@sg-ignore`s matching
the established "Need to add nil check here" convention.

One real (harmless) restructuring: `NodeChainer#generate_links`'s
`:or` branch built a two-element array inline
(`[NodeChainer.chain(n.children[0], ...), NodeChainer.chain(n.children[1], ...)]`),
which put both nilable-argument call sites on the same logical
statement -- Solargraph could only attribute one `@sg-ignore` to it.
Split into two local variables assigned separately so each call site
gets its own annotation; no behavior change.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (330 -> 298 problems this batch; 497 -> 298
overall across five batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 3960c11)
…y clean

Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/parser/comment_ripper.rb: fully clean (15 -> 0
  problems). All from the same root: Ripper's `result` tuple is
  declared `Array(Symbol, String, Array(...))`, but Solargraph
  doesn't narrow positional `result[N]` indexing to each tuple slot's
  specific type -- every index resolves to the full element-type
  union instead. `@sg-ignore`s matching this file's existing
  convention for the identical pattern.
- lib/solargraph/parser/flow_sensitive_typing.rb: fully clean
  (13 -> 0 problems). Same nil-narrowing gaps as prior batches
  (`@type` tags asserting non-nil on values Solargraph itself infers
  as nilable from `node.children[N]`; a nested generic Hash/Array
  value type Solargraph can't fully resolve).

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (298 -> 270 problems this batch; 497 -> 270
overall across six batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 319dea9)
Continues re-enabling `solargraph typecheck --level strong` in CI.

lib/solargraph/library.rb: fully clean (12 -> 0 problems).

Real fixes:
- `references`: `[api_map.source_map(filename)]` could contain a nil
  element (source_map returns nil if the file isn't mapped); `.compact`
  it before iterating, avoiding a latent `NoMethodError` on `nil` if
  that branch were ever hit with an unmapped file.
- `next_map`: was writing to and then immediately re-reading from
  `source_map_hash` to get its own return value, which Solargraph
  can't see is guaranteed present -- keep the mapped source in a
  local variable and return that instead of re-fetching from the
  hash.

The rest are `@sg-ignore`s matching established conventions: the
`nil`-literal-vs-`NilClass` representation mismatch also seen in
batch 4 (`attach nil`, `Bench.new(live_map: ...)`), the
`Open3.capture3` overload-resolution gap from batch 4 applied to a
second call site, and a few more `Array#shift`/`Hash#[]`-after-a-set
nil-narrowing gaps.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (270 -> 258 problems this batch; 497 -> 258
overall across seven batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 4b62fd4)
Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/api_map.rb: fully clean (12 -> 0 problems).
- lib/solargraph/language_server/host.rb: fully clean (12 -> 0
  problems).

Real fixes:
- `Host#pending_completions?` was tagged `@return [Bool]` -- not a
  real YARD/Solargraph type name (should be `Boolean`), so the
  declared type itself was unresolvable.
- `Host#client_supports_progress?` / `#prepare_rename?` had no
  `@return` tag at all and returned a raw `&&` chain (which could
  yield a Hash value, not just true/false); added `@return [Boolean]`
  and wrapped the body in `!!(...)` so the return value is a real
  boolean, not just type-annotated as one.

The rest are `@sg-ignore`s matching established conventions: several
more `Hash#[]`-after-a-truthy-check nil-narrowing gaps, the
`nil`-literal-vs-`NilClass` mismatch (`Source::Change.new` with a
ternary that can yield literal `nil`), and one gap in a third-party
gem's return typing (`Diff::LCS.diff`, which doesn't ship strong
RBS/YARD types Solargraph can resolve).

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (258 -> 234 problems this batch; 497 -> 234
overall across eight batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit d0cb67a)
Continues re-enabling `solargraph typecheck --level strong` in CI.

lib/solargraph/api_map/store.rb: fully clean (10 -> 0 problems).

Real fixes:
- `get_path_pins`: `index.path_pin_hash[path]` falls back to `[]`
  (matches the declared non-nilable `Array<Pin::Base>` return type;
  Hash#[] on a missing key is a normal, expected case here, not an
  error).
- `fqns_pins`: `fqns_pins_map[[base, name]]` falls back to `[]` too --
  the hash has a default proc that always populates the key, so this
  never actually returns nil, but Solargraph can't see through
  `Hash.new { ... }` default-proc population.

The rest are `@sg-ignore`s matching established conventions:
`Hash#key?`-guard-then-`[]`-fetch not narrowing (same pattern fixed
repeatedly in prior batches, here across `superclass_references`,
`namespace_hash`, `@indexes.last`), and the `nil`-literal-vs-`NilClass`
representation mismatch in a cached Hash assignment expression.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (234 -> 224 problems this batch; 497 -> 224
overall across nine batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 5e8f295)
Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/pin/block.rb: fully clean (9 -> 0 problems).
- lib/solargraph/parser/parser_gem/node_processors/sclass_node.rb:
  fully clean (9 -> 0 problems).
- lib/solargraph/diagnostics/rubocop.rb: fully clean (9 -> 0
  problems).

Real fix: `Block#destructure_yield_types`'s `parameters.map.with_index
{ ... }` (map called without a block, then chained through
`with_index`) return-typed as `Enumerator` instead of `Array` --
rewritten as the equivalent, more standard
`parameters.each_with_index.map { ... }`, which Solargraph resolves
correctly and matches the declared `Array<ComplexType>` return type.

The rest are `@sg-ignore`s matching established conventions:
`is_a?` checks combined with `&&` in an `if`/`elsif` chain not
narrowing the checked variable for later `.type`/`.children` calls in
sclass_node.rb (same class of gap as the plain single-condition case
fixed in earlier batches, just with more conditions in the same
`if`); repeated `Hash#[]`-chain nil-narrowing gaps parsing RuboCop's
JSON offense output in diagnostics/rubocop.rb.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (224 -> 197 problems this batch; 497 -> 197
overall across ten batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 2a262cd)
Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/convention/data_definition/data_assignment_node.rb:
  fully clean (7 -> 0).
- lib/solargraph/convention/struct_definition/struct_assignment_node.rb:
  fully clean (7 -> 0).
- lib/solargraph/convention/struct_definition/struct_definition_node.rb:
  fully clean (7 -> 0).

Real fix, applied identically across the two `*_assignment_node.rb`
files (they're structurally the same class, one for `Data.define`,
one for `Struct.new`): `node.children[2]` and `node.children[0]` were
each re-evaluated 2-3 times across a nil check and subsequent uses.
Solargraph doesn't narrow a repeated method-call expression the way
it narrows a plain local variable, so each re-access re-triggered the
same nilable warning even though the code was already guarded.
Extracting each into a local variable once, right after computing it,
lets Solargraph's ordinary local-variable nil-narrowing do its job
instead of suppressing each repeated access individually.

The remaining occurrences (mostly in `struct_node`/`data_node`
private helper methods that intentionally re-derive from `node`
without a preceding nil check, and a few multi-level `.children[0]`
chains) are `@sg-ignore`s matching this codebase's established
conventions.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (197 -> 176 problems this batch; 497 -> 176
overall across eleven batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit e51fe1f)
Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/doc_map.rb: fully clean (8 -> 0 problems).
- lib/solargraph/pin/callable.rb: fully clean (7 -> 0 problems).
- lib/solargraph/source.rb: fully clean (7 -> 0 problems).

All `@sg-ignore`s matching this codebase's established conventions
from earlier batches: `Hash#key?`-guard/`Hash#[]=`-then-fetch not
narrowing, the `Open3.capture3` overload-resolution gap (a third
call site, same as batches 4 and 7), `||=` on a Hash key not
narrowing, and the `nil`-literal-vs-`NilClass` representation
mismatch. One case in `source.rb` also carries a real type-hierarchy
gap Solargraph can't see: `Parser::AST::Node` is a subclass of the
`ast` gem's `AST::Node`, but nothing tells Solargraph about that
relationship, so a method declared to return `AST::Node` that
actually returns a `Parser::AST::Node, nil` needs suppressing on both
counts.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (176 -> 154 problems this batch; 497 -> 154
overall across twelve batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 287fff7)
Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/complex_type.rb: fully clean (7 -> 0 problems).
- lib/solargraph/complex_type/unique_type.rb: fully clean (7 -> 0
  problems).

Real fix: `ComplexType#expand` and `UniqueType#expand` had no
`@param`/`@return` tags at all; added `@param named_types
[Hash{String => UniqueType}]` / `@return` tags matching how they're
actually used (`named_types[name] || self`).

The rest are `@sg-ignore`s matching established conventions:
`Array#first`/`Array#[]` on `@items` treated as guaranteed-present
(a ComplexType always wraps at least one UniqueType) but not
provable statically, and the `nil`-literal-vs-`NilClass`
representation mismatch.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (154 -> 140 problems this batch; 497 -> 140
overall across thirteen batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 246d72b)
Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/pin_cache.rb: fully clean (6 -> 0 problems).
- lib/solargraph/pin/base.rb: fully clean (6 -> 0 problems).
- lib/solargraph/yard_map/mapper/to_method.rb: fully clean (6 -> 0
  problems).
- lib/solargraph/shell.rb: fully clean (6 -> 0 problems).

Real fixes: `Pin::Base#macro_names` and `#collect_macro_names` had no
`@return` tag at all; added `@return [Array<String>]` matching their
actual behavior. `Shell#rbs` (a Thor CLI command) had no `@return`
tag either; added `@return [void]`.

The rest are `@sg-ignore`s matching established conventions,
including a new instance of the `FileUtils::path` RBS type-alias gap
(6 call sites across pin_cache.rb and shell.rb -- `FileUtils::path`
is an RBS type alias for a String/Pathname union, but Solargraph
doesn't resolve the alias against a literal String argument) and the
`choose_pin_attr_with_same_name` dynamic-`send`-based generic return
gap already seen for its sibling `choose_pin_attr` in batch 12.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (140 -> 117 problems this batch; 497 -> 117
overall across fourteen batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 5f63ed2)
…specs

Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/api_map/cache.rb: fully clean (5 -> 0).
- lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb:
  fully clean (5 -> 0).
- lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb:
  fully clean (5 -> 0).
- lib/solargraph/source_map/clip.rb: fully clean (5 -> 0).
- lib/solargraph/workspace/gemspecs.rb: fully clean (5 -> 0).

Real fixes:
- `Cache#get_methods`/`#get_constants`/`#get_receiver_definition` are
  Hash-backed cache lookups that can genuinely miss (declared
  non-nilable but a `Hash#[]` cache read can return nil) -- widened
  their `@return` tags to include `nil`, matching how their one
  caller (`ApiMap#get_methods`, `unless cached.nil?`) already treats
  them.
- `NamespaceNode#parameters_from_inline_rbs`: replaced a
  guard-then-repeated-access on `match[1]` with a local variable so
  Solargraph's ordinary nil-narrowing applies.
- `ResbodyNode#process`: same fix for `node.children[1]`, reused
  across four lines in the method.
- `Workspace::Gemspecs#gemspec_or_preference`: same `preference_map`
  `Hash#key?`-guard pattern already fixed in `DocMap` (batch 12) --
  this is a separate, similarly-named method in a different class.

The rest are `@sg-ignore`s matching established conventions,
including a fourth `Open3.capture3` overload-resolution gap site.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (117 -> 92 problems this batch; 497 -> 92
overall across fifteen batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit c1e5db8)
Extract per-overload signature matching in Call#inferred_pins into
match_overload_type, improving how argument/block types are matched
against method overloads and how macro/directive-based pins are
reprocessed when no signature matches by type alone.

Extracted from castwide#1006 (Improve pin caching) as a
standalone piece: this is a type-inference improvement to method call
resolution, independent of the gem pin caching machinery in the rest
of that PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… nodes

Continues re-enabling `solargraph typecheck --level strong` in CI.

Note: this branch's original base already called simple_resolve(name,
mixin, internal) in Constants#complex_resolve's mixin-recursion
branch (a pre-existing bug: simple_resolve only resolves one gate,
unlike resolve(name, mixin), which recurses through resolve_and_cache
across all of mixin's own ancestry, so multi-hop transitive constant
resolution silently broke -- e.g. Module4 includes Module3 includes
Module2 includes Module1, with a constant assigned in Module2
referenced from Module4). castwide/master fixed this independently in
castwide#1234 ('resolves remote constants'), which also added a regression
test for it, after this branch was created. Since this consolidation
branch is built on current master, that fix is already present;
keeping master's resolve(name, mixin) as-is here rather than
reintroducing the stale simple_resolve call via this cherry-pick's
patch context. Kept the rest of this commit's sg-ignore comments and
annotation fixes elsewhere in this file/batch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 035705ca4d2d7f00c5a67e0c9a24f81f14fa789e)
…, ParseDirective

Continues re-enabling `solargraph typecheck --level strong` in CI.

- lib/solargraph/parser/node_processor.rb: fully clean (3 -> 0).
- lib/solargraph/parser/parser_gem/node_processors/args_node.rb:
  fully clean (3 -> 0).
- lib/solargraph/source/chain.rb: fully clean (3 -> 0).
- lib/solargraph/source_map.rb: fully clean (3 -> 0).
- lib/solargraph/yard_map/directives/parse_directive.rb: fully clean
  (3 -> 0).

All `@sg-ignore`s matching established conventions from earlier
batches: `||=` on a class variable Hash not narrowing, `Array#last`
treated as guaranteed-present, generic-method (`_locate_pin`)
downcasts to specific return types, and the
`nil`-literal-vs-`NilClass` ternary mismatch.

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong (72 -> 57 problems this batch; 497 -> 57
overall across seventeen batches).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 6d0b3ad)
…e hard-fail

Closes the last 41 problems, bringing `solargraph typecheck --level
strong` from 497 problems (when this PR started, method stubbed) to
0. Also removes `continue-on-error: true` from the typecheck CI step
(the `@todo Temporary, expect to revert in 0.60` this PR has been
working toward since batch 1) -- strong mode is now a real, enforced
gate again, not just informational.

18 files hit real fixes:
- `Workspace#source` / `#synchronize!`, `YardMap::Cache#get_path_pins`,
  `YardMap::Mapper#macros_for_method_object`: Hash-backed lookups
  declared non-nilable but genuinely can miss -- widened return types
  or added `|| []`/`|| default` fallbacks matching how callers
  already treat them.
- `Host::Message.select`, three `set_result nil` call sites,
  `RbsMap#short_name`, `Source::Chain::Literal#value`: missing or
  wrong `@return`/`@param` tags (a literal `[Bool]` typo, an
  `attr_reader` with no declared type at all).
- Five identical `closure_at` methods across
  yard_map/directives/{attribute,domain,method,override,visibility}_directive.rb
  shared the exact same `Array#select.last` pattern already root-caused
  and fixed once for parse_directive.rb in batch 17.

The remaining ~30 files are `@sg-ignore`s matching every convention
established across this PR's 18 batches: `Hash#[]`/`Array#last`
guard-then-fetch not narrowing, the `nil`-literal-vs-`NilClass`
mismatch, the `Open3.capture3` overload-resolution gap (two more
sites), and the `FileUtils::path` RBS type-alias gap (now also fixed
in this repo's own Rakefile, which the strong-mode target apparently
covers too).

Verified: full rspec suite (1618 examples, 0 failures, 60 pending)
and typecheck strong: 41 -> 0 problems in 250 files, exit code 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 1ecdfc7)
…-room verification

`Gem::StubSpecification` was unresolvable as a constant in a freshly
bundled, freshly `rbs collection install`-ed environment (Docker
ruby:4.0, matching the CI recipe exactly), even though this repo's
long-lived local development bundle didn't hit it -- this repo has
no committed Gemfile.lock or rbs_collection.lock.yaml (both
gitignored), so every fresh install resolves whatever gem/RBS
versions are current at that moment. `@sg-ignore` matching this
PR's established pattern for RBS-resolution gaps in `case`/`when`.

Caught by re-verifying the final batch in a brand-new Docker
container + fresh clone, rather than trusting the long-lived local
bundle this whole PR was developed against -- worth flagging as a
real (if narrow) source of CI flakiness independent of any code
change here, since a *different* constant could equally fail to
resolve on a different day depending on what gem_rbs_collection's
`main` branch or RubyGems' own RBS core sigs look like at that
moment.

Verified in a fresh Docker clean-room (bundle install + rbs
collection install from scratch, matching CI): typecheck strong
0 problems, exit 0. Full rspec suite: 1618 examples, 0 failures,
60 pending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 6a4961f)
apiology and others added 30 commits August 14, 2026 14:35
BaseVariable#definite_reaches? no longer compares a query Location
against a separately-stored conditional_override_boundary Range.
Instead it checks whether the location falls within this pin's own
compound_statement's location range - the CompoundStatement pin
already carries that range, and since a nested CompoundStatement's
location is always a subrange of its parent's, this single
containment check already accounts for arbitrarily nested branches
without needing to walk the chain further.

This removes the duplicate bookkeeping the original PR 1282 fix
introduced: Region#conditional_boundary (a Range) and
BaseVariable#conditional_override_boundary are gone, along with the
Range.from_node(...) computation every conditional-construct node
processor performed to populate them - that range is now read
directly off the compound_statement pin instead of being computed a
second time.

lvasgn_node.rb's `definite` computation goes back to a plain
Region#conditional boolean rather than `conditional_boundary.nil?`
(and was briefly, incorrectly, tried as `compound_statement.is_a?
(Closure)` during this rewrite - reverted because a block's body
pin IS a Closure, for variable-scoping purposes, despite running
zero or many times, which is exactly the case
`conditional_boundary`/`conditional` exists to distinguish). Every
closure-creating node processor (def_node.rb, defs_node.rb,
namespace_node.rb) now explicitly resets `conditional: false` for
its body, since entering a fresh method/namespace scope always runs
its body top-to-bottom regardless of how the closure itself was
reached, unlike a block.

Added:
- A loop-ordering regression test confirming a reassignment inside a
  while body doesn't affect a reference textually before it.
- combine_with specs for Pin::CompoundStatement covering the
  location-based tiebreak and the nil-vs-non-nil case.

Verified: full suite (1638 examples, 0 failures), typecheck self-check
diffed against the pre-fix baseline (587 problems vs. 591 baseline -
net fewer, since deleting the Range.from_node calls also removed
several instances of the pre-existing nilable-AST-child pattern
already tolerated throughout these files).

Combines what were originally staged as two follow-up PRs into one -
see castwide#1282 for the base fix
and design discussion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YbhZvdCv7xdziXyKJiPuGk
Add a CompoundStatement parent chain and use it for reassignment override eligibility
Reported at castwide#1223 (comment):
on RBS 4.0.x, a single-argument Hash#fetch call resolves K to the
receiver's literal key type (e.g. "Index" from a Hash{"Index" =>
Float} @PARAM tag), leaving Hash#fetch with one candidate overload
and no non-literal sibling to fall back to. The exact-literal-match
gate added for tuple's literal-indexed overloads (so a non-literal
argument falls through to tuple's safe catch-all) rejected that
candidate outright, since the calls plain string argument is not
itself literal-typed. With no overload matching at all,
Call#inferred_pins fell back to the union of every overloads
declared return type, leaking generic<X> from fetchs default-value
and block overloads into the inferred return type.

Chain::Call#inferred_pins now tries the overload list twice: first
requiring an exact-literal match (unchanged default behavior), then,
only if nothing matched at all, retrying the same overloads without
that requirement. Pin::Parameter#compatible_arg? takes a
require_literal keyword controlling whether literal_arg_matches?
applies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sv19rvjCK7SVSvTCuh3kN2
Region#conditional was a separate boolean threaded alongside
compound_statement, requiring every node processor to pass both in
lockstep (e.g. block_node.rb: compound_statement: block_pin,
conditional: true). Keeping two parallel values in sync at every
call site is exactly the kind of duplication this refactor set out
to remove, and it's the shape of bug that broke Block handling
mid-refactor (definite briefly, incorrectly, derived from
compound_statement.is_a?(Closure), which is true for Block despite
a block body running zero or many times).

conditional is now a constructor attribute on Pin::CompoundStatement
itself, set once where each construct is built (Pin::Block.new(...,
conditional: true), Pin::Method.new(...) defaulting false), so
there's only one thing to get right per site instead of two. It
can't be a class-level constant: the bare Pin::CompoundStatement
class is used both for an if's own condition (never conditional)
and for then/else/rhs/rescue bodies (always conditional) - same
class, different instances, different answers - so it stays an
instance attribute, same as closure:/compound_statement: already
are.

lvasgn_node.rb's definite computation becomes a single-hop read:
`!region.compound_statement.conditional`, no separate Region field.
Pin::CompoundStatement#combine_with merges the new attribute via
`choose`, since two versions of the same construct should already
agree on it.

Verified: full suite (1638 examples, 0 failures), typecheck
self-check diffed clean against the prior baseline (587 problems,
unchanged), rubocop clean on touched files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YbhZvdCv7xdziXyKJiPuGk
… when no literal one exists

# Conflicts:
#	lib/solargraph/pin/parameter.rb
#	lib/solargraph/source/chain/call.rb
… the use site is dominated by it

# Conflicts:
#	lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb
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
…rameter

# Conflicts:
#	spec/type_checker/levels/strong_spec.rb
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
The spec asserted the broken output verbatim on RBS < 4.1:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdEpnChUZyPznDWimtWmJL
`parameters_from_inline_rbs` scanned the entire class body for
`#[...]`, so any bracketed comment in the body was read as the
superclass's type arguments. `# [:b, { c: :d }]` produced the
superclass name `Base<:b, { c: :d`, which resolves to nothing, and
every inherited method in the class then reported `Unresolved call` —
including on lines above the comment.

Match only where ruby/rbs's inline syntax puts it: directly after the
superclass, on the same line, `#[` with no space, with a closing `]`
required. `class Foo < Array # [String]` is now an ordinary comment.

Also removes a leftover debug `logger.warn` from
castwide#1173.

Fixes castwide#1300

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtwP1oKRXfJAbPQrPHm5oU
ApiMap::Index#redefine_return_type set the pin's @return_type and then
unconditionally iterated pin.signatures. Only Pin::Method defines
#signatures, so an @!override naming a constant (e.g. URI::DEFAULT_PARSER)
raised NoMethodError from inside map_overrides and aborted the entire
catalog/typecheck run with a traceback into Solargraph internals, with
nothing pointing back at the annotation.

Guard the signatures loop with a Pin::Method check. The @return_type
assignment above it already does the right thing for a constant:
Pin::Constant#return_type is `@return_type ||= generate_complex_type`,
and neither Pin::Base#reset_generated! nor
Pin::BaseVariable#reset_generated! clears @return_type, so the override
sticks. map_overrides also adds the tag to the pin's docstring
beforehand, which generate_complex_type would pick up on its own.

So @!override now works on constants rather than merely not crashing,
and any other non-method pin reaching this path degrades to setting just
the return type instead of aborting the run.

Fixes castwide#1302

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
Gems commonly build classes with an anonymous-class assignment instead of
a `class` keyword, e.g. Asana's error hierarchy:

    module Vendor
      Specific = Class.new(StandardError) do
        # @return [Integer]
        def retry_after
          5
        end
      end
    end

`ParserGem::NodeProcessors::CasgnNode` mapped that `casgn` to an untyped
`Pin::Constant`, and the methods in the block landed on the enclosing
namespace, so `Vendor::Specific#retry_after` did not resolve:
`Unresolved call to retry_after on Vendor::Specific` at level strong.

Adds `Convention::ClassDefinition`, following the existing
`Convention::StructDefinition` / `Convention::DataDefinition` pattern: a
`casgn` node processor that recognizes `Class.new(...)` (with or without
a block), pushes a `Pin::Namespace` named after the constant, pushes a
`Pin::Reference::Superclass` when the argument is a constant, and
processes the block body with that namespace as the closure.

The processor is registered for `:casgn` after the Struct and Data
processors -- which keep winning for `Struct.new` / `Data.define` -- and
before `CasgnNode`, which still handles every other constant assignment
because the new processor returns true on non-match.

Fixes castwide#1303

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
A macro-generated `@!method` whose `@return` is a duck type resolves
correctly: the generated pin carries `return_type.tag == "#quack"`, and the
type resolves through ApiMap#get_complex_type_methods to a Pin::DuckMethod.
This is easy to believe otherwise, because `solargraph pin` renders the pin
via UniqueType#to_rbs and RBS has no duck-type syntax, so any duck type
prints as `untyped` — identically for a plain method, a `@!method` directive,
and a macro-generated one.

Pin the working behavior so the macro path stays covered, alongside the
existing class-name case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
The default-argument idiom - `tasks = ['a'] if tasks.nil?` followed by
`tasks.each` - still reported `Unresolved call to each on Array<String>,
nil`. PR castwide#1282 covered the dominance case (a use site inside the branch
the reassignment dominates); here the use site is *after* the
conditional, so what establishes the type on the path where the
assignment did not run is the guard's condition, not dominance.

At a merge point after an `if`, the incoming paths are (a) the clause
ran and assigned a new value - already handled, that pin is unioned in -
and (b) the clause did not run, leaving the original value, about which
the condition tells us something. Path (b) was never asserted, so the
original `Array<String>, nil` was unioned in unnarrowed.

FlowSensitiveTyping#process_if now also asserts the opposite branch's
condition facts over the rest of the enclosing compound statement, for
the variables the clause definitely reassigns. Reusing
#process_expression for that gets `&&`/`||`/`!` handling for free,
including `and`'s deliberate refusal to propagate false-facts.

The restriction to definitely-reassigned variables is what keeps this
sound. Facts are filtered by variable name in #add_downcast_var, driven
by a second FlowSensitiveTyping built over the same locals/ivars arrays
with `restricted_names:` set. Without it, `xs = [] if xs.nil? ||
ys.nil?` would also narrow `ys` after the conditional, even though only
`xs` was replaced. Likewise, only unconditional `lvasgn`/`ivasgn` in the
clause count: an assignment nested in another conditional, or an `||=`,
may leave the previous value in play.

Guards that test something other than the variable (`tasks = ['a'] if
flag`) and nil guards that don't reassign (`puts 'hi' if tasks.nil?`)
keep nil in the type, as they must; specs cover both, plus the
non-modifier `if`, `unless`, and else-clause forms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
The ignore added with the fix carried a one-off description. rules.rb keeps
a tally of @sg-ignore texts grouped into buckets, so a novel string creates
a bucket of one instead of joining an existing count. Reuse the established
"Need to add nil check here" wording, matching this file's three sibling
ignores on Range.from_node results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
…faults

Conflict resolution in flow_sensitive_typing.rb:

- #initialize takes both this branch's `closure` positional arg and castwide#1282's
  `restricted_names:` keyword; the internal FlowSensitiveTyping.new in
  assert_after_guard now passes `closure` through.
- attr_reader lists both :closure and :restricted_names.
- Dropped castwide#1282's local always_leaves_compound_statement?. This branch already
  gets a richer version from Parser::NodeMethods that recurses into :begin for
  multi-statement clauses and treats raise/fail sends as leaving; a definition
  in the class shadows the included module, which broke the four
  "raise if()" nil-refinement specs. castwide#1282 wrote its simple copy before
  NodeMethods had one.

Full suite: 1899 examples, 0 failures, 47 pending.
YARD has no handler for `Class.new`, so a gem that writes

    module Asana
      module Errors
        RateLimitEnforced = Class.new(APIError) do
          attr_accessor :retry_after_seconds
        end
      end
    end

ships a yardoc containing one `ConstantObject` and no method objects at
all -- the block body survives only as the raw source string in
`ConstantObject#value`. `Mapper::ToConstant` ignores that string, so the
gem's pins held an untyped `Pin::Constant` and nothing else: no
superclass, no methods, and `ApiMap#get_method_stack` returned [].

`Mapper` now reparses that value. It builds `<name> = <value>`, checks
the parsed `casgn` node with the same
`Convention::ClassDefinition::ClassAssignmentNode.match?` predicate the
workspace path uses, and on a match maps a copy wrapped in the
constant's original module nesting -- which is what lets a superclass
written relative to that nesting (`APIError`, not
`Asana::Errors::APIError`) resolve through the same gates it had in the
gem. The resulting namespace, superclass reference and method pins are
emitted *instead of* the constant pin, so no two pins compete at that
path. Anything that fails to parse, or that does not produce a namespace
at the constant's path, falls back to the previous `ToConstant`
behavior.

Pins from that reparse cannot be emitted as-is. `NodeStripper` copies
each one, drops its parser nodes and any memoized YARD docstring, and
points it at the constant's location in the gem. Both matter: a retained
node makes `Pin::Method#probe` reach for `ApiMap#clip_at`, which raises
`FileNotFoundError` because the reparsed source has no cataloged source
map, and a retained node or docstring drags its parser buffer (or the
whole YARD registry) into the marshalled gem cache. Against the cached
asana-0.10.6 yardoc, the `Asana::Errors` pins marshal to 12,613 bytes
stripped versus 883,131 unstripped, against 6,142 bytes for the untyped
constants they replace.

The cost is that these pins no longer infer a return type from a method
body -- they keep only the YARD tags the gem wrote, which is all that
YARD-sourced pins ever had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
A modifier-if guard stopped being applied once the variable it guards
had been reassigned:

    got = lookup(name)
    return got.length if got   # asserts got is nil/false below here

    got = lookup(name)
    got.length if got          # Unresolved call to length on nil, Boolean

The first guard's `return` leaves the method, so FlowSensitiveTyping
asserts the false branch's facts - `got` is `nil, false` - over the rest
of the compound statement, and that downcast pin's presence runs to the
end of the method. The second `got = lookup(name)` overwrites the value
the fact was about, but ApiMap#var_at_location still combined the stale
pin in: Pin::BaseVariable#combine_with already let a definite
reassignment supersede the earlier pin's *assignments*, yet unioned
intersection_return_type and exclude_return_type unconditionally. The
`nil, false` intersection survived and intersected the new value down to
nothing.

Narrowing recorded against a value expires when that value is definitely
overwritten, so when #override_assignments? says `other` supersedes us,
keep only `other`'s intersection/exclude types instead of unioning ours
in.

#references_name? then blocked the supersede in the shape this was
actually observed in, `lib/solargraph/workspace/gemspecs.rb`:

    specish = all_gemspecs_from_bundle.find { |specish| specish.name == name }
    return to_gem_specification specish if specish

The self-reference exclusion exists so `x = x.foo` keeps the assignment
its own right-hand side resolves against, but a block parameter of the
same name shadows the outer variable for the whole block - the mention
inside the body is the parameter, not the variable being assigned. The
walk now descends only into a shadowing block's receiver, which is still
evaluated outside the block.

Two @sg-ignore comments in gemspecs.rb are no longer needed and are
removed. Facts stay in force up to the reassignment, and a reassignment
that only runs in a nested branch still does not supersede; specs cover
both, plus a guard on an unrelated variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
A codebase that documented one of these constants by hand still carries
a `@!parse` stub for it, so the gem's new pins and the stub's pins now
sit at the same path in different pinsets. Specs record what that
produces.

Resolution works: two `Pin::Namespace` pins (gem, then workspace), one
superclass chain, and `get_method_stack` returns both method pins, so
the call site type checks clean at strong and strict. Before this
branch the same stub failed -- the gem's `Pin::Constant` sorted first
and `get_method_stack` short-circuited on its undefined type.

The stub no longer contributes its return tag, though.
`Source::Chain::Call#resolve` infers from `stack.first`, which is the
gem's untyped pin, so `@return [Integer]` written in the stub does not
reach the call site -- inference is `undefined` with the stub and
`undefined` without it. The stub is redundant rather than harmful, and
`@!override <path>#<method>` with a `@return` tag retypes the gem pin
directly, which is what a codebase wanting the type should use instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
A reassignment made inside a branch was ignored by a use site later in
that same branch:

    def clean(items)   # @PARAM items [Array<String>, nil]
      if items.nil?
        items = fetch_items
        items.reject! { |i| i.empty? }   # Unresolved call to reject! on nil
      end
    end

Pin::Parameter#typify prefers a reassignment's inferred type over the
declared @PARAM type only when the reassigning pin is `definite`, and an
assignment inside an `if` body is not definite - it may never run.
#override_assignments? already handles that distinction for a specific
position via #definite_reaches?: the use site falls inside the
CompoundStatement the assignment was made in, so on every path that
reaches it the assignment ran. But that verdict only reached
#combine_assignments; the combined pin still carried
`definite: definite || other.definite`, which was false on both sides,
so #typify fell back to the declared type and kept nil in the union.

The combined pin is built for one resolved location, so when the
supersede check passes there, the result is definite at that location.
ApiMap#var_at_location is the only caller that passes a location, so
locationless combines are unaffected: without one, #override_assignments?
already requires `other.definite`.

A reassignment nested in a further conditional, and a use site earlier in
the branch than the reassignment, both still keep the original type;
specs cover each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
The assignment-as-condition idiom asserted nothing about the variable it
assigns:

    if (md = name.match(/\[(.*)\]/))
      md[1].to_i        # Unresolved call to []
    else
      0
    end

Two things were missing. FlowSensitiveTyping#process_expression handled
:send, :and, :or and bare variable references, but not the one-child
:begin that parentheses produce, nor :lvasgn/:ivasgn - so the condition
was walked past without a fact being recorded. An assignment used as a
condition evaluates to the value assigned, so the branches say the same
thing about the variable as a bare reference would: not nil where the
condition held, `nil, false` where it did not.

Adding those handlers alone changed nothing, because IfNode#process ran
FlowSensitiveTyping *before* processing the condition node. The pin for
`md` is created by that condition, so #find_var had nothing to look up
and the facts were dropped. The FlowSensitiveTyping call now runs after
the condition is processed; the then/else clauses are still processed
after it, as before.

`if (md = ...) || fallback` stays unnarrowed without further work:
#process_or deliberately passes no true ranges down to its operands,
since either side alone may be what made the disjunction true. In the
else clause the variable is correctly narrowed to `nil, false` instead.
Four @sg-ignore comments in position.rb are no longer needed and are
removed.

WhileNode#process has the same FlowSensitiveTyping-before-condition
ordering, so `while (x = f.gets)` still misses this when `x` has no
earlier assignment; left alone here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
`NodeStripper` cleared a fixed list of instance variables --
`@node`, `@receiver`, `@assignments`, `@mass_assignment`. That list
was complete when it was written and stopped being complete as soon
as a pin grew another one: merging this branch with a base that
carries `Pin::Method#compound_statement` left two
`Parser::AST::Node`s alive on `#initialize`, reachable as
`@compound_statement.@node` and `@compound_statement.@receiver`.
Nothing named `@compound_statement` was in the list, so the pin
holding those nodes was never copied or cleared.

The stripper now walks every instance variable of every pin it
copies and decides by what the value is: a parser node or a memoized
YARD docstring is dropped, a pin is replaced by its stripped copy, an
array of pins is mapped, and an array of nodes is emptied. A pin type
or ivar added later is handled without this class knowing its name.
`@compound_statement` in particular has to be copied rather than
dropped, because `Pin::Base#closure` walks that chain when a pin has
no directly assigned closure.

Two coexistence specs asserted pin ordering that only holds on bases
without `ApiMap::Store#combine_duplicate_method_pins`, which merges a
gem pin and a `@!parse` stub pin at the same path into one
`:combined` pin. They now assert what holds either way -- the method
resolves, and the stub's `@return` tag is present in the stack -- with
the difference described in a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
The integration branch renders a falsy-only receiver as `nil, false`
where this branch renders `nil, Boolean`, so three exact-message
assertions passed on each branch and failed on the merge. The property
under test is that exactly one problem remains and its receiver is
narrowed to the falsy types - not which of the two spellings the
formatter picks - so match either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEDQFCJ2M7gaYkUQVpiQzn
…l assignment, assignment-in-condition

# Conflicts:
#	lib/solargraph/parser/parser_gem/node_processors/if_node.rb
#	lib/solargraph/pin/base_variable.rb
#	lib/solargraph/workspace/gemspecs.rb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant