Skip to content

Resolve RBS type arguments on mixed-in modules - #1344

Open
apiology wants to merge 13 commits into
castwide:masterfrom
apiology:rooted-rbs-pin-names
Open

apiology wants to merge 13 commits into
castwide:masterfrom
apiology:rooted-rbs-pin-names

Conversation

@apiology

@apiology apiology commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

This PR was written by Claude Code on behalf of @apiology.

Problem: a generic module mixed in with prepend or extend loses its type argument.

# module Prependable[T]
#   def value: () -> T
# end
# class Prepender
#   prepend Prependable[Integer]
# end

api_map.get_method_stack('Prepender', 'value').first.return_type.tag
# => "generic<T>"                          (expected "Integer")

RBS's inline form of the same annotation - prepend Prependable #[Integer] - was read only after a superclass, so a Ruby class could not express any of these relationships at all.

Solution: name reference pins from type.rooted_name and namespace pins from fqns, build every generic_values list from rooted_tags, pass those values through on prepend and extend and route both through inner_get_methods_from_reference so they resolve, and read the trailing #[...] annotation in the three mixin processors through a parser shared with the superclass path.

apiology and others added 7 commits September 5, 2026 22:13
prepend_to_pin and extend_to_pin computed generic_values from the
mixin type arguments and then dropped the value on the floor, so a
`prepend Foo[Integer]` or `extend Foo[String]` in RBS produced a
reference pin with no type arguments at all. The generic module was
mixed in as if it were bare, and the instantiated element type was
lost. RuboCop flagged both lines as Lint/UselessAssignment, which was
the only signal the bug existed.

Pass generic_values through to the constructor, the way the sibling
include_to_pin already does. Pin::Reference accepts the keyword
already, so Prepend and Extend needed no widening.

Also switch both from all_params.map(&:rooted_tags) to map(&:to_s),
matching every live caller of build_type in this file. The two differ:
rooted_tags emits a leading :: that no other reference pin carries, and
Reference#type re-parses the joined values, so the include path format
is the one already proven to round-trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Include, Prepend and Extend reference pins, and Interface and Module
namespace pins, took their name from decl.name.relative!.to_s, which
yields a name relative to wherever the declaration sat. Superclass
reference pins already used the rooted form, so the two kinds disagreed.

Name them from the built type instead - rooted_name for references, fqns
for namespaces - and build every generic_values list from rooted_tags
rather than to_s, so a pin carries a name that does not depend on the
context it was created in. Prepend and Extend were also dropping their
generic values entirely; pass them through.
convert_self_type_to_pins was the one reference pin still named from the
unrooted form, so it disagreed with the Include pins built everywhere
else in this file.
The reference pins built by prepend_to_pin and extend_to_pin now pass
their generic values through, but nothing exercised that. Add an RBS
fixture declaring a generic module that a class both prepends and
extends, and assert the resulting pins carry the type argument.

The extend case needs the negative form as well: add_mixins emits its
own Extend pin for the same mixin and has always set generic values on
it, so asserting only that some Extend pin carries ::Integer passes even
without the change. Asserting that no Extend pin is left with an empty
list is what actually covers extend_to_pin.
Carrying generic_values onto the Prepend and Extend reference pins had
no user-visible effect on its own: ApiMap#inner_get_methods resolved
both through the plain inner_get_methods, which performs no generic
substitution. A method on a generic prepended module inferred
generic<T> rather than the type argument the RBS declaration supplied.

Route prepends and extends through inner_get_methods_from_reference,
the same path includes and superclasses already take, so the reference
type resolves its generics against the namespace pin before its methods
are pulled in.

namespace_pin is nil whenever the namespace has no Pin::Namespace, and
all four call sites already passed a possibly-nil value; widening the
three @PARAM tags to match clears four pre-existing strong-typecheck
problems (591 to 587 locally, none introduced).

Replace the two pin-inspection specs with specs on inferred method
return types, which is where the behavior is observable. The included
case is a control: it passed before this change and still passes.
RBS documents a trailing type-argument annotation on mixins, written
`include Enumerable #[String]`. Solargraph read that annotation only
after a superclass, so a generic module mixed into Ruby source resolved
its methods to the bare type variable rather than the argument given.

Read it in the include, prepend and extend processors and pass the
values through as generic_values, which Pin::Reference already renders
into the reference type.

