Skip to content

Merge signatures only on matching parameter types - #95

Closed
apiology wants to merge 10 commits into
masterfrom
merge-signatures-on-parameter-types
Closed

Merge signatures only on matching parameter types#95
apiology wants to merge 10 commits into
masterfrom
merge-signatures-on-parameter-types

Conversation

@apiology

@apiology apiology commented Sep 8, 2026

Copy link
Copy Markdown
Owner

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

Problem: Combining two methods that declare the same parameter arity with different parameter types collapses them into one signature and drops one of the parameter types, so an argument type can no longer select its own return type.

(Integer) -> Integer   combined with   (Float) -> Float
  => (Integer) -> Integer, Float

Any method whose overloads dispatch on parameter type is affected, which is how RBS distinguishes overloads from one another.

Solution: Replace the per-parameter union-member count that stood in for "did the parameter-to-return mapping survive" — it compared the combined signature against its own input, and Pin::Parameter#combine_with returns the left side unchanged when the two parameters have different closures, so the count never moved — with Callable#same_parameter_types?, which compares the two inputs by generics, block and per-parameter rooted_tags, leaving the return type free to widen.

apiology and others added 9 commits September 8, 2026 14:17
Combining two method pins that describe the same arity with different
parameter types should keep one signature per parameter type, so a call
with a Float argument still selects the Float return.  Today the two
collapse into a single signature whose parameter type is whichever side
came first and whose return type is the union of both:

    (Integer) -> Integer  combined with  (Float) -> Float
      => (Integer) -> Integer, Float

Pin::Method#combine_same_type_arity_signatures already means to prevent
this: it merges two signatures only when the merge leaves type_arity
unchanged, precisely so parameter types can go on choosing the return
type.  That check cannot fire.  Pin::Parameter#combine_with returns self
unless the other parameter shares its closure, and a parameter pin's
closure is its enclosing signature pin, so parameters from two
separately-parsed method pins never combine.  Their types never widen,
type_arity never changes, and the guard sees a merge that looks free.

The example asserts the desired behavior and is marked pending until
parameter types can widen across method pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Combining two method pins collapsed signatures describing different
parameter types into one, so a call could no longer use its argument
types to pick a return type:

    (Integer) -> Integer  combine_with  (Float) -> Float
      => (Integer) -> Integer, Float

combine_same_type_arity_signatures meant to prevent exactly that: it
merged only when combining left type_arity unchanged, type_arity being
the parameter decl markers plus a count of each parameter type's union
members.  That count cannot change on this path.  Parameter#combine_with
returns self unless the other parameter shares its closure, and a
parameter pin's closure is its enclosing signature pin, which differs
between two separately parsed methods.  The combined signature therefore
always carried the left side's parameter types, type_arity always
matched, and the guard always merged.

type_arity was doing two jobs at once.  Bucketing now keys on arity, the
parameter shape by itself, and the merge test is a direct comparison of
the two input signatures: matching generics, matching block, and every
parameter type equal by rooted_tags.  Comparing the inputs rather than
the combined result is what lets the test fire at all.  Return types
stay out of it, since widening one is the point of merging.

full_type_arity, type_arity and type_arity_decl are left in place; no
caller remains outside full_type_arity, which itself has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Merging on matching parameter types keeps the Integer and Float
signatures apart, so the example passes and RSpec fails it as a pending
test that succeeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Integer#+ declares four RBS signatures differing only in parameter
type. Merging them into one would leave a single return type and lose
the distinction the declarations exist to make.

The spec drives a real RBS-backed ApiMap and infers at four call sites,
asserting Integer, Float, Rational and Complex in turn - one per
signature. Nothing else in the suite covers dispatch end to end; the
existing coverage checks the signature count on the pin, which stays
correct even if inference collapses downstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
The existing end-to-end spec drives Integer#+, which is core RBS.
Pin::Method#combine_with runs only from GemPins.combine_method_pins, so
core method pins never reach combine_signatures at all and that spec
passes with or without the parameter-type guard.

Reproduce the merge where it actually happens: two Pin::Method pins for
one method, combined the way a gem yardoc and its RBS collection entry
are, with same-arity signatures differing only in parameter type. Master
collapses them into one signature and Clip#infer returns the union of
both return types; the guard keeps them apart so the Float argument
still selects String.

The same collapse is observable on the date gem today, where
Date#upto(Date.new) infers Enumerator<Date, Date>, Date instead of
Enumerator<Date, Date>.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
combine_same_arity_signatures merges two same-arity signatures only
when Signature#same_parameter_types? agrees, and that compares
param.return_type.rooted_tags. YARD pin types are still unqualified
when GemPins.combine runs while RBS types are rooted, so a YARD
(String) -> Boolean and an RBS (::String) -> ::Boolean compare unequal
and both survive, where they name the same types and should collapse
to one.

Measured over the 73 gems carrying both a YARD and an RBS-collection
cache: 18059 method paths appear in both, 2818 reach the by-arity
merge, and 867 produce different signature sets between this branch
and master. Over 800 of those 867 are this rooted-versus-unrooted
spelling difference rather than a real type difference, and nothing in
the suite catches it.

