Skip to content

Commit 8e94b1c

Browse files
apiologyclaude
andcommitted
Check restarg argument types against the receiver's element type
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 #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
1 parent edb944d commit 8e94b1c

4 files changed

Lines changed: 166 additions & 12 deletions

File tree

lib/solargraph/rbs_translator.rb

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,45 @@ def self.to_complex_type(type)
2525
# @param closure [Pin::Closure]
2626
# @return [Pin::Parameter]
2727
def self.to_parameter_pin(param_type, name, decl, closure)
28-
return_type = if decl == :restarg
29-
ComplexType.parse('Array')
30-
elsif decl == :kwrestarg
31-
ComplexType.parse('Hash{Symbol => Object}')
32-
else
33-
RbsTranslator.to_complex_type(param_type.type)
34-
end
28+
return_type = case decl
29+
when :restarg
30+
# @sg-ignore RBS type understanding issue - see to_complex_type
31+
RbsTranslator.to_restarg_return_type(param_type.type)
32+
when :kwrestarg
33+
# @sg-ignore RBS type understanding issue - see to_complex_type
34+
RbsTranslator.to_kwrestarg_return_type(param_type.type)
35+
else
36+
# @sg-ignore RBS type understanding issue - to_complex_type's own param type is too narrow
37+
RbsTranslator.to_complex_type(param_type.type)
38+
end
3539
Solargraph::Pin::Parameter.new(decl: decl, name: name, closure: closure, return_type: return_type, source: :rbs, type_location: to_sg_location(param_type.location) || closure.type_location)
3640
end
3741

42+
# The type of the local variable a restarg is captured into
43+
# inside the method body - a wrapped Array of its per-element
44+
# type, e.g. `Array<Integer>` for `*args: Integer`. When the
45+
# element type isn't known (e.g. an untyped inline `#:`
46+
# annotation), falls back to a bare, unparameterized Array.
47+
#
48+
# @param elem_rbs_type [RBS::Types::Bases::Base]
49+
# @return [ComplexType]
50+
def self.to_restarg_return_type elem_rbs_type
51+
elem_type = RbsTranslator.to_complex_type(elem_rbs_type)
52+
return ComplexType.parse('Array') if elem_type.undefined?
53+
ComplexType.new([ComplexType::UniqueType.new('Array', [], [elem_type], rooted: true, parameters_type: :list)])
54+
end
55+
56+
# Likewise, the type of the local variable a kwrestarg is
57+
# captured into - a wrapped Hash of Symbol to its per-value type.
58+
#
59+
# @param elem_rbs_type [RBS::Types::Bases::Base]
60+
# @return [ComplexType]
61+
def self.to_kwrestarg_return_type elem_rbs_type
62+
elem_type = RbsTranslator.to_complex_type(elem_rbs_type)
63+
return ComplexType.parse('Hash{Symbol => Object}') if elem_type.undefined?
64+
ComplexType.new([ComplexType::UniqueType.new('Hash', [ComplexType.try_parse('Symbol')], [elem_type], rooted: true, parameters_type: :hash)])
65+
end
66+
3867
# @param method_type [RBS::MethodType]
3968
# @param closure [Pin::Closure]
4069
# @param parameter_names [Array<String>]

lib/solargraph/type_checker.rb

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,10 +407,12 @@ def argument_problems_for chain, api_map, closure_pin, locals, location
407407
return [] if !rules.validate_calls? || base.links.first.is_a?(Solargraph::Source::Chain::ZSuper)
408408

409409
all_errors = []
410+
receiver_type = base.base.infer(api_map, closure_pin, locals)
410411
pin.signatures.sort_by { |sig| sig.parameters.length }.each do |sig|
411412
params = param_details_from_stack(sig, pins)
412413

413-
signature_errors = signature_argument_problems_for location, locals, closure_pin, params, arguments, sig, pin
414+
signature_errors = signature_argument_problems_for(location, locals, closure_pin, params, arguments, sig,
415+
pin, receiver_type)
414416

