From 0945ff37389019fe93f3609409e6b4284d35cb3d Mon Sep 17 00:00:00 2001 From: Yusuke Endoh Date: Thu, 26 Feb 2026 12:29:59 +0000 Subject: [PATCH 1/2] Add keyword argument type checking to overload resolution --- lib/typeprof/core/graph/box.rb | 60 ++++++++++++++++--- scenario/rbs/keyword-overload.rb | 20 +++++++ .../keyword-generic-overload-oscillation.rb | 18 ++++++ 3 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 scenario/rbs/keyword-overload.rb create mode 100644 scenario/regressions/keyword-generic-overload-oscillation.rb diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index 07936c74..b229708d 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -179,6 +179,19 @@ def match_arguments?(genv, changes, param_map, a_args, method_type) end end + # Check keyword arguments by inspecting the keywords vertex types + # directly. We avoid get_keyword_arg here because it creates a fresh + # Vertex each call, which would destabilize the change-set edges and + # cause oscillation when match_arguments? runs on every box re-eval. + if a_args.keywords + method_type.req_keyword_keys.zip(method_type.req_keyword_values) do |key, ty| + return false unless keyword_arg_typecheck?(genv, changes, a_args.keywords, key, ty, param_map) + end + method_type.opt_keyword_keys.zip(method_type.opt_keyword_values) do |key, ty| + return false unless keyword_arg_typecheck?(genv, changes, a_args.keywords, key, ty, param_map) + end + end + return true end @@ -260,21 +273,25 @@ def resolve_overloads(changes, genv, node, param_map, a_args, ret, &blk) # # Top-level empty vertices are always uninformative. For type # parameter vertices (e.g., Array[T], Hash[K,V], tuples), we - # only recurse when overloads differ in their positional parameter - # types -- otherwise empty type params (like those of `{}`) cannot - # cause oscillation and should not trigger bail-out. - overloads_differ_in_positionals = !@method_types.each_cons(2).all? {|mt1, mt2| - positionals_match?(mt1, mt2) + # only recurse when overloads differ in their positional or keyword + # parameter types -- otherwise empty type params (like those of + # `{}`) cannot cause oscillation and should not trigger bail-out. + overloads_differ = !@method_types.each_cons(2).all? {|mt1, mt2| + positionals_match?(mt1, mt2) && keywords_match?(mt1, mt2) } - has_uninformative_args = if overloads_differ_in_positionals - a_args.positionals.any? {|vtx| vertex_uninformative?(genv, vtx) } + has_uninformative_args = if overloads_differ + a_args.positionals.any? {|vtx| vertex_uninformative?(genv, vtx) } || + (a_args.keywords && vertex_uninformative?(genv, a_args.keywords)) else - a_args.positionals.any? {|vtx| vtx.types.empty? } + a_args.positionals.any? {|vtx| vtx.types.empty? } || + (a_args.keywords && a_args.keywords.types.empty?) end if has_uninformative_args a_args.positionals.each do |vtx| changes.add_edge(genv, vtx, changes.target) end + # Note: keywords already have a permanent edge to the box + # (established in MethodCallBox#initialize), so no extra edge needed. return end @@ -303,6 +320,22 @@ def vertex_uninformative?(genv, vtx, depth = 0) false end + # Typecheck a single keyword argument value against the expected type + # by directly inspecting the pre-existing value vertices in the + # keywords vertex's types (Record, Hash, Instance). + def keyword_arg_typecheck?(genv, changes, keywords_vtx, key, expected_ty, param_map) + keywords_vtx.each_type do |kw_ty| + val_vtx = case kw_ty + when Type::Hash then kw_ty.get_value(key) + when Type::Record then kw_ty.get_value(key) + when Type::Instance then kw_ty.mod == genv.mod_hash ? kw_ty.args[1] : nil + else nil + end + return false if val_vtx && !expected_ty.typecheck(genv, changes, val_vtx, param_map) + end + true + end + # Check if two method types have structurally identical positional # parameter types (req, opt, rest). def positionals_match?(mt1, mt2) @@ -314,6 +347,17 @@ def positionals_match?(mt1, mt2) (mt1.rest_positionals.nil? || sig_types_match?(mt1.rest_positionals, mt2.rest_positionals)) end + # Check if two method types have structurally identical keyword + # parameter types (req, opt, rest). + def keywords_match?(mt1, mt2) + return false unless mt1.req_keyword_keys == mt2.req_keyword_keys + return false unless mt1.opt_keyword_keys == mt2.opt_keyword_keys + return false unless mt1.rest_keywords.nil? == mt2.rest_keywords.nil? + mt1.req_keyword_values.zip(mt2.req_keyword_values).all? {|a, b| sig_types_match?(a, b) } && + mt1.opt_keyword_values.zip(mt2.opt_keyword_values).all? {|a, b| sig_types_match?(a, b) } && + (mt1.rest_keywords.nil? || sig_types_match?(mt1.rest_keywords, mt2.rest_keywords)) + end + # Structural equality check for two SigTyNode objects. def sig_types_match?(a, b) return false unless a.class == b.class diff --git a/scenario/rbs/keyword-overload.rb b/scenario/rbs/keyword-overload.rb new file mode 100644 index 00000000..c0af60b9 --- /dev/null +++ b/scenario/rbs/keyword-overload.rb @@ -0,0 +1,20 @@ +## update: test.rbs +class C + def foo: (key: Integer) -> Integer | (key: String) -> String +end + +## update: test.rb +class C + def bar + foo(key: 1) + end + def baz + foo(key: "s") + end +end + +## assert: test.rb +class C + def bar: -> Integer + def baz: -> String +end diff --git a/scenario/regressions/keyword-generic-overload-oscillation.rb b/scenario/regressions/keyword-generic-overload-oscillation.rb new file mode 100644 index 00000000..65c4a1e5 --- /dev/null +++ b/scenario/regressions/keyword-generic-overload-oscillation.rb @@ -0,0 +1,18 @@ +## update: test.rbs +class Foo + def self.f: (key: Array[Integer]) -> String | (key: Array[String]) -> Symbol +end + +## update: test.rb +# Keyword arguments with generic types could cause oscillation +# if the keyword arg has empty type parameter vertices. +def check + @x = Foo.f(key: [@x]) +end + +## assert +class Object + def check: -> untyped +end + +## diagnostics From 8d2b5db13068c7e58a5b24d9eceb3d6a72cb4d1b Mon Sep 17 00:00:00 2001 From: Yusuke Endoh Date: Thu, 26 Feb 2026 12:38:53 +0000 Subject: [PATCH 2/2] Add rest keywords (**) support for overload resolution and fix FIXME This allows overload resolution for `(**Integer) -> A | (**String) -> B`. --- lib/typeprof/core/graph/box.rb | 41 ++++++++++++++++++++++++-- scenario/method/keywords.rb | 2 +- scenario/rbs/mixed-keyword-overload.rb | 21 +++++++++++++ scenario/rbs/rest-keyword-overload.rb | 20 +++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 scenario/rbs/mixed-keyword-overload.rb create mode 100644 scenario/rbs/rest-keyword-overload.rb diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index b229708d..33fb495d 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -190,6 +190,9 @@ def match_arguments?(genv, changes, param_map, a_args, method_type) method_type.opt_keyword_keys.zip(method_type.opt_keyword_values) do |key, ty| return false unless keyword_arg_typecheck?(genv, changes, a_args.keywords, key, ty, param_map) end + if method_type.rest_keywords + return false unless rest_keyword_args_typecheck?(genv, changes, a_args.keywords, method_type, param_map) + end end return true @@ -336,6 +339,30 @@ def keyword_arg_typecheck?(genv, changes, keywords_vtx, key, expected_ty, param_ true end + # Typecheck rest keyword argument values (those not consumed by named + # keywords) against the method type's rest_keywords type. + def rest_keyword_args_typecheck?(genv, changes, keywords_vtx, method_type, param_map) + named_keys = method_type.req_keyword_keys + method_type.opt_keyword_keys + rest_ty = method_type.rest_keywords + keywords_vtx.each_type do |kw_ty| + case kw_ty + when Type::Record + kw_ty.fields.each do |key, val_vtx| + next if named_keys.include?(key) + return false unless rest_ty.typecheck(genv, changes, val_vtx, param_map) + end + when Type::Hash + val_vtx = kw_ty.base_type(genv).args[1] + return false if val_vtx && !rest_ty.typecheck(genv, changes, val_vtx, param_map) + when Type::Instance + if kw_ty.mod == genv.mod_hash && kw_ty.args[1] + return false unless rest_ty.typecheck(genv, changes, kw_ty.args[1], param_map) + end + end + end + true + end + # Check if two method types have structurally identical positional # parameter types (req, opt, rest). def positionals_match?(mt1, mt2) @@ -726,8 +753,18 @@ def pass_arguments(changes, genv, a_args) end if @node.rest_keywords - # FIXME: Extract the rest keywords excluding req_keywords and opt_keywords. - changes.add_edge(genv, a_args.keywords, @f_args.rest_keywords) + named_keys = @node.req_keywords + @node.opt_keywords + a_args.keywords.each_type do |kw_ty| + case kw_ty + when Type::Record + rest_fields = kw_ty.fields.reject {|key, _| named_keys.include?(key) } + base = kw_ty.base_type(genv) + rest_record = Type::Record.new(genv, rest_fields, base) + changes.add_edge(genv, Source.new(rest_record), @f_args.rest_keywords) + when Type::Hash, Type::Instance + changes.add_edge(genv, Source.new(kw_ty), @f_args.rest_keywords) + end + end end end diff --git a/scenario/method/keywords.rb b/scenario/method/keywords.rb index 8bd5af34..2f5a1207 100644 --- a/scenario/method/keywords.rb +++ b/scenario/method/keywords.rb @@ -71,5 +71,5 @@ def foo(a:, b: 1, **c) ## assert class Object - def foo: (a: String, ?b: Integer, **String | Integer | true) -> { a: String, b: Integer, c: true } + def foo: (a: String, ?b: Integer, **true) -> { c: true } end diff --git a/scenario/rbs/mixed-keyword-overload.rb b/scenario/rbs/mixed-keyword-overload.rb new file mode 100644 index 00000000..784494f2 --- /dev/null +++ b/scenario/rbs/mixed-keyword-overload.rb @@ -0,0 +1,21 @@ +## update: test.rbs +class C + def foo: (mode: :read, **Integer) -> Array[Integer] + | (mode: :write, **String) -> Array[String] +end + +## update: test.rb +class C + def bar + foo(mode: :read, x: 1) + end + def baz + foo(mode: :write, x: "a") + end +end + +## assert: test.rb +class C + def bar: -> Array[Integer] + def baz: -> Array[String] +end diff --git a/scenario/rbs/rest-keyword-overload.rb b/scenario/rbs/rest-keyword-overload.rb new file mode 100644 index 00000000..7430dbe9 --- /dev/null +++ b/scenario/rbs/rest-keyword-overload.rb @@ -0,0 +1,20 @@ +## update: test.rbs +class C + def foo: (**Integer) -> Integer | (**String) -> String +end + +## update: test.rb +class C + def bar + foo(x: 1, y: 2) + end + def baz + foo(x: "a", y: "b") + end +end + +## assert: test.rb +class C + def bar: -> Integer + def baz: -> String +end