From a98d374513a971bc570bb738c77e0cae347d0894 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Mon, 20 Jul 2026 13:57:07 -0400 Subject: [PATCH 1/2] Ship the rspock agent skill in the gem; prepare 2.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skills/rspock/SKILL.md teaches agents the RSpock dialect — the transformed-assertion invariant first (bare statements assert inside transform! classes, silently no-op outside), then blocks, assertion forms, Where tables, interaction mocking, and wrong/right pitfall pairs. Shipping it in the gem makes the skill part of what installing the dependency means; skills-aware tooling (e.g. d3mlabs/dev) links it project-scoped at install time. Co-authored-by: Cursor --- CHANGELOG.md | 9 ++- Gemfile.lock | 2 +- lib/rspock/version.rb | 2 +- skills/rspock/SKILL.md | 160 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 skills/rspock/SKILL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8814d..400125a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.6.0] - 2026-07-20 + +### Added + +- `skills/rspock/SKILL.md` ships in the gem: an agent skill teaching the RSpock dialect (the transformed-assertion invariant, code blocks, Where tables, interaction mocking, pitfalls). Installed automatically by tooling that scans gems for skills (e.g. d3mlabs/dev); consumable by any skills-aware agent. + ## [2.5.0] - 2026-02-28 ### Added @@ -152,7 +158,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release. -[Unreleased]: https://github.com/rspockframework/rspock/compare/v2.5.0...HEAD +[Unreleased]: https://github.com/rspockframework/rspock/compare/v2.6.0...HEAD +[2.6.0]: https://github.com/rspockframework/rspock/compare/v2.5.0...v2.6.0 [2.5.0]: https://github.com/rspockframework/rspock/compare/v2.4.0...v2.5.0 [2.4.0]: https://github.com/rspockframework/rspock/compare/v2.3.1...v2.4.0 [2.3.1]: https://github.com/rspockframework/rspock/compare/v2.3.0...v2.3.1 diff --git a/Gemfile.lock b/Gemfile.lock index a7d6882..ad6c877 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rspock (2.5.0) + rspock (2.6.0) ast_transform (~> 2.0) minitest (~> 5.0) mocha (>= 1.0) diff --git a/lib/rspock/version.rb b/lib/rspock/version.rb index 5c35291..2323284 100644 --- a/lib/rspock/version.rb +++ b/lib/rspock/version.rb @@ -1,3 +1,3 @@ module RSpock - VERSION = "2.5.0" + VERSION = "2.6.0" end diff --git a/skills/rspock/SKILL.md b/skills/rspock/SKILL.md new file mode 100644 index 0000000..e402557 --- /dev/null +++ b/skills/rspock/SKILL.md @@ -0,0 +1,160 @@ +--- +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: + +- Never write `assert_equal a, b` in an RSpock test — write `a == b` in a + Then/Expect block. The transform compiles it to an assertion with a + proper failure message. +- Never write bare comparisons in a plain Minitest class expecting them to + assert. A file missing the `transform!` annotation is a silently green + test suite. +- When editing a test file, first check for the `transform!` line at the + class definition; it decides which dialect you are writing. + +## 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`. Mixed files can use +`transform!(RSpock::AST::Transformation.new(strict: false))` to allow +plain Minitest tests alongside. + +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/`. + +## 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. `_test_index_` / `_line_number_` are available for +pinpointing failing rows. + +## Interaction mocking (Then only) + +```ruby +Then +1 * subscriber.receive("hello") # exactly one call +0 * mailer.deliver # must never be called +(1..3) * poller.tick # range cardinality +_ * 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. + +## 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 51897cfeb76777d9dd60ab659cd126145c5e03a1 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Tue, 21 Jul 2026 10:42:42 -0400 Subject: [PATCH 2/2] Reframe skill as dialect mechanics; fill gaps from README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invariant section now teaches which dialect each class speaks (expression assertions inside transform!, assert API in plain Minitest) instead of prescribing one style — mixed files via strict: false are a supported migration path, not something to unify. Adds the truth-table generator, open-ended cardinalities ((1.._), (_..3)), Rails backtrace-cleaner setup, and a debugging-failures section (source-mapped backtraces, Where-row pinpointing via _test_index_ / _line_number_). Co-authored-by: Cursor --- skills/rspock/SKILL.md | 59 ++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/skills/rspock/SKILL.md b/skills/rspock/SKILL.md index e402557..86af61d 100644 --- a/skills/rspock/SKILL.md +++ b/skills/rspock/SKILL.md @@ -23,14 +23,16 @@ NOT — it evaluates and silently discards.** Consequences: -- Never write `assert_equal a, b` in an RSpock test — write `a == b` in a - Then/Expect block. The transform compiles it to an assertion with a - proper failure message. -- Never write bare comparisons in a plain Minitest class expecting them to - assert. A file missing the `transform!` annotation is a silently green - test suite. -- When editing a test file, first check for the `transform!` line at the - class definition; it decides which dialect you are writing. +- 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 @@ -46,9 +48,15 @@ end ``` The application must install the hook once (usually in the test helper): -`require "ast_transform"; ASTTransform.install`. Mixed files can use +`require "ast_transform"; ASTTransform.install`. In a Rails app, also run +`rails g rspock:install` once — it adds an initializer registering +RSpock's filter with `Rails.backtrace_cleaner`, without which failure +backtraces point into transformed code instead of your source lines +(non-Rails projects need nothing; a bundled Minitest plugin handles it). +Mixed files can use `transform!(RSpock::AST::Transformation.new(strict: false))` to allow -plain Minitest tests alongside. +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 @@ -102,8 +110,18 @@ 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. `_test_index_` / `_line_number_` are available for -pinpointing failing rows. +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) @@ -111,7 +129,9 @@ pinpointing failing rows. Then 1 * subscriber.receive("hello") # exactly one call 0 * mailer.deliver # must never be called -(1..3) * poller.tick # range cardinality +(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 @@ -124,6 +144,19 @@ 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 are source-mapped: line numbers point at the file you wrote, + not the transformed output. Trust them; don't second-guess against + `tmp/ast_transform/`. +- In Where-driven tests, the generated test name embeds the failing row's + test index and source line number — read those from the failure output + to identify the row. The same values are available in test scope as + `_test_index_` (zero-based) and `_line_number_`, e.g. for a conditional + `binding.pry if _line_number_ == 15` when debugging interactively. + Comparisons against them in Then/Expect blocks are not transformed into + assertions. + ## Pitfalls (wrong → right) ```ruby