The example asserts the collapse to a single signature and stays
agnostic on which spelling survives: combine_return_type picks the
unrooted side today, while a qualify-before-compare fix would keep the
rooted one, and pinning either would leave the example pending forever
under the other. It fails on the signature count, so RSpec reports it
pending rather than passing unexpectedly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
combine_same_arity_signatures merges two same-arity signatures only
when Signature#same_parameter_types? agrees, and that compares
param.return_type.rooted_tags. YARD pin types are unqualified when
GemPins.combine runs while RBS types are rooted, so a YARD
(Integer) -> String and an RBS (::Integer) -> ::String compare unequal
and both survive, where they name one type and one overload.

The previous example asserted signatures.length on the combined pin.
That is the point of explosion rather than the consequence, and it
would keep reporting pending even if the consequence were fixed some
other way. Replace it with the symptom a user meets: typing
Widget.new.scan( puts two entries labelled scan(count) in the
parameter-hints popup, because TextDocument::SignatureHelp feeds
Clip#signify results through Pin::Method#signature_help, which emits
one entry per signature. The example asserts a single entry and names
no type, so a qualify-before-compare fix and an unroot-before-compare
fix both satisfy it.

Confirmed against real gem data. Loading the cached YARD and RBS pins
for the 62 gems in this namespace and running GemPins.combine leaves
88 method paths whose surviving signatures become identical once the
leading :: is stripped from every parameter type. AST::Node#to_sexp is
the clearest: (::Integer) -> ::String alongside (Integer) -> String,
whose Pin::Method#detail then renders (*) => String instead of
(count) => String in hover and in the completion list.

Three other candidate symptoms show no difference and are not
asserted. Clip#infer returns ::String either way, since the union is
qualified before it reaches the caller. Strong typecheck reports no
problems either way. Both signify and define collapse to one signature
once a real argument is present, because overload selection picks the
matching one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
999d703 pins this defect at Clip#signify, one layer below the
protocol. Add a second pending example driving the same input through
TextDocument::SignatureHelp, so it fails where an editor actually reads
the result: message.result[:signatures] carries two entries labelled
scan(count) where one describes the overload.

Getting a combined pin into a Host-driven spec needs a route that
survives Library#sync_catalog, which rebuilds the ApiMap from the bench
on every request. Injecting pins with ApiMap#index, as the clip_spec
example does, is wiped by that rebuild. A Convention supplying the pins
in its Environ is carried through it, and spec/source_map_spec.rb
already registers one that way.

The unregister call sits in an ensure block rather than at the end of
the example. A pending example stops at the raised expectation, so a
trailing unregister would never run and Widget#scan would be injected
into every source map mapped later in the same process.

Verified against a control: two pins both spelling the parameter
::Integer collapse to a single scan(count) entry through this same
harness, so the duplication comes from the rooted-unrooted comparison
rather than from the Convention route.

The assertion names only the label, so a qualify-before-compare fix and
an unroot-before-compare fix both satisfy it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
Pin::Method#combine_same_arity_signatures merges two signatures only
when Callable#same_parameter_types? says their parameter types agree.
That comparison keyed on ComplexType#rooted_tags, but the two pins
GemPins.combine hands it come from different sources: YARD types are
still unqualified, RBS types are rooted.  So (String) and (::String) -
one type, two spellings - compared unequal and both signatures survived.
AST::Node#to_sexp came out of combine with two, where master had one:

    signatures=2 detail="(*) => String"
      params=["::Integer"] return="::String"
      params=["Integer"]   return="String"

88 method paths across this workspace's cached gems show that shape.

Key on ComplexType#tags instead.  It renders every union member and
every subtype unrooted, so Array<::String> and Array<String> both come
out "Array<String>", while ::Foo::Bar still renders "Foo::Bar" and stays
distinct from "Bar".  Qualifying the YARD types up front was ruled out:
at both pin-creation and combine time the map lacks dependency-gem
namespaces, and a name like Parser::Source::Range mis-resolves to core
::Range rather than failing.

The two examples that pinned the gap are unpended here rather than in a
follow-up commit, so that no commit reports them as passing
unexpectedly.
Pin::Parameter#type_arity_decl, Pin::Callable#type_arity and
Pin::Callable#full_type_arity existed to decide whether combining two
signatures would lose the mapping from specific parameter types to
specific return types. That guard now runs through
same_parameter_types?, which compares parameter type tags directly, so
the three became unreachable except from each other.

Pin::Parameter#arity_decl stays: Pin::Callable#arity still uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNEbPE8fjo8Ub7EXsBkbJJ
@apiology
apiology added this pull request to stack #101 September 9, 2026 13:33
@apiology

apiology commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Claude: Reopened upstream as castwide#1356; closing this one so the work has a single home.

@apiology apiology closed this Sep 9, 2026
@apiology
apiology removed this pull request from stack #101 September 10, 2026 03:02
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