From 472de5deb71a114a3b4b2740fa0a424752f6a5f8 Mon Sep 17 00:00:00 2001 From: Takumi Shotoku Date: Sun, 27 Sep 2026 00:04:19 +0900 Subject: [PATCH 1/6] Give an empty block body its own local scope A block or lambda with an empty body built its DummyNilNode with the enclosing LocalEnv, and install0 takes the body's LocalEnv as its own. So `->(x) { }` bound x in the enclosing scope, and an empty lambda took the enclosing method's return and the enclosing block's break as the value #call returns. An empty block had the same bug before a lambda literal was analyzed at all. --- lib/typeprof/core/ast/call.rb | 2 +- scenario/block/empty_body.rb | 12 ++++++++++++ scenario/lambda/empty_body.rb | 36 +++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 scenario/block/empty_body.rb create mode 100644 scenario/lambda/empty_body.rb diff --git a/lib/typeprof/core/ast/call.rb b/lib/typeprof/core/ast/call.rb index 5f3b5747..deb2502d 100644 --- a/lib/typeprof/core/ast/call.rb +++ b/lib/typeprof/core/ast/call.rb @@ -33,7 +33,7 @@ def initialize(raw_node, lenv, mid) else raise "not supported yet: #{ raw_node.parameters.class }" end - @body = raw_node.body ? AST.create_node(raw_node.body, nlenv) : DummyNilNode.new(code_range, lenv) + @body = raw_node.body ? AST.create_node(raw_node.body, nlenv) : DummyNilNode.new(code_range, nlenv) end attr_reader :tbl, :f_args, :opt_positional_defaults, :body diff --git a/scenario/block/empty_body.rb b/scenario/block/empty_body.rb new file mode 100644 index 00000000..b56a7166 --- /dev/null +++ b/scenario/block/empty_body.rb @@ -0,0 +1,12 @@ +## update +# The parameter of an empty block is its own, not the enclosing x +def outer_local + x = 1 + ["str"].each {|x| } + x +end + +## assert +class Object + def outer_local: -> Integer +end diff --git a/scenario/lambda/empty_body.rb b/scenario/lambda/empty_body.rb new file mode 100644 index 00000000..01ec1b30 --- /dev/null +++ b/scenario/lambda/empty_body.rb @@ -0,0 +1,36 @@ +## update +# The parameter of an empty lambda is its own, not the enclosing x +def outer_local + x = 1 + f = ->(x) { } + f.call("str") + x +end + +def check(v) = v + +# An empty lambda returns nil, not what the enclosing method returns +def enclosing_return(c) + return :sym if c + f = -> { } + check(f.call) + nil +end + +# An empty lambda returns nil, not what the enclosing block breaks with +def enclosing_break + [1].each do + g = -> { } + check(g.call) + break "str" + end + nil +end + +## assert +class Object + def outer_local: -> Integer + def check: (nil) -> nil + def enclosing_return: (untyped) -> :sym? + def enclosing_break: -> nil +end From 183509067c084b58ffbaabd4cd0b5f20e3acd60c Mon Sep 17 00:00:00 2001 From: Takumi Shotoku Date: Sun, 27 Sep 2026 00:06:33 +0900 Subject: [PATCH 2/6] Pass keywords to a lambda without keyword parameters as a hash A method with no keyword parameters takes `f(k: 1)` as a trailing positional hash, but a lambda call skipped that step and reported `->(h) { h }.call(k: 1)` as a wrong number of arguments. --- lib/typeprof/core/ast/call.rb | 1 + lib/typeprof/core/env/method.rb | 10 ++++++++++ lib/typeprof/core/graph/box.rb | 10 +--------- scenario/lambda/keyword_hash.rb | 15 +++++++++++++++ 4 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 scenario/lambda/keyword_hash.rb diff --git a/lib/typeprof/core/ast/call.rb b/lib/typeprof/core/ast/call.rb index deb2502d..c3ab7228 100644 --- a/lib/typeprof/core/ast/call.rb +++ b/lib/typeprof/core/ast/call.rb @@ -44,6 +44,7 @@ def req_keywords = @params[:req_keywords] def opt_keywords = @params[:opt_keywords] def rest_keywords = @params[:rest_keywords] def opt_keyword_defaults = @params[:opt_keyword_defaults] + def no_keywords = @params[:no_keywords] def subnodes = { opt_positional_defaults:, body: } # f_args covers only the parameters a block binds, so the rest have to be diff --git a/lib/typeprof/core/env/method.rb b/lib/typeprof/core/env/method.rb index b23cdc75..1d609868 100644 --- a/lib/typeprof/core/env/method.rb +++ b/lib/typeprof/core/env/method.rb @@ -182,6 +182,15 @@ def with_keywords_as_last_positional_hash ) end + # Keywords passed to formals that take none are the last positional hash. + def with_keywords_normalized_for(node) + return self unless keywords + return self if node.no_keywords || node.rest_keywords + return self unless node.req_keywords.empty? && node.opt_keywords.empty? + + with_keywords_as_last_positional_hash + end + def prepend_positionals(positionals, splat_flags) return self if positionals.empty? @@ -570,6 +579,7 @@ def initialize(node, f_ary_arg, f_args, next_boxes, formals = nil) # The arguments of a call that enters this body directly, as Proc#call does. def pass_arguments(genv, changes, a_args) if @formals + a_args = a_args.with_keywords_normalized_for(@node) @formals.pass_arguments(changes, genv, a_args, @node) else accept_args(genv, changes, a_args.positionals) diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index 8d531caf..747a6023 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -827,17 +827,9 @@ def run0(genv, changes) def pass_arguments(changes, genv, a_args) @f_args.pass_arguments(changes, genv, a_args, @node) end - def normalize_keyword_hash_argument_for_def(a_args) - return a_args unless a_args.keywords - return a_args if @node.no_keywords - return a_args if @node.rest_keywords - return a_args unless @node.req_keywords.empty? && @node.opt_keywords.empty? - - a_args.with_keywords_as_last_positional_hash - end def call(changes, genv, a_args, ret) - a_args = normalize_keyword_hash_argument_for_def(a_args) + a_args = a_args.with_keywords_normalized_for(@node) if pass_arguments(changes, genv, a_args) if @node.is_a?(AST::DefNode) @node.body.lenv.forward_args&.accept_actual_arguments(genv, changes, a_args) diff --git a/scenario/lambda/keyword_hash.rb b/scenario/lambda/keyword_hash.rb new file mode 100644 index 00000000..e7f8a3f9 --- /dev/null +++ b/scenario/lambda/keyword_hash.rb @@ -0,0 +1,15 @@ +## update +# With no keyword parameters, keywords are passed as a trailing hash +def one_hash = ->(h) { h }.call(k: 1) +one_hash + +def trailing_hash = ->(a, b) { b }.call(1, k: 2) +trailing_hash + +## diagnostics + +## assert +class Object + def one_hash: -> { k: Integer } + def trailing_hash: -> { k: Integer } +end From 2d5706acd5c29fcb8df4a117b118aaba7d42c33d Mon Sep 17 00:00:00 2001 From: Takumi Shotoku Date: Sun, 27 Sep 2026 00:07:11 +0900 Subject: [PATCH 3/6] Bind the block passed to a lambda call A method call binds the block it is given to its `&b` parameter, but a lambda call left that parameter untyped. Proc#call already receives the block, so it is bound the same way. --- lib/typeprof/core/env/method.rb | 1 + scenario/lambda/call_args.rb | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/lib/typeprof/core/env/method.rb b/lib/typeprof/core/env/method.rb index 1d609868..654d6e31 100644 --- a/lib/typeprof/core/env/method.rb +++ b/lib/typeprof/core/env/method.rb @@ -581,6 +581,7 @@ def pass_arguments(genv, changes, a_args) if @formals a_args = a_args.with_keywords_normalized_for(@node) @formals.pass_arguments(changes, genv, a_args, @node) + changes.add_edge(genv, a_args.block, @formals.block) if @formals.block && a_args.block else accept_args(genv, changes, a_args.positionals) end diff --git a/scenario/lambda/call_args.rb b/scenario/lambda/call_args.rb index 5b2982b5..b72065b4 100644 --- a/scenario/lambda/call_args.rb +++ b/scenario/lambda/call_args.rb @@ -16,6 +16,9 @@ def rest_keywords = ->(**kw) { kw }.call(a: 1) def block_param = ->(&b) { b }.call +def block_given = ->(&b) { b }.call { 1 } +block_given + ## assert class Object def rest: -> Array[Integer | String] @@ -24,4 +27,5 @@ def post: -> :sym def keywords: -> (Integer | String) def rest_keywords: -> { a: Integer } def block_param: -> untyped + def block_given: -> Proc end From 59425265dab87fcb59ac4a343e3259940344655a Mon Sep 17 00:00:00 2001 From: Takumi Shotoku Date: Sun, 27 Sep 2026 00:07:19 +0900 Subject: [PATCH 4/6] Bind a lambda passed with `&` through its formals A lambda given with `&` to an RBS-declared method was still bound like a block: its rest and post parameters got nothing and its arity was not checked, while the same lambda called with #call bound fully. --- lib/typeprof/core/graph/box.rb | 2 +- scenario/lambda/block_arg.rb | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 scenario/lambda/block_arg.rb diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index 747a6023..5720e145 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -399,7 +399,7 @@ def resolve_overload(changes, genv, method_type, node, param_map, a_args, ret, f a_args.block.each_type do |ty| case ty when Type::Proc - ty.block.accept_args(genv, changes, blk_a_args) + ty.block.pass_arguments(genv, changes, ActualArguments.new(blk_a_args, ::Array.new(blk_a_args.size, false), nil, nil)) if ty.block.is_a?(Block) ty.block.next_boxes.each do |next_box| diff --git a/scenario/lambda/block_arg.rb b/scenario/lambda/block_arg.rb new file mode 100644 index 00000000..582b18c7 --- /dev/null +++ b/scenario/lambda/block_arg.rb @@ -0,0 +1,20 @@ +## update +# A lambda passed as a block binds like a method, as it does for #call +def rest = [1, 2].map(&->(*a) { a }) +rest + +def post = [1, 2].map(&->(*a, b) { b }) +post + +def too_many = [1, 2].map(&->(x, y) { y }) +too_many + +## diagnostics +(8,22)-(8,25): wrong number of arguments (1 for 2) + +## assert +class Object + def rest: -> Array[Array[Integer]] + def post: -> Array[Integer] + def too_many: -> Array[untyped] +end From 4e9922d56ababa3582e62bea77f9ee7e125d652d Mon Sep 17 00:00:00 2001 From: Takumi Shotoku Date: Sun, 27 Sep 2026 00:07:31 +0900 Subject: [PATCH 5/6] Analyze the keyword defaults of a block parse_params builds nodes for keyword defaults, but in a block they were never installed, so a call in `{ |x: helper(1)| }` got no diagnostics. A block still does not bind keywords. --- lib/typeprof/core/ast/call.rb | 10 +++++++--- lib/typeprof/core/ast/value.rb | 2 -- scenario/block/keyword_default.rb | 29 +++++++++++++++++++++++++++++ scenario/lambda/call_args.rb | 4 ++++ 4 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 scenario/block/keyword_default.rb diff --git a/lib/typeprof/core/ast/call.rb b/lib/typeprof/core/ast/call.rb index c3ab7228..73ae903f 100644 --- a/lib/typeprof/core/ast/call.rb +++ b/lib/typeprof/core/ast/call.rb @@ -46,7 +46,7 @@ def rest_keywords = @params[:rest_keywords] def opt_keyword_defaults = @params[:opt_keyword_defaults] def no_keywords = @params[:no_keywords] - def subnodes = { opt_positional_defaults:, body: } + def subnodes = { opt_positional_defaults:, body:, opt_keyword_defaults: } # f_args covers only the parameters a block binds, so the rest have to be # compared too or an edit that only touches them looks like no edit at all. def attrs = { tbl:, f_args:, formal_names: } @@ -117,8 +117,12 @@ def install0(genv) end # A block is yielded to, and what a yielding method passes is the positional - # list alone; there are no formals to bind beyond it. - def build_formals(genv, blenv, f_args) = nil + # list alone; there are no formals to bind beyond it. Keyword defaults are + # still code in the block, so they are analyzed without being bound. + def build_formals(genv, blenv, f_args) + opt_keyword_defaults.each {|expr| expr.install(genv) } + nil + end # Block-local variables shadow the outer ones, so writes to them are not # modifications of the enclosing scope. diff --git a/lib/typeprof/core/ast/value.rb b/lib/typeprof/core/ast/value.rb index b8604acf..dce4344a 100644 --- a/lib/typeprof/core/ast/value.rb +++ b/lib/typeprof/core/ast/value.rb @@ -336,8 +336,6 @@ def initialize(raw_node, lenv) def lambda? = true - def subnodes = { opt_positional_defaults:, body:, opt_keyword_defaults: } - # A lambda is entered like a method, so every parameter kind binds, not just # the positionals a block is handed. def build_formals(genv, blenv, f_args) diff --git a/scenario/block/keyword_default.rb b/scenario/block/keyword_default.rb new file mode 100644 index 00000000..ed6bd0c6 --- /dev/null +++ b/scenario/block/keyword_default.rb @@ -0,0 +1,29 @@ +## update +def helper(a, b) = a + +# The default value of a block keyword is analyzed as a lambda's is +def foo = [1].each { |x: helper(1)| x } +foo + +## diagnostics +(4,25)-(4,31): wrong number of arguments (1 for 2) + +## assert +class Object + def helper: (untyped, untyped) -> untyped + def foo: -> Array[Integer] +end + +## update +def helper(a, b) = a + +def foo = [1].each { |x: helper(1, 2)| x } +foo + +## diagnostics + +## assert +class Object + def helper: (Integer, Integer) -> Integer + def foo: -> Array[Integer] +end diff --git a/scenario/lambda/call_args.rb b/scenario/lambda/call_args.rb index b72065b4..cfdd38b3 100644 --- a/scenario/lambda/call_args.rb +++ b/scenario/lambda/call_args.rb @@ -11,6 +11,9 @@ def post = ->(x, *y, z) { z }.call(1, 2, :sym) def keywords = ->(k: 1) { k }.call(k: "str") keywords +def keyword_from_keyword = ->(k:, j: k) { j }.call(k: 1) +keyword_from_keyword + def rest_keywords = ->(**kw) { kw }.call(a: 1) rest_keywords @@ -25,6 +28,7 @@ def rest: -> Array[Integer | String] def lead_and_rest: -> Array[Integer] def post: -> :sym def keywords: -> (Integer | String) + def keyword_from_keyword: -> Integer def rest_keywords: -> { a: Integer } def block_param: -> untyped def block_given: -> Proc From b4061a61deb459e684268678f40723a0c626e06c Mon Sep 17 00:00:00 2001 From: Takumi Shotoku Date: Sun, 27 Sep 2026 00:07:59 +0900 Subject: [PATCH 6/6] Add a scenario for the return type of a block passed to super The block of `super() { ... }` used to fall through to a debug `pp` in wrong_return_type and report nothing. --- scenario/rbs/super_block.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 scenario/rbs/super_block.rb diff --git a/scenario/rbs/super_block.rb b/scenario/rbs/super_block.rb new file mode 100644 index 00000000..a7c3e805 --- /dev/null +++ b/scenario/rbs/super_block.rb @@ -0,0 +1,19 @@ +## update: test.rbs +class C + def foo: () { (Integer) -> Integer } -> :ok +end + +## update: test.rb +class D < C + def foo + super() { |x| "str" } + end +end + +## assert +class D < C + def foo: -> :ok +end + +## diagnostics +(3,18)-(3,23): expected: Integer; actual: String