Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/add-rubyzen-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ class AssertZenEmptyTest < Minitest::Test
end
```

Run with `bundle exec rake test` (or `bundle exec ruby -Itest test/assertions/assert_zen_empty_test.rb`). The assertion tests cover the same cases as the matcher specs — pass/fail, `allowlist:`, `baseline:`, stale entries, custom `message:` — plus a missing block raising `ArgumentError` for `assert_zen_true` / `assert_zen_false`.
Run with `bundle exec rake test` (or `bundle exec ruby -Itest test/assertions/assert_zen_empty_test.rb`). The assertion tests cover the same cases as the matcher specs — pass/fail, `allowlist:`, `baseline:`, stale entries, custom `message:`, a `rule:` (`Rubyzen::Rule`, rendered ahead of the violations and overridden by `message:`) — plus a missing block raising `ArgumentError` for `assert_zen_true` / `assert_zen_false`.

## When to Use Fixture Files

Expand Down
84 changes: 77 additions & 7 deletions .claude/skills/write-lint-rule/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Asserts the collection has no elements. Use for "must not" / "should never" rule

```ruby
expect(violations).to zen_empty
expect(violations).to zen_empty("Custom failure message explaining the rule")
expect(violations).to zen_empty(rule:) # a Rubyzen::Rule — see "Explaining the Rule" below

# Allowlist: permanently accepted exceptions
expect(violations).to zen_empty(allowlist: ['LegacyController'])
Expand Down Expand Up @@ -163,6 +163,7 @@ end
| RSpec matcher | Minitest assertion |
|---|---|
| `expect(c).to zen_empty` | `assert_zen_empty(c)` |
| `expect(c).to zen_empty(rule:)` | `assert_zen_empty(c, rule: RULE)` |
| `expect(c).to zen_empty("msg")` | `assert_zen_empty(c, message: "msg")` |
| `expect(c).to zen_empty(allowlist: [...], baseline: [...])` | `assert_zen_empty(c, allowlist: [...], baseline: [...])` |
| `expect(c).to zen_true { \|x\| ... }` | `assert_zen_true(c) { \|x\| ... }` |
Expand Down Expand Up @@ -276,20 +277,89 @@ file.blocks.with_method_name('describe') # RSpec describe blocks
block.call_sites # calls within the block
```

## Explaining the Rule

Every lint rule should explain itself with a `Rubyzen::Rule`, passed as `rule:` to any matcher or assertion. When the rule fails, the reader (often an AI agent fixing the code) sees what the rule requires, why it exists, and how to fix it — not just a list of violations. Without that context the "fix" is often to work around the rule instead of fixing the actual production violation.

```ruby
RSpec.describe 'No if statements in controllers' do
it 'has no if statements in methods' do
expect(controllers.all_methods.if_statements).to zen_empty(rule:, baseline:)
end

let(:rule) do
Rubyzen::Rule.new do
description 'Controllers do not contain if statements.'
rationale <<~TEXT
Controllers only orchestrate a request. Branching in them hides business logic
where it cannot be reused or unit-tested.
TEXT
fix_hint 'Move the condition into a Service and call it from the controller.'
bad_example <<~RUBY
def call
return error(:forbidden) if profile.banned?
end
RUBY
good_example <<~RUBY
def call
Services::Profiles::Update.new.call(profile:)
end
RUBY
end
end

let(:baseline) { %w[LegacyController] }
end
```

| Section | Write it as |
|---|---|
| `description` | One sentence stating what the code must (not) do. |
| `rationale` | The architectural reason — *why*, not a restatement of the description. |
| `fix_hint` | The concrete change that brings a violation into compliance. |
| `bad_example` / `good_example` | Short snippets; heredocs keep their indentation. |

- Use the block DSL. Write prose as a plain string, or as a `<<~TEXT` heredoc when it spans lines. Write examples as `<<~RUBY` heredocs, or another delimiter such as `<<~TREE` when an example isn't Ruby. Examples are strings on purpose: Rubyzen parses spec files too, so example code written as real Ruby would show up as real call sites in rules that scan specs.
- For a one-line rule, keywords are fine: `Rubyzen::Rule.new(description: '...', fix_hint: '...')`. Don't mix keywords with a block.
- Inside the block, `self` is the builder. To use a helper from the surrounding scope, take the builder as a parameter instead: `Rubyzen::Rule.new { |r| r.description summary }`.
- In RSpec, define the rule as `let(:rule)` (a constant inside a `describe` block leaks into the global namespace). In Minitest, define it as a constant on the test class: `RULE = Rubyzen::Rule.new do ... end`.
- When one file has several `it` blocks that check different things, give each its own rule (`let(:ifs_rule)`, `let(:size_rule)`).
- A plain string message still works and takes precedence if both are passed, but prefer `rule:`.

## Failure Messages

Rubyzen matchers automatically format failure messages with:
- Element name (method name, class name, etc.)
- Class name (parent class)
- File path and line number
With a rule, the failure message is the rule followed by the violations. Every violation lists its element name, class, and `file:line`, so it is immediately actionable:

```
RULE: Controllers do not contain if statements.

WHY: Controllers only orchestrate a request. ...

HOW TO FIX: Move the condition into a Service and call it from the controller.

BAD:
def call
return error(:forbidden) if profile.banned?
end

GOOD:
...

Violations:
- element: call
- class: Controllers::Profiles::Update
- file: app/controllers/profiles/update.rb:5
Stale baseline entries:
- LegacyController
```

This makes violations immediately actionable — developers see exactly where the violation is.
`Stale baseline entries` / `Stale allowlist entries` are entries that no longer match any violation. Remove them from the list.

## Checklist

- [ ] Rule file is in the correct spec directory
- [ ] Shared context is available (auto-included or via `include_context 'project_config'`)
- [ ] Uses `zen_empty` for "must not" rules, `zen_true { }` for "must always" rules
- [ ] Uses `{ }` braces (not `do...end`) with `zen_true`/`zen_false`
- [ ] Custom failure message explains *why* the rule exists (optional but recommended)
- [ ] Passes a `Rubyzen::Rule` via `rule:` with a description, rationale, fix hint, and bad/good examples
- [ ] `bundle exec rspec path/to/spec` runs successfully
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:

jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
ruby-version: ['3.1', '3.2', '3.3']
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## [0.4.0]

### Added

- **`Rubyzen::Rule`** — a structured explanation of a lint rule (`description`, plus optional
`rationale`, `fix_hint`, `bad_example`, `good_example`), defined with a block DSL
(`Rubyzen::Rule.new do description '...' end`) or keywords. Pass it as `rule:` to any RSpec
matcher (`zen_empty(rule:)`, `zen_true(rule:) { }`, `zen_false(rule:) { }`) or Minitest assertion
(`assert_zen_empty(collection, rule:)`, …). The failure message then shows `RULE:`, `WHY:`,
`HOW TO FIX:`, `BAD:` and `GOOD:` sections ahead of the violations, giving developers and
AI agents the context needed to fix a violation.
- **`ClassesCollection#constants`** — the constant references and assignments across every class,
as a `ConstantsCollection` (e.g. `repos.constants.with_name('Singleton')`).

All additions are backward-compatible: plain string messages still work and take precedence over `rule:`.

## [0.3.0]

### Added
Expand Down
31 changes: 28 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Project
│ │ ├── .rescues → RescuesCollection
│ │ └── .raises → RaisesCollection
│ ├── .attributes → AttributesCollection
│ ├── .constants → ConstantsCollection
│ ├── .macros → MacrosCollection
│ ├── .rescues → RescuesCollection
│ └── .raises → RaisesCollection
Expand All @@ -77,6 +78,7 @@ lib/rubyzen/
├── rspec.rb # RSpec adapter (require 'rubyzen/rspec'): core + matchers
├── minitest.rb # Minitest adapter (require 'rubyzen/minitest'): core + assertions
├── expectation_helpers.rb # Rubyzen::ExpectationHelpers — shared engine for matchers + assertions
├── rule.rb # Rubyzen::Rule — structured explanation passed as `rule:` to matchers + assertions
├── project.rb # Parses all .rb files, returns FileCollection
├── declarations/ # Domain objects wrapping AST nodes
│ ├── file_declaration.rb
Expand Down Expand Up @@ -251,9 +253,9 @@ Rubyzen exposes the same three checks for both frameworks. The RSpec matchers (`

| RSpec matcher | Minitest assertion | Purpose |
|---|---|---|
| `zen_empty` | `assert_zen_empty(collection)` | Collection has no elements. Supports `allowlist:` / `baseline:` for gradual adoption. |
| `zen_true { \|item\| }` | `assert_zen_true(collection) { \|item\| }` | Block returns true for ALL elements. Supports `allowlist:` / `baseline:`. |
| `zen_false { \|item\| }` | `assert_zen_false(collection) { \|item\| }` | Block returns false for ALL elements. Supports `allowlist:` / `baseline:`. |
| `zen_empty` | `assert_zen_empty(collection)` | Collection has no elements. Supports `rule:`, `allowlist:` / `baseline:` for gradual adoption. |
| `zen_true { \|item\| }` | `assert_zen_true(collection) { \|item\| }` | Block returns true for ALL elements. Supports `rule:`, `allowlist:` / `baseline:`. |
| `zen_false { \|item\| }` | `assert_zen_false(collection) { \|item\| }` | Block returns false for ALL elements. Supports `rule:`, `allowlist:` / `baseline:`. |

### Examples

Expand All @@ -273,6 +275,29 @@ assert_zen_true(methods) { |m| m.parameters.any? }
assert_zen_false(methods) { |m| m.name == :biz }
```

### Rules

Every matcher and assertion also accepts `rule:`, a `Rubyzen::Rule` (`lib/rubyzen/rule.rb`) that explains the lint rule. `Rule#message` renders the `RULE:` / `WHY:` / `HOW TO FIX:` / `BAD:` / `GOOD:` sections, and `ExpectationHelpers#message_for_failure` prints them ahead of the violations. Only `description` is required. A plain message still works and takes precedence over the rule, so the API is backward-compatible.

Define the sections with the block DSL (evaluated by `Rubyzen::Rule::Builder`), using `<<~TEXT` heredocs for prose and `<<~RUBY` heredocs for code. Keywords (`Rubyzen::Rule.new(description: '...')`) also work and suit one-line rules.

```ruby
rule = Rubyzen::Rule.new do
description 'Controllers do not contain if statements.'
rationale 'Controllers only orchestrate; branching belongs in a Service.'
fix_hint 'Move the condition into a Service.'
bad_example <<~RUBY
return error if banned?
RUBY
good_example <<~RUBY
Services::Update.new.call
RUBY
end

expect(violations).to zen_empty(rule:, baseline: [...]) # RSpec
assert_zen_empty(violations, rule: rule, baseline: [...]) # Minitest
```

**Important (RSpec only):** Use `{ }` braces (not `do...end`) with `zen_true`/`zen_false` — `do...end` binds to `expect()` instead of the matcher due to Ruby precedence. This caveat does not apply to the Minitest assertions (the block binds to the assertion method either way).

## Configuration
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,33 @@ Rubyzen provides three checks for your architectural lint rules:
| `zen_true { \|item\| }` | `assert_zen_true(collection) { \|item\| }` | the block returns true for every element |
| `zen_false { \|item\| }` | `assert_zen_false(collection) { \|item\| }` | the block returns false for every element |

All three accept an `allowlist:` of exceptions that are permanently exempt from the rule, a `baseline:` of known existing violations (technical debt) to fix over time, and a custom failure message.
All three accept an `allowlist:` of exceptions that are permanently exempt from the rule, a `baseline:` of known existing violations (technical debt) to fix over time, and a `rule:` that contains the description of the rule, why it is enforced, and how to fix the violations.

## Adding context to failure messages

Instead of a plain string in the failure message of a lint rule, you can pass a `Rubyzen::Rule`. The failure then tells the reader what the rule requires, why it exists, and how to fix the violation. This helps AI agents understand and fix the violations more reliably:

```ruby
let(:rule) do
Rubyzen::Rule.new do
description 'Controllers do not contain if statements.'
rationale <<~TEXT
Controllers only orchestrate; business logic belongs in a Service.
TEXT
fix_hint 'Move the condition into a Service and call it from the controller.'
bad_example <<~RUBY
return error(:forbidden) if profile.banned?
RUBY
good_example <<~RUBY
Services::Profiles::Update.new.call(profile:)
RUBY
end
end

it 'controllers have no if statements' do
expect(controllers.all_methods.if_statements).to zen_empty(rule:, baseline: ['LegacyController'])
end
```

## Run your lint rules

Expand Down
11 changes: 8 additions & 3 deletions lib/rubyzen/assertions/assert_zen_empty.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ module Assertions
# pattern (e.g., no controllers call +.where+ directly).
#
# @param collection [Enumerable] a Rubyzen collection of declarations
# @param message [String, nil] optional custom failure message
# @param message [String, nil] optional custom failure message (takes precedence over +rule:+)
# @param rule [Rubyzen::Rule, nil] rule whose description, rationale, fix hint and examples
# are shown in the failure message
# @param allowlist [Array<String>, nil] items to permanently ignore
# @param baseline [Array<String>, nil] known violations for gradual adoption
# @return [true] when the assertion passes
Expand All @@ -18,9 +20,12 @@ module Assertions
#
# @example With a baseline for gradual adoption
# assert_zen_empty(violations, baseline: ['LegacyController'])
def assert_zen_empty(collection, message: nil, allowlist: nil, baseline: nil)
#
# @example With a rule that explains the failure (see {Rubyzen::Rule})
# assert_zen_empty(controllers.all_methods.if_statements, rule: NO_IFS_IN_CONTROLLERS)
def assert_zen_empty(collection, message: nil, rule: nil, allowlist: nil, baseline: nil)
@failure_message = nil
@custom_message = message
assign_failure_explanation(message, rule)
@classified_items = classify_items(collection, allowlist: allowlist, baseline: baseline)

violations = @classified_items[:violations]
Expand Down
11 changes: 8 additions & 3 deletions lib/rubyzen/assertions/assert_zen_false.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ module Assertions
# Minitest counterpart of the +zen_false+ matcher).
#
# @param collection [Enumerable] a Rubyzen collection of declarations
# @param message [String, nil] optional custom failure message
# @param message [String, nil] optional custom failure message (takes precedence over +rule:+)
# @param rule [Rubyzen::Rule, nil] rule whose description, rationale, fix hint and examples
# are shown in the failure message
# @param allowlist [Array<String>, nil] items to permanently ignore
# @param baseline [Array<String>, nil] known violations for gradual adoption
# @yield [item] block that should return false for each item
Expand All @@ -17,11 +19,14 @@ module Assertions
#
# @example With a baseline for gradual adoption
# assert_zen_false(classes, baseline: ['LegacyModel']) { |k| k.lines_of_code > 200 }
def assert_zen_false(collection, message: nil, allowlist: nil, baseline: nil, &block)
#
# @example With a rule that explains the failure (see {Rubyzen::Rule})
# assert_zen_false(models, rule: NO_STATIC_MODEL_METHODS) { |m| m.class_methods.any? }
def assert_zen_false(collection, message: nil, rule: nil, allowlist: nil, baseline: nil, &block)
raise ArgumentError, 'assert_zen_false requires a block' unless block

@failure_message = nil
@custom_message = message
assign_failure_explanation(message, rule)
failing_items = Array(collection).filter { |item| block.call(item) }
@classified_items = classify_items(failing_items, allowlist: allowlist, baseline: baseline)

Expand Down
11 changes: 8 additions & 3 deletions lib/rubyzen/assertions/assert_zen_true.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ module Assertions
# Minitest counterpart of the +zen_true+ matcher).
#
# @param collection [Enumerable] a Rubyzen collection of declarations
# @param message [String, nil] optional custom failure message
# @param message [String, nil] optional custom failure message (takes precedence over +rule:+)
# @param rule [Rubyzen::Rule, nil] rule whose description, rationale, fix hint and examples
# are shown in the failure message
# @param allowlist [Array<String>, nil] items to permanently ignore
# @param baseline [Array<String>, nil] known violations for gradual adoption
# @yield [item] block that should return true for each item
Expand All @@ -17,11 +19,14 @@ module Assertions
#
# @example With a custom failure message
# assert_zen_true(services, message: 'All services must inherit from BaseService') { |s| s.superclass_name == 'BaseService' }
def assert_zen_true(collection, message: nil, allowlist: nil, baseline: nil, &block)
#
# @example With a rule that explains the failure (see {Rubyzen::Rule})
# assert_zen_true(repos, rule: REPOS_IN_REPOS_MODULE) { |r| r.top_level_module == 'Repos' }
def assert_zen_true(collection, message: nil, rule: nil, allowlist: nil, baseline: nil, &block)
raise ArgumentError, 'assert_zen_true requires a block' unless block

@failure_message = nil
@custom_message = message
assign_failure_explanation(message, rule)
failing_items = Array(collection).filter { |item| !block.call(item) }
@classified_items = classify_items(failing_items, allowlist: allowlist, baseline: baseline)

Expand Down
33 changes: 30 additions & 3 deletions lib/rubyzen/expectation_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,36 @@ def formatted_matcher_groups
sections.join("\n")
end

# Sets the +@custom_message+ and +@rule+ read by {#message_for_failure}.
# A {Rubyzen::Rule} passed as the message is treated as the rule.
#
# @param message [String, Rubyzen::Rule, nil] the custom failure message
# @param rule [Rubyzen::Rule, nil] the rule explaining the failure
# @raise [ArgumentError] if the rule is neither +nil+ nor a {Rubyzen::Rule}
# @return [void]
def assign_failure_explanation(message, rule)
message_rule = message if message.is_a?(Rubyzen::Rule)
rule ||= message_rule
unless rule.nil? || rule.is_a?(Rubyzen::Rule)
raise ArgumentError, "Expected rule: to be a Rubyzen::Rule, got #{rule.class}. " \
'Pass a plain String as the message instead.'
end

@custom_message = message_rule ? nil : message
@rule = rule
end

# Formats the failure message by combining the base message with
# custom messages and classified item details (violations, stale entries).
# custom messages, the rule, and classified item details (violations, stale entries).
#
# Precedence: a custom message replaces the base message; otherwise a rule's
# {Rubyzen::Rule#message} does. The details are appended in every case. When
# there are no details (e.g. a missing block or a failed negation), the base
# message follows the rule so the reason for the failure is not lost.
#
# Works unchanged in both RSpec matchers and Minitest assertions, since it
# only reads instance variables set by the caller (+@failure_message+,
# +@custom_message+, +@classified_items+).
# +@custom_message+, +@rule+, +@classified_items+).
#
# @param base_message [String] the default failure message
# @return [String] formatted failure message
Expand All @@ -168,7 +192,10 @@ def message_for_failure(base_message)

details = formatted_matcher_groups

if @custom_message
if !@custom_message && @rule
reason = details.nil? || details.empty? ? base_message : details
"#{@rule.message}\n\n#{reason}"
elsif @custom_message
if details && !details.empty?
"#{@custom_message}\n#{details}"
else
Expand Down
Loading
Loading