Hand the argument list to RBS::Parser and RbsTranslator rather than
splitting it locally, so a nested argument converts to Solargraph
syntax: Hash[String, Integer] becomes Hash{String => Integer}. The
superclass path, which does split locally, still truncates that form at
its first bracket.

Follow RBS on the two rules it defines for the annotation: one module
argument per call, and no space between the hash mark and the bracket.
Undercover reported parse_type_args at 75 percent: nothing exercised the
branch where RBS refuses the annotation. Add a spec whose brackets are
balanced, so the regex accepts them, but whose contents are not a type.
@apiology
apiology marked this pull request as ready for review September 7, 2026 15:34
These three @PARAM tags had been widened to accept nil, which cleared six
Solargraph complaints by declaring the nil legitimate rather than by
stopping it. Restore them so the gap stays visible.

The nil has a single source. Boolean has no Pin::Namespace at all, yet
qualify_superclass('Boolean') still returns 'Object', so the superclass
branch of inner_get_methods calls onward with a nil namespace_pin.
Instrumenting the full suite counted four such calls out of 35,837, every
one of them Boolean.

Six errors come back, four already present before this branch and two the
branch adds by routing prepends and extends through
inner_get_methods_from_reference. All six are accurate: nil does reach
those parameters. Fixing that means giving Boolean a namespace pin, not
describing the nil as valid input.
A reference pin's name is a rooted name, so its type arguments should be
rooted too. to_s drops that, which makes the stored value depend on where
it happened to be read from.

This also moves include_to_pin, which was already on to_s, so all three
mixin builders agree.

No measured difference: strong typecheck stays at 593 and the suite stays
at 1657 examples. The rootedness matters for what the value means, not
for anything currently exercised.
The mixin processors and the superclass processor each had their own
scan for a trailing #[...] annotation, with the same offset and
end-of-line scaffolding written twice.

Move the regex and both helpers into ParserGem::NodeMethods, which both
processors already include, and have each call trailing_rbs_type_args.

The superclass path gains what the mixin one already had: a recursive
bracket match, so a nested argument is not cut off at its first bracket,
and conversion through RBS::Parser and RbsTranslator, so RBS syntax
becomes Solargraph syntax. class Foo < Array #[Hash[String, Integer]]
gave Array<Hash[String and now gives Array<Hash{String => Integer}>.

Strong typecheck drops 593 to 591 with the duplicated code.
Three sites still built generic_values with to_s: the self-type include,
the superclass pin in the class declaration path, and add_mixins. All
feed the same field on the same kind of pin as the three mixin builders
already moved, so a reference pin's type arguments are now rooted
wherever they are built.

The superclass one already said so: name: type.rooted_name carries the
comment "reference pins use rooted names", six lines under a to_s that
produced an unrooted one.

Strong typecheck stays at 591 and the suite stays at 1658 examples.
castwide#1344 already passes generic_values through on the
prepend and extend pins and builds every generic_values list from
rooted_tags, which this branch had arrived at independently. Take its
conversions.rb wholesale, so the file is now identical to that branch and
this one carries none of it.

What remains here is the part castwide#1344 does not do: routing prepends and
extends through inner_get_methods_from_reference so those values actually
resolve, and reading RBS's inline #[...] annotation on mixins through a
parser shared with the superclass path.
@apiology apiology changed the title Root RBS reference pin and namespace names Resolve RBS type arguments on mixed-in modules Sep 9, 2026
These same three @PARAM tags were widened to accept nil, then reverted
back to strict types in 5f4eb8f, on the reasoning that the nil source
(Boolean lacks a Pin::Namespace) should be fixed at its root rather than
described as valid input.

A second, permanent source of nil exists alongside Boolean:
ActiveSupportConcern guesses a "<Mod>::ClassMethods" submodule path that
often does not exist, and inner_get_methods correctly returns zero
methods for it without namespace_pin ever being non-nil. Unlike
Boolean, there is no namespace pin to add here - a nonexistent
submodule is supposed to resolve to nothing, forever. Restore the nil
so the type matches both call sites' real behavior.

Verified by comparing typecheck output before and after this revert on
lib/solargraph/api_map.rb, lib/solargraph/complex_type.rb and
lib/solargraph/complex_type/unique_type.rb: it removes exactly the four
"Wrong argument type ... namespace_pin expected Solargraph::Pin::Base,
received Solargraph::Pin::Base, nil" errors at the
inner_get_methods_from_reference call sites, and introduces none.
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