From cfae40402e2c54aa44de5096167129e4c80ca6bf Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 16 Aug 2026 16:15:47 -0400 Subject: [PATCH 1/2] Infer Object, not undefined, from .new on an unparameterized Class RBS declares Class#new as (*untyped) -> untyped because RBS cannot parameterize Class. ApiMap#get_methods already replaces that pin with a self-returning synthesis for concrete namespaces, but deliberately skips rooted_tag Class / Class, leaving untyped - so Class.new.new and k.new (k: bare Class) were uninferrable. Proxy the pin instead: scope :class (literal Class.new) -> Class (the default-superclass anonymous class), scope :instance (.new on an unparameterized Class-typed receiver) -> Object (some instance of an unknown class). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT --- lib/solargraph/api_map.rb | 14 +++++++++++++- spec/source_map/clip_spec.rb | 19 +++++++++++++++---- spec/type_checker/levels/strong_spec.rb | 16 ++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 26b42ddb4..669b2e4a9 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -470,7 +470,19 @@ def get_methods rooted_tag, scope: :instance, visibility: [:public], deep: true result.concat inner_get_methods('Kernel', :instance, visibility, deep, skip) else result.concat inner_get_methods(rooted_tag, scope, visibility, deep, skip) - unless %w[Class Class].include?(rooted_tag) + if %w[Class Class].include?(rooted_tag) + # RBS declares Class#new as untyped because it cannot + # parameterize Class. We know more: Class.new returns a new + # class whose instances are (at least) Objects, and calling + # .new on an unparameterized Class-typed receiver returns + # some instance of it - an Object. + result.map! do |pin| + next pin unless pin.path == 'Class#new' + + return_tag = scope == :class ? 'Class' : 'Object' + pin.proxy_with_signatures ComplexType.try_parse(return_tag) + end + else result.map! do |pin| next pin unless pin.path == 'Class#new' init_pin = get_method_stack(rooted_tag, 'initialize').first diff --git a/spec/source_map/clip_spec.rb b/spec/source_map/clip_spec.rb index b30002967..77d09a146 100644 --- a/spec/source_map/clip_spec.rb +++ b/spec/source_map/clip_spec.rb @@ -822,7 +822,7 @@ def self.new expect(clip.infer.tag).to eq('Class') end - it 'infers undefined from Class#new' do + it 'infers Object from .new on an unparameterized Class' do source = Solargraph::Source.load_string(%( cls = Class.new cls.new @@ -830,17 +830,28 @@ def self.new api_map = Solargraph::ApiMap.new api_map.map source clip = api_map.clip_at('test.rb', [2, 11]) - expect(clip.infer.tag).to eq('undefined') + expect(clip.infer.tag).to eq('Object') end - it 'infers undefined from Class.new.new' do + it 'infers Object from Class.new.new' do source = Solargraph::Source.load_string(%( Class.new.new ), 'test.rb') api_map = Solargraph::ApiMap.new api_map.map source clip = api_map.clip_at('test.rb', [1, 17]) - expect(clip.infer.tag).to eq('undefined') + expect(clip.infer.tag).to eq('Object') + end + + it 'still infers Class for a variable assigned from Class.new' do + source = Solargraph::Source.load_string(%( + cls = Class.new + cls + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + clip = api_map.clip_at('test.rb', [2, 7]) + expect(clip.infer.tag).to eq('Class') end it 'completes class instance variables in the namespace' do diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 1043a192d..1985538cf 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -925,5 +925,21 @@ def baz(bases) # an error when trying to declare sub as Subclass expect(checker.problems.map(&:message)).not_to include('Unresolved call to bar on Base') end + + it 'infers a return through .new on an unparameterized Class' do + checker = type_checker(%( + # @return [Object] + def plain_class_new + Class.new.new + end + + # @return [Object] + def local_class_new + k = Class.new + k.new + end + )) + expect(checker.problems).to be_empty + end end end From d3fa5a1569130a6364c0a30b0759efc59efe3c10 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 16 Aug 2026 16:17:10 -0400 Subject: [PATCH 2/2] Keep Class>#new symbolic instead of undefined For a receiver typed Class>, singleton-method lookup runs against the nonexistent 'generic' namespace, so no Class#new pin ever appears and .new goes undefined - even though the instance type is exactly the generic the caller binds. Synthesize a permissive new pin (restarg/kwrestarg, since the real initializer is unknown) returning the generic tag itself. Caller-side binding already worked; this fixes the method-body side. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT --- lib/solargraph/api_map.rb | 22 ++++++++++++++++++ spec/source_map/clip_spec.rb | 31 +++++++++++++++++++++++++ spec/type_checker/levels/strong_spec.rb | 25 ++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 669b2e4a9..004f59fbb 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -483,6 +483,28 @@ def get_methods rooted_tag, scope: :instance, visibility: [:public], deep: true pin.proxy_with_signatures ComplexType.try_parse(return_tag) end else + if scope == :class && rooted_type.name == ComplexType::GENERIC_TAG_NAME + # A Class> receiver: the namespace lookup on the + # unresolved generic finds no singleton methods (there is no + # 'generic' namespace), so no Class#new pin ever appears and + # .new goes undefined. The instance type is knowable, though - + # it is the generic itself, which the caller binds. Synthesize + # a permissive new pin that keeps it symbolic; argument + # checking stays lenient because the real initializer is + # unknown here. + generic_new = Pin::Method.new( + name: 'new', + scope: :class, + return_type: rooted_type, + closure: Pin::Namespace.new(name: 'Class', source: :api_map), + source: :api_map + ) + generic_new.parameters = [ + Pin::Parameter.new(decl: :restarg, name: 'args', closure: generic_new, source: :api_map), + Pin::Parameter.new(decl: :kwrestarg, name: 'kwargs', closure: generic_new, source: :api_map) + ].freeze + result.push generic_new + end result.map! do |pin| next pin unless pin.path == 'Class#new' init_pin = get_method_stack(rooted_tag, 'initialize').first diff --git a/spec/source_map/clip_spec.rb b/spec/source_map/clip_spec.rb index 77d09a146..f5b2fc897 100644 --- a/spec/source_map/clip_spec.rb +++ b/spec/source_map/clip_spec.rb @@ -854,6 +854,37 @@ def self.new expect(clip.infer.tag).to eq('Class') end + it 'infers the symbolic generic from Class>#new' do + source = Solargraph::Source.load_string(%( + # @generic T + # @param clazz [Class>] + # @return [generic] + def create_object(clazz) + clazz.new + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + clip = api_map.clip_at('test.rb', [5, 15]) + expect(clip.infer.tag).to eq('generic') + end + + it 'binds the generic at call sites of a Class>#new factory' do + source = Solargraph::Source.load_string(%( + # @generic T + # @param clazz [Class>] + # @return [generic] + def create_object(clazz) + clazz.new + end + create_object(String) + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + clip = api_map.clip_at('test.rb', [7, 8]) + expect(clip.infer.tag).to eq('String') + end + it 'completes class instance variables in the namespace' do source = Solargraph::Source.load_string(%( class Foo diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 1985538cf..00b1c017b 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -941,5 +941,30 @@ def local_class_new )) expect(checker.problems).to be_empty end + + it 'infers a generic return through Class>#new' do + checker = type_checker(%( + # @generic T + # @param clazz [Class>] + # @return [generic] + def create_object(clazz) + clazz.new + end + )) + expect(checker.problems).to be_empty + end + + it 'accepts arguments through Class>#new' do + checker = type_checker(%( + # @generic T + # @param clazz [Class>] + # @param opts [Hash{Symbol => Object}] + # @return [generic] + def create_object_with_args(clazz, opts) + clazz.new(:sym, **opts) + end + )) + expect(checker.problems).to be_empty + end end end