In checkout_spec.rb
allow(gateway)
.to receive(:charge)
.and_return(receipt) On this branch · gateway_spec.rb
def charge(amount)
Result.new(…)
end Receipt, the real
Gateway#charge now returns a Result, and only findings the change introduced
are reported.
The problem
Mocks drift. Months ago you wrote Checkout’s spec with the gateway stubbed:
allow(gateway).to receive(:charge).and_return(Receipt.new(id: "rc_1"))
Today a PR changes Gateway#charge to return a Result, and updates Checkout to call #ok? on
it. Gateway’s own spec is updated and goes green. Checkout’s spec still stubs the old Receipt,
and it’s green too, because a double does exactly what it’s told. The build passes, review passes,
and the first real checkout raises NoMethodError.
Design slips in the same way. A PR that makes Invoice call back into Report closes a dependency
cycle. Nobody sees it in the diff, because the cycle is spread across two files and a method call.
Running every design check on the whole app doesn’t help a reviewer either: forty findings, thirty-eight of them older than the PR.
The fix
Check only what the change introduced:
$ ra check --since origin/main
✗ drifted mock spec/models/checkout_spec.rb:12
stub Gateway#charge returns Receipt
on this branch the real one returns Result (#ok?, #receipt)
⚠ new cycle app/models/invoice.rb ⇄ app/reports/report.rb
lowest-traffic edge to cut: Invoice#summary → Report#row_for
· 2 findings introduced · 38 already on main, not shown
Update the stub to the recorded shape:
result = instance_double(Gateway::Result, ok?: true, receipt: Receipt.new(id: "rc_1"))
allow(gateway).to receive(:charge).and_return(result)
And run it on every PR:
- run: bundle exec rspec
- run: bundle exec ra check --since origin/${{ github.base_ref }}
A drifted mock fails the job. Design findings are posted as warnings, never as a failed build.
How it works
- Two recordings. The base branch’s recording is kept from its last CI run. The branch records as
its specs run, and
specs_that_reachkeeps that to the examples the diff touches. - Run the checks on both.
verify_mock,break_cycle,feature_envyand the other design checks run against each recording. - Keep the difference. A finding on the branch that isn’t on the base was introduced by the change. That’s the only kind it reports.
Over MCP an agent can run the same check on its own work before it opens the PR.
Limits
- It needs the base recording. Without one from
main, everything looks new. Cache the recording as a CI artefact on your main branch. - Only what the specs reach. A change on a path no spec runs produces no recording to compare.
- Warnings are warnings. Only mocks that the change made wrong fail the build by default. Every design finding is a hint you can dismiss.
Related tools
verify_mock
Catch a lying mock. A stub that passes green but returns something the real collaborator never would. Mutation testing can't see it. The recording can.
See more →specs_that_reach
Run only the specs that matter. The spec examples whose recorded runs reach a method, so a change reruns those and nothing else. Keeps a red-green loop in seconds.
See more →break_cycle
Cut a dependency cycle. Files or namespaces caught in a runtime dependency cycle, flagged with the single lowest-traffic edge to sever to break the loop.
See more →