From 67ffcbad29e9d4dddce7c86bc56b70770877eb83 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Tue, 21 Jul 2026 11:46:00 -0400 Subject: [PATCH 01/26] Add regression tests for backtrace source mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing tests must report line numbers from the source the developer wrote, not the transformed code that executes. Unit tests cover RSpock::BacktraceFilter and the Minitest wrapper against a real registered SourceMap; a subprocess integration test pins the end-to-end behavior, including one test documenting that the Minitest plugin filter is still required — ast-transform's compile-with-source-path fix corrected file paths but line mapping still needs the filter. Co-authored-by: Cursor --- CHANGELOG.md | 4 + test/rspock/backtrace_filter_test.rb | 131 ++++++++++++++++++ test/rspock/backtrace_source_mapping_test.rb | 132 +++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 test/rspock/backtrace_filter_test.rb create mode 100644 test/rspock/backtrace_source_mapping_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8814d..560549e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Regression tests for backtrace source mapping: unit coverage for `RSpock::BacktraceFilter` / `RSpock::Minitest::BacktraceFilter`, and a subprocess integration test pinning that failing tests report source line numbers — including one documenting that the Minitest plugin filter is still required for line mapping (ast-transform alone only corrects file paths). + ## [2.5.0] - 2026-02-28 ### Added diff --git a/test/rspock/backtrace_filter_test.rb b/test/rspock/backtrace_filter_test.rb new file mode 100644 index 0000000..b423df0 --- /dev/null +++ b/test/rspock/backtrace_filter_test.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true +require 'test_helper' +require 'tmpdir' +require 'ast_transform/transformation' +require 'ast_transform/transformer' +require 'rspock/declarative' +require 'rspock/ast/transformation' +require 'rspock/backtrace_filter' + +module RSpock + # ::Minitest is explicit throughout: requiring rspock/minitest/backtrace_filter + # defines RSpock::Minitest, which shadows the top-level constant inside this module. + class BacktraceFilterTest < ::Minitest::Test + extend RSpock::Declarative + + # Multi-line expressions collapse during unparse and the rescue wrapper + # shifts the class body, so transformed line numbers differ from source + # line numbers — making the mapping observable. + FIXTURE_SOURCE = <<~RUBY + transform!(RSpock::AST::Transformation) + class BacktraceFilterFixture + def kaboom + value = 1 + + 2 + + 3 + raise ArgumentError, + "kaboom-\#{value}" + end + end + RUBY + + test "filter_string maps a transformed line number back to the source line" do + with_registered_source_map do |source_path, _transformed_path, transformed_source| + location = "#{source_path}:#{raise_line(transformed_source)}:in 'kaboom'" + + filtered = BacktraceFilter.new.filter_string(location) + + assert_equal "#{source_path}:#{raise_line(FIXTURE_SOURCE)}:in 'kaboom'", filtered + end + end + + test "filter_string maps a transformed file path back to the source file path" do + with_registered_source_map do |source_path, transformed_path, transformed_source| + location = "#{transformed_path}:#{raise_line(transformed_source)}:in 'kaboom'" + + filtered = BacktraceFilter.new.filter_string(location) + + assert_equal "#{source_path}:#{raise_line(FIXTURE_SOURCE)}:in 'kaboom'", filtered + end + end + + test "filter_string reports ? for an unmappable line number" do + with_registered_source_map do |source_path, _transformed_path, _transformed_source| + filtered = BacktraceFilter.new.filter_string("#{source_path}:99999:in 'kaboom'") + + assert_equal "#{source_path}:?:in 'kaboom'", filtered + end + end + + test "filter_string passes through locations without a registered source map" do + location = "/nonexistent/other_file.rb:12:in 'foo'" + + assert_equal location, BacktraceFilter.new.filter_string(location) + end + + test "filter_exception rewrites the exception backtrace to source locations" do + with_registered_source_map do |source_path, transformed_path, _transformed_source| + load transformed_path + exception = assert_raises(ArgumentError) { BacktraceFilterFixture.new.kaboom } + + BacktraceFilter.new.filter_exception(exception) + + assert_equal "#{source_path}:#{raise_line(FIXTURE_SOURCE)}", exception.backtrace.first + end + end + + test "minitest filter maps mappable lines and passes through the rest" do + # Required here, not at the top: loading this file defines RSpock::Minitest, + # which would shadow ::Minitest for test files nested in module RSpock that + # load after this one. In production the plugin loads at autorun time (after + # all files), which is the timing this mirrors. + require 'rspock/minitest/backtrace_filter' + + with_registered_source_map do |source_path, _transformed_path, transformed_source| + backtrace = [ + "#{source_path}:#{raise_line(transformed_source)}:in 'kaboom'", + "/nonexistent/other_file.rb:5:in 'x'", + ] + + filtered = RSpock::Minitest::BacktraceFilter.new.filter(backtrace) + + assert_equal [ + "#{source_path}:#{raise_line(FIXTURE_SOURCE)}:in 'kaboom'", + "/nonexistent/other_file.rb:5:in 'x'", + ], filtered + end + end + + private + + # Transforms the fixture through the real pipeline, registering its + # SourceMap, and writes both files to disk. + def with_registered_source_map + Dir.mktmpdir do |tmpdir| + # Realpath matters: on macOS mktmpdir yields a /var symlink but + # Thread::Backtrace::Location#absolute_path resolves to /private/var, + # and SourceMap registration is keyed by exact path string. + dir = File.realpath(tmpdir) + source_path = File.join(dir, "backtrace_filter_fixture.rb") + transformed_path = File.join(dir, "backtrace_filter_fixture_transformed.rb") + File.write(source_path, FIXTURE_SOURCE) + + transformer = ASTTransform::Transformer.new(ASTTransform::Transformation.new) + transformed_source = transformer.transform_file_source(FIXTURE_SOURCE, source_path, transformed_path) + File.write(transformed_path, transformed_source) + + yield source_path, transformed_path, transformed_source + end + end + + def raise_line(source) + line_number_of(source, "raise") + end + + def line_number_of(source, snippet) + index = source.lines.index { |line| line.include?(snippet) } + flunk "snippet not found in source: #{snippet}" if index.nil? + index + 1 + end + end +end diff --git a/test/rspock/backtrace_source_mapping_test.rb b/test/rspock/backtrace_source_mapping_test.rb new file mode 100644 index 0000000..bb70b31 --- /dev/null +++ b/test/rspock/backtrace_source_mapping_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true +require 'test_helper' +require 'tmpdir' +require 'open3' +require 'rspock/declarative' + +module RSpock + # End-to-end pin for backtrace source mapping: a failing RSpock test run in a + # subprocess must report line numbers from the file the developer wrote, not + # from the transformed code that actually executes. + # + # The filter-disabled variant documents that the Minitest plugin + # (lib/minitest/rspock_plugin.rb) is what delivers the mapping — ast-transform + # alone makes the *paths* correct but leaves *line numbers* transformed. If + # that test starts failing, the plugin may genuinely be redundant. + class BacktraceSourceMappingTest < ::Minitest::Test + extend RSpock::Declarative + + # Blank lines and multi-line expressions make source line numbers diverge + # from transformed ones, so a wrong mapping cannot pass by coincidence. + FIXTURE_SOURCE = <<~RUBY + transform!(RSpock::AST::Transformation) + class SourceMappingFixtureTest < Minitest::Test + test "assertion failure" do + Given "a value built across lines" + value = 1 + + 1 + + When "doubling it" + doubled = value * 2 + + Then "an expectation that cannot hold" + doubled == 999 + end + + test "runtime error" do + When "raising mid-stimulus" + raise ArgumentError, + "kaboom" + + Then "unreached" + raises RuntimeError + end + end + RUBY + + # Ruby subprocesses inherit bundler state pointing at rspock's Gemfile; + # scrub it so the child resolves gems from the parent's live load paths. + # MT_NO_PLUGINS: minitest's gem-scanning plugin discovery can activate a + # second minitest version (the Ruby default gem) over the -I one, breaking + # the run; the runner requires the rspock plugin explicitly instead. + CLEAN_ENV = { + "RUBYOPT" => nil, + "BUNDLE_GEMFILE" => nil, + "BUNDLE_BIN_PATH" => nil, + "MT_NO_PLUGINS" => "1", + }.freeze + + test "failure messages and backtraces cite source line numbers" do + output = run_fixture_in_subprocess + + assertion_line = line_number_of(FIXTURE_SOURCE, "doubled == 999") + raise_line = line_number_of(FIXTURE_SOURCE, "raise ArgumentError") + + assert_includes output, "[test/source_mapping_fixture_test.rb:#{assertion_line}]", + "assertion failure should cite the source line of the failed expectation\n#{output}" + assert_includes output, "test/source_mapping_fixture_test.rb:#{raise_line}:in", + "error backtrace should cite the source line of the raise\n#{output}" + assert_includes output, "2 failures", output + end + + test "without the plugin filter, line numbers are transformed — the plugin is still needed" do + with_filter = run_fixture_in_subprocess + without_filter = run_fixture_in_subprocess(disable_plugin_filter: true) + + refute_equal cited_line_numbers(with_filter), cited_line_numbers(without_filter), + "disabling the plugin filter no longer changes reported line numbers; " \ + "the Minitest plugin may have become redundant\nwith: #{with_filter}\nwithout: #{without_filter}" + end + + private + + def run_fixture_in_subprocess(disable_plugin_filter: false) + Dir.mktmpdir do |tmpdir| + dir = File.realpath(tmpdir) + FileUtils.mkdir_p(File.join(dir, "test")) + File.write(File.join(dir, "test", "source_mapping_fixture_test.rb"), FIXTURE_SOURCE) + File.write(File.join(dir, "runner.rb"), runner_source(disable_plugin_filter)) + + output, status = Open3.capture2e(CLEAN_ENV, RbConfig.ruby, *load_path_flags, "runner.rb", chdir: dir) + refute status.success?, "fixture run should fail (it contains failing tests)\n#{output}" + + output + end + end + + # Plugin discovery is off (MT_NO_PLUGINS), so the enabled variant applies + # the plugin's init by hand — the same call minitest would make; the + # disabled variant simply leaves minitest's default filter in place. + def runner_source(disable_plugin_filter) + install_plugin_filter = <<~RUBY + require "minitest/rspock_plugin" + Minitest.plugin_rspock_init({}) + RUBY + + <<~RUBY + require "ast_transform" + ASTTransform.install + require "minitest/autorun" + require "rspock" + #{disable_plugin_filter ? "" : install_plugin_filter} + require_relative "test/source_mapping_fixture_test" + RUBY + end + + # The child needs the same rspock and dependency load paths as this + # process, without going through bundler. + def load_path_flags + $LOAD_PATH.grep(%r{/(lib|gems)(/|\z)}).flat_map { |path| ["-I", path] } + end + + def cited_line_numbers(output) + output.scan(%r{test/source_mapping_fixture_test\.rb:(\d+)}).flatten.map(&:to_i).sort.uniq + end + + def line_number_of(source, snippet) + index = source.lines.index { |line| line.include?(snippet) } + flunk "snippet not found in source: #{snippet}" if index.nil? + index + 1 + end + end +end From 6ca140c1da0153ab60c7337ceaf8f5ba60948efb Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 22 Jul 2026 10:39:55 -0400 Subject: [PATCH 02/26] Add red acceptance tests for line-aligned emission through RSpock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw line numbers — no Minitest plugin, no BacktraceFilter, no SourceMap — must be source line numbers once ast-transform emits line-aligned code. Four angles through the full RSpock transformation: - Then assertion failure cites the statement's source line - failing Where row cites the failing statement's source line - interaction setup executes at the interaction's own source line (today interactions hoist textually to the When position) - Cleanup-block failure cites the cleanup statement's source line Plus one green pin: the generated test name embeds the data row's source line — the row-isolation selector that must survive _line_number_ removal. All four alignment tests are red today. Co-authored-by: Cursor --- test/rspock/line_alignment_test.rb | 192 +++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 test/rspock/line_alignment_test.rb diff --git a/test/rspock/line_alignment_test.rb b/test/rspock/line_alignment_test.rb new file mode 100644 index 0000000..13bc3ba --- /dev/null +++ b/test/rspock/line_alignment_test.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true +require 'test_helper' +require 'tmpdir' +require 'open3' +require 'rspock/declarative' + +module RSpock + # Acceptance tests for line-aligned emission through the full RSpock + # transformation: raw line numbers — no Minitest plugin, no BacktraceFilter, + # no SourceMap — must already be source line numbers. When these are green, + # the whole mapping apparatus is deletable. + # + # Counterpart of ast-transform's LineAlignmentTest; the fixtures exercise + # the RSpock dialect specifically (Then assertions, Where tables, + # interactions, Cleanup). + class LineAlignmentTest < ::Minitest::Test + extend RSpock::Declarative + + # Same env scrubbing rationale as BacktraceSourceMappingTest: children + # inherit bundler state pointing at rspock's Gemfile, and minitest's + # gem-scanning plugin discovery can activate a second minitest version. + CLEAN_ENV = { + "RUBYOPT" => nil, + "BUNDLE_GEMFILE" => nil, + "BUNDLE_BIN_PATH" => nil, + "MT_NO_PLUGINS" => "1", + }.freeze + + THEN_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class ThenFixtureTest < Minitest::Test + test "assertion failure cites its own line" do + Given "a value built across lines" + value = 1 + + 1 + + When "doubling it" + doubled = value * 2 + + Then "an expectation that cannot hold" + doubled == 999 + end + end + RUBY + + test "Then assertion failure cites the statement's source line, raw" do + output = run_fixture("then_fixture_test.rb", THEN_FIXTURE) + assertion_line = line_number_of(THEN_FIXTURE, "doubled == 999") + + assert_includes output, "test/then_fixture_test.rb:#{assertion_line}", + "raw failure output should cite the assertion's source line\n#{output}" + end + + WHERE_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class WhereFixtureTest < Minitest::Test + test "row \#{a} plus \#{b} makes \#{c}" do + When "adding a and b" + actual = a + b + + Then "matching the expected column" + actual == c + + Where + a | b | c + 1 | 2 | 3 + 4 | 5 | 999 + end + end + RUBY + + test "failing Where row cites the failing statement's source line, raw" do + output = run_fixture("where_fixture_test.rb", WHERE_FIXTURE) + assertion_line = line_number_of(WHERE_FIXTURE, "actual == c") + + assert_includes output, "test/where_fixture_test.rb:#{assertion_line}", + "raw failure output should cite the Then statement's source line\n#{output}" + assert_includes output, "1 failures", output + end + + test "generated test name embeds the failing row's source line" do + output = run_fixture("where_fixture_test.rb", WHERE_FIXTURE) + failing_row_line = line_number_of(WHERE_FIXTURE, "4 | 5 | 999") + + # Minitest sanitizes test names, turning "line 13" into "line_13". + assert_includes output, "line_#{failing_row_line}", + "the failing test's name should embed the data row's source line " \ + "(the row-isolation selector target)\n#{output}" + end + + # The interaction argument expression `Integer("boom")` raises when the + # Mocha expectation setup executes — pinning the line at which interaction + # setup runs. Today interactions are hoisted textually to the When + # position, so the raw line is a transformed one; aligned emission must + # keep the interaction's own source line while deferring the When body. + INTERACTION_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class InteractionFixtureTest < Minitest::Test + class Subject + def initialize(dep) + @dep = dep + end + + def call + @dep.ping(1) + end + end + + test "interaction setup executes at its own source line" do + Given "a mocked collaborator" + dep = mock + subject = Subject.new(dep) + + When "exercising the subject" + subject.call + + Then "the collaborator was pinged" + 1 * dep.ping(Integer("boom")) + end + end + RUBY + + test "interaction setup execution is observed at the interaction's own source line, raw" do + output = run_fixture("interaction_fixture_test.rb", INTERACTION_FIXTURE) + interaction_line = line_number_of(INTERACTION_FIXTURE, "1 * dep.ping") + + assert_includes output, "test/interaction_fixture_test.rb:#{interaction_line}:in", + "the raise from the interaction's argument expression should cite " \ + "the interaction's source line\n#{output}" + end + + CLEANUP_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class CleanupFixtureTest < Minitest::Test + test "cleanup failure cites its own line" do + Given "a resource" + resource = Object.new + + Expect "a passing expectation" + resource.frozen? == false + + Cleanup "raising during teardown" + raise "cleanup boom" + end + end + RUBY + + test "Cleanup-block failure cites the cleanup statement's source line, raw" do + output = run_fixture("cleanup_fixture_test.rb", CLEANUP_FIXTURE) + cleanup_raise_line = line_number_of(CLEANUP_FIXTURE, 'raise "cleanup boom"') + + assert_includes output, "test/cleanup_fixture_test.rb:#{cleanup_raise_line}", + "the cleanup raise should cite its source line\n#{output}" + end + + private + + # Runs +fixture_source+ in a subprocess with NO backtrace filtering of any + # kind: plugin discovery is off and the rspock plugin is never installed. + # Whatever line numbers appear in the output are the VM's raw truth. + def run_fixture(file_name, fixture_source) + Dir.mktmpdir do |tmpdir| + dir = File.realpath(tmpdir) + FileUtils.mkdir_p(File.join(dir, "test")) + File.write(File.join(dir, "test", file_name), fixture_source) + File.write(File.join(dir, "runner.rb"), <<~RUBY) + require "ast_transform" + ASTTransform.install + require "minitest/autorun" + require "mocha/minitest" + require "rspock" + require_relative "test/#{file_name.delete_suffix('.rb')}" + RUBY + + output, status = Open3.capture2e(CLEAN_ENV, RbConfig.ruby, *load_path_flags, "runner.rb", chdir: dir) + refute status.success?, "fixture run should fail (it contains a failing test)\n#{output}" + + output + end + end + + def load_path_flags + $LOAD_PATH.grep(%r{/(lib|gems)(/|\z)}).flat_map { |path| ["-I", path] } + end + + def line_number_of(source, snippet) + index = source.lines.index { |line| line.include?(snippet) } + flunk "snippet not found in source: #{snippet}" if index.nil? + index + 1 + end + end +end From ebca5770732a2871c5b96dbdb5785c455443daf3 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 22 Jul 2026 12:32:06 -0400 Subject: [PATCH 03/26] Adopt line-aligned ast_transform 3.0; delete the mapping apparatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 + 2b + 3 of the line-aligned emission plan: Adopt promoted machinery: node classes register on ASTTransform::Node (REGISTRY/Node.build/NodeBuilder deleted); parsers anchor IR nodes (s_at) at the statements they classify so lowered assertions and Mocha setups emit at their own source lines. TestMethodTransformation rebuilt around source order: Then/Expect sections lower in place, and execution-order needs that source order cannot express use ast-transform's deferral facility — interactions defer the When body via run_after (the paved road); raises-with- interactions composes the deferred execution into assert_raises via low-level defer; raises-without-interactions inlines the When body. The hoisted_setups accumulator is gone. _test_index_/_line_number_ removed: MethodCallToLVarTransformation (a full extra AST pass per test) deleted; the row line still flows into the generated test NAME (uniqueness + -n selector target) through internal __rspock_row_index__/__rspock_row_line__ block params. Deleted: BacktraceFilter, its Minitest adapter and plugin, the Rails generator + initializer template, and the source_map_rescue_wrapper — line-aligned emission makes raw VM line numbers the source line numbers, so there is nothing left to map. The subprocess canary inverts: it now asserts source-true output with zero filter machinery. All five line-alignment acceptance tests green; suite 232 tests, 0 failures. rspock now requires ast_transform ~> 3.0. Co-authored-by: Cursor --- Gemfile | 3 + Gemfile.lock | 21 +- lib/generators/rspock/install_generator.rb | 17 -- .../templates/rspock_initializer.rb | 2 - lib/minitest/rspock_plugin.rb | 10 - lib/rspock.rb | 1 - .../ast/method_call_to_lvar_transformation.rb | 24 -- lib/rspock/ast/node.rb | 60 ++-- lib/rspock/ast/parser/block.rb | 3 +- lib/rspock/ast/parser/interaction_parser.rb | 11 +- lib/rspock/ast/parser/statement_parser.rb | 18 +- lib/rspock/ast/parser/test_method_parser.rb | 3 +- .../statement_to_assertion_transformation.rb | 3 +- .../ast/test_method_def_transformation.rb | 19 +- .../ast/test_method_dstr_transformation.rb | 12 +- lib/rspock/ast/test_method_transformation.rb | 281 +++++++++++------- lib/rspock/ast/transformation.rb | 28 +- lib/rspock/backtrace_filter.rb | 53 ---- lib/rspock/minitest/backtrace_filter.rb | 16 - rspock.gemspec | 2 +- .../minitest/reporters/rake_rerun_reporter.rb | 3 +- ...method_call_to_lvar_transformation_test.rb | 53 ---- ...tement_to_assertion_transformation_test.rb | 4 +- .../test_method_def_transformation_test.rb | 12 +- .../test_method_dstr_transformation_test.rb | 10 +- test/rspock/ast/transformation_test.rb | 197 ++++++------ test/rspock/backtrace_filter_test.rb | 131 -------- test/rspock/backtrace_source_mapping_test.rb | 62 ++-- 28 files changed, 383 insertions(+), 676 deletions(-) delete mode 100644 lib/generators/rspock/install_generator.rb delete mode 100644 lib/generators/templates/rspock_initializer.rb delete mode 100644 lib/minitest/rspock_plugin.rb delete mode 100644 lib/rspock/ast/method_call_to_lvar_transformation.rb delete mode 100644 lib/rspock/backtrace_filter.rb delete mode 100644 lib/rspock/minitest/backtrace_filter.rb delete mode 100644 test/rspock/ast/method_call_to_lvar_transformation_test.rb delete mode 100644 test/rspock/backtrace_filter_test.rb diff --git a/Gemfile b/Gemfile index 4a8be62..c2afa0b 100644 --- a/Gemfile +++ b/Gemfile @@ -2,5 +2,8 @@ source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } +# Temporarily pin to the line-aligned-emission branch until ast_transform 3.0.0 ships. +gem "ast_transform", git: "https://github.com/rspockframework/ast-transform.git", branch: "jpd/line-aligned-emission" + # Specify your gem's dependencies in rspock.gemspec gemspec diff --git a/Gemfile.lock b/Gemfile.lock index a7d6882..044af2d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,18 @@ +GIT + remote: https://github.com/rspockframework/ast-transform.git + revision: eb7313ce63fb838515efb2ccaf8d880dda28d95b + branch: jpd/line-aligned-emission + specs: + ast_transform (3.0.0) + parser (>= 3.0) + prism (>= 1.5) + unparser (>= 0.6) + PATH remote: . specs: rspock (2.5.0) - ast_transform (~> 2.0) + ast_transform (~> 3.0) minitest (~> 5.0) mocha (>= 1.0) parser (>= 3.0) @@ -13,10 +23,6 @@ GEM specs: ansi (1.5.0) ast (2.4.3) - ast_transform (2.1.4) - parser (>= 3.0) - prism (>= 1.5) - unparser (>= 0.6) builder (3.3.0) byebug (13.0.0) reline (>= 0.6.0) @@ -33,7 +39,7 @@ GEM ruby-progressbar mocha (3.0.2) ruby2_keywords (>= 0.0.5) - parser (3.3.10.2) + parser (3.3.12.0) ast (~> 2.4.1) racc prism (1.9.0) @@ -56,7 +62,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - unparser (0.8.2) + unparser (0.9.0) diff-lcs (>= 1.6, < 3) parser (>= 3.3.0) prism (>= 1.5.1) @@ -66,6 +72,7 @@ PLATFORMS ruby DEPENDENCIES + ast_transform! bundler (>= 2.1) minitest (~> 5.14) minitest-reporters (~> 1.4) diff --git a/lib/generators/rspock/install_generator.rb b/lib/generators/rspock/install_generator.rb deleted file mode 100644 index d292c42..0000000 --- a/lib/generators/rspock/install_generator.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -# 's' in Rspock is lowercase on purpose, so generator name is +rspock+ instead of +r_spock+ -module Rspock - module Generators - class InstallGenerator < Rails::Generators::Base - source_root File.expand_path("../../templates", __FILE__) - desc "Creates RSpock initializer for your application" - - def copy_initializer - template "rspock_initializer.rb", "config/initializers/rspock.rb" - - puts "Install complete!" - end - end - end -end diff --git a/lib/generators/templates/rspock_initializer.rb b/lib/generators/templates/rspock_initializer.rb deleted file mode 100644 index 1c8bfb9..0000000 --- a/lib/generators/templates/rspock_initializer.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -Rails.backtrace_cleaner.add_filter { |line| RSpock::BacktraceFilter.new.filter_string(line) } diff --git a/lib/minitest/rspock_plugin.rb b/lib/minitest/rspock_plugin.rb deleted file mode 100644 index 3799d74..0000000 --- a/lib/minitest/rspock_plugin.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true -require 'rspock/minitest/backtrace_filter' - -module Minitest - def self.plugin_rspock_init(_options) - unless defined?(Rails) - Minitest.backtrace_filter = RSpock::Minitest::BacktraceFilter.new - end - end -end diff --git a/lib/rspock.rb b/lib/rspock.rb index ffc4c81..ae29ae6 100644 --- a/lib/rspock.rb +++ b/lib/rspock.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'rspock/version' -require 'rspock/backtrace_filter' require 'rspock/declarative' require 'ast_transform' diff --git a/lib/rspock/ast/method_call_to_lvar_transformation.rb b/lib/rspock/ast/method_call_to_lvar_transformation.rb deleted file mode 100644 index 960a643..0000000 --- a/lib/rspock/ast/method_call_to_lvar_transformation.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true -require 'ast_transform/abstract_transformation' - -module RSpock - module AST - class MethodCallToLVarTransformation < ASTTransform::AbstractTransformation - def initialize(*method_symbols) - @method_call_nodes = method_symbols.map { |method_sym| - s(:send, nil, method_sym) - } - end - - def on_send(node) - return super unless method_call_node?(node) - - node.updated(:lvar, [node.children[1]]) - end - - def method_call_node?(node) - @method_call_nodes.include?(node) - end - end - end -end diff --git a/lib/rspock/ast/node.rb b/lib/rspock/ast/node.rb index 6c48881..d07abdd 100644 --- a/lib/rspock/ast/node.rb +++ b/lib/rspock/ast/node.rb @@ -1,22 +1,14 @@ # frozen_string_literal: true -require 'ast_transform/transformation_helper' +require 'ast_transform/node' module RSpock module AST - class Node < ::Parser::AST::Node - REGISTRY = {} - - def self.register(type) - REGISTRY[type] = self - end - - def self.build(type, *children) - klass = REGISTRY[type] || self - klass.new(type, children) - end - end - - class TestNode < Node + # RSpock's intermediate representation: custom node types registered on + # ASTTransform::Node, so +s(:rspock_*, ...)+ constructs these classes with + # their domain accessors. They exist only between the parser and + # TestMethodTransformation — the transformation lowers every one of them + # to plain Ruby nodes before emission. + class TestNode < ASTTransform::Node register :rspock_test def def_node = children[0] @@ -24,38 +16,38 @@ def body_node = children[1] def where_node = children[2] end - class BodyNode < Node + class BodyNode < ASTTransform::Node register :rspock_body end - class DefNode < Node + class DefNode < ASTTransform::Node register :rspock_def def method_call = children[0] def args = children[1] end - class GivenNode < Node + class GivenNode < ASTTransform::Node register :rspock_given end - class WhenNode < Node + class WhenNode < ASTTransform::Node register :rspock_when end - class ThenNode < Node + class ThenNode < ASTTransform::Node register :rspock_then end - class ExpectNode < Node + class ExpectNode < ASTTransform::Node register :rspock_expect end - class CleanupNode < Node + class CleanupNode < ASTTransform::Node register :rspock_cleanup end - class WhereNode < Node + class WhereNode < ASTTransform::Node register :rspock_where def header @@ -70,7 +62,7 @@ def data_rows end end - class OutcomeNode < Node + class OutcomeNode < ASTTransform::Node end class StubReturnsNode < OutcomeNode @@ -81,7 +73,7 @@ class StubRaisesNode < OutcomeNode register :rspock_stub_raises end - class RaisesNode < Node + class RaisesNode < ASTTransform::Node register :rspock_raises def exception_class = children[0] @@ -89,7 +81,7 @@ def capture_var = children[1] def capture_name = capture_var&.children&.[](0) end - class InteractionNode < Node + class InteractionNode < ASTTransform::Node register :rspock_interaction def cardinality = children[0] @@ -101,7 +93,7 @@ def outcome = children[4] def block_pass = children[5] end - class BinaryStatementNode < Node + class BinaryStatementNode < ASTTransform::Node register :rspock_binary_statement def lhs = children[0] @@ -109,23 +101,11 @@ def operator = children[1] def rhs = children[2] end - class StatementNode < Node + class StatementNode < ASTTransform::Node register :rspock_statement def expression = children[0] def source = children[1] end - - module NodeBuilder - include ASTTransform::TransformationHelper - - def s(type, *children) - if type.to_s.start_with?('rspock_') - Node.build(type, *children) - else - super - end - end - end end end diff --git a/lib/rspock/ast/parser/block.rb b/lib/rspock/ast/parser/block.rb index 738a81e..24a60d1 100644 --- a/lib/rspock/ast/parser/block.rb +++ b/lib/rspock/ast/parser/block.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -7,7 +8,7 @@ module Parser class BlockError < StandardError; end class Block - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper # Constructs a new Block. # diff --git a/lib/rspock/ast/parser/interaction_parser.rb b/lib/rspock/ast/parser/interaction_parser.rb index e768551..a00b66f 100644 --- a/lib/rspock/ast/parser/interaction_parser.rb +++ b/lib/rspock/ast/parser/interaction_parser.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -17,7 +18,7 @@ module Parser # [4] outcome - nil if no >>, otherwise s(:rspock_stub_returns, value) or s(:rspock_stub_raises, *args) # [5] block_pass - nil if no &, otherwise s(:block_pass, ...) class InteractionParser - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper class InteractionError < RuntimeError; end @@ -34,6 +35,10 @@ def interaction_node?(node) def parse(node) return node unless interaction_node?(node) + # Anchor at the full interaction expression (including >> outcome), so + # the Mocha setup it lowers into is emitted at the interaction's line. + anchor = node + if return_value_node?(node) outcome = parse_outcome(node.children[2]) node = node.children[0] @@ -45,7 +50,7 @@ def parse(node) rhs = node.children[2] receiver, message, args, block_pass = parse_rhs(rhs) - s(:rspock_interaction, + interaction = s(:rspock_interaction, cardinality, receiver, s(:sym, message), @@ -53,6 +58,8 @@ def parse(node) outcome, block_pass ) + + anchor.loc&.expression ? s_at(anchor, interaction.type, *interaction.children) : interaction end private diff --git a/lib/rspock/ast/parser/statement_parser.rb b/lib/rspock/ast/parser/statement_parser.rb index df6035f..4921d14 100644 --- a/lib/rspock/ast/parser/statement_parser.rb +++ b/lib/rspock/ast/parser/statement_parser.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -10,7 +11,7 @@ module Parser # - Binary operators (==, !=, =~, etc.) become :rspock_binary_statement nodes. # - Everything else becomes :rspock_statement nodes with the original source text captured. class StatementParser - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper BINARY_OPERATORS = %i[== != =~ !~ > < >= <=].freeze ASSIGNMENT_TYPES = %i[lvasgn masgn op_asgn or_asgn and_asgn].freeze @@ -44,10 +45,10 @@ def build_raises(node) if node.type == :lvasgn variable = s(:sym, node.children[0]) exception_class = node.children[1].children[2] - s(:rspock_raises, exception_class, variable) + s_anchored(node, :rspock_raises, exception_class, variable) else exception_class = node.children[2] - s(:rspock_raises, exception_class) + s_anchored(node, :rspock_raises, exception_class) end end @@ -61,13 +62,20 @@ def binary_statement?(node) BINARY_OPERATORS.include?(node.children[1]) end + # RSpock IR nodes are anchored at the statement they classify, so the + # assertions they lower into are emitted at the statement's source line. def build_binary_statement(node) - s(:rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2]) + s_anchored(node, :rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2]) end def build_statement(node) source = node.loc&.expression&.source || node.inspect - s(:rspock_statement, node, s(:str, source)) + s_anchored(node, :rspock_statement, node, s(:str, source)) + end + + # s_at that tolerates loc-less anchors (unit tests build synthetic ASTs). + def s_anchored(anchor, type, *children) + anchor.loc&.expression ? s_at(anchor, type, *children) : s(type, *children) end end end diff --git a/lib/rspock/ast/parser/test_method_parser.rb b/lib/rspock/ast/parser/test_method_parser.rb index 67047a0..8ceb048 100644 --- a/lib/rspock/ast/parser/test_method_parser.rb +++ b/lib/rspock/ast/parser/test_method_parser.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -12,7 +13,7 @@ module Parser # s(:rspock_body, s(:rspock_given, ...), s(:rspock_when, ...), ...), # s(:rspock_where, ...)) # optional class TestMethodParser - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper def initialize(block_registry, strict: true) @block_registry = block_registry diff --git a/lib/rspock/ast/statement_to_assertion_transformation.rb b/lib/rspock/ast/statement_to_assertion_transformation.rb index f73dac9..ce417c8 100644 --- a/lib/rspock/ast/statement_to_assertion_transformation.rb +++ b/lib/rspock/ast/statement_to_assertion_transformation.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -8,7 +9,7 @@ module AST # Binary statements dispatch to specialized assertions (assert_equal, assert_match, assert_operator). # General statements use assert_equal(true/false, expr, source_message) with negation detection. class StatementToAssertionTransformation - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper BINARY_DISPATCH = { :== => :assert_equal, diff --git a/lib/rspock/ast/test_method_def_transformation.rb b/lib/rspock/ast/test_method_def_transformation.rb index f21f864..cf24f70 100644 --- a/lib/rspock/ast/test_method_def_transformation.rb +++ b/lib/rspock/ast/test_method_def_transformation.rb @@ -4,15 +4,18 @@ module RSpock module AST + # Appends the data row's index and source line to a Where-driven test's + # name. The values arrive through internal block parameters on the row + # iterator (see TestMethodTransformation#build_where_args) and surface in + # the test NAME only: the name is what makes identical data rows unique and + # what the -n selector matches to isolate a row. They are deliberately not + # exposed as test-scope variables. class TestMethodDefTransformation < ASTTransform::AbstractTransformation - TEST_INDEX_AST = s(:begin, - s(:lvar, :_test_index_)) - - LINE_NUMBER_AST = s(:begin, - s(:lvar, :_line_number_)) - - SPACE_STR_AST = s(:str, " ") + ROW_INDEX_ARG = :__rspock_row_index__ + ROW_LINE_ARG = :__rspock_row_line__ + ROW_INDEX_AST = s(:begin, s(:lvar, ROW_INDEX_ARG)) + ROW_LINE_AST = s(:begin, s(:lvar, ROW_LINE_ARG)) LINE_NUMBER_STR_AST = s(:str, " line ") def run(node) @@ -23,7 +26,7 @@ def run(node) def on_str(node) merged = s(:str, "#{node.children[0]} ") - node.updated(:dstr, [merged, TEST_INDEX_AST, LINE_NUMBER_STR_AST, LINE_NUMBER_AST]) + node.updated(:dstr, [merged, ROW_INDEX_AST, LINE_NUMBER_STR_AST, ROW_LINE_AST]) end def on_dstr(node) diff --git a/lib/rspock/ast/test_method_dstr_transformation.rb b/lib/rspock/ast/test_method_dstr_transformation.rb index e1b1795..2ed7997 100644 --- a/lib/rspock/ast/test_method_dstr_transformation.rb +++ b/lib/rspock/ast/test_method_dstr_transformation.rb @@ -3,15 +3,13 @@ module RSpock module AST + # dstr counterpart of TestMethodDefTransformation: appends the row index + # and source line interpolations to an already-interpolated test name. class TestMethodDstrTransformation < ASTTransform::AbstractTransformation - TEST_INDEX_AST = s(:begin, - s(:lvar, :_test_index_)) - - LINE_NUMBER_AST = s(:begin, - s(:lvar, :_line_number_)) + ROW_INDEX_AST = s(:begin, s(:lvar, :__rspock_row_index__)) + ROW_LINE_AST = s(:begin, s(:lvar, :__rspock_row_line__)) SPACE_STR_AST = s(:str, " ") - LINE_NUMBER_STR_AST = s(:str, " line ") def on_dstr(node) @@ -24,7 +22,7 @@ def on_dstr(node) children << SPACE_STR_AST end - children.push(TEST_INDEX_AST, LINE_NUMBER_STR_AST, LINE_NUMBER_AST) + children.push(ROW_INDEX_AST, LINE_NUMBER_STR_AST, ROW_LINE_AST) node.updated(nil, children) end end diff --git a/lib/rspock/ast/test_method_transformation.rb b/lib/rspock/ast/test_method_transformation.rb index 79cb75c..1529ba0 100644 --- a/lib/rspock/ast/test_method_transformation.rb +++ b/lib/rspock/ast/test_method_transformation.rb @@ -5,7 +5,6 @@ require 'rspock/ast/header_nodes_transformation' require 'rspock/ast/interaction_to_mocha_mock_transformation' require 'rspock/ast/interaction_to_block_identity_assertion_transformation' -require 'rspock/ast/method_call_to_lvar_transformation' require 'rspock/ast/test_method_def_transformation' require 'rspock/ast/parser/test_method_parser' @@ -26,158 +25,217 @@ def run(node) private def transform(rspock_ast) - hoisted_setups = [] - method_call = rspock_ast.def_node.method_call method_args = rspock_ast.def_node.args where = rspock_ast.where_node - transformed_blocks = rspock_ast.body_node.children.map do |block_node| - case block_node.type - when :rspock_then - transform_then_block(block_node, hoisted_setups) - when :rspock_expect - transform_expect_block(block_node) - else - block_node - end + body = build_test_body(rspock_ast.body_node) + build_ruby_ast(method_call, method_args, body, where) + end + + # --- Test body assembly --- + # + # Statements are assembled in SOURCE order so line-aligned emission keeps + # each one on its own line. Execution-order requirements that source + # order cannot express (interaction setups in Then must run before the + # When body they observe) are carried by ast-transform's deferral + # facility (run_after / defer) instead of by textual hoisting. + def build_test_body(body_node) + blocks = body_node.children + sections = blocks.map { |block_node| transform_block(block_node) } + + when_statements = statements_of_type(blocks, sections, :rspock_when) + cleanup_statements = statements_of_type(blocks, sections, :rspock_cleanup) + interaction_setups = sections.flat_map(&:interaction_setups) + raises_node = blocks.filter_map { |block_node| find_raises(block_node) }.first + + source_order = blocks.zip(sections).flat_map do |block_node, section| + next [] if block_node.type == :rspock_cleanup + + section.statements.reject { |statement| statement.type == :rspock_raises } end - transformed_body = rspock_ast.body_node.updated(nil, transformed_blocks) - build_ruby_ast(method_call, method_args, transformed_body, where, hoisted_setups) - end + body_children = order_execution(source_order, when_statements, interaction_setups, raises_node) - def transform_then_block(then_node, hoisted_setups) - interaction_setups = [] - then_children = [] + ast = s(:begin, *body_children) + cleanup_statements.empty? ? ast : s(:kwbegin, s(:ensure, ast, s(:begin, *cleanup_statements))) + end - then_node.children.each_with_index do |child, idx| - if child.type == :rspock_interaction - setup = InteractionToMochaMockTransformation.new(idx).run(child) - assertion = InteractionToBlockIdentityAssertionTransformation.new(idx).run(child) + Section = Data.define(:statements, :interaction_setups) - interaction_setups << setup - then_children << assertion unless assertion.equal?(child) - else - then_children << transform_statement_or_passthrough(child) - end + def transform_block(block_node) + case block_node.type + when :rspock_then, :rspock_expect + transform_assertion_block(block_node) + else + Section.new(statements: block_node.children, interaction_setups: []) end + end - unless interaction_setups.empty? - interaction_setups.each do |node| - if node.type == :begin - hoisted_setups.concat(node.children) - else - hoisted_setups << node - end + # Then/Expect children become plain Ruby in place: interactions lower to + # Mocha setups anchored at the interaction's own source line (plus an + # identity assertion for &block forwarding), statements become assertions + # at their own lines. + # + # Within the section, ALL setups come before all assertions: the deferred + # When body executes right after the last setup, and every assertion + # (identity or otherwise) observes the When body's effects, so none may + # precede that point. Setups keep source order and their anchors, so + # alignment holds; assertions after them are either synthetic (identity + # assertions, loc-less, pack anywhere) or textually below the + # interactions in the common case. + def transform_assertion_block(block_node) + setups = [] + assertions = [] + interaction_index = 0 + + block_node.children.each do |child| + case child.type + when :rspock_interaction + interaction_setups, identity_assertions = lower_interaction(child, interaction_index) + interaction_index += 1 + setups.concat(interaction_setups) + assertions.concat(identity_assertions) + when :rspock_binary_statement, :rspock_statement + assertions << anchored_at(child, @statement_transformation.run(child)) + else + assertions << child end end - then_node.updated(nil, then_children) + Section.new(statements: setups + assertions, interaction_setups: setups) end - def transform_expect_block(expect_node) - new_children = expect_node.children.map { |child| transform_statement_or_passthrough(child) } - expect_node.updated(nil, new_children) + # @return [Array(Array, Array)] Mocha setup statements, identity assertions + def lower_interaction(interaction, index) + setup = InteractionToMochaMockTransformation.new(index).run(interaction) + assertion = InteractionToBlockIdentityAssertionTransformation.new(index).run(interaction) + + setups = setup.type == :begin ? setup.children.dup : [setup] + setups[0] = anchored_at(interaction, setups[0]) + + [setups, assertion.equal?(interaction) ? [] : [assertion]] end - def transform_statement_or_passthrough(child) - case child.type - when :rspock_binary_statement, :rspock_statement - @statement_transformation.run(child) + # Reorders execution (not text) where required: + # - interactions without raises: defer the When body until after the last + # interaction setup (run_after — the paved road). + # - raises without interactions: the When body inlines directly into + # assert_raises; no deferral needed. + # - raises with interactions: the When body is deferred at its source + # position and its execution composes into the assert_raises block after + # the last setup (low-level defer). + def order_execution(source_order, when_statements, interaction_setups, raises_node) + if raises_node + build_raises_body(source_order, when_statements, interaction_setups, raises_node) + elsif interaction_setups.any? && when_statements.any? + run_after(source_order, run: when_statements, after: interaction_setups.last) else - child + source_order end end - # --- Build final Ruby AST --- - - def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups) - if where - test_def = s(:block, - TestMethodDefTransformation.new.run(method_call), - method_args, - build_test_body(body_node, hoisted_setups) - ) - test_def = HeaderNodesTransformation.new(where.header).run(test_def) + def build_raises_body(source_order, when_statements, interaction_setups, raises_node) + if interaction_setups.any? + deferral = defer(*when_statements) + assertion = build_assert_raises(raises_node, deferral.execution) - s(:block, - build_where_iterator(where.data_rows), - build_where_args(where.header), - test_def - ) + reordered = replace_run(source_order, when_statements, [deferral.placement]) + insert_after(reordered, interaction_setups.last, assertion) else - s(:block, - method_call, - method_args, - build_test_body(body_node, hoisted_setups) - ) + when_body = when_statements.length == 1 ? when_statements[0] : s(:begin, *when_statements) + assertion = build_assert_raises(raises_node, when_body) + + replace_run(source_order, when_statements, [assertion]) end end - def build_test_body(body_node, hoisted_setups) - body_children = [] - blocks = body_node.children + def build_assert_raises(raises_node, body) + assert_raises_call = s(:block, + s(:send, nil, :assert_raises, raises_node.exception_class), + s(:args), + body + ) - blocks.each_with_index do |block_node, i| - case block_node.type - when :rspock_given - body_children.concat(block_node.children) - when :rspock_when - body_children.concat(hoisted_setups) - raises_node = find_raises_in_next_then(blocks, i) - - if raises_node - body_children << build_assert_raises(block_node, raises_node) - else - body_children.concat(block_node.children) - end - when :rspock_then, :rspock_expect - block_node.children.each do |child| - body_children << child unless child.type == :rspock_raises - end - when :rspock_cleanup - # handled below as ensure - end + if raises_node.capture_name + s(:lvasgn, raises_node.capture_name, assert_raises_call) + else + assert_raises_call end + end - ast = s(:begin, *body_children) + def find_raises(block_node) + return nil unless block_node.type == :rspock_then - cleanup = body_node.children.find { |n| n.type == :rspock_cleanup } - if cleanup && !cleanup.children.empty? - ensure_node = s(:begin, *cleanup.children) - ast = s(:kwbegin, s(:ensure, ast, ensure_node)) - end + block_node.children.find { |child| child.type == :rspock_raises } + end - MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast) + def statements_of_type(blocks, sections, type) + blocks.zip(sections) + .select { |block_node, _section| block_node.type == type } + .flat_map { |_block_node, section| section.statements } end - # --- Raises condition helpers --- + # --- Identity-based sequence edits (non-deferral counterparts of run_after) --- - def find_raises_in_next_then(blocks, current_index) - next_block = blocks[current_index + 1] - return nil unless next_block&.type == :rspock_then + def replace_run(statements, run, replacement) + start = statements.index { |statement| statement.equal?(run.first) } + raise ArgumentError, "run is not part of statements" unless start - next_block.children.find { |c| c.type == :rspock_raises } + result = statements.dup + result[start, run.length] = replacement + result end - def build_assert_raises(when_node, raises_node) - when_body = when_node.children.length == 1 ? when_node.children[0] : s(:begin, *when_node.children) + def insert_after(statements, anchor, insertion) + index = statements.index { |statement| statement.equal?(anchor) } + raise ArgumentError, "anchor is not part of statements" unless index - assert_raises_call = s(:block, - s(:send, nil, :assert_raises, raises_node.exception_class), - s(:args), - when_body - ) + result = statements.dup + result.insert(index + 1, insertion) + result + end - if raises_node.capture_name - s(:lvasgn, raises_node.capture_name, assert_raises_call) + # Re-anchors +node+ at +anchor+'s source location so emission places it + # on the anchor's line. No-op for anchors without locations. + def anchored_at(anchor, node) + return node unless anchor.loc&.expression + + s_at(anchor, node.type, *node.children) + end + + # --- Build final Ruby AST --- + + def build_ruby_ast(method_call, method_args, body_node, where) + if where + test_def = anchored_at(method_call, s(:block, + TestMethodDefTransformation.new.run(method_call), + method_args, + body_node + )) + test_def = HeaderNodesTransformation.new(where.header).run(test_def) + + s(:block, + build_where_iterator(where.data_rows), + build_where_args(where.header), + test_def + ) else - assert_raises_call + anchored_at(method_call, s(:block, + method_call, + method_args, + body_node + )) end end # --- Where block helpers --- + # + # Each data row carries its source line as a trailing element, surfaced + # in the generated test NAME only (uniqueness for identical rows + the -n + # selector target) through internal block parameters. There is no + # user-facing runtime variable: isolate a row by running its generated + # test by name, then break normally. def build_where_iterator(data_rows) s(:send, @@ -192,15 +250,16 @@ def build_where_iterator(data_rows) def build_where_data_row(row) children = row.dup children << s(:int, row.first&.loc&.expression&.line) - s(:array, *children) + anchor = row.first + anchor&.loc&.expression ? s_at(anchor, :array, *children) : s(:array, *children) end def build_where_args(header) injected_args = header.map { |column| s(:arg, column) } - injected_args << s(:arg, :_line_number_) + injected_args << s(:arg, TestMethodDefTransformation::ROW_LINE_ARG) s(:args, s(:mlhs, *injected_args), - s(:arg, :_test_index_), + s(:arg, TestMethodDefTransformation::ROW_INDEX_ARG), ) end end diff --git a/lib/rspock/ast/transformation.rb b/lib/rspock/ast/transformation.rb index 7ec4c4b..2c82b65 100644 --- a/lib/rspock/ast/transformation.rb +++ b/lib/rspock/ast/transformation.rb @@ -77,8 +77,7 @@ def process_casgn_block(node) def process_rspock(node) processed = process_all(node).compact - children = [source_map_rescue_wrapper(s(:begin, *[EXTEND_RSPOCK_DECLARATIVE, *processed]))] - node.updated(nil, children) + node.updated(nil, [EXTEND_RSPOCK_DECLARATIVE, *processed]) end def on_block(node) @@ -91,31 +90,6 @@ def on_block(node) strict: @strict ).run(node) end - - def source_map_rescue_wrapper(node) - s(:kwbegin, - s(:rescue, - node, - s(:resbody, - s(:array, - s(:const, nil, :StandardError) - ), - s(:lvasgn, :e), - s(:begin, - s(:send, - s(:send, - s(:const, - s(:const, - s(:cbase), :RSpock), :BacktraceFilter), :new), :filter_exception, - s(:lvar, :e) - ), - s(:send, nil, :raise) - ) - ), - nil - ) - ) - end end end end diff --git a/lib/rspock/backtrace_filter.rb b/lib/rspock/backtrace_filter.rb deleted file mode 100644 index 135a36b..0000000 --- a/lib/rspock/backtrace_filter.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true -require 'ast_transform/source_map' - -module RSpock - class BacktraceFilter - # Constructs a new BacktraceFilter instance. - # - # @param source_map_provider [::ASTTransform::SourceMap] The source map provider to be used. - def initialize(source_map_provider: ::ASTTransform::SourceMap) - @source_map_provider = source_map_provider - end - - # Filters the backtrace of the given +exception+ and applies the filtered backtrace to the exception. - # - # @param exception [Exception] The exception to be filtered. - # - # @return [void] - def filter_exception(exception) - exception.set_backtrace(source_mapped_backtrace(exception)) - end - - # Filters the given location. - # - # @param location [String] A location string. - # - # @return [String] The filtered location. - def filter_string(location) - file_path, lineno = location.match(/([\S]+):(\d+)/).captures - lineno = lineno.to_i - absolute_path = File.expand_path(file_path) - - source_map = @source_map_provider.for_file_path(absolute_path) - return location unless source_map - - line_number = source_map.line(lineno) || '?' - location.sub("#{file_path}:#{lineno}", "#{source_map.source_file_path}:#{line_number}") - end - - private - - def source_mapped_backtrace(e) - e.backtrace_locations&.map(&method(:location_builder)) - end - - def location_builder(location) - source_map = @source_map_provider.for_file_path(location.absolute_path || location.path) - return location.to_s unless source_map - - line_number = source_map.line(location.lineno) || '?' - "#{source_map.source_file_path}:#{line_number}" - end - end -end diff --git a/lib/rspock/minitest/backtrace_filter.rb b/lib/rspock/minitest/backtrace_filter.rb deleted file mode 100644 index 408e5ff..0000000 --- a/lib/rspock/minitest/backtrace_filter.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true -require 'rspock/backtrace_filter' - -module RSpock - module Minitest - class BacktraceFilter - def initialize - @backtrace_filter = RSpock::BacktraceFilter.new - end - - def filter(backtrace) - backtrace.map { |line| @backtrace_filter.filter_string(line) } - end - end - end -end diff --git a/rspock.gemspec b/rspock.gemspec index 1a2c963..3fc3062 100644 --- a/rspock.gemspec +++ b/rspock.gemspec @@ -31,7 +31,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "simplecov", "~> 0.22" # Runtime dependencies - spec.add_runtime_dependency "ast_transform", "~> 2.0" + spec.add_runtime_dependency "ast_transform", "~> 3.0" spec.add_runtime_dependency "minitest", "~> 5.0" spec.add_runtime_dependency "mocha", ">= 1.0" spec.add_runtime_dependency "parser", ">= 3.0" diff --git a/test/minitest/reporters/rake_rerun_reporter.rb b/test/minitest/reporters/rake_rerun_reporter.rb index 896d25f..188362d 100644 --- a/test/minitest/reporters/rake_rerun_reporter.rb +++ b/test/minitest/reporters/rake_rerun_reporter.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true require 'minitest/reporters' -require 'ast_transform/source_map' module Minitest module Reporters @@ -33,8 +32,8 @@ def print_rerun_command(test) end def rerun_message_for(test) + # Line-aligned emission makes backtrace paths the source paths; no mapping needed. file_path = location(test.failure).gsub(/(\:\d*)\z/, "") - file_path = ASTTransform::SourceMap.for_file_path(file_path)&.source_file_path || file_path "Rerun:\n#{@rerun_user_prefix} rake test TEST=#{file_path} TESTOPTS=\"--name=#{test.name} -v\"" end diff --git a/test/rspock/ast/method_call_to_lvar_transformation_test.rb b/test/rspock/ast/method_call_to_lvar_transformation_test.rb deleted file mode 100644 index f42e9a5..0000000 --- a/test/rspock/ast/method_call_to_lvar_transformation_test.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'transformation_helper' -require 'rspock/ast/method_call_to_lvar_transformation' - -module RSpock - module AST - class MethodCallToLVarTransformationTest < Minitest::Test - extend RSpock::Declarative - include RSpock::Helpers::TransformationHelper - - test "#run transforms passed symbol calls into lvar" do - transformation = RSpock::AST::MethodCallToLVarTransformation.new(:_test_index_) - - ast = s(:begin, - s(:send, nil, :a), - s(:send, nil, :_test_index_), - s(:send, nil, :b), - s(:send, nil, :c)) - - actual = transformation.run(ast) - - expected = s(:begin, - s(:send, nil, :a), - s(:lvar, :_test_index_), - s(:send, nil, :b), - s(:send, nil, :c)) - - assert_equal expected, actual - end - - test "#run does not transform any method calls if no symbols were passed" do - transformation = RSpock::AST::MethodCallToLVarTransformation.new - - ast = s(:begin, - s(:send, nil, :a), - s(:send, nil, :_test_index_), - s(:send, nil, :b), - s(:send, nil, :c)) - - actual = transformation.run(ast) - - expected = s(:begin, - s(:send, nil, :a), - s(:send, nil, :_test_index_), - s(:send, nil, :b), - s(:send, nil, :c)) - - assert_equal expected, actual - end - end - end -end diff --git a/test/rspock/ast/statement_to_assertion_transformation_test.rb b/test/rspock/ast/statement_to_assertion_transformation_test.rb index 3d20084..6daf253 100644 --- a/test/rspock/ast/statement_to_assertion_transformation_test.rb +++ b/test/rspock/ast/statement_to_assertion_transformation_test.rb @@ -144,11 +144,11 @@ def setup private def build_binary(op, lhs, rhs) - RSpock::AST::Node.build(:rspock_binary_statement, lhs, s(:sym, op), rhs) + s(:rspock_binary_statement, lhs, s(:sym, op), rhs) end def build_statement(expr, source_text) - RSpock::AST::Node.build(:rspock_statement, expr, s(:str, source_text)) + s(:rspock_statement, expr, s(:str, source_text)) end end end diff --git a/test/rspock/ast/test_method_def_transformation_test.rb b/test/rspock/ast/test_method_def_transformation_test.rb index c2ff6ee..7817917 100644 --- a/test/rspock/ast/test_method_def_transformation_test.rb +++ b/test/rspock/ast/test_method_def_transformation_test.rb @@ -22,7 +22,7 @@ def setup assert_same ast, actual end - test "#run transforms str into dstr and injects _test_index_ and _line_number_ lvar" do + test "#run transforms str into dstr and injects __rspock_row_index__ and __rspock_row_line__ lvar" do ast = s(:send, nil, :test, s(:str, "Test Name")) @@ -31,14 +31,14 @@ def setup expected = s(:send, nil, :test, s(:dstr, s(:str, "Test Name "), - s(:begin, s(:lvar, :_test_index_)), + s(:begin, s(:lvar, :__rspock_row_index__)), s(:str, " line "), - s(:begin, s(:lvar, :_line_number_)))) + s(:begin, s(:lvar, :__rspock_row_line__)))) assert_equal expected, actual end - test "#run injects _test_index_ and _line_number_ lvar into dstr" do + test "#run injects __rspock_row_index__ and __rspock_row_line__ lvar into dstr" do ast = s(:send, nil, :test, s(:dstr, s(:begin, s(:lvar, :a)), @@ -50,9 +50,9 @@ def setup s(:dstr, s(:begin, s(:lvar, :a)), s(:str, "Test Name "), - s(:begin, s(:lvar, :_test_index_)), + s(:begin, s(:lvar, :__rspock_row_index__)), s(:str, " line "), - s(:begin, s(:lvar, :_line_number_)))) + s(:begin, s(:lvar, :__rspock_row_line__)))) assert_equal expected, actual end diff --git a/test/rspock/ast/test_method_dstr_transformation_test.rb b/test/rspock/ast/test_method_dstr_transformation_test.rb index 159d4d9..05cd41a 100644 --- a/test/rspock/ast/test_method_dstr_transformation_test.rb +++ b/test/rspock/ast/test_method_dstr_transformation_test.rb @@ -31,7 +31,7 @@ def setup assert_same ast, actual end - test "#run injects _test_index_ and _line_number_ lvar into dstr" do + test "#run injects __rspock_row_index__ and __rspock_row_line__ lvar into dstr" do ast = s(:send, nil, :test, s(:dstr, s(:begin, s(:lvar, :a)), @@ -43,9 +43,9 @@ def setup s(:dstr, s(:begin, s(:lvar, :a)), s(:str, "Test Name "), - s(:begin, s(:lvar, :_test_index_)), + s(:begin, s(:lvar, :__rspock_row_index__)), s(:str, " line "), - s(:begin, s(:lvar, :_line_number_)))) + s(:begin, s(:lvar, :__rspock_row_line__)))) assert_equal expected, actual end @@ -63,9 +63,9 @@ def setup s(:str, "Test "), s(:begin, s(:lvar, :a)), s(:str, " "), - s(:begin, s(:lvar, :_test_index_)), + s(:begin, s(:lvar, :__rspock_row_index__)), s(:str, " line "), - s(:begin, s(:lvar, :_line_number_)))) + s(:begin, s(:lvar, :__rspock_row_line__)))) assert_equal expected, actual end diff --git a/test/rspock/ast/transformation_test.rb b/test/rspock/ast/transformation_test.rb index 799da93..f2612da 100644 --- a/test/rspock/ast/transformation_test.rb +++ b/test/rspock/ast/transformation_test.rb @@ -52,12 +52,12 @@ def setup HEREDOC expected = <<~HEREDOC - test("Adding 1 and 2 results in 3") { - assert_equal(3, 1 + 2) - } + test("Adding 1 and 2 results in 3") do + assert_equal(3, 1 + 2) + end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "first node cannot be a non-starting block" do @@ -183,17 +183,10 @@ def can_end? HEREDOC expected = <<~HEREDOC - Potato = Class.new { - (begin - (extend(RSpock::Declarative)) - rescue StandardError => e - ::RSpock::BacktraceFilter.new.filter_exception(e) - raise - end) - } + Potato = Class.new do; extend(RSpock::Declarative); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "#run adds extend RSpock::Declarative when using traditional class definition" do @@ -204,13 +197,8 @@ class Potato HEREDOC expected = <<~HEREDOC - class Potato - (begin - (extend(RSpock::Declarative)) - rescue StandardError => e - ::RSpock::BacktraceFilter.new.filter_exception(e) - raise - end) + class Potato; extend(RSpock::Declarative) + end HEREDOC @@ -257,6 +245,9 @@ def can_end? end end + # Line-aligned emission: each statement is on its SOURCE line — the When + # assignment on line 3 (descriptions on 2 and 5 vanish into blank lines), + # the assertion on line 6 where `actual == 3` was written. test "test without where block" do source = <<~HEREDOC test "Adding 1 and 2 results in 3" do @@ -269,13 +260,15 @@ def can_end? HEREDOC expected = <<~HEREDOC - test(\"Adding 1 and 2 results in 3\") { - actual = 1 + 2 - assert_equal(3, actual) - } + test("Adding 1 and 2 results in 3") do + + actual = 1 + 2 + + + assert_equal(3, actual); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with where block" do @@ -295,15 +288,15 @@ def can_end? HEREDOC expected = <<~HEREDOC - [[1, 2, 3, 10], [4, 5, 9, 11]].each.with_index { |(a, b, c, _line_number_), _test_index_| - test("Adding \#{a} and \#{b} results in \#{c} \#{_test_index_} line \#{_line_number_}") { - actual = a + b - assert_equal(c, actual) - } - } + [[1, 2, 3, 10], [4, 5, 9, 11]].each.with_index { |(a, b, c, __rspock_row_line__), __rspock_row_index__|; test("Adding \#{a} and \#{b} results in \#{c} \#{__rspock_row_index__} line \#{__rspock_row_line__}") do + + actual = a + b + + + assert_equal(c, actual); end; } HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with cleanup block" do @@ -322,18 +315,19 @@ def can_end? HEREDOC expected = <<~HEREDOC - test(\"Adding 1 and 2 results in 3\") { - begin - actual = 1 + 2 - assert_equal(3, actual) - ensure - method1 - method2 - end - } + test("Adding 1 and 2 results in 3") do; begin + + actual = 1 + 2 + + + assert_equal(3, actual) + ensure + + method1 + method2; end; end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with interactions" do @@ -352,17 +346,23 @@ def can_end? end HEREDOC + # The When body is deferred (interactions must execute first) at its + # source position; each Mocha setup lands on its interaction's line. expected = <<~HEREDOC - test(\"interactions\") { - dep = mock - foo = Foo.new(dep) - dep.expects(:bar).times(0) - dep.expects(:foo).times(1) - foo.foo - } + test("interactions") do + + dep = mock + foo = Foo.new(dep); __ast_deferred_1__ = ->() do + + + foo.foo; end + + + dep.expects(:bar).times(0) + dep.expects(:foo).times(1); __ast_deferred_1__.call; end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with interaction and &block forwarding" do @@ -381,18 +381,19 @@ def can_end? HEREDOC expected = <<~HEREDOC - test("block forwarding") { - my_proc = Proc.new { - } - dep = mock - dep.expects(:call_method).with("arg").times(1) - __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method) - dep.call_method("arg", &my_proc) - assert_same(my_proc, __rspock_blk_0.call) - } + test("block forwarding") do + + my_proc = Proc.new do; end + dep = mock; __ast_deferred_1__ = ->() do + + + dep.call_method("arg", &my_proc); end + + + dep.expects(:call_method).with("arg").times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_deferred_1__.call; assert_same(my_proc, __rspock_blk_0.call); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with multiple &block interactions" do @@ -414,24 +415,22 @@ def can_end? HEREDOC expected = <<~HEREDOC - test("multiple blocks") { - cb1 = Proc.new { - } - cb2 = Proc.new { - } - dep = mock - dep.expects(:method1).times(1) - __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :method1) - dep.expects(:method2).times(1) - __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2) - dep.method1(&cb1) - dep.method2(&cb2) - assert_same(cb1, __rspock_blk_0.call) - assert_same(cb2, __rspock_blk_1.call) - } + test("multiple blocks") do + + cb1 = Proc.new do; end + cb2 = Proc.new do; end + dep = mock; __ast_deferred_1__ = ->() do + + + dep.method1(&cb1) + dep.method2(&cb2); end + + + dep.expects(:method1).times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :method1) + dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_deferred_1__.call; assert_same(cb1, __rspock_blk_0.call); assert_same(cb2, __rspock_blk_1.call); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with &block and >> return value" do @@ -450,18 +449,19 @@ def can_end? HEREDOC expected = <<~HEREDOC - test("block with return") { - my_proc = Proc.new { - } - dep = mock - dep.expects(:call_method).times(1).returns("result") - __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method) - dep.call_method(&my_proc) - assert_same(my_proc, __rspock_blk_0.call) - } + test("block with return") do + + my_proc = Proc.new do; end + dep = mock; __ast_deferred_1__ = ->() do + + + dep.call_method(&my_proc); end + + + dep.expects(:call_method).times(1).returns("result"); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_deferred_1__.call; assert_same(my_proc, __rspock_blk_0.call); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with mixed interactions (with and without &block)" do @@ -482,20 +482,21 @@ def can_end? HEREDOC expected = <<~HEREDOC - test("mixed interactions") { - my_proc = Proc.new { - } - dep = mock - dep.expects(:method1).with("arg").times(1) - dep.expects(:method2).times(1) - __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2) - dep.method1("arg") - dep.method2(&my_proc) - assert_same(my_proc, __rspock_blk_1.call) - } + test("mixed interactions") do + + my_proc = Proc.new do; end + dep = mock; __ast_deferred_1__ = ->() do + + + dep.method1("arg") + dep.method2(&my_proc); end + + + dep.expects(:method1).with("arg").times(1) + dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_deferred_1__.call; assert_same(my_proc, __rspock_blk_1.call); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end private diff --git a/test/rspock/backtrace_filter_test.rb b/test/rspock/backtrace_filter_test.rb deleted file mode 100644 index b423df0..0000000 --- a/test/rspock/backtrace_filter_test.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'tmpdir' -require 'ast_transform/transformation' -require 'ast_transform/transformer' -require 'rspock/declarative' -require 'rspock/ast/transformation' -require 'rspock/backtrace_filter' - -module RSpock - # ::Minitest is explicit throughout: requiring rspock/minitest/backtrace_filter - # defines RSpock::Minitest, which shadows the top-level constant inside this module. - class BacktraceFilterTest < ::Minitest::Test - extend RSpock::Declarative - - # Multi-line expressions collapse during unparse and the rescue wrapper - # shifts the class body, so transformed line numbers differ from source - # line numbers — making the mapping observable. - FIXTURE_SOURCE = <<~RUBY - transform!(RSpock::AST::Transformation) - class BacktraceFilterFixture - def kaboom - value = 1 + - 2 + - 3 - raise ArgumentError, - "kaboom-\#{value}" - end - end - RUBY - - test "filter_string maps a transformed line number back to the source line" do - with_registered_source_map do |source_path, _transformed_path, transformed_source| - location = "#{source_path}:#{raise_line(transformed_source)}:in 'kaboom'" - - filtered = BacktraceFilter.new.filter_string(location) - - assert_equal "#{source_path}:#{raise_line(FIXTURE_SOURCE)}:in 'kaboom'", filtered - end - end - - test "filter_string maps a transformed file path back to the source file path" do - with_registered_source_map do |source_path, transformed_path, transformed_source| - location = "#{transformed_path}:#{raise_line(transformed_source)}:in 'kaboom'" - - filtered = BacktraceFilter.new.filter_string(location) - - assert_equal "#{source_path}:#{raise_line(FIXTURE_SOURCE)}:in 'kaboom'", filtered - end - end - - test "filter_string reports ? for an unmappable line number" do - with_registered_source_map do |source_path, _transformed_path, _transformed_source| - filtered = BacktraceFilter.new.filter_string("#{source_path}:99999:in 'kaboom'") - - assert_equal "#{source_path}:?:in 'kaboom'", filtered - end - end - - test "filter_string passes through locations without a registered source map" do - location = "/nonexistent/other_file.rb:12:in 'foo'" - - assert_equal location, BacktraceFilter.new.filter_string(location) - end - - test "filter_exception rewrites the exception backtrace to source locations" do - with_registered_source_map do |source_path, transformed_path, _transformed_source| - load transformed_path - exception = assert_raises(ArgumentError) { BacktraceFilterFixture.new.kaboom } - - BacktraceFilter.new.filter_exception(exception) - - assert_equal "#{source_path}:#{raise_line(FIXTURE_SOURCE)}", exception.backtrace.first - end - end - - test "minitest filter maps mappable lines and passes through the rest" do - # Required here, not at the top: loading this file defines RSpock::Minitest, - # which would shadow ::Minitest for test files nested in module RSpock that - # load after this one. In production the plugin loads at autorun time (after - # all files), which is the timing this mirrors. - require 'rspock/minitest/backtrace_filter' - - with_registered_source_map do |source_path, _transformed_path, transformed_source| - backtrace = [ - "#{source_path}:#{raise_line(transformed_source)}:in 'kaboom'", - "/nonexistent/other_file.rb:5:in 'x'", - ] - - filtered = RSpock::Minitest::BacktraceFilter.new.filter(backtrace) - - assert_equal [ - "#{source_path}:#{raise_line(FIXTURE_SOURCE)}:in 'kaboom'", - "/nonexistent/other_file.rb:5:in 'x'", - ], filtered - end - end - - private - - # Transforms the fixture through the real pipeline, registering its - # SourceMap, and writes both files to disk. - def with_registered_source_map - Dir.mktmpdir do |tmpdir| - # Realpath matters: on macOS mktmpdir yields a /var symlink but - # Thread::Backtrace::Location#absolute_path resolves to /private/var, - # and SourceMap registration is keyed by exact path string. - dir = File.realpath(tmpdir) - source_path = File.join(dir, "backtrace_filter_fixture.rb") - transformed_path = File.join(dir, "backtrace_filter_fixture_transformed.rb") - File.write(source_path, FIXTURE_SOURCE) - - transformer = ASTTransform::Transformer.new(ASTTransform::Transformation.new) - transformed_source = transformer.transform_file_source(FIXTURE_SOURCE, source_path, transformed_path) - File.write(transformed_path, transformed_source) - - yield source_path, transformed_path, transformed_source - end - end - - def raise_line(source) - line_number_of(source, "raise") - end - - def line_number_of(source, snippet) - index = source.lines.index { |line| line.include?(snippet) } - flunk "snippet not found in source: #{snippet}" if index.nil? - index + 1 - end - end -end diff --git a/test/rspock/backtrace_source_mapping_test.rb b/test/rspock/backtrace_source_mapping_test.rb index bb70b31..9f3aee3 100644 --- a/test/rspock/backtrace_source_mapping_test.rb +++ b/test/rspock/backtrace_source_mapping_test.rb @@ -5,19 +5,17 @@ require 'rspock/declarative' module RSpock - # End-to-end pin for backtrace source mapping: a failing RSpock test run in a - # subprocess must report line numbers from the file the developer wrote, not - # from the transformed code that actually executes. - # - # The filter-disabled variant documents that the Minitest plugin - # (lib/minitest/rspock_plugin.rb) is what delivers the mapping — ast-transform - # alone makes the *paths* correct but leaves *line numbers* transformed. If - # that test starts failing, the plugin may genuinely be redundant. + # End-to-end pin for backtrace correctness: a failing RSpock test run in a + # subprocess must report line numbers from the file the developer wrote — + # with NO filtering machinery of any kind. Line-aligned emission places each + # transformed statement on its source line, so the VM's raw line numbers are + # already the source line numbers; there is no plugin, no BacktraceFilter, + # and no SourceMap left to install. class BacktraceSourceMappingTest < ::Minitest::Test extend RSpock::Declarative # Blank lines and multi-line expressions make source line numbers diverge - # from transformed ones, so a wrong mapping cannot pass by coincidence. + # from a naively unparsed layout, so alignment cannot pass by coincidence. FIXTURE_SOURCE = <<~RUBY transform!(RSpock::AST::Transformation) class SourceMappingFixtureTest < Minitest::Test @@ -48,7 +46,7 @@ class SourceMappingFixtureTest < Minitest::Test # scrub it so the child resolves gems from the parent's live load paths. # MT_NO_PLUGINS: minitest's gem-scanning plugin discovery can activate a # second minitest version (the Ruby default gem) over the -I one, breaking - # the run; the runner requires the rspock plugin explicitly instead. + # the run. Nothing rspock-specific needs plugging in anymore. CLEAN_ENV = { "RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil, @@ -56,7 +54,7 @@ class SourceMappingFixtureTest < Minitest::Test "MT_NO_PLUGINS" => "1", }.freeze - test "failure messages and backtraces cite source line numbers" do + test "raw failure messages and backtraces cite source line numbers, with zero filter machinery" do output = run_fixture_in_subprocess assertion_line = line_number_of(FIXTURE_SOURCE, "doubled == 999") @@ -69,23 +67,20 @@ class SourceMappingFixtureTest < Minitest::Test assert_includes output, "2 failures", output end - test "without the plugin filter, line numbers are transformed — the plugin is still needed" do - with_filter = run_fixture_in_subprocess - without_filter = run_fixture_in_subprocess(disable_plugin_filter: true) - - refute_equal cited_line_numbers(with_filter), cited_line_numbers(without_filter), - "disabling the plugin filter no longer changes reported line numbers; " \ - "the Minitest plugin may have become redundant\nwith: #{with_filter}\nwithout: #{without_filter}" - end - private - def run_fixture_in_subprocess(disable_plugin_filter: false) + def run_fixture_in_subprocess Dir.mktmpdir do |tmpdir| dir = File.realpath(tmpdir) FileUtils.mkdir_p(File.join(dir, "test")) File.write(File.join(dir, "test", "source_mapping_fixture_test.rb"), FIXTURE_SOURCE) - File.write(File.join(dir, "runner.rb"), runner_source(disable_plugin_filter)) + File.write(File.join(dir, "runner.rb"), <<~RUBY) + require "ast_transform" + ASTTransform.install + require "minitest/autorun" + require "rspock" + require_relative "test/source_mapping_fixture_test" + RUBY output, status = Open3.capture2e(CLEAN_ENV, RbConfig.ruby, *load_path_flags, "runner.rb", chdir: dir) refute status.success?, "fixture run should fail (it contains failing tests)\n#{output}" @@ -94,35 +89,12 @@ def run_fixture_in_subprocess(disable_plugin_filter: false) end end - # Plugin discovery is off (MT_NO_PLUGINS), so the enabled variant applies - # the plugin's init by hand — the same call minitest would make; the - # disabled variant simply leaves minitest's default filter in place. - def runner_source(disable_plugin_filter) - install_plugin_filter = <<~RUBY - require "minitest/rspock_plugin" - Minitest.plugin_rspock_init({}) - RUBY - - <<~RUBY - require "ast_transform" - ASTTransform.install - require "minitest/autorun" - require "rspock" - #{disable_plugin_filter ? "" : install_plugin_filter} - require_relative "test/source_mapping_fixture_test" - RUBY - end - # The child needs the same rspock and dependency load paths as this # process, without going through bundler. def load_path_flags $LOAD_PATH.grep(%r{/(lib|gems)(/|\z)}).flat_map { |path| ["-I", path] } end - def cited_line_numbers(output) - output.scan(%r{test/source_mapping_fixture_test\.rb:(\d+)}).flatten.map(&:to_i).sort.uniq - end - def line_number_of(source, snippet) index = source.lines.index { |line| line.include?(snippet) } flunk "snippet not found in source: #{snippet}" if index.nil? From 4c734148fa25e48a56530569e3598a888bb6c84b Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 22 Jul 2026 12:37:07 -0400 Subject: [PATCH 04/26] Prepare 3.0.0: docs, skill, changelog README: the Rails generator section is replaced by "no setup needed"; the Debugging chapter now documents source-true backtraces and debugger display, the one interaction-stepping oddity, and the Where-row isolation triage (rerun command / -n by row line / conditional break on column locals) replacing the deleted _test_index_/_line_number_ magic variables. Test-name interpolation documents the appended ' line ' suffix as the row-isolation selector target. Skill (from the PR #11 branch, riding this one): drops the Rails generator setup, reframes tmp/ast_transform as line-matched, and replaces the magic-variable debugging advice with the same triage. Version 3.0.0; rspock requires ast_transform ~> 3.0. Co-authored-by: Cursor --- CHANGELOG.md | 20 ++++- Gemfile.lock | 2 +- README.md | 52 +++-------- lib/rspock/version.rb | 2 +- skills/rspock/SKILL.md | 198 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 45 deletions(-) create mode 100644 skills/rspock/SKILL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 560549e..b15bdbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.0.0] - Unreleased + +### Changed + +- Adopted ast_transform 3.0 line-aligned emission: transformed code is emitted with every statement on its original source line, so backtraces, failure messages, breakpoints, and debugger display are source-true natively — with zero runtime filtering. +- Interactions in Then blocks are emitted at their own source lines; the When body they must follow is deferred via ast_transform's deferral facility instead of textually hoisting the interaction setups. +- Where-driven test names now embed the row line via internal block parameters (`__rspock_row_index__` / `__rspock_row_line__`); the appended ` line ` name suffix is unchanged. +- RSpock node classes now register on `ASTTransform::Node` (the promoted registry) instead of RSpock's hand-rolled `Node::REGISTRY`/`NodeBuilder`. + +### Removed + +- **Breaking:** the `_test_index_` and `_line_number_` test-scope variables. Isolate a Where row by running its generated test by name (the name embeds the row line; the failure output prints the exact rerun command) and break normally — see the README's "Isolating a Where Block row". +- **Breaking:** the Rails generator (`rails g rspock:install`) and its backtrace-cleaner initializer template — there is no backtrace cleaning to configure anymore. +- `RSpock::BacktraceFilter`, `RSpock::Minitest::BacktraceFilter`, and the Minitest plugin (`minitest/rspock_plugin`) — line-aligned emission makes raw VM line numbers the source line numbers, so the whole mapping apparatus is gone. +- The rescue wrapper previously injected around transformed class bodies for backtrace mapping. +- `MethodCallToLVarTransformation` (only existed to service `_test_index_`/`_line_number_`). ### Added -- Regression tests for backtrace source mapping: unit coverage for `RSpock::BacktraceFilter` / `RSpock::Minitest::BacktraceFilter`, and a subprocess integration test pinning that failing tests report source line numbers — including one documenting that the Minitest plugin filter is still required for line mapping (ast-transform alone only corrects file paths). +- Subprocess acceptance tests pinning line-aligned behavior end to end: Then assertion failures, failing Where rows, interaction setups, and Cleanup failures all cite their own source lines with zero filter machinery. +- The rspock agent skill (`skills/rspock/SKILL.md`) ships in the gem: the RSpock dialect's mechanics, pitfalls, and debugging triage for coding agents. ## [2.5.0] - 2026-02-28 diff --git a/Gemfile.lock b/Gemfile.lock index 044af2d..489098a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,7 +11,7 @@ GIT PATH remote: . specs: - rspock (2.5.0) + rspock (3.0.0) ast_transform (~> 3.0) minitest (~> 5.0) mocha (>= 1.0) diff --git a/README.md b/README.md index 7679041..55121ee 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,7 @@ load rakefile ### Rails -If you are using Rails, it is necessary to add a filter to *Rails.backtrace_cleaner* for [source mapping](#backtraces) to work, so that you get proper line numbers in Minitest backtraces. For your convenience, we've built a Rails Generator just for that: - - $ rails g rspock:install - -Note: If you are not using Rails, you don't have anything to do, as RSpock includes a Minitest plugin that will set its own backtrace filter. +No Rails-specific setup is required. Backtraces and debuggers show correct source locations natively (see [Debugging](#debugging)), so there is no backtrace cleaner to configure. ## Usage @@ -297,6 +293,8 @@ This effectively creates one version of the Feature Method for each data row. No You might have noticed above that the test name contains string interpolations, that's one of the features of RSpock! You can interpolate test names and use Where block header variables to parameterize the test name using the test data. +RSpock also appends each data row's index and source line number to the generated test name (e.g. `... 1 line 15`). This keeps names unique for identical data rows and gives the `-n` selector a stable target for isolating a row (see [Isolating a Where Block row](#isolating-a-where-block-row)). + ##### Truth Table Formatting Where Blocks as a truth table is the recommended way to organize your Where Blocks in RSpock. It makes test cases more maintainable since it creates an order between the different test cases and makes it easier to spot missing or duplicated test cases. It also makes it easier to add a column: @@ -586,47 +584,19 @@ This produces tests that are simpler, more explicit, and less coupled to impleme ## Debugging -### Pry - -Let's be honest, at some point you will need to debug your tests. Because RSpock requires transforming the AST, the executed code is slightly different from the source code that you wrote. Although we have plans to add Source Mapping support in Pry so that you can see the exact source code you wrote, this is not currently available. This means that code shown in the Pry console will be slightly different. We think the value of using RSpock greatly outweighs this current limitation. We encourage you to try debugging a test to see what the transformed code looks like, or look through `tmp/rspock` for the transformed files. - -### Backtraces - -RSpock supports Source Mapping so that backtraces for the executed code point to the correct line numbers in your source code, and so that the correct files are referenced. This is achieved through wrapping the executed code in rescue blocks, processing the backtraces (source mapping) and re-raising the error. - -### Tips and Tricks - -#### _test_index_ and _line_number_ - -The generated test name for each test case will contain the test index and the line number, corresponding to the Where Block data row for that case, which is available in the test scope as `_test_index_` and `_line_number_` respectively. This can be leveraged to conditionally break on certain test cases, so that you can have a more granular debugging session. - -```ruby -test "Adding #{a} and #{b} results in #{c}" do - When "Adding two numbers" - actual = a + b - - Then "We get the expected result" - # Breaks on the first test case - binding.pry if _test_index_ == 0 - # Breaks on the second test case - binding.pry if _line_number_ == 15 - actual == c +Debugging just works. The transformed code is emitted line-aligned — every statement occupies its original source line — so backtraces, failure messages, `break file:line` breakpoints, and debugger display (Pry, byebug, debug.rb) all point at the file you wrote, with no filtering or mapping layer in between. The transformed files under `tmp/ast_transform` are a pure debugging artifact: their line numbers match your source exactly. - Where - a | b | c - 1 | 2 | 3 - 4 | 5 | 9 # Line 15 -end -``` +One documented oddity: interaction setups in a `Then` block execute *before* the `When` body (see [Execution Order](#execution-order)), so stepping through a test with interactions jumps from the interaction lines back up to the `When` line once. -A few notes: +### Isolating a Where Block row -* Comparison with `_test_index_` and `_line_number_` is not transformed to assertions in Then and Expect Code Blocks -* `_test_index_` is zero-based, meaning the index of the first test case is `0` +Each `Where` data row generates a separate test whose name embeds the row's index and source line number (e.g. `... 1 line 15`), so isolating a row is standard Minitest: -#### _line_number_ +* **Newly failing row:** copy the rerun command printed with the failure (the test name embeds the row line), add a plain `binding.pry` (or `debugger`) in the test body, and run it. It fires exactly once, for that row, with the column values inspectable as locals. +* **Chosen row by position:** run with `-n` and a regex on the row's line number from your editor gutter, e.g. `-n /line_15/`. +* **Chosen row by meaning:** use a conditional break on the column locals themselves, e.g. `binding.pry if input == "not json"` — this survives row reordering. -The Line number is extremely useful for figuring out exactly which test case failed in your Where Block, especially if you have many rows in your Where Block data table. +Note: a source-line *breakpoint* on a data row cannot isolate that row's run — the Where table is evaluated once, in class scope, when tests are defined. Name-selection is the row-isolation mechanism. ## More info diff --git a/lib/rspock/version.rb b/lib/rspock/version.rb index 5c35291..8b4aae4 100644 --- a/lib/rspock/version.rb +++ b/lib/rspock/version.rb @@ -1,3 +1,3 @@ module RSpock - VERSION = "2.5.0" + VERSION = "3.0.0" end diff --git a/skills/rspock/SKILL.md b/skills/rspock/SKILL.md new file mode 100644 index 0000000..ed1234b --- /dev/null +++ b/skills/rspock/SKILL.md @@ -0,0 +1,198 @@ +--- +name: rspock +description: >- + MUST be used when writing or modifying Minitest tests in any repo using + rspock (look for transform!(RSpock::AST::Transformation) in test files or + rspock in the Gemfile). RSpock rewrites test semantics via AST + transformation — code that looks like a no-op statement is an assertion, + and Minitest habits produce silently wrong tests. +--- + +# RSpock: writing tests + +RSpock is a Spock-inspired testing framework on top of Minitest. Tests are +valid Ruby syntax with **different semantics**, applied by AST +transformation at load time. Do not reason about these files as plain +Minitest. + +## The invariant that must never be violated + +**Inside a `transform!(RSpock::AST::Transformation)` class, every bare +statement in a `Then`/`Expect` block IS an assertion. Outside one, it is +NOT — it evaluates and silently discards.** + +Consequences: + +- Inside a `transform!` class, write expression assertions — `a == b` in a + Then/Expect block. The transform compiles them to assertions with proper + failure messages; the Minitest assert API is not the dialect here. +- In a plain Minitest class, use the assert API (`assert_equal` and + friends). A bare comparison there evaluates and discards — a silently + green test. +- When editing a test file, first check for the `transform!` line at each + class definition; it decides which dialect that class speaks. Both + styles may legitimately coexist in one file (see `strict: false` below) + — match the dialect of the class you are in. + +## Boilerplate + +```ruby +require "test_helper" + +transform!(RSpock::AST::Transformation) +class MyThingTest < Minitest::Test + test "descriptive name" do + # code blocks here + end +end +``` + +The application must install the hook once (usually in the test helper): +`require "ast_transform"; ASTTransform.install`. That is the whole setup — +Rails apps need nothing extra (backtraces and debuggers are source-true by +construction; there is no backtrace cleaner to configure). Mixed files can +use `transform!(RSpock::AST::Transformation.new(strict: false))` to allow +plain Minitest tests alongside — this exists to ease gradual migration, +so treat mixed files as normal, not as something to unify. + +The transform is an abstraction — trust it. If you ever need to see the +compiled Ruby (debugging only, never as routine verification), the +transformed files are written under `tmp/ast_transform/`; +they are emitted line-aligned, so their line numbers match your source +exactly. + +## Code blocks and their order + +`Given` (setup) → `When` (stimulus) → `Then` (response), or `Expect` +(stimulus+response in one), plus `Cleanup` (always runs; code defensively +with `&.`) and `Where` (data table, last in source but evaluated first). +Every block takes an optional description string. A `When` is always +followed by a `Then`. Use When+Then for side-effecting code, Expect for +pure functions. + +## Assertion forms (Then/Expect) + +```ruby +Then "the walk produced the right state" +actual == expected # binary operators: == != =~ !~ > < >= <= +list.include?(x) # bare boolean expression asserts +!cart.empty? # negation asserts +name = actual.first # assignments pass through (not assertions) +``` + +LHS is actual, RHS is expected. Exception assertions live in Then, apply +to the preceding When, one per block: + +```ruby +Then "a parse error names the token" +e = raises JSON::ParserError # capture optional +e.message.include?("unexpected token") +``` + +`raises` is not supported in Expect blocks. + +## Where tables (data-driven) + +```ruby +test "adding #{a} and #{b} gives #{c}" do + Expect + a + b == c + + Where + a | b | c + -1 | 1 | 0 + 0 | 0 | 0 + 1 | 2 | 3 +end +``` + +Header names become local variables and interpolate into the test name. +The table is evaluated in class scope — it cannot see instance methods or +test-local variables. Order rows like a truth table; rightmost column is +the expected result. + +To generate an exhaustive table instead of writing it by hand: + +``` +rake rspock:truth_table -- a=-1,0,1 b=-1,0,1 expected_result="'?'" +``` + +It emits the formatted cross-product (fill the `'?'` column manually). +Escape commas inside a value with `\,` (e.g. `b="gen(1\, 2)","gen(3\, 4)"`). +Non-Rails projects must load the gem's Rakefile once to get the task — +see the README's installation section. + +## Interaction mocking (Then only) + +```ruby +Then +1 * subscriber.receive("hello") # exactly one call +0 * mailer.deliver # must never be called +(1..3) * poller.tick # between one and three +(1.._) * poller.tick # at least once +(_..3) * poller.tick # at most three times +_ * cache.fetch("key") >> cached # any count, stubbed return +1 * repo.find(42) >> raises(RecordNotFound) # stubbed exception +1 * ui.frame("Build", &my_block) # block-identity check +``` + +Declared in Then but installed before When runs — declare naturally, +RSpock handles ordering. Compiles to Mocha. Inline blocks (`{ }` / +`do...end`) are not allowed in interactions — use a named proc with `&`. +Mocks never yield blocks by design: needing that signals the unit under +test is doing too much — restructure so the mock boundary sits between +responsibilities. + +## Debugging failures + +- Backtraces AND debugger display are source-true: transformed code is + emitted with every statement on its original source line, so line + numbers point at the file you wrote with no mapping layer. Trust them; + don't second-guess against `tmp/ast_transform/`. +- `break file:line` binds on user statements; interactive debuggers show + your real source. One documented oddity: interaction setups execute + before the When body, so stepping through a test with interactions + jumps from the interaction lines back up to the When line once. +- To isolate a Where row: the generated test name embeds the row's index + and source line (e.g. `... 1 line 15`). For a newly failing row, copy + the rerun command printed with the failure and add a plain + `binding.pry`. For a chosen row, run `-n /line_15/` with the line from + the editor gutter, or break conditionally on the column locals + themselves (`binding.pry if input == "not json"`). A source-line + breakpoint on a data row cannot isolate its run — the table evaluates + once, in class scope; name-selection is the mechanism. + +## Pitfalls (wrong → right) + +```ruby +# WRONG: Minitest API inside an RSpock class +assert_equal 3, add(1, 2) +# RIGHT +Expect +add(1, 2) == 3 +``` + +```ruby +# WRONG: bare comparison in a class without transform! — silently green +class FooTest < Minitest::Test + test("x") { compute == 42 } +end +# RIGHT: add transform!(RSpock::AST::Transformation) above the class, +# or use assert_equal in plain Minitest +``` + +```ruby +# WRONG: Where table using an instance method for column data +Where +input | expected +helper_val | 1 # NameError: class scope +# RIGHT: use class methods or literals in Where rows +``` + +```ruby +# WRONG: expecting a mocked method to yield +1 * ui.with_spinner("work") { drain(io) } +# RIGHT: restructure — mock boundary between responsibilities +success = drain(io) # test with a real StringIO +1 * ui.ok("work") # simple expectation, no block +``` From bf97fa30360e18831115712b0ab76063abf3021a Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 22 Jul 2026 12:40:26 -0400 Subject: [PATCH 05/26] Pin unparser below 0.9 (requires Ruby >= 3.3; CI tests 3.2) Co-authored-by: Cursor --- Gemfile | 3 +++ Gemfile.lock | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index c2afa0b..366d356 100644 --- a/Gemfile +++ b/Gemfile @@ -5,5 +5,8 @@ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } # Temporarily pin to the line-aligned-emission branch until ast_transform 3.0.0 ships. gem "ast_transform", git: "https://github.com/rspockframework/ast-transform.git", branch: "jpd/line-aligned-emission" +# unparser 0.9 requires Ruby >= 3.3; we still test against 3.2. +gem "unparser", "< 0.9" + # Specify your gem's dependencies in rspock.gemspec gemspec diff --git a/Gemfile.lock b/Gemfile.lock index 489098a..34ec24d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -62,7 +62,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - unparser (0.9.0) + unparser (0.8.2) diff-lcs (>= 1.6, < 3) parser (>= 3.3.0) prism (>= 1.5.1) @@ -81,6 +81,7 @@ DEPENDENCIES rake (~> 13.0) rspock! simplecov (~> 0.22) + unparser (< 0.9) BUNDLED WITH 2.5.22 From 8d39c11c6506fde01a1fdaac939f38a36fdcda9a Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 23 Jul 2026 08:32:44 -0400 Subject: [PATCH 06/26] Require Ruby >= 3.3; drop direct parser/unparser dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby 3.2 is EOL (March 2026) — required_ruby_version and the CI matrix move to 3.3+, which also lifts the temporary unparser < 0.9 Gemfile pin. The direct parser and unparser runtime dependencies are removed: rspock only touches both through ast_transform, which owns their floors (unparser >= 0.8, parser >= 3.3). Lockfile now on unparser 0.9.0. Co-authored-by: Cursor --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 ++ Gemfile | 3 --- Gemfile.lock | 21 +++++++++------------ rspock.gemspec | 5 ++--- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66ef1de..e21ba7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ['3.2', '3.3', '4.0'] + ruby: ['3.3', '4.0'] steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index b15bdbe..1aba637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Interactions in Then blocks are emitted at their own source lines; the When body they must follow is deferred via ast_transform's deferral facility instead of textually hoisting the interaction setups. - Where-driven test names now embed the row line via internal block parameters (`__rspock_row_index__` / `__rspock_row_line__`); the appended ` line ` name suffix is unchanged. - RSpock node classes now register on `ASTTransform::Node` (the promoted registry) instead of RSpock's hand-rolled `Node::REGISTRY`/`NodeBuilder`. +- **Breaking:** requires Ruby >= 3.3 (3.2 is EOL since March 2026). ### Removed @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `RSpock::BacktraceFilter`, `RSpock::Minitest::BacktraceFilter`, and the Minitest plugin (`minitest/rspock_plugin`) — line-aligned emission makes raw VM line numbers the source line numbers, so the whole mapping apparatus is gone. - The rescue wrapper previously injected around transformed class bodies for backtrace mapping. - `MethodCallToLVarTransformation` (only existed to service `_test_index_`/`_line_number_`). +- The direct `parser` and `unparser` runtime dependencies: rspock uses both only through ast_transform, which owns their version floors. ### Added diff --git a/Gemfile b/Gemfile index 366d356..c2afa0b 100644 --- a/Gemfile +++ b/Gemfile @@ -5,8 +5,5 @@ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } # Temporarily pin to the line-aligned-emission branch until ast_transform 3.0.0 ships. gem "ast_transform", git: "https://github.com/rspockframework/ast-transform.git", branch: "jpd/line-aligned-emission" -# unparser 0.9 requires Ruby >= 3.3; we still test against 3.2. -gem "unparser", "< 0.9" - # Specify your gem's dependencies in rspock.gemspec gemspec diff --git a/Gemfile.lock b/Gemfile.lock index 34ec24d..847e2c4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,12 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: eb7313ce63fb838515efb2ccaf8d880dda28d95b + revision: 6a842292a8ff4b3bf0593ad656007a893d0de2b2 branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) - parser (>= 3.0) + parser (>= 3.3) prism (>= 1.5) - unparser (>= 0.6) + unparser (>= 0.8) PATH remote: . @@ -15,13 +15,11 @@ PATH ast_transform (~> 3.0) minitest (~> 5.0) mocha (>= 1.0) - parser (>= 3.0) - unparser (>= 0.6) GEM remote: https://rubygems.org/ specs: - ansi (1.5.0) + ansi (1.6.0) ast (2.4.3) builder (3.3.0) byebug (13.0.0) @@ -32,12 +30,12 @@ GEM io-console (0.8.2) method_source (1.1.0) minitest (5.27.0) - minitest-reporters (1.7.1) + minitest-reporters (1.8.0) ansi builder - minitest (>= 5.0) + minitest (>= 5.0, < 7) ruby-progressbar - mocha (3.0.2) + mocha (3.1.0) ruby2_keywords (>= 0.0.5) parser (3.3.12.0) ast (~> 2.4.1) @@ -51,7 +49,7 @@ GEM byebug (~> 13.0) pry (>= 0.13, < 0.17) racc (1.8.1) - rake (13.3.1) + rake (13.4.2) reline (0.6.3) io-console (~> 0.5) ruby-progressbar (1.13.0) @@ -62,7 +60,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - unparser (0.8.2) + unparser (0.9.0) diff-lcs (>= 1.6, < 3) parser (>= 3.3.0) prism (>= 1.5.1) @@ -81,7 +79,6 @@ DEPENDENCIES rake (~> 13.0) rspock! simplecov (~> 0.22) - unparser (< 0.9) BUNDLED WITH 2.5.22 diff --git a/rspock.gemspec b/rspock.gemspec index 3fc3062..400c518 100644 --- a/rspock.gemspec +++ b/rspock.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |spec| spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.required_ruby_version = '>= 3.2' + spec.required_ruby_version = '>= 3.3' # Development dependencies spec.add_development_dependency "bundler", ">= 2.1" @@ -31,9 +31,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency "simplecov", "~> 0.22" # Runtime dependencies + # parser and unparser are used only through ast_transform, which owns their floors. spec.add_runtime_dependency "ast_transform", "~> 3.0" spec.add_runtime_dependency "minitest", "~> 5.0" spec.add_runtime_dependency "mocha", ">= 1.0" - spec.add_runtime_dependency "parser", ">= 3.0" - spec.add_runtime_dependency "unparser", ">= 0.6" end From 17f63326507d91dba0039a60d92ac77b98790eff Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 23 Jul 2026 08:49:25 -0400 Subject: [PATCH 07/26] CHANGELOG: state the Ruby 3.2 support drop explicitly Co-authored-by: Cursor --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aba637..07a4414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Interactions in Then blocks are emitted at their own source lines; the When body they must follow is deferred via ast_transform's deferral facility instead of textually hoisting the interaction setups. - Where-driven test names now embed the row line via internal block parameters (`__rspock_row_index__` / `__rspock_row_line__`); the appended ` line ` name suffix is unchanged. - RSpock node classes now register on `ASTTransform::Node` (the promoted registry) instead of RSpock's hand-rolled `Node::REGISTRY`/`NodeBuilder`. -- **Breaking:** requires Ruby >= 3.3 (3.2 is EOL since March 2026). +- **Breaking:** dropped Ruby 3.2 support (EOL since March 2026); `required_ruby_version` is now `>= 3.3`. ### Removed From f7aaf9bb7d3dd551f41428690c378f87abf963b9 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 23 Jul 2026 09:32:14 -0400 Subject: [PATCH 08/26] Adopt dev + shadowenv for the development environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev.yml pins Ruby 4.0.6 (latest; converges with the org toolchain) and declares up/test; dev up provisions the per-machine .shadowenv.d (now gitignored) via rbenv, so the right Ruby activates without manual PATH surgery. .ruby-version gives plain rbenv users the same pin. Inert for external contributors — plain Bundler still works, README says so. Co-authored-by: Cursor --- .gitignore | 5 ++++- .ruby-version | 1 + README.md | 2 ++ dev.yml | 9 +++++++++ 4 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .ruby-version create mode 100644 dev.yml diff --git a/.gitignore b/.gitignore index 19257f0..a86f13e 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,7 @@ build-iPhoneSimulator/ .rvmrc # RubyMine -/.idea \ No newline at end of file +/.idea + +# Generated per-machine by `dev up` (d3mlabs dev tool); never committed +/.shadowenv.d/ \ No newline at end of file diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..d13e837 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.6 diff --git a/README.md b/README.md index 55121ee..a6b5bf8 100644 --- a/README.md +++ b/README.md @@ -637,6 +637,8 @@ end After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +If you use the [d3mlabs dev tool](https://github.com/d3mlabs/dev), `dev up` provisions the pinned Ruby (see `.ruby-version`) with a per-project shadowenv, and `dev test` runs the suite — plain Bundler as above works just as well. + To install this gem onto your local machine, run `bundle exec rake install`. ## Releasing a New Version diff --git a/dev.yml b/dev.yml new file mode 100644 index 0000000..5555c0b --- /dev/null +++ b/dev.yml @@ -0,0 +1,9 @@ +name: rspock +ruby: "4.0.6" +commands: + up: + desc: Install gems + run: bundle install + test: + desc: Run this repo's tests + run: bundle exec rake test From bf24270fcb954f3d4192cc5152f60fabf18a4302 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 23 Jul 2026 12:58:36 -0400 Subject: [PATCH 09/26] Declare the Ruby toolchain in dependencies.rb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev now single-sources the project Ruby from the dependencies.rb manifest; dev.yml's ruby: key is removed. Toolchain-only manifest — gems stay bundler-managed via the hand-written gemspec/Gemfile. Co-authored-by: Cursor --- dependencies.rb | 11 +++++++++++ dev.yml | 1 - 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 dependencies.rb diff --git a/dependencies.rb b/dependencies.rb new file mode 100644 index 0000000..7d83308 --- /dev/null +++ b/dependencies.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Toolchain-only manifest for d3mlabs' dev tool: it provisions this exact +# Ruby (rbenv + shadowenv) for `dev` commands. Gems stay bundler-managed +# through the hand-written gemspec/Gemfile; contributors without dev can +# ignore this file and use .ruby-version. +require "dev/deps" + +Dev::Deps.define do + ruby "4.0.6" +end diff --git a/dev.yml b/dev.yml index 5555c0b..222b798 100644 --- a/dev.yml +++ b/dev.yml @@ -1,5 +1,4 @@ name: rspock -ruby: "4.0.6" commands: up: desc: Install gems From 127c09750f5c4be5716e4535380d8b1d7af44375 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 23 Jul 2026 16:25:31 -0400 Subject: [PATCH 10/26] Adopt proc-lowered deferral: When locals reach Then and Cleanup Updates to ast_transform's proc lowering (hidden closure is a non-lambda proc with method-scope pre-declarations): - Fixes the long-standing NameError when a Then expectation or an ensure-run Cleanup reads a local assigned in a When body deferred behind interaction setups. - New subprocess acceptance fixtures pin both: When-result readable in Then, and Cleanup seeing the When local after a failing Then. - Transformation expectations updated (proc do + result = result pre-declaration) and a new expectation pinning the emission shape. - CI adds Ruby 3.4. Co-authored-by: Cursor --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 8 ++- Gemfile.lock | 2 +- test/rspock/ast/transformation_test.rb | 46 ++++++++++-- test/rspock/line_alignment_test.rb | 96 ++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e21ba7a..65e2ce2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ['3.3', '4.0'] + ruby: ['3.3', '3.4', '4.0'] steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a4414..0c17693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.0.0] - Unreleased +### Fixed + +- Locals assigned in a When block are now readable in Then and Cleanup when the test declares interactions. The deferred When body lowers to a non-lambda proc with method-scope pre-declarations, so `result = subject.call` in When no longer raises `NameError` in a Then expectation or in an ensure-run Cleanup — a long-standing bug of the hoisted-interaction era. + ### Changed - Adopted ast_transform 3.0 line-aligned emission: transformed code is emitted with every statement on its original source line, so backtraces, failure messages, breakpoints, and debugger display are source-true natively — with zero runtime filtering. -- Interactions in Then blocks are emitted at their own source lines; the When body they must follow is deferred via ast_transform's deferral facility instead of textually hoisting the interaction setups. +- Interactions in Then blocks are emitted at their own source lines; the When body they must follow is deferred via ast_transform's deferral facility instead of textually hoisting the interaction setups. A deferred `return` still returns from the test method (proc semantics). - Where-driven test names now embed the row line via internal block parameters (`__rspock_row_index__` / `__rspock_row_line__`); the appended ` line ` name suffix is unchanged. - RSpock node classes now register on `ASTTransform::Node` (the promoted registry) instead of RSpock's hand-rolled `Node::REGISTRY`/`NodeBuilder`. - **Breaking:** dropped Ruby 3.2 support (EOL since March 2026); `required_ruby_version` is now `>= 3.3`. @@ -26,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Subprocess acceptance tests pinning line-aligned behavior end to end: Then assertion failures, failing Where rows, interaction setups, and Cleanup failures all cite their own source lines with zero filter machinery. +- Subprocess acceptance tests pinning line-aligned behavior end to end: Then assertion failures, failing Where rows, interaction setups, and Cleanup failures all cite their own source lines with zero filter machinery; When-assigned locals stay readable in Then and in ensure-run Cleanup under interaction deferral. - The rspock agent skill (`skills/rspock/SKILL.md`) ships in the gem: the RSpock dialect's mechanics, pitfalls, and debugging triage for coding agents. ## [2.5.0] - 2026-02-28 diff --git a/Gemfile.lock b/Gemfile.lock index 847e2c4..b796e30 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: 6a842292a8ff4b3bf0593ad656007a893d0de2b2 + revision: 88034e05a84e55df7de148c4ead86fcc37a3cfc3 branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) diff --git a/test/rspock/ast/transformation_test.rb b/test/rspock/ast/transformation_test.rb index f2612da..c71912c 100644 --- a/test/rspock/ast/transformation_test.rb +++ b/test/rspock/ast/transformation_test.rb @@ -352,7 +352,7 @@ def can_end? test("interactions") do dep = mock - foo = Foo.new(dep); __ast_deferred_1__ = ->() do + foo = Foo.new(dep); __ast_deferred_1__ = proc do foo.foo; end @@ -365,6 +365,42 @@ def can_end? assert_equal expected, transform(source) end + test "test with interactions and a When result read by Then" do + source = <<~HEREDOC + test "when result" do + Given + dep = mock + foo = Foo.new(dep) + + When + result = foo.foo + + Then + 1 * dep.foo + result == 42 + end + HEREDOC + + # `result` is assigned inside the deferred proc; the lowering + # pre-declares it (`result = result`) at method scope so the + # assertion after the execution point can read it. + expected = <<~HEREDOC + test("when result") do + + dep = mock + foo = Foo.new(dep); result = result; __ast_deferred_1__ = proc do + + + result = foo.foo; end + + + dep.expects(:foo).times(1); __ast_deferred_1__.call + assert_equal(42, result); end + HEREDOC + + assert_equal expected, transform(source) + end + test "test with interaction and &block forwarding" do source = <<~HEREDOC test "block forwarding" do @@ -384,7 +420,7 @@ def can_end? test("block forwarding") do my_proc = Proc.new do; end - dep = mock; __ast_deferred_1__ = ->() do + dep = mock; __ast_deferred_1__ = proc do dep.call_method("arg", &my_proc); end @@ -419,7 +455,7 @@ def can_end? cb1 = Proc.new do; end cb2 = Proc.new do; end - dep = mock; __ast_deferred_1__ = ->() do + dep = mock; __ast_deferred_1__ = proc do dep.method1(&cb1) @@ -452,7 +488,7 @@ def can_end? test("block with return") do my_proc = Proc.new do; end - dep = mock; __ast_deferred_1__ = ->() do + dep = mock; __ast_deferred_1__ = proc do dep.call_method(&my_proc); end @@ -485,7 +521,7 @@ def can_end? test("mixed interactions") do my_proc = Proc.new do; end - dep = mock; __ast_deferred_1__ = ->() do + dep = mock; __ast_deferred_1__ = proc do dep.method1("arg") diff --git a/test/rspock/line_alignment_test.rb b/test/rspock/line_alignment_test.rb index 13bc3ba..0429978 100644 --- a/test/rspock/line_alignment_test.rb +++ b/test/rspock/line_alignment_test.rb @@ -129,6 +129,54 @@ def call "the interaction's source line\n#{output}" end + # When the Then block declares interactions, the When body is deferred + # past the Mocha setups — historically that hid `result` (a local first + # assigned inside the deferral closure is closure-local), so reading it + # in Then raised NameError. The lowering now pre-declares deferred + # assignments at method scope; the expectation must fail as a plain + # assertion failure that can SEE the value. + WHEN_RESULT_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class WhenResultFixtureTest < Minitest::Test + class Subject + def initialize(dep) + @dep = dep + end + + def call + @dep.ping(1) + 42 + end + end + + test "When result stays readable when interactions defer the When body" do + Given "a mocked collaborator" + dep = mock + subject = Subject.new(dep) + + When "exercising the subject" + result = subject.call + + Then "the interaction and an expectation on the result that cannot hold" + 1 * dep.ping(1) + result == 999 + end + end + RUBY + + test "a When-assigned local is readable in Then despite interaction deferral, raw" do + output = run_fixture("when_result_fixture_test.rb", WHEN_RESULT_FIXTURE) + assertion_line = line_number_of(WHEN_RESULT_FIXTURE, "result == 999") + + # A failure (not an error): a NameError on `result` would be an error. + assert_includes output, "1 failures, 0 errors", + "the result expectation should fail as an assertion, proving `result` was readable\n#{output}" + assert_includes output, "42", + "the failure message should show the deferred When's actual result\n#{output}" + assert_includes output, "test/when_result_fixture_test.rb:#{assertion_line}", + "the failing expectation should cite its source line\n#{output}" + end + CLEANUP_FIXTURE = <<~RUBY transform!(RSpock::AST::Transformation) class CleanupFixtureTest < Minitest::Test @@ -153,6 +201,54 @@ class CleanupFixtureTest < Minitest::Test "the cleanup raise should cite its source line\n#{output}" end + # The compound of the two hazards above: Cleanup runs inside ensure, so it + # observes the When local BOTH after a failing Then AND with the When body + # deferred behind interaction setups. Historically this raised NameError + # inside ensure, masking the real failure. + CLEANUP_WHEN_LOCAL_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class CleanupWhenLocalFixtureTest < Minitest::Test + class Subject + def initialize(dep) + @dep = dep + end + + def call + @dep.ping(1) + :open + end + end + + test "Cleanup sees the When-assigned local" do + Given "a mocked collaborator" + dep = mock + subject = Subject.new(dep) + + When "exercising the subject" + handle = subject.call + + Then "an interaction and an expectation that cannot hold" + 1 * dep.ping(1) + handle == :closed + + Cleanup "reporting what the ensure block can see" + puts "cleanup saw \#{handle.inspect}" + end + end + RUBY + + test "Cleanup reads a When-assigned local after a failing Then, raw" do + output = run_fixture("cleanup_when_local_fixture_test.rb", CLEANUP_WHEN_LOCAL_FIXTURE) + assertion_line = line_number_of(CLEANUP_WHEN_LOCAL_FIXTURE, "handle == :closed") + + assert_includes output, "cleanup saw :open", + "the ensure-run Cleanup should see the When-assigned local\n#{output}" + assert_includes output, "1 failures, 0 errors", + "the Then failure should surface as an assertion failure, not be masked by a NameError in ensure\n#{output}" + assert_includes output, "test/cleanup_when_local_fixture_test.rb:#{assertion_line}", + "the failing expectation should cite its source line\n#{output}" + end + private # Runs +fixture_source+ in a subprocess with NO backtrace filtering of any From 1c0df72f06a994563e0534b2bfbba17c027772c5 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 23 Jul 2026 21:25:37 -0400 Subject: [PATCH 11/26] Adopt ast_transform's single-node Thunk API The When body is now wrapped in one thunk node placed at its execution point (after the last interaction setup, or composed into the assert_raises block); ast_transform's lowering re-emits the body at its own source lines. The raises-with-interactions branch loses the manual placement splice, and the hidden lvar is now __ast_thunk_N__. Co-authored-by: Cursor --- CHANGELOG.md | 6 ++-- Gemfile.lock | 2 +- lib/rspock/ast/test_method_transformation.rb | 23 +++++++-------- test/rspock/ast/transformation_test.rb | 30 ++++++++++---------- test/rspock/line_alignment_test.rb | 6 ++-- 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c17693..891ca23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Locals assigned in a When block are now readable in Then and Cleanup when the test declares interactions. The deferred When body lowers to a non-lambda proc with method-scope pre-declarations, so `result = subject.call` in When no longer raises `NameError` in a Then expectation or in an ensure-run Cleanup — a long-standing bug of the hoisted-interaction era. +- Locals assigned in a When block are now readable in Then and Cleanup when the test declares interactions. The thunked When body lowers to a non-lambda proc with method-scope pre-declarations, so `result = subject.call` in When no longer raises `NameError` in a Then expectation or in an ensure-run Cleanup — a long-standing bug of the hoisted-interaction era. ### Changed - Adopted ast_transform 3.0 line-aligned emission: transformed code is emitted with every statement on its original source line, so backtraces, failure messages, breakpoints, and debugger display are source-true natively — with zero runtime filtering. -- Interactions in Then blocks are emitted at their own source lines; the When body they must follow is deferred via ast_transform's deferral facility instead of textually hoisting the interaction setups. A deferred `return` still returns from the test method (proc semantics). +- Interactions in Then blocks are emitted at their own source lines; the When body they must follow is wrapped in an ast_transform thunk instead of textually hoisting the interaction setups. A `return` inside the thunked body still returns from the test method (proc semantics). - Where-driven test names now embed the row line via internal block parameters (`__rspock_row_index__` / `__rspock_row_line__`); the appended ` line ` name suffix is unchanged. - RSpock node classes now register on `ASTTransform::Node` (the promoted registry) instead of RSpock's hand-rolled `Node::REGISTRY`/`NodeBuilder`. - **Breaking:** dropped Ruby 3.2 support (EOL since March 2026); `required_ruby_version` is now `>= 3.3`. @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Subprocess acceptance tests pinning line-aligned behavior end to end: Then assertion failures, failing Where rows, interaction setups, and Cleanup failures all cite their own source lines with zero filter machinery; When-assigned locals stay readable in Then and in ensure-run Cleanup under interaction deferral. +- Subprocess acceptance tests pinning line-aligned behavior end to end: Then assertion failures, failing Where rows, interaction setups, and Cleanup failures all cite their own source lines with zero filter machinery; When-assigned locals stay readable in Then and in ensure-run Cleanup when interactions thunk the When body. - The rspock agent skill (`skills/rspock/SKILL.md`) ships in the gem: the RSpock dialect's mechanics, pitfalls, and debugging triage for coding agents. ## [2.5.0] - 2026-02-28 diff --git a/Gemfile.lock b/Gemfile.lock index b796e30..c96d38b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: 88034e05a84e55df7de148c4ead86fcc37a3cfc3 + revision: 05375a31e7bfe41d16d0fccbc0dfa819a5d55474 branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) diff --git a/lib/rspock/ast/test_method_transformation.rb b/lib/rspock/ast/test_method_transformation.rb index 1529ba0..df31043 100644 --- a/lib/rspock/ast/test_method_transformation.rb +++ b/lib/rspock/ast/test_method_transformation.rb @@ -38,8 +38,8 @@ def transform(rspock_ast) # Statements are assembled in SOURCE order so line-aligned emission keeps # each one on its own line. Execution-order requirements that source # order cannot express (interaction setups in Then must run before the - # When body they observe) are carried by ast-transform's deferral - # facility (run_after / defer) instead of by textual hoisting. + # When body they observe) are carried by ast-transform's thunk facility + # (run_after / thunk) instead of by textual hoisting. def build_test_body(body_node) blocks = body_node.children sections = blocks.map { |block_node| transform_block(block_node) } @@ -77,7 +77,7 @@ def transform_block(block_node) # identity assertion for &block forwarding), statements become assertions # at their own lines. # - # Within the section, ALL setups come before all assertions: the deferred + # Within the section, ALL setups come before all assertions: the thunked # When body executes right after the last setup, and every assertion # (identity or otherwise) observes the When body's effects, so none may # precede that point. Setups keep source order and their anchors, so @@ -118,13 +118,13 @@ def lower_interaction(interaction, index) end # Reorders execution (not text) where required: - # - interactions without raises: defer the When body until after the last + # - interactions without raises: run the When body after the last # interaction setup (run_after — the paved road). # - raises without interactions: the When body inlines directly into - # assert_raises; no deferral needed. - # - raises with interactions: the When body is deferred at its source - # position and its execution composes into the assert_raises block after - # the last setup (low-level defer). + # assert_raises; no thunk needed. + # - raises with interactions: the When body is thunked into the + # assert_raises block inserted after the last setup; the lowering + # re-emits the body at its own source lines. def order_execution(source_order, when_statements, interaction_setups, raises_node) if raises_node build_raises_body(source_order, when_statements, interaction_setups, raises_node) @@ -137,10 +137,9 @@ def order_execution(source_order, when_statements, interaction_setups, raises_no def build_raises_body(source_order, when_statements, interaction_setups, raises_node) if interaction_setups.any? - deferral = defer(*when_statements) - assertion = build_assert_raises(raises_node, deferral.execution) + assertion = build_assert_raises(raises_node, thunk(*when_statements)) - reordered = replace_run(source_order, when_statements, [deferral.placement]) + reordered = replace_run(source_order, when_statements, []) insert_after(reordered, interaction_setups.last, assertion) else when_body = when_statements.length == 1 ? when_statements[0] : s(:begin, *when_statements) @@ -176,7 +175,7 @@ def statements_of_type(blocks, sections, type) .flat_map { |_block_node, section| section.statements } end - # --- Identity-based sequence edits (non-deferral counterparts of run_after) --- + # --- Identity-based sequence edits (non-thunk counterparts of run_after) --- def replace_run(statements, run, replacement) start = statements.index { |statement| statement.equal?(run.first) } diff --git a/test/rspock/ast/transformation_test.rb b/test/rspock/ast/transformation_test.rb index c71912c..17027df 100644 --- a/test/rspock/ast/transformation_test.rb +++ b/test/rspock/ast/transformation_test.rb @@ -346,20 +346,20 @@ def can_end? end HEREDOC - # The When body is deferred (interactions must execute first) at its - # source position; each Mocha setup lands on its interaction's line. + # The When body is thunked (interactions must execute first) but keeps + # its source position; each Mocha setup lands on its interaction's line. expected = <<~HEREDOC test("interactions") do dep = mock - foo = Foo.new(dep); __ast_deferred_1__ = proc do + foo = Foo.new(dep); __ast_thunk_1__ = proc do foo.foo; end dep.expects(:bar).times(0) - dep.expects(:foo).times(1); __ast_deferred_1__.call; end + dep.expects(:foo).times(1); __ast_thunk_1__.call; end HEREDOC assert_equal expected, transform(source) @@ -381,20 +381,20 @@ def can_end? end HEREDOC - # `result` is assigned inside the deferred proc; the lowering + # `result` is assigned inside the thunk's proc; the lowering # pre-declares it (`result = result`) at method scope so the # assertion after the execution point can read it. expected = <<~HEREDOC test("when result") do dep = mock - foo = Foo.new(dep); result = result; __ast_deferred_1__ = proc do + foo = Foo.new(dep); result = result; __ast_thunk_1__ = proc do result = foo.foo; end - dep.expects(:foo).times(1); __ast_deferred_1__.call + dep.expects(:foo).times(1); __ast_thunk_1__.call assert_equal(42, result); end HEREDOC @@ -420,13 +420,13 @@ def can_end? test("block forwarding") do my_proc = Proc.new do; end - dep = mock; __ast_deferred_1__ = proc do + dep = mock; __ast_thunk_1__ = proc do dep.call_method("arg", &my_proc); end - dep.expects(:call_method).with("arg").times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_deferred_1__.call; assert_same(my_proc, __rspock_blk_0.call); end + dep.expects(:call_method).with("arg").times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_0.call); end HEREDOC assert_equal expected, transform(source) @@ -455,7 +455,7 @@ def can_end? cb1 = Proc.new do; end cb2 = Proc.new do; end - dep = mock; __ast_deferred_1__ = proc do + dep = mock; __ast_thunk_1__ = proc do dep.method1(&cb1) @@ -463,7 +463,7 @@ def can_end? dep.expects(:method1).times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :method1) - dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_deferred_1__.call; assert_same(cb1, __rspock_blk_0.call); assert_same(cb2, __rspock_blk_1.call); end + dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_thunk_1__.call; assert_same(cb1, __rspock_blk_0.call); assert_same(cb2, __rspock_blk_1.call); end HEREDOC assert_equal expected, transform(source) @@ -488,13 +488,13 @@ def can_end? test("block with return") do my_proc = Proc.new do; end - dep = mock; __ast_deferred_1__ = proc do + dep = mock; __ast_thunk_1__ = proc do dep.call_method(&my_proc); end - dep.expects(:call_method).times(1).returns("result"); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_deferred_1__.call; assert_same(my_proc, __rspock_blk_0.call); end + dep.expects(:call_method).times(1).returns("result"); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_0.call); end HEREDOC assert_equal expected, transform(source) @@ -521,7 +521,7 @@ def can_end? test("mixed interactions") do my_proc = Proc.new do; end - dep = mock; __ast_deferred_1__ = proc do + dep = mock; __ast_thunk_1__ = proc do dep.method1("arg") @@ -529,7 +529,7 @@ def can_end? dep.expects(:method1).with("arg").times(1) - dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_deferred_1__.call; assert_same(my_proc, __rspock_blk_1.call); end + dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_1.call); end HEREDOC assert_equal expected, transform(source) diff --git a/test/rspock/line_alignment_test.rb b/test/rspock/line_alignment_test.rb index 0429978..7b5dc7d 100644 --- a/test/rspock/line_alignment_test.rb +++ b/test/rspock/line_alignment_test.rb @@ -129,10 +129,10 @@ def call "the interaction's source line\n#{output}" end - # When the Then block declares interactions, the When body is deferred + # When the Then block declares interactions, the When body is thunked # past the Mocha setups — historically that hid `result` (a local first - # assigned inside the deferral closure is closure-local), so reading it - # in Then raised NameError. The lowering now pre-declares deferred + # assigned inside the thunk's closure is closure-local), so reading it + # in Then raised NameError. The lowering now pre-declares thunked # assignments at method scope; the expectation must fail as a plain # assertion failure that can SEE the value. WHEN_RESULT_FIXTURE = <<~RUBY From ceedc188ab86b88ee763e89d4cb4f221631b906c Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 23 Jul 2026 23:05:19 -0400 Subject: [PATCH 12/26] Adopt ast_transform's source-column indentation in expectations ast_transform's line-aligned emitter now indents freshly placed statements to their source column; transformation expectations pick up the indented (source-shaped) layout. Co-authored-by: Cursor --- Gemfile.lock | 2 +- test/rspock/ast/transformation_test.rb | 80 +++++++++++++------------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c96d38b..37b237d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: 05375a31e7bfe41d16d0fccbc0dfa819a5d55474 + revision: 85679989e249ab013fbf2570662cdc11cd222487 branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) diff --git a/test/rspock/ast/transformation_test.rb b/test/rspock/ast/transformation_test.rb index 17027df..caea9b6 100644 --- a/test/rspock/ast/transformation_test.rb +++ b/test/rspock/ast/transformation_test.rb @@ -53,7 +53,7 @@ def setup expected = <<~HEREDOC test("Adding 1 and 2 results in 3") do - assert_equal(3, 1 + 2) + assert_equal(3, 1 + 2) end HEREDOC @@ -262,10 +262,10 @@ def can_end? expected = <<~HEREDOC test("Adding 1 and 2 results in 3") do - actual = 1 + 2 + actual = 1 + 2 - assert_equal(3, actual); end + assert_equal(3, actual); end HEREDOC assert_equal expected, transform(source) @@ -290,10 +290,10 @@ def can_end? expected = <<~HEREDOC [[1, 2, 3, 10], [4, 5, 9, 11]].each.with_index { |(a, b, c, __rspock_row_line__), __rspock_row_index__|; test("Adding \#{a} and \#{b} results in \#{c} \#{__rspock_row_index__} line \#{__rspock_row_line__}") do - actual = a + b + actual = a + b - assert_equal(c, actual); end; } + assert_equal(c, actual); end; } HEREDOC assert_equal expected, transform(source) @@ -317,14 +317,14 @@ def can_end? expected = <<~HEREDOC test("Adding 1 and 2 results in 3") do; begin - actual = 1 + 2 + actual = 1 + 2 - assert_equal(3, actual) + assert_equal(3, actual) ensure - method1 - method2; end; end + method1 + method2; end; end HEREDOC assert_equal expected, transform(source) @@ -351,15 +351,15 @@ def can_end? expected = <<~HEREDOC test("interactions") do - dep = mock - foo = Foo.new(dep); __ast_thunk_1__ = proc do + dep = mock + foo = Foo.new(dep); __ast_thunk_1__ = proc do - foo.foo; end + foo.foo; end - dep.expects(:bar).times(0) - dep.expects(:foo).times(1); __ast_thunk_1__.call; end + dep.expects(:bar).times(0) + dep.expects(:foo).times(1); __ast_thunk_1__.call; end HEREDOC assert_equal expected, transform(source) @@ -387,15 +387,15 @@ def can_end? expected = <<~HEREDOC test("when result") do - dep = mock - foo = Foo.new(dep); result = result; __ast_thunk_1__ = proc do + dep = mock + foo = Foo.new(dep); result = result; __ast_thunk_1__ = proc do - result = foo.foo; end + result = foo.foo; end - dep.expects(:foo).times(1); __ast_thunk_1__.call - assert_equal(42, result); end + dep.expects(:foo).times(1); __ast_thunk_1__.call + assert_equal(42, result); end HEREDOC assert_equal expected, transform(source) @@ -419,14 +419,14 @@ def can_end? expected = <<~HEREDOC test("block forwarding") do - my_proc = Proc.new do; end - dep = mock; __ast_thunk_1__ = proc do + my_proc = Proc.new do; end + dep = mock; __ast_thunk_1__ = proc do - dep.call_method("arg", &my_proc); end + dep.call_method("arg", &my_proc); end - dep.expects(:call_method).with("arg").times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_0.call); end + dep.expects(:call_method).with("arg").times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_0.call); end HEREDOC assert_equal expected, transform(source) @@ -453,17 +453,17 @@ def can_end? expected = <<~HEREDOC test("multiple blocks") do - cb1 = Proc.new do; end - cb2 = Proc.new do; end - dep = mock; __ast_thunk_1__ = proc do + cb1 = Proc.new do; end + cb2 = Proc.new do; end + dep = mock; __ast_thunk_1__ = proc do - dep.method1(&cb1) - dep.method2(&cb2); end + dep.method1(&cb1) + dep.method2(&cb2); end - dep.expects(:method1).times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :method1) - dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_thunk_1__.call; assert_same(cb1, __rspock_blk_0.call); assert_same(cb2, __rspock_blk_1.call); end + dep.expects(:method1).times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :method1) + dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_thunk_1__.call; assert_same(cb1, __rspock_blk_0.call); assert_same(cb2, __rspock_blk_1.call); end HEREDOC assert_equal expected, transform(source) @@ -487,14 +487,14 @@ def can_end? expected = <<~HEREDOC test("block with return") do - my_proc = Proc.new do; end - dep = mock; __ast_thunk_1__ = proc do + my_proc = Proc.new do; end + dep = mock; __ast_thunk_1__ = proc do - dep.call_method(&my_proc); end + dep.call_method(&my_proc); end - dep.expects(:call_method).times(1).returns("result"); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_0.call); end + dep.expects(:call_method).times(1).returns("result"); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_0.call); end HEREDOC assert_equal expected, transform(source) @@ -520,16 +520,16 @@ def can_end? expected = <<~HEREDOC test("mixed interactions") do - my_proc = Proc.new do; end - dep = mock; __ast_thunk_1__ = proc do + my_proc = Proc.new do; end + dep = mock; __ast_thunk_1__ = proc do - dep.method1("arg") - dep.method2(&my_proc); end + dep.method1("arg") + dep.method2(&my_proc); end - dep.expects(:method1).with("arg").times(1) - dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_1.call); end + dep.expects(:method1).with("arg").times(1) + dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_1.call); end HEREDOC assert_equal expected, transform(source) From 320aa900f1dd560aee4d6ba2feff46c2e5679d51 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 00:17:47 -0400 Subject: [PATCH 13/26] Bump ast_transform to the rebased, style-clean branch revision Co-authored-by: Cursor --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 37b237d..53b63e1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: 85679989e249ab013fbf2570662cdc11cd222487 + revision: e587836dd3caebe9d6e221227cb35d77647d4793 branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) From dbd7db505f56280e46c816bb58e643d07e172e6a Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 01:35:32 -0400 Subject: [PATCH 14/26] Bump ast_transform to the stateless emitter/lowering revision Co-authored-by: Cursor --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 53b63e1..2f67d70 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: e587836dd3caebe9d6e221227cb35d77647d4793 + revision: adcc36990e7ff86c22dd1fa871c3988f31090461 branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) From 9d73b19ecdef262b68b395a9f6309720fad622f8 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 02:27:07 -0400 Subject: [PATCH 15/26] Bump ast_transform to the Thunk capture revision Co-authored-by: Cursor --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2f67d70..436da1b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: adcc36990e7ff86c22dd1fa871c3988f31090461 + revision: 55a1fd8a6c774ccf7912044314ee2329f781cb27 branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) From b5b57c186b4825fe6e162a96b6d65183d4e0236f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 11:07:26 -0400 Subject: [PATCH 16/26] Bump ast_transform to the producer-owned errors revision Co-authored-by: Cursor --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 436da1b..af4659a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: 55a1fd8a6c774ccf7912044314ee2329f781cb27 + revision: a1a7e6b78c96cef89053891900820d6c00c93bfb branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) From f7614954f8149849367593e55eded0452873e830 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 11:09:34 -0400 Subject: [PATCH 17/26] Bump ast_transform to the ArgumentError invariants revision Co-authored-by: Cursor --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index af4659a..aad77ab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: a1a7e6b78c96cef89053891900820d6c00c93bfb + revision: 4da21d2f0be1c986b96f1201f0883f67c54e5682 branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) From c6bd2447803f3e2f7f7354b6415d8b7dbf017df1 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 11:35:49 -0400 Subject: [PATCH 18/26] Bump ast_transform to the testing namespace revision Co-authored-by: Cursor --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index aad77ab..c49c4c7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: 4da21d2f0be1c986b96f1201f0883f67c54e5682 + revision: 0609d420d528e18c413aeac3ce52501a79c29a30 branch: jpd/line-aligned-emission specs: ast_transform (3.0.0) From 6521a855fbb76bf72d91e2fafd32f23c7d835403 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 11:52:26 -0400 Subject: [PATCH 19/26] Repoint ast_transform at git main now that ast-transform#12 is merged Co-authored-by: Cursor --- Gemfile | 4 ++-- Gemfile.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index c2afa0b..d5a2435 100644 --- a/Gemfile +++ b/Gemfile @@ -2,8 +2,8 @@ source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } -# Temporarily pin to the line-aligned-emission branch until ast_transform 3.0.0 ships. -gem "ast_transform", git: "https://github.com/rspockframework/ast-transform.git", branch: "jpd/line-aligned-emission" +# Temporarily pin to git main until ast_transform 3.0.0 ships. +gem "ast_transform", git: "https://github.com/rspockframework/ast-transform.git", branch: "main" # Specify your gem's dependencies in rspock.gemspec gemspec diff --git a/Gemfile.lock b/Gemfile.lock index c49c4c7..271ea14 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GIT remote: https://github.com/rspockframework/ast-transform.git - revision: 0609d420d528e18c413aeac3ce52501a79c29a30 - branch: jpd/line-aligned-emission + revision: 4eeb123dee9c96daa3e4df93397e1fef8eb405d6 + branch: main specs: ast_transform (3.0.0) parser (>= 3.3) From cdc3d24dca5565615250b27a866e7f0702719de0 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 14:48:43 -0400 Subject: [PATCH 20/26] Pin exact unparsed output in mocha mock transformation tests The &block tests asserted regex fragments on deterministic unparser output. Replace them with exact expected source via assert_transforms, and make assert_transforms compare exactly instead of massaging trailing newlines (gsub(/\n$/, '') stripped two newlines from multi-line expectations, since $ also matches before a final newline). Co-authored-by: Cursor --- ...ction_to_mocha_mock_transformation_test.rb | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb index d19a225..d515ff3 100644 --- a/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb +++ b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true require 'test_helper' -require 'string_helper' require 'rspock/ast/parser/interaction_parser' require 'rspock/ast/interaction_to_mocha_mock_transformation' @@ -8,7 +7,6 @@ module RSpock module AST class InteractionToMochaMockTransformationTest < Minitest::Test extend RSpock::Declarative - include RSpock::Helpers::StringHelper include ASTTransform::TransformationHelper def setup @@ -190,31 +188,24 @@ def setup end test "&block produces setup with expects and capture" do - ir_node = parse_to_ir('1 * receiver.message("arg", &my_proc)') - result = @transformation.run(ir_node) - - source = Unparser.unparse(result) - assert_match(/receiver\.expects\(:message\)\.with\("arg"\)\.times\(1\)/, source) - assert_match(/RSpock::Helpers::BlockCapture\.capture\(receiver, :message\)/, source) + assert_transforms('1 * receiver.message("arg", &my_proc)', <<~RUBY) + receiver.expects(:message).with("arg").times(1) + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY end test "&block without other args" do - ir_node = parse_to_ir('1 * receiver.message(&my_proc)') - result = @transformation.run(ir_node) - - source = Unparser.unparse(result) - refute_match(/\.with\(/, source) - assert_match(/receiver\.expects\(:message\)\.times\(1\)/, source) - assert_match(/BlockCapture\.capture/, source) + assert_transforms('1 * receiver.message(&my_proc)', <<~RUBY) + receiver.expects(:message).times(1) + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY end test "&block with >> produces returns and capture" do - ir_node = parse_to_ir('1 * receiver.message(&my_proc) >> "result"') - result = @transformation.run(ir_node) - - source = Unparser.unparse(result) - assert_match(/\.returns\("result"\)/, source) - assert_match(/BlockCapture\.capture/, source) + assert_transforms('1 * receiver.message(&my_proc) >> "result"', <<~RUBY) + receiver.expects(:message).times(1).returns("result") + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY end test "unique index produces unique capture variable names" do @@ -223,8 +214,14 @@ def setup result0 = InteractionToMochaMockTransformation.new(0).run(ir_node) result1 = InteractionToMochaMockTransformation.new(1).run(ir_node) - assert_match(/__rspock_blk_0/, Unparser.unparse(result0)) - assert_match(/__rspock_blk_1/, Unparser.unparse(result1)) + assert_equal <<~RUBY, Unparser.unparse(result0) + receiver.expects(:message).times(1) + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY + assert_equal <<~RUBY, Unparser.unparse(result1) + receiver.expects(:message).times(1) + __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY end test "non-interaction node is returned unchanged" do @@ -247,7 +244,7 @@ def transform(source) end def assert_transforms(source, expected) - assert_equal strip_end_line(expected + "\n"), transform(source) + assert_equal expected, transform(source) end end end From d826036c5ec9a0476a79ae5563a12ca9b65cbd1c Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 15:06:21 -0400 Subject: [PATCH 21/26] Depend on published ast_transform 3.0.0 instead of git main Co-authored-by: Cursor --- Gemfile | 3 --- Gemfile.lock | 15 ++++----------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/Gemfile b/Gemfile index d5a2435..4a8be62 100644 --- a/Gemfile +++ b/Gemfile @@ -2,8 +2,5 @@ source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } -# Temporarily pin to git main until ast_transform 3.0.0 ships. -gem "ast_transform", git: "https://github.com/rspockframework/ast-transform.git", branch: "main" - # Specify your gem's dependencies in rspock.gemspec gemspec diff --git a/Gemfile.lock b/Gemfile.lock index 271ea14..6636138 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,3 @@ -GIT - remote: https://github.com/rspockframework/ast-transform.git - revision: 4eeb123dee9c96daa3e4df93397e1fef8eb405d6 - branch: main - specs: - ast_transform (3.0.0) - parser (>= 3.3) - prism (>= 1.5) - unparser (>= 0.8) - PATH remote: . specs: @@ -21,6 +11,10 @@ GEM specs: ansi (1.6.0) ast (2.4.3) + ast_transform (3.0.0) + parser (>= 3.3) + prism (>= 1.5) + unparser (>= 0.8) builder (3.3.0) byebug (13.0.0) reline (>= 0.6.0) @@ -70,7 +64,6 @@ PLATFORMS ruby DEPENDENCIES - ast_transform! bundler (>= 2.1) minitest (~> 5.14) minitest-reporters (~> 1.4) From 22c46660cc44b0a3093ef6845c4f5b85ddc8b3c0 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 15:16:05 -0400 Subject: [PATCH 22/26] Cover the raises-with-interactions branch Codecov flagged build_raises_body's interactions arm and its insert_after helper as the only uncovered lines in test_method_transformation.rb. Pin the transformed shape (assert_raises wrapping the thunk call, inserted after the last Mocha setup) and add a behavioral example test exercising the combination end to end. Co-authored-by: Cursor --- test/example_rspock_test.rb | 13 ++++++++++ test/rspock/ast/transformation_test.rb | 35 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/test/example_rspock_test.rb b/test/example_rspock_test.rb index 044ab38..c486d1f 100644 --- a/test/example_rspock_test.rb +++ b/test/example_rspock_test.rb @@ -161,6 +161,19 @@ def mul(a, b) "hello" | ArgumentError end + test "raises with interactions catches the exception from the deferred When body" do + Given + dep = mock + + When + dep.foo(1) + raise IndexError, "boom" + + Then + 1 * dep.foo(1) + raises IndexError + end + test "interactions" do Given dep = mock diff --git a/test/rspock/ast/transformation_test.rb b/test/rspock/ast/transformation_test.rb index caea9b6..e6ea22e 100644 --- a/test/rspock/ast/transformation_test.rb +++ b/test/rspock/ast/transformation_test.rb @@ -401,6 +401,41 @@ def can_end? assert_equal expected, transform(source) end + test "test with interactions and raises" do + source = <<~HEREDOC + test "raises with interactions" do + Given + dep = mock + foo = Foo.new(dep) + + When + foo.explode + + Then + 1 * dep.foo + raises ExplosionError + end + HEREDOC + + # The thunked When body executes inside assert_raises, which is + # inserted after the last Mocha setup; the raises statement itself + # contributes no statement of its own. + expected = <<~HEREDOC + test("raises with interactions") do + + dep = mock + foo = Foo.new(dep); __ast_thunk_1__ = proc do + + + foo.explode; end + + + dep.expects(:foo).times(1); assert_raises(ExplosionError) do; __ast_thunk_1__.call; end; end + HEREDOC + + assert_equal expected, transform(source) + end + test "test with interaction and &block forwarding" do source = <<~HEREDOC test "block forwarding" do From ec8d4d3ecf4373ea7ce8f8452701798ce17c4a53 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 15:40:55 -0400 Subject: [PATCH 23/26] Remove the Rails README section Nothing Rails-specific remains: no generator, no backtrace cleaner to configure. The railtie still loads the rake tasks automatically, which is why the Rakefile snippet stays qualified as non-Rails. Co-authored-by: Cursor --- README.md | 4 ---- ...e_source_mapping_test.rb => source_true_backtrace_test.rb} | 0 2 files changed, 4 deletions(-) rename test/rspock/{backtrace_source_mapping_test.rb => source_true_backtrace_test.rb} (100%) diff --git a/README.md b/README.md index a6b5bf8..755bfd8 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,6 @@ rakefile = "#{spec.gem_dir}/lib/Rakefile" load rakefile ``` -### Rails - -No Rails-specific setup is required. Backtraces and debuggers show correct source locations natively (see [Debugging](#debugging)), so there is no backtrace cleaner to configure. - ## Usage Getting started using RSpock is extremely easy! diff --git a/test/rspock/backtrace_source_mapping_test.rb b/test/rspock/source_true_backtrace_test.rb similarity index 100% rename from test/rspock/backtrace_source_mapping_test.rb rename to test/rspock/source_true_backtrace_test.rb From 72ba1044317188dbdb96c601ccace55587fd3dac Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 15:40:55 -0400 Subject: [PATCH 24/26] Rename BacktraceSourceMappingTest to SourceTrueBacktraceTest The old name described the deleted mechanism, not the behavior the test pins. Also spell out why the fixture's blank lines and split expressions are load-bearing: without them a non-aligned emitter would reproduce the source line numbers by coincidence. Co-authored-by: Cursor --- test/rspock/line_alignment_test.rb | 43 +++++++++-------------- test/rspock/source_true_backtrace_test.rb | 41 +++++++++++---------- 2 files changed, 37 insertions(+), 47 deletions(-) diff --git a/test/rspock/line_alignment_test.rb b/test/rspock/line_alignment_test.rb index 7b5dc7d..f8d5234 100644 --- a/test/rspock/line_alignment_test.rb +++ b/test/rspock/line_alignment_test.rb @@ -5,20 +5,17 @@ require 'rspock/declarative' module RSpock - # Acceptance tests for line-aligned emission through the full RSpock - # transformation: raw line numbers — no Minitest plugin, no BacktraceFilter, - # no SourceMap — must already be source line numbers. When these are green, + # Acceptance tests for line-aligned emission through the full RSpock transformation: raw line numbers — no + # Minitest plugin, no BacktraceFilter, no SourceMap — must already be source line numbers. When these are green, # the whole mapping apparatus is deletable. # - # Counterpart of ast-transform's LineAlignmentTest; the fixtures exercise - # the RSpock dialect specifically (Then assertions, Where tables, - # interactions, Cleanup). + # Counterpart of ast-transform's LineAlignmentTest; the fixtures exercise the RSpock dialect specifically + # (Then assertions, Where tables, interactions, Cleanup). class LineAlignmentTest < ::Minitest::Test extend RSpock::Declarative - # Same env scrubbing rationale as BacktraceSourceMappingTest: children - # inherit bundler state pointing at rspock's Gemfile, and minitest's - # gem-scanning plugin discovery can activate a second minitest version. + # Same env scrubbing rationale as SourceTrueBacktraceTest: children inherit bundler state pointing at rspock's + # Gemfile, and minitest's gem-scanning plugin discovery can activate a second minitest version. CLEAN_ENV = { "RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil, @@ -88,11 +85,9 @@ class WhereFixtureTest < Minitest::Test "(the row-isolation selector target)\n#{output}" end - # The interaction argument expression `Integer("boom")` raises when the - # Mocha expectation setup executes — pinning the line at which interaction - # setup runs. Today interactions are hoisted textually to the When - # position, so the raw line is a transformed one; aligned emission must - # keep the interaction's own source line while deferring the When body. + # The interaction argument expression `Integer("boom")` raises when the Mocha expectation setup executes — + # pinning the line at which interaction setup runs. The setup must execute at the interaction's own source line + # (the When body defers past it), not at the When position interactions used to hoist to. INTERACTION_FIXTURE = <<~RUBY transform!(RSpock::AST::Transformation) class InteractionFixtureTest < Minitest::Test @@ -129,12 +124,10 @@ def call "the interaction's source line\n#{output}" end - # When the Then block declares interactions, the When body is thunked - # past the Mocha setups — historically that hid `result` (a local first - # assigned inside the thunk's closure is closure-local), so reading it - # in Then raised NameError. The lowering now pre-declares thunked - # assignments at method scope; the expectation must fail as a plain - # assertion failure that can SEE the value. + # When the Then block declares interactions, the When body is thunked past the Mocha setups — historically that + # hid `result` (a local first assigned inside the thunk's closure is closure-local), so reading it in Then + # raised NameError. The lowering now pre-declares thunked assignments at method scope; the expectation must + # fail as a plain assertion failure that can SEE the value. WHEN_RESULT_FIXTURE = <<~RUBY transform!(RSpock::AST::Transformation) class WhenResultFixtureTest < Minitest::Test @@ -201,9 +194,8 @@ class CleanupFixtureTest < Minitest::Test "the cleanup raise should cite its source line\n#{output}" end - # The compound of the two hazards above: Cleanup runs inside ensure, so it - # observes the When local BOTH after a failing Then AND with the When body - # deferred behind interaction setups. Historically this raised NameError + # The compound of the two hazards above: Cleanup runs inside ensure, so it observes the When local BOTH after + # a failing Then AND with the When body deferred behind interaction setups. Historically this raised NameError # inside ensure, masking the real failure. CLEANUP_WHEN_LOCAL_FIXTURE = <<~RUBY transform!(RSpock::AST::Transformation) @@ -251,9 +243,8 @@ def call private - # Runs +fixture_source+ in a subprocess with NO backtrace filtering of any - # kind: plugin discovery is off and the rspock plugin is never installed. - # Whatever line numbers appear in the output are the VM's raw truth. + # Runs +fixture_source+ in a subprocess with NO backtrace filtering of any kind: plugin discovery is off and + # the rspock plugin is never installed. Whatever line numbers appear in the output are the VM's raw truth. def run_fixture(file_name, fixture_source) Dir.mktmpdir do |tmpdir| dir = File.realpath(tmpdir) diff --git a/test/rspock/source_true_backtrace_test.rb b/test/rspock/source_true_backtrace_test.rb index 9f3aee3..35b16f0 100644 --- a/test/rspock/source_true_backtrace_test.rb +++ b/test/rspock/source_true_backtrace_test.rb @@ -5,20 +5,21 @@ require 'rspock/declarative' module RSpock - # End-to-end pin for backtrace correctness: a failing RSpock test run in a - # subprocess must report line numbers from the file the developer wrote — - # with NO filtering machinery of any kind. Line-aligned emission places each - # transformed statement on its source line, so the VM's raw line numbers are - # already the source line numbers; there is no plugin, no BacktraceFilter, - # and no SourceMap left to install. - class BacktraceSourceMappingTest < ::Minitest::Test + # End-to-end pin for source-true backtraces: a failing RSpock test run in a subprocess must report + # line numbers from the file the developer wrote — with NO filtering machinery of any kind. + # Line-aligned emission places each transformed statement on its source line, so the VM's raw line + # numbers are already the source line numbers; there is no plugin, no BacktraceFilter, and no + # SourceMap left to install. + class SourceTrueBacktraceTest < ::Minitest::Test extend RSpock::Declarative - # Blank lines and multi-line expressions make source line numbers diverge - # from a naively unparsed layout, so alignment cannot pass by coincidence. + # The blank lines and split expressions (`1 +` / `raise ArgumentError,`) are load-bearing: a + # naive unparse would compress them, shifting every later statement's line number. Without them, + # even a non-aligned emitter would happen to reproduce the source line numbers, and this test + # would pass without proving alignment. FIXTURE_SOURCE = <<~RUBY transform!(RSpock::AST::Transformation) - class SourceMappingFixtureTest < Minitest::Test + class BacktraceFixtureTest < Minitest::Test test "assertion failure" do Given "a value built across lines" value = 1 + @@ -42,11 +43,10 @@ class SourceMappingFixtureTest < Minitest::Test end RUBY - # Ruby subprocesses inherit bundler state pointing at rspock's Gemfile; - # scrub it so the child resolves gems from the parent's live load paths. - # MT_NO_PLUGINS: minitest's gem-scanning plugin discovery can activate a - # second minitest version (the Ruby default gem) over the -I one, breaking - # the run. Nothing rspock-specific needs plugging in anymore. + # Ruby subprocesses inherit bundler state pointing at rspock's Gemfile; scrub it so the child + # resolves gems from the parent's live load paths. MT_NO_PLUGINS: minitest's gem-scanning plugin + # discovery can activate a second minitest version (the Ruby default gem) over the -I one, + # breaking the run. Nothing rspock-specific needs plugging in anymore. CLEAN_ENV = { "RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil, @@ -60,9 +60,9 @@ class SourceMappingFixtureTest < Minitest::Test assertion_line = line_number_of(FIXTURE_SOURCE, "doubled == 999") raise_line = line_number_of(FIXTURE_SOURCE, "raise ArgumentError") - assert_includes output, "[test/source_mapping_fixture_test.rb:#{assertion_line}]", + assert_includes output, "[test/backtrace_fixture_test.rb:#{assertion_line}]", "assertion failure should cite the source line of the failed expectation\n#{output}" - assert_includes output, "test/source_mapping_fixture_test.rb:#{raise_line}:in", + assert_includes output, "test/backtrace_fixture_test.rb:#{raise_line}:in", "error backtrace should cite the source line of the raise\n#{output}" assert_includes output, "2 failures", output end @@ -73,13 +73,13 @@ def run_fixture_in_subprocess Dir.mktmpdir do |tmpdir| dir = File.realpath(tmpdir) FileUtils.mkdir_p(File.join(dir, "test")) - File.write(File.join(dir, "test", "source_mapping_fixture_test.rb"), FIXTURE_SOURCE) + File.write(File.join(dir, "test", "backtrace_fixture_test.rb"), FIXTURE_SOURCE) File.write(File.join(dir, "runner.rb"), <<~RUBY) require "ast_transform" ASTTransform.install require "minitest/autorun" require "rspock" - require_relative "test/source_mapping_fixture_test" + require_relative "test/backtrace_fixture_test" RUBY output, status = Open3.capture2e(CLEAN_ENV, RbConfig.ruby, *load_path_flags, "runner.rb", chdir: dir) @@ -89,8 +89,7 @@ def run_fixture_in_subprocess end end - # The child needs the same rspock and dependency load paths as this - # process, without going through bundler. + # The child needs the same rspock and dependency load paths as this process, without going through bundler. def load_path_flags $LOAD_PATH.grep(%r{/(lib|gems)(/|\z)}).flat_map { |path| ["-I", path] } end From d7a3bcc4dfac138d31e9db14f7b343fecd9a9af0 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 15:40:55 -0400 Subject: [PATCH 25/26] Fit code within 120 columns; rewrap comments to match Prep for a future rubocop-shopify adoption (Layout/LineLength: 120), mirroring ast-transform. Expected-output heredocs keep their long lines (AllowHeredoc exempts them, and they pin real emitted output). Co-authored-by: Cursor --- ...nteraction_to_mocha_mock_transformation.rb | 4 +- lib/rspock/ast/node.rb | 9 ++-- lib/rspock/ast/parser/interaction_parser.rb | 8 +-- lib/rspock/ast/parser/statement_parser.rb | 4 +- lib/rspock/ast/parser/test_method_parser.rb | 3 +- .../ast/test_method_def_transformation.rb | 10 ++-- .../ast/test_method_dstr_transformation.rb | 4 +- lib/rspock/ast/test_method_transformation.rb | 52 ++++++++----------- .../minitest/reporters/rake_rerun_reporter.rb | 3 +- test/rspock/ast/transformation_test.rb | 19 +++---- 10 files changed, 53 insertions(+), 63 deletions(-) diff --git a/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb index d00f37d..d78f70f 100644 --- a/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb +++ b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb @@ -30,7 +30,9 @@ def run(interaction) result = chain_call(interaction.receiver, :expects, s(:sym, interaction.message)) result = chain_call(result, :with, *interaction.args.children) if interaction.args result = build_cardinality(result, interaction.cardinality) - result = chain_call(result, OUTCOME_METHODS.fetch(interaction.outcome.type), *interaction.outcome.children) if interaction.outcome + if interaction.outcome + result = chain_call(result, OUTCOME_METHODS.fetch(interaction.outcome.type), *interaction.outcome.children) + end if interaction.block_pass build_block_capture_setup(result, interaction.receiver, interaction.message) diff --git a/lib/rspock/ast/node.rb b/lib/rspock/ast/node.rb index d07abdd..3c3e5ef 100644 --- a/lib/rspock/ast/node.rb +++ b/lib/rspock/ast/node.rb @@ -3,11 +3,10 @@ module RSpock module AST - # RSpock's intermediate representation: custom node types registered on - # ASTTransform::Node, so +s(:rspock_*, ...)+ constructs these classes with - # their domain accessors. They exist only between the parser and - # TestMethodTransformation — the transformation lowers every one of them - # to plain Ruby nodes before emission. + # RSpock's intermediate representation: custom node types registered on ASTTransform::Node, so + # +s(:rspock_*, ...)+ constructs these classes with their domain accessors. They exist only between the parser + # and TestMethodTransformation — the transformation lowers every one of them to plain Ruby nodes before + # emission. class TestNode < ASTTransform::Node register :rspock_test diff --git a/lib/rspock/ast/parser/interaction_parser.rb b/lib/rspock/ast/parser/interaction_parser.rb index a00b66f..f66e20a 100644 --- a/lib/rspock/ast/parser/interaction_parser.rb +++ b/lib/rspock/ast/parser/interaction_parser.rb @@ -35,8 +35,8 @@ def interaction_node?(node) def parse(node) return node unless interaction_node?(node) - # Anchor at the full interaction expression (including >> outcome), so - # the Mocha setup it lowers into is emitted at the interaction's line. + # Anchor at the full interaction expression (including >> outcome), so the Mocha setup it lowers into is + # emitted at the interaction's line. anchor = node if return_value_node?(node) @@ -108,8 +108,8 @@ def validate_cardinality(node) def parse_rhs(node) if node.type == :block - raise InteractionError, "Inline blocks (do...end / { }) are not supported in interactions @ #{range(node)}. " \ - "Use &var for block forwarding verification, or << for method body override (future)." + raise InteractionError, "Inline blocks (do...end / { }) are not supported in interactions " \ + "@ #{range(node)}. Use &var for block forwarding verification, or << for method body override (future)." end if node.type != :send diff --git a/lib/rspock/ast/parser/statement_parser.rb b/lib/rspock/ast/parser/statement_parser.rb index 4921d14..17bd9f6 100644 --- a/lib/rspock/ast/parser/statement_parser.rb +++ b/lib/rspock/ast/parser/statement_parser.rb @@ -62,8 +62,8 @@ def binary_statement?(node) BINARY_OPERATORS.include?(node.children[1]) end - # RSpock IR nodes are anchored at the statement they classify, so the - # assertions they lower into are emitted at the statement's source line. + # RSpock IR nodes are anchored at the statement they classify, so the assertions they lower into are + # emitted at the statement's source line. def build_binary_statement(node) s_anchored(node, :rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2]) end diff --git a/lib/rspock/ast/parser/test_method_parser.rb b/lib/rspock/ast/parser/test_method_parser.rb index 8ceb048..d0329c5 100644 --- a/lib/rspock/ast/parser/test_method_parser.rb +++ b/lib/rspock/ast/parser/test_method_parser.rb @@ -75,7 +75,8 @@ def validate_blocks(blocks, node) end unless blocks.last.can_end? - raise BlockError, "Block #{blocks.last.type} @ #{blocks.last.range} must be followed by one of these Blocks: #{blocks.last.successors}" + raise BlockError, "Block #{blocks.last.type} @ #{blocks.last.range} " \ + "must be followed by one of these Blocks: #{blocks.last.successors}" end end diff --git a/lib/rspock/ast/test_method_def_transformation.rb b/lib/rspock/ast/test_method_def_transformation.rb index cf24f70..750ed78 100644 --- a/lib/rspock/ast/test_method_def_transformation.rb +++ b/lib/rspock/ast/test_method_def_transformation.rb @@ -4,12 +4,10 @@ module RSpock module AST - # Appends the data row's index and source line to a Where-driven test's - # name. The values arrive through internal block parameters on the row - # iterator (see TestMethodTransformation#build_where_args) and surface in - # the test NAME only: the name is what makes identical data rows unique and - # what the -n selector matches to isolate a row. They are deliberately not - # exposed as test-scope variables. + # Appends the data row's index and source line to a Where-driven test's name. The values arrive through + # internal block parameters on the row iterator (see TestMethodTransformation#build_where_args) and surface in + # the test NAME only: the name is what makes identical data rows unique and what the -n selector matches to + # isolate a row. They are deliberately not exposed as test-scope variables. class TestMethodDefTransformation < ASTTransform::AbstractTransformation ROW_INDEX_ARG = :__rspock_row_index__ ROW_LINE_ARG = :__rspock_row_line__ diff --git a/lib/rspock/ast/test_method_dstr_transformation.rb b/lib/rspock/ast/test_method_dstr_transformation.rb index 2ed7997..8b4a41c 100644 --- a/lib/rspock/ast/test_method_dstr_transformation.rb +++ b/lib/rspock/ast/test_method_dstr_transformation.rb @@ -3,8 +3,8 @@ module RSpock module AST - # dstr counterpart of TestMethodDefTransformation: appends the row index - # and source line interpolations to an already-interpolated test name. + # dstr counterpart of TestMethodDefTransformation: appends the row index and source line interpolations to an + # already-interpolated test name. class TestMethodDstrTransformation < ASTTransform::AbstractTransformation ROW_INDEX_AST = s(:begin, s(:lvar, :__rspock_row_index__)) ROW_LINE_AST = s(:begin, s(:lvar, :__rspock_row_line__)) diff --git a/lib/rspock/ast/test_method_transformation.rb b/lib/rspock/ast/test_method_transformation.rb index df31043..90e09e2 100644 --- a/lib/rspock/ast/test_method_transformation.rb +++ b/lib/rspock/ast/test_method_transformation.rb @@ -35,11 +35,10 @@ def transform(rspock_ast) # --- Test body assembly --- # - # Statements are assembled in SOURCE order so line-aligned emission keeps - # each one on its own line. Execution-order requirements that source - # order cannot express (interaction setups in Then must run before the - # When body they observe) are carried by ast-transform's thunk facility - # (run_after / thunk) instead of by textual hoisting. + # Statements are assembled in SOURCE order so line-aligned emission keeps each one on its own line. + # Execution-order requirements that source order cannot express (interaction setups in Then must run before the + # When body they observe) are carried by ast-transform's thunk facility (run_after / thunk) instead of by + # textual hoisting. def build_test_body(body_node) blocks = body_node.children sections = blocks.map { |block_node| transform_block(block_node) } @@ -72,18 +71,15 @@ def transform_block(block_node) end end - # Then/Expect children become plain Ruby in place: interactions lower to - # Mocha setups anchored at the interaction's own source line (plus an - # identity assertion for &block forwarding), statements become assertions - # at their own lines. + # Then/Expect children become plain Ruby in place: interactions lower to Mocha setups anchored at the + # interaction's own source line (plus an identity assertion for &block forwarding), statements become + # assertions at their own lines. # - # Within the section, ALL setups come before all assertions: the thunked - # When body executes right after the last setup, and every assertion - # (identity or otherwise) observes the When body's effects, so none may - # precede that point. Setups keep source order and their anchors, so - # alignment holds; assertions after them are either synthetic (identity - # assertions, loc-less, pack anywhere) or textually below the - # interactions in the common case. + # Within the section, ALL setups come before all assertions: the thunked When body executes right after the + # last setup, and every assertion (identity or otherwise) observes the When body's effects, so none may + # precede that point. Setups keep source order and their anchors, so alignment holds; assertions after them + # are either synthetic (identity assertions, loc-less, pack anywhere) or textually below the interactions in + # the common case. def transform_assertion_block(block_node) setups = [] assertions = [] @@ -118,13 +114,11 @@ def lower_interaction(interaction, index) end # Reorders execution (not text) where required: - # - interactions without raises: run the When body after the last - # interaction setup (run_after — the paved road). - # - raises without interactions: the When body inlines directly into - # assert_raises; no thunk needed. - # - raises with interactions: the When body is thunked into the - # assert_raises block inserted after the last setup; the lowering - # re-emits the body at its own source lines. + # - interactions without raises: run the When body after the last interaction setup (run_after — the paved + # road). + # - raises without interactions: the When body inlines directly into assert_raises; no thunk needed. + # - raises with interactions: the When body is thunked into the assert_raises block inserted after the last + # setup; the lowering re-emits the body at its own source lines. def order_execution(source_order, when_statements, interaction_setups, raises_node) if raises_node build_raises_body(source_order, when_statements, interaction_setups, raises_node) @@ -195,8 +189,8 @@ def insert_after(statements, anchor, insertion) result end - # Re-anchors +node+ at +anchor+'s source location so emission places it - # on the anchor's line. No-op for anchors without locations. + # Re-anchors +node+ at +anchor+'s source location so emission places it on the anchor's line. + # No-op for anchors without locations. def anchored_at(anchor, node) return node unless anchor.loc&.expression @@ -230,11 +224,9 @@ def build_ruby_ast(method_call, method_args, body_node, where) # --- Where block helpers --- # - # Each data row carries its source line as a trailing element, surfaced - # in the generated test NAME only (uniqueness for identical rows + the -n - # selector target) through internal block parameters. There is no - # user-facing runtime variable: isolate a row by running its generated - # test by name, then break normally. + # Each data row carries its source line as a trailing element, surfaced in the generated test NAME only + # (uniqueness for identical rows + the -n selector target) through internal block parameters. There is no + # user-facing runtime variable: isolate a row by running its generated test by name, then break normally. def build_where_iterator(data_rows) s(:send, diff --git a/test/minitest/reporters/rake_rerun_reporter.rb b/test/minitest/reporters/rake_rerun_reporter.rb index 188362d..564f313 100644 --- a/test/minitest/reporters/rake_rerun_reporter.rb +++ b/test/minitest/reporters/rake_rerun_reporter.rb @@ -16,7 +16,8 @@ def record(test) puts puts - puts yellow("You can rerun failed/error test by commands (you can add rerun prefix with 'rerun_prefix' option):") + puts yellow("You can rerun failed/error test by commands " \ + "(you can add rerun prefix with 'rerun_prefix' option):") print_rerun_command(test) puts end diff --git a/test/rspock/ast/transformation_test.rb b/test/rspock/ast/transformation_test.rb index e6ea22e..27a9290 100644 --- a/test/rspock/ast/transformation_test.rb +++ b/test/rspock/ast/transformation_test.rb @@ -245,9 +245,8 @@ def can_end? end end - # Line-aligned emission: each statement is on its SOURCE line — the When - # assignment on line 3 (descriptions on 2 and 5 vanish into blank lines), - # the assertion on line 6 where `actual == 3` was written. + # Line-aligned emission: each statement is on its SOURCE line — the When assignment on line 3 (descriptions + # on 2 and 5 vanish into blank lines), the assertion on line 6 where `actual == 3` was written. test "test without where block" do source = <<~HEREDOC test "Adding 1 and 2 results in 3" do @@ -346,8 +345,8 @@ def can_end? end HEREDOC - # The When body is thunked (interactions must execute first) but keeps - # its source position; each Mocha setup lands on its interaction's line. + # The When body is thunked (interactions must execute first) but keeps its source position; each Mocha + # setup lands on its interaction's line. expected = <<~HEREDOC test("interactions") do @@ -381,9 +380,8 @@ def can_end? end HEREDOC - # `result` is assigned inside the thunk's proc; the lowering - # pre-declares it (`result = result`) at method scope so the - # assertion after the execution point can read it. + # `result` is assigned inside the thunk's proc; the lowering pre-declares it (`result = result`) at method + # scope so the assertion after the execution point can read it. expected = <<~HEREDOC test("when result") do @@ -417,9 +415,8 @@ def can_end? end HEREDOC - # The thunked When body executes inside assert_raises, which is - # inserted after the last Mocha setup; the raises statement itself - # contributes no statement of its own. + # The thunked When body executes inside assert_raises, which is inserted after the last Mocha setup; + # the raises statement itself contributes no statement of its own. expected = <<~HEREDOC test("raises with interactions") do From 8565cc984a4edc1ebb5a6f7ae83e5bbe47403d29 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 24 Jul 2026 15:47:34 -0400 Subject: [PATCH 26/26] Cover the dangling-block validation The line-length wrap of validate_blocks' second raise pulled it into the PR's patch, and Codecov showed it had never been exercised: no test ended a test method on a block that requires a successor. Pin the BlockError for a When with no Then. Co-authored-by: Cursor --- test/rspock/ast/transformation_test.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/rspock/ast/transformation_test.rb b/test/rspock/ast/transformation_test.rb index 27a9290..c8e0c92 100644 --- a/test/rspock/ast/transformation_test.rb +++ b/test/rspock/ast/transformation_test.rb @@ -74,6 +74,21 @@ def setup assert_equal "Test method @ tmp:1:1 must start with one of: Given, When, Expect", error.message end + test "test cannot end with a block that requires a successor" do + source = <<~HEREDOC + test "dangling When" do + When "stimulus with no Then" + 1 + 1 + end + HEREDOC + + error = assert_raises RSpock::AST::Parser::BlockError do + transform(source) + end + + assert_equal "Block When @ tmp:2:3 must be followed by one of these Blocks: [:Then]", error.message + end + test "expect block can be followed by nothing" do source = <<~HEREDOC test "Adding \#{a} and \#{b} results in \#{c}" do