diff --git a/spec/pipeline_host_lower_where_sig_spec.rb b/spec/pipeline_host_lower_where_sig_spec.rb new file mode 100644 index 000000000..98ec28bde --- /dev/null +++ b/spec/pipeline_host_lower_where_sig_spec.rb @@ -0,0 +1,81 @@ +require "rspec" +require "sorbet-runtime" +require_relative "../src/ast/lexer" +require_relative "../src/ast/ast" +require_relative "../src/backends/pipeline_host" + +# Regression: PipelineHost#lower_where had a Sorbet sig that constrained +# `expr_node` to `AST::BinaryOp`. CLEAR's `WHERE` clause accepts ANY +# boolean expression: `WHERE x > 0` (BinaryOp), `WHERE isPositive(x)` +# (FuncCall), `WHERE TRUE` (Literal), `WHERE flag` (Identifier), +# `WHERE !blocked` (UnaryOp), etc. The narrow sig caused a Sorbet +# TypeError at runtime for any non-BinaryOp predicate that flowed +# through the bc emitter's CONCURRENT pipeline path. +# +# The Zig backend doesn't typically reach `lower_where` for the same +# expressions (its lower_pipeline path falls back to the legacy +# string-based `transpile_pipeline` for CONCURRENT operators), which +# is why master tests passed -- the bug only surfaced via the bc +# target's bc-specific concurrent dispatch. +RSpec.describe "PipelineHost#lower_where signature" do + it "accepts any expression node (not just AST::BinaryOp)" do + sig = T::Utils.signature_for_method(PipelineHost.instance_method(:lower_where)) + expr_pair = sig.arg_types.find { |name, _| name == :expr_node } + expect(expr_pair).not_to be_nil + expr_type = expr_pair[1] + + # The fix matches `visit_pipeline_expr_mir`'s `T.untyped` (the + # method `lower_where`'s body delegates to). A union like + # `T.any(AST::BinaryOp, AST::FuncCall)` would still reject + # other valid predicate shapes (Literal, Identifier, UnaryOp, + # MethodCall, ...). Match the precedent. + expect(expr_type).to be_a(T::Types::Untyped), + "expected T.untyped to match visit_pipeline_expr_mir; got #{expr_type.inspect}" + end + + # Behavioral check: calling lower_where with each valid predicate + # shape must NOT raise a Sorbet TypeError. The body fails for + # unrelated reasons (we pass stub site / no lowering setup), but a + # TypeError specifically would mean the sig rejected the input + # shape -- the regression we're guarding against. + [ + [:BinaryOp, :"`x > 0` (BinaryOp)"], + [:FuncCall, :"`isPositive(_)` (FuncCall)"], + [:Literal, :"`TRUE` (Literal)"], + [:Identifier, :"`flag` (Identifier)"], + [:UnaryOp, :"`!cond` (UnaryOp)"], + [:MethodCall, :"`x.isPositive()` (MethodCall)"], + ].each do |ast_class, label| + it "does not raise TypeError when expr_node is #{label}" do + tok = Lexer::Token.new(:VAR_ID, "x", 1, 1) + node = case ast_class + when :BinaryOp then AST::BinaryOp.new(tok, AST::Identifier.new(tok, "x"), :GT, AST::Literal.new(tok, :INT64, 0, :stack)) + when :FuncCall then AST::FuncCall.new(tok, "isPositive", []) + when :Literal then AST::Literal.new(tok, :BOOLEAN, true, :stack) + when :Identifier then AST::Identifier.new(tok, "flag") + when :UnaryOp then AST::UnaryOp.new(tok, :NOT, AST::Identifier.new(tok, "cond")) + when :MethodCall then AST::MethodCall.new(tok, AST::Identifier.new(tok, "x"), "isPositive", []) + end + + host = PipelineHost.allocate + site = PipelineHost::PipelineSite.new(list: nil, options: nil) + + begin + host.send(:lower_where, site, node) + rescue TypeError => e + # If the sig rejects this shape, Sorbet raises TypeError with + # "Parameter 'expr_node': Expected type ..., got type ...". + # That's the regression. Anything else (TypeError on `site`, + # NoMethodError, etc.) is fine -- it just means the body + # tried to do real work against the stub and failed downstream. + if e.message.include?("Parameter 'expr_node'") + fail "Sorbet sig rejected #{label}: #{e.message[0, 200]}" + end + # Other TypeErrors (e.g. site validation) — body fired, sig OK. + rescue + # Body failed for unrelated reasons — that's expected with no + # full lowering setup. The sig accepted the input. + end + end + end +end diff --git a/src/backends/pipeline_host.rb b/src/backends/pipeline_host.rb index dd84ccc76..c98917d32 100644 --- a/src/backends/pipeline_host.rb +++ b/src/backends/pipeline_host.rb @@ -859,7 +859,13 @@ def pipeline_alloc(smooth_node) smooth_node.respond_to?(:storage) && smooth_node.storage == :heap ? :heap : :frame end - sig { params(site: PipelineHost::PipelineSite, expr_node: AST::BinaryOp).returns(MIR::BlockExpr) } + # `expr_node` is any boolean-typed expression: BinaryOp (`x > 0`), + # FuncCall (`isPositive(x)`), Literal (`TRUE`), Identifier (`flag`), + # MethodCall, UnaryOp, etc. Match `visit_pipeline_expr_mir`'s + # `T.untyped` (line 676) since the body just delegates to it; a + # narrower union (e.g., `T.any(AST::BinaryOp, AST::FuncCall)`) would + # reject other valid predicates at runtime via Sorbet's validator. + sig { params(site: PipelineHost::PipelineSite, expr_node: T.untyped).returns(MIR::BlockExpr) } def lower_where(site, expr_node) list_node = site.list smooth_node = site.options