From 07cf6f585d9f8960a33471689325a87ad23fb106 Mon Sep 17 00:00:00 2001 From: Yusuke Endoh Date: Wed, 25 Feb 2026 08:09:53 +0000 Subject: [PATCH] Skip overload resolution when positional arguments have no type information When a multi-overload method is called with an empty (untyped) positional argument, we cannot determine which overload to select. Previously, empty vertices matched all overloads via the found_any mechanism in typecheck, which caused infinite oscillation in cyclic patterns like: @x = Foo.transform(@x) # transform has disjoint overloads The cycle was: empty -> all match -> types flow in -> mismatch -> types removed -> empty -> repeat forever. Fix: in resolve_overloads, if any positional argument vertex is empty, skip overload resolution entirely and return untyped. Dependency edges are still added so the box re-runs when arguments later receive types. This is stateless (no flags on boxes or vertices), naturally convergent, and semantically correct: we cannot dispatch on what we do not know. Trade-off: passing untyped to an overloaded method now returns untyped instead of the union of all return types. This affects 4 existing tests. Single-signature methods (force=true) are unaffected. --- lib/typeprof/core/graph/box.rb | 15 +++++++++++++++ scenario/rbs/untyped-for-overload-record.rb | 2 +- scenario/rbs/untyped-for-overload-singleton.rb | 2 +- scenario/rbs/untyped-for-overload-tuple.rb | 2 +- scenario/rbs/untyped-for-overload.rb | 2 +- .../avoid-infinite-loop-overload-chain.rb | 18 ++++++++++++++++++ .../avoid-infinite-loop-overload-disjoint.rb | 17 +++++++++++++++++ .../avoid-infinite-loop-overload-partial.rb | 17 +++++++++++++++++ 8 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 scenario/regressions/avoid-infinite-loop-overload-chain.rb create mode 100644 scenario/regressions/avoid-infinite-loop-overload-disjoint.rb create mode 100644 scenario/regressions/avoid-infinite-loop-overload-partial.rb diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index a0be290c1..dc359c09c 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -213,6 +213,7 @@ def resolve_overload(changes, genv, method_type, node, param_map, a_args, ret, f end return false end + if rbs_blk && a_args.block # rbs_blk_func.optional_keywords, ... blk_a_args = rbs_blk.req_positionals.map do |blk_a_arg| @@ -251,6 +252,20 @@ def resolve_overloads(changes, genv, node, param_map, a_args, ret, &blk) return end + # If any positional argument has no type information, we cannot + # determine which overload to select. Return silently (untyped) + # rather than attempting to match. This prevents oscillation in + # cyclic cases like @x = Foo.transform(@x), and avoids false + # "failed to resolve overloads" diagnostics for untyped arguments. + # We still set up dependency edges so the box re-runs when the + # empty arguments later receive types. + if a_args.positionals.any? {|vtx| vtx.types.empty? } + a_args.positionals.each do |vtx| + changes.add_edge(genv, vtx, changes.target) + end + return + end + match_any_overload = false @method_types.each do |method_type| if resolve_overload(changes, genv, method_type, node, param_map, a_args, ret, false, &blk) diff --git a/scenario/rbs/untyped-for-overload-record.rb b/scenario/rbs/untyped-for-overload-record.rb index 653ca874a..cfc83a88d 100644 --- a/scenario/rbs/untyped-for-overload-record.rb +++ b/scenario/rbs/untyped-for-overload-record.rb @@ -13,5 +13,5 @@ def check(unknown) ## assert class Object - def check: (untyped) -> (:record | :str) + def check: (untyped) -> untyped end diff --git a/scenario/rbs/untyped-for-overload-singleton.rb b/scenario/rbs/untyped-for-overload-singleton.rb index 9933790ca..df4c5ae72 100644 --- a/scenario/rbs/untyped-for-overload-singleton.rb +++ b/scenario/rbs/untyped-for-overload-singleton.rb @@ -13,5 +13,5 @@ def check(unknown) ## assert class Object - def check: (untyped) -> (:int | :str) + def check: (untyped) -> untyped end diff --git a/scenario/rbs/untyped-for-overload-tuple.rb b/scenario/rbs/untyped-for-overload-tuple.rb index 62b4a2ee4..0411c6119 100644 --- a/scenario/rbs/untyped-for-overload-tuple.rb +++ b/scenario/rbs/untyped-for-overload-tuple.rb @@ -13,5 +13,5 @@ def check(unknown) ## assert class Object - def check: (untyped) -> (:str | :tuple) + def check: (untyped) -> untyped end diff --git a/scenario/rbs/untyped-for-overload.rb b/scenario/rbs/untyped-for-overload.rb index 529e6cf3e..620f85d29 100644 --- a/scenario/rbs/untyped-for-overload.rb +++ b/scenario/rbs/untyped-for-overload.rb @@ -11,5 +11,5 @@ def check ## assert class Object - def check: -> (:A | :B) + def check: -> untyped end diff --git a/scenario/regressions/avoid-infinite-loop-overload-chain.rb b/scenario/regressions/avoid-infinite-loop-overload-chain.rb new file mode 100644 index 000000000..4cf255c35 --- /dev/null +++ b/scenario/regressions/avoid-infinite-loop-overload-chain.rb @@ -0,0 +1,18 @@ +## update: test.rbs +class Foo + def self.transform: (Integer) -> String + | (String) -> Float + | (Float) -> Symbol +end + +## update: test.rb +def check + @x = Foo.transform(@x) +end + +## diagnostics: test.rb + +## assert +class Object + def check: -> untyped +end diff --git a/scenario/regressions/avoid-infinite-loop-overload-disjoint.rb b/scenario/regressions/avoid-infinite-loop-overload-disjoint.rb new file mode 100644 index 000000000..0832f2462 --- /dev/null +++ b/scenario/regressions/avoid-infinite-loop-overload-disjoint.rb @@ -0,0 +1,17 @@ +## update: test.rbs +class Foo + def self.transform: (Integer) -> Float + | (String) -> Symbol +end + +## update: test.rb +def check + @x = Foo.transform(@x) +end + +## diagnostics: test.rb + +## assert +class Object + def check: -> untyped +end diff --git a/scenario/regressions/avoid-infinite-loop-overload-partial.rb b/scenario/regressions/avoid-infinite-loop-overload-partial.rb new file mode 100644 index 000000000..c5153e1e4 --- /dev/null +++ b/scenario/regressions/avoid-infinite-loop-overload-partial.rb @@ -0,0 +1,17 @@ +## update: test.rbs +class Foo + def self.transform: (Integer) -> String + | (String) -> Float +end + +## update: test.rb +def check + @x = Foo.transform(@x) +end + +## diagnostics: test.rb + +## assert +class Object + def check: -> untyped +end