415417
if signature_errors.empty?
416418
# we found a signature that works - meaning errors from
@@ -431,16 +433,28 @@ def argument_problems_for chain, api_map, closure_pin, locals, location
431433
# @param arguments [Array<Source::Chain>]
432434
# @param sig [Pin::Signature]
433435
# @param pin [Pin::Method]
436+
# @param receiver_type [ComplexType] the type of the object the
437+
# method is being called on, used to resolve the restarg's
438+
# declared type (e.g. `Elem` for `Array#push`) against the
439+
# receiver's actual generic parameters (e.g. `Integer` for an
440+
# `Array<Integer>` receiver)
434441
#
435442
# @return [Array<Problem>]
436-
def signature_argument_problems_for location, locals, closure_pin, params, arguments, sig, pin
443+
def signature_argument_problems_for location, locals, closure_pin, params, arguments, sig, pin, receiver_type
437444
errors = []
438445
# @todo add logic mapping up restarg parameters with
439446
# arguments (including restarg arguments). Use tuples
440447
# when possible, and when not, ensure provably
441448
# incorrect situations are detected.
442449
sig.parameters.each_with_index do |par, idx|
443-
return errors if par.decl == :restarg # bail out and assume the rest is valid pending better arg processing
450+
if par.decl == :restarg
451+
# A restarg absorbs every remaining positional argument at
452+
# the call site - check each of them against the restarg's
453+
# own declared/resolved type instead of bailing out on the
454+
# whole signature. This is what catches e.g. `y.push('two')`
455+
# on a `y: Array<Integer>`.
456+
return restarg_problems_for(location, locals, closure_pin, arguments, sig, pin, receiver_type, par, idx)
457+
end
444458
argchain = arguments[idx]
445459
if argchain.nil?
446460
final_arg = arguments.last
@@ -500,6 +514,91 @@ def signature_argument_problems_for location, locals, closure_pin, params, argum
500514
errors
501515
end
502516

517+
# Checks each call-site argument absorbed by a restarg parameter
518+
# against the restarg's own declared/resolved type, instead of
519+
# bailing out on the whole signature. This is what catches e.g.
520+
# `y.push('two')` on a `y: Array<Integer>`.
521+
#
522+
# @param location [Location]
523+
# @param locals [Array<Pin::LocalVariable>]
524+
# @param closure_pin [Pin::Closure]
525+
# @param arguments [Array<Source::Chain>]
526+
# @param sig [Pin::Signature]
527+
# @param pin [Pin::Method]
528+
# @param receiver_type [ComplexType] the type of the object the
529+
# method is being called on, used to resolve the restarg's
530+
# declared type (e.g. `Elem` for `Array#push`) against the
531+
# receiver's actual generic parameters (e.g. `Integer` for an
532+
# `Array<Integer>` receiver)
533+
# @param par [Pin::Parameter] the restarg parameter
534+
# @param idx [Integer] the restarg's index within sig.parameters
535+
#
536+
# @return [Array<Problem>]
537+
def restarg_problems_for location, locals, closure_pin, arguments, sig, pin, receiver_type, par, idx
538+
errors = []
539+
540+
# par.return_type is the type of the local variable the
541+
# restarg is captured into inside the method body (e.g.
542+
# `Array<Integer>`, not the unwrapped per-element `Integer`) -
543+
# resolve any remaining generics against the receiver, then
544+
# unwrap one level to get the type each individual argument
545+
# must conform to.
546+
# @sg-ignore pin.closure is a Pin::Namespace for a top-level method pin
547+
wrapped_ptype = par.return_type.resolve_generics(pin.closure, receiver_type)
548+
ptype = ComplexType.new(wrapped_ptype.items.flat_map(&:subtypes).flat_map(&:items))
549+
# @sg-ignore pin.closure is a Pin::Namespace for a top-level method pin
550+
ptype = ptype.qualify(api_map, *pin.closure.gates).self_to_type(par.context)
551+
return errors if ptype.nil? || ptype.undefined?
552+
553+
restarg_arguments(sig, arguments, idx).each do |restargchain|
554+
# A spread argument's own element types aren't statically
555+
# known here - skip it rather than guess.
556+
next if restargchain.nil? || restargchain.node.type == :splat
557+
558+
restargtype = restargchain.infer(api_map, closure_pin, locals).self_to_type(closure_pin.context)
559+
next unless restargtype.defined?
560+
next if arg_conforms_to?(restargtype, ptype)
561+
562+
errors.push Problem.new(location,
563+
"Wrong argument type for #{pin.path}: #{par.name} expected #{ptype}, received #{restargtype}")
564+
end
565+
errors
566+
end
567+
568+
# @param sig [Pin::Signature]
569+
# @param arguments [Array<Source::Chain>]
570+
# @param idx [Integer] the restarg's index within sig.parameters
571+
# @return [Array<Source::Chain>] the call-site arguments absorbed
572+
# by the restarg at idx
573+
# @sg-ignore flow sensitive typing incorrectly includes an
574+
# intermediate local variable's type in the inferred return type
575+
def restarg_arguments sig, arguments, idx
576+
# A restarg can be followed by trailing positional parameters
577+
# (`def foo(*path, baz)`) - those consume the last N call-site
578+
# arguments, so they don't belong to this restarg's own
579+
# arguments.
580+
# @type [Array<Pin::Parameter>]
581+
trailing_positional_params = sig.parameters[(idx + 1)..] || []
582+
trailing_positional_count = trailing_positional_params.count { |p| p.decl == :arg }
583+
# @type [Array<Source::Chain>]
584+
# @sg-ignore flow sensitive typing issue with the ternary above
585+
restargs = trailing_positional_count.zero? ? arguments[idx..] || [] : arguments[idx...-trailing_positional_count] || []
586+
587+
# A trailing bare hash argument (`foo(*args, key: val)`) is
588+
# parsed as an implicit kwargs hash appended to the call's
589+
# arguments - it belongs to the signature's keyword parameters,
590+
# not the restarg.
591+
# @type [Source::Chain, nil]
592+
last_arg = restargs.last
593+
has_trailing_hash = last_arg && last_arg.links.last.is_a?(Solargraph::Source::Chain::Hash)
594+
has_keyword_params = sig.parameters.any? { |p| %i[kwarg kwoptarg kwrestarg].include?(p.decl) }
595+
if has_trailing_hash && has_keyword_params
596+
restargs[0...-1]
597+
else
598+
restargs
599+
end
600+
end
601+
503602
# @param sig [Pin::Signature]
504603
# @param argchain [Solargraph::Source::Chain]
505604
# @param api_map [ApiMap]

spec/pin/method_spec.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,10 @@ def foo(*bar); end
709709
expect(pin.signatures.first.parameters).to be_one
710710
expect(pin.signatures.first.parameters.first.name).to eq('bar')
711711
expect(pin.signatures.first.parameters.first.decl).to eq(:restarg)
712-
expect(pin.signatures.first.parameters.first.return_type.to_s).to eq('Array')
712+
# `bar` here is the restarg's declared per-element type (RBS's
713+
# inline shorthand identifies it by position, not name), now
714+
# tracked instead of being erased to a bare `Array`
715+
expect(pin.signatures.first.parameters.first.return_type.to_s).to eq('Array<bar>')
713716
end
714717

715718
it 'sets required keyword parameters' do
@@ -754,7 +757,9 @@ def foo(**bar); end
754757
expect(pin.signatures.first.parameters).to be_one
755758
expect(pin.signatures.first.parameters.first.name).to eq('bar')
756759
expect(pin.signatures.first.parameters.first.decl).to eq(:kwrestarg)
757-
expect(pin.signatures.first.parameters.first.return_type.to_s).to eq('Hash{Symbol => Object}')
760+
# `bar` here is the kwrestarg's declared per-value type, now
761+
# tracked instead of being erased to `Hash{Symbol => Object}`
762+
expect(pin.signatures.first.parameters.first.return_type.to_s).to eq('Hash{Symbol => bar}')
758763
end
759764

760765
it 'sets block parameters' do

spec/type_checker/levels/strict_spec.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,27 @@ def foo str; end
4949
.to eq(['Wrong argument type for #foo: str expected String, received Class<File>'])
5050
end
5151

52+
it 'catches a bad #push argument against an inferred Array element type (#1223)' do
53+
# Reported by @castwide on PR #1223: https://github.com/castwide/solargraph/pull/1223#issuecomment-3138551901
54+
#
55+
# y = [1]
56+
# y.push 'two'
57+
# y # => inferred as Array<Integer>, silently missing the pushed String
58+
#
59+
# Inference still can't track the mutation (see the "does not
60+
# track a plain array through a mutating call" spec in
61+
# clip_spec.rb), but type checking can now catch the bad
62+
# argument at the call site itself, since Array#push's restarg
63+
# is checked against the receiver's element type instead of
64+
# being skipped entirely.
65+
checker = type_checker(%(
66+
y = [1]
67+
y.push 'two'
68+
))
69+
expect(checker.problems.map(&:message))
70+
.to eq(['Wrong argument type for Array#push: objects expected Integer, received String'])
71+
end
72+
5273
it 'handles compatible interfaces with self types on call' do
5374
checker = type_checker(%(
5475
# @param a [Enumerable<String>]

0 commit comments

Comments
 (0)