Skip to content

Match trailing keyword arguments to keyword/kwrest parameters, not by position - #1292

Draft
apiology wants to merge 3 commits into
castwide:masterfrom
apiology:fix-kwarg-signature-matching
Draft

Match trailing keyword arguments to keyword/kwrest parameters, not by position#1292
apiology wants to merge 3 commits into
castwide:masterfrom
apiology:fix-kwarg-signature-matching

Conversation

@apiology

Copy link
Copy Markdown
Contributor

Summary

Call#inferred_pins matched call-site arguments to a signature's parameters purely by array index. A trailing keyword-arguments hash (e.g. use_ssl: true) is just another element in that array, so it was checked against whatever positional parameter happened to sit at that index instead of the method's keyword or kwrest parameter. The type mismatch rejected the whole overload, so a generic block-form overload lost its block-param typing whenever the call also passed a keyword argument.

Found while following up on #1290, which reported this against Net::HTTP.start, an RBS-declared generic block-form overload:

require 'net/http'

Net::HTTP.start('example.com', use_ssl: true) do |http|
  http.request(Net::HTTP::Get.new('/'))
end
$ bundle exec solargraph typecheck --level strong repro.rb
repro.rb:4: Unresolved call to request

Dropping use_ssl: true resolves http correctly, matching the original report.

Confirmed this is independent of #1290/#1289: it reproduces on master before that PR's commit, and with a plain YARD **kwrest method that has no RBS involvement at all — the gap is in call-site argument matching (Call#inferred_pins, lib/solargraph/source/chain/call.rb), not in RBS translation.

Fix

Split the trailing keyword-arguments hash out of the positional argument list and match it against the signature's keyword?/kwrestarg? parameters instead of by position.

Test plan

  • Added spec/source/chain/call_spec.rb cases using an inline RBS signature (Box.start) shaped like Net::HTTP.start (multiple optional positional params before a generic block-form overload with **kwrest), covering: kwarg matches the kwrest param, no-kwarg baseline still works.
  • bundle exec rspec — 1626 examples, 0 failures
  • bundle exec rubocop on changed files — no new offenses
  • bundle exec solargraph typecheck --level strong on changed files — 0 problems

… position

Call#inferred_pins matched call-site arguments to a signature's
parameters purely by array index. A trailing keyword-arguments hash
(e.g. use_ssl: true) is just another element in that array, so it was
checked against whatever positional parameter happened to sit at that
index instead of the method's keyword or kwrest parameter. The type
mismatch rejected the whole overload, so a generic block-form overload
lost its block-param typing whenever the call also passed a keyword
argument.

Confirmed independent of PR castwide#1289: reproducible on master before that
fix, and with a plain YARD **kwrest method (no RBS involved).

Split the keyword-arguments hash out of the positional list and match
it against the signature's keyword?/kwrestarg? parameters instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vue2FLfnJ3M7pM1oGdYiZy
@apiology

Copy link
Copy Markdown
Contributor Author

Claude: This regresses overload resolution for any method whose overloads differ only in which keyword(s) they accept, when an earlier overload also has a *rest parameter. match_overload_type's loop stops at the first overload where positional_arguments_match? returns true and breaks - and positional_arguments_match? has a pre-existing bypass (param.compatible_arg?(atype, api_map) || param.restarg?) that accepts any argument type against a *rest param regardless of fit. Splitting the keyword argument out lets an earlier, wrong overload "match" this way before the loop ever reaches a later, correct one.

require 'rbs'

class Foo
  # @return [void]
  def bar
    loader = RBS::EnvironmentLoader.new
    loader.add(path: Pathname.new('.'))
  end
end

RBS::EnvironmentLoader#add has two overloads - (path: Pathname) -> void and (library: String, version: (String | nil), ?resolve_dependencies: boolish) -> void. solargraph typecheck --level strong reports Missing keyword argument library to RBS::EnvironmentLoader#add on the path: call, because it gets matched against the library: overload instead. 0 problems on the parent commit, 1 on this branch's tip (b5c4dff6f).

keyword_argument_matches? inferred a single type for the entire
trailing keyword-arguments hash and asked whether ANY of an
overload's keyword/kwrest parameters accepted that type. A parameter
whose type didn't resolve (e.g. an RBS `untyped` keyword) trivially
"matched" via Parameter#compatible_arg?'s undefined-type bypass,
regardless of which keys the call actually passed. This let an
earlier overload's unrelated keyword steal the match from a later,
correct overload whenever it had such a parameter, e.g.
RBS::EnvironmentLoader#add(path:) getting matched against
add(library:, ?resolve_dependencies: boolish) because
resolve_dependencies has no resolvable type.

Fix it by walking the hash literal's actual keys and checking each
one against the identically-named parameter (or the kwrest
parameter, if present), and requiring all non-optional keyword
parameters to be present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyGKiHX7YHkwAoX4fHe3nU
apiology added a commit to apiology/solargraph that referenced this pull request Aug 13, 2026
…st parameters, not by position

A trailing keyword-arguments hash doesn't line up positionally with the
method's declared parameters, so Chain::Call now splits it off and matches
it separately against the overload's keyword/kwrest parameters instead of
checking it positionally by index.

Follow-up commit fixes a regression this introduced: matching by inferring
a single type for the whole trailing hash let a parameter with an
unresolvable type (e.g. an RBS `untyped` keyword) trivially "match" via
Parameter#compatible_arg?'s undefined-type bypass, regardless of which
keys the call actually passed - stealing the match from a later, correct
overload (e.g. RBS::EnvironmentLoader#add(path:) matching against
add(library:, ?resolve_dependencies: boolish) instead). Now walks the
hash literal's actual keys and checks each one against the
identically-named parameter (or kwrest, if present), requiring all
non-optional keyword parameters to be present.

Removes 2 @sg-ignore comments the fix made unneeded.

One residual, narrower issue remains and is marked `pending` in
spec/type_checker/levels/strong_spec.rb's "understands Open3 methods"
test with a citation to castwide#1292's PR comment: a
pre-existing bypass in positional_arguments_match? (param.restarg?
accepts any argument type against a *rest param) lets an earlier,
positionally-wrong overload "match" and win via first-match-wins
before a later, correct overload is tried. This predates castwide#1292 (masked
previously by less precise position-index matching) and doesn't surface
anywhere in solargraph's own codebase - confirmed via a clean full-project
strong typecheck - only in this one synthetic Open3.capture2e regression
test.
Comment thread spec/source/chain/call_spec.rb Outdated
api_map = Solargraph::ApiMap.new
source = Solargraph::Source.load_string(code, 'test.rb')
source_map = Solargraph::SourceMap.map(source)
source_map.send(:convention_pins=, conversions.pins)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not introduce use of private methods here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0beed30. Both call sites are gone ��� this one and a second one added later in the "differ only in which keyword(s) they accept" context.

The pins now arrive through the public Solargraph::Convention extension point: RbsPinConvention < Solargraph::Convention::Base returns them from #local, and each example registers and unregisters it. That is the path a real gem's pins already travel, via Convention.for_local in SourceMap#initialize, so the setup exercises the supported mechanism instead of reaching past it.

ApiMap#index was the other public candidate, and it is what spec/convention/activesupport_concern_spec.rb uses, but it does not fit here: ApiMap#catalog rebuilds the store from the core map, doc map, conventions environ, and the bench's iced and live pins, so pins supplied through #index are dropped as soon as a source is cataloged. These contexts need both the pins and a mapped source.

The two contexts duplicated their tmpdir, loader, and infer_at helper, so that setup moved into a shared context.

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

…iter

The RBS-backed contexts in call_spec.rb reached the pins they needed
with source_map.send(:convention_pins=, conversions.pins). That writer
is private on SourceMap, so the setup depended on an implementation
detail rather than a supported entry point.

Serve the same pins from a Convention instead. RbsPinConvention
returns them from #local, and each example registers and unregisters
it, so the pins arrive through Convention.for_local in
SourceMap#initialize - the path a real gem's pins already travel.

ApiMap#index is the other public option and is what
activesupport_concern_spec.rb uses, but it does not fit here:
ApiMap#catalog rebuilds the store from the core map, doc map,
conventions environ, and the bench's iced and live pins, so pins
supplied through #index are dropped once a source is cataloged. These
contexts need both the pins and a mapped source.

The two contexts shared their tmpdir, loader, and infer_at helper, so
that setup moves into a shared context.

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

Copy link
Copy Markdown
Contributor Author

Measured with the repro above, under solargraph typecheck --level strong:

  • b5c4dff: 1 problem, Missing keyword argument library to RBS::EnvironmentLoader#add
  • fa659d3: 0 problems

The mechanism was not the one described here. After split_keyword_argument runs, the trailing keyword hash never reaches positional_arguments_match?, so the param.restarg? bypass could not be what let the wrong overload win.

What actually happened was in keyword_argument_matches?: it inferred a single type for the whole trailing hash and asked whether any keyword/kwrest parameter accepted that type. resolve_dependencies in the library: overload has no resolvable type, so Parameter#compatible_arg?'s undefined-type bypass accepted anything, and the library: overload matched a call passing only path:. fa659d3, pushed 20 minutes after this comment, replaces that with a per-key walk: each key is checked against the identically-named parameter, or the kwrest parameter when there is one, and every non-optional keyword parameter must be present.

I also checked the shape described here directly ��� an earlier overload carrying both a *rest positional and a keyword the call does not pass:

class Box
  def self.pick: (*Integer vals, alpha: String) -> Integer
                | (beta: String) -> String
end

Box.pick(beta: "x") infers ::String, so the later overload wins. The earlier one is rejected by name in keyword_argument_matches? before the restarg? bypass is reachable.

The || param.restarg? looseness is real and pre-existing ��� it skips the element-type check for a *rest parameter ��� but it is reachable only through positional arguments and is unrelated to this change, so I have left it rather than widen the diff.

Regression coverage for the path:/library: case is in spec/source/chain/call_spec.rb.

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

apiology added a commit to apiology/solargraph that referenced this pull request Aug 18, 2026
One conflict in spec/source/chain/call_spec.rb: this branch added a
standalone example just above the block the PR converts from a context into
a shared_context served through the public Convention API. Kept the example
and took the PR shared_context opener; both include_context call sites come
from the PR side.
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