diff --git a/.claude/skills/add-rubyzen-tests/SKILL.md b/.claude/skills/add-rubyzen-tests/SKILL.md index a06b607..00d1869 100644 --- a/.claude/skills/add-rubyzen-tests/SKILL.md +++ b/.claude/skills/add-rubyzen-tests/SKILL.md @@ -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 diff --git a/.claude/skills/write-lint-rule/SKILL.md b/.claude/skills/write-lint-rule/SKILL.md index 2b3937b..be6350d 100644 --- a/.claude/skills/write-lint-rule/SKILL.md +++ b/.claude/skills/write-lint-rule/SKILL.md @@ -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']) @@ -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\| ... }` | @@ -276,14 +277,83 @@ 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 @@ -291,5 +361,5 @@ This makes violations immediately actionable — developers see exactly where th - [ ] 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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dd6c3cc..a5c655a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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'] diff --git a/CHANGELOG.md b/CHANGELOG.md index b38dc24..f0172d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index eb3fd07..228318b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ Project │ │ ├── .rescues → RescuesCollection │ │ └── .raises → RaisesCollection │ ├── .attributes → AttributesCollection + │ ├── .constants → ConstantsCollection │ ├── .macros → MacrosCollection │ ├── .rescues → RescuesCollection │ └── .raises → RaisesCollection @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 7435ea5..c5aa16d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/rubyzen/assertions/assert_zen_empty.rb b/lib/rubyzen/assertions/assert_zen_empty.rb index 1c9d90f..ac1f532 100644 --- a/lib/rubyzen/assertions/assert_zen_empty.rb +++ b/lib/rubyzen/assertions/assert_zen_empty.rb @@ -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, nil] items to permanently ignore # @param baseline [Array, nil] known violations for gradual adoption # @return [true] when the assertion passes @@ -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] diff --git a/lib/rubyzen/assertions/assert_zen_false.rb b/lib/rubyzen/assertions/assert_zen_false.rb index f98d999..98a56fb 100644 --- a/lib/rubyzen/assertions/assert_zen_false.rb +++ b/lib/rubyzen/assertions/assert_zen_false.rb @@ -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, nil] items to permanently ignore # @param baseline [Array, nil] known violations for gradual adoption # @yield [item] block that should return false for each item @@ -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) diff --git a/lib/rubyzen/assertions/assert_zen_true.rb b/lib/rubyzen/assertions/assert_zen_true.rb index 3a60aae..3ec09ff 100644 --- a/lib/rubyzen/assertions/assert_zen_true.rb +++ b/lib/rubyzen/assertions/assert_zen_true.rb @@ -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, nil] items to permanently ignore # @param baseline [Array, nil] known violations for gradual adoption # @yield [item] block that should return true for each item @@ -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) diff --git a/lib/rubyzen/expectation_helpers.rb b/lib/rubyzen/expectation_helpers.rb index 9db3439..11572bd 100644 --- a/lib/rubyzen/expectation_helpers.rb +++ b/lib/rubyzen/expectation_helpers.rb @@ -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 @@ -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 diff --git a/lib/rubyzen/matchers/zen_empty_matcher.rb b/lib/rubyzen/matchers/zen_empty_matcher.rb index a07740a..045b4fe 100644 --- a/lib/rubyzen/matchers/zen_empty_matcher.rb +++ b/lib/rubyzen/matchers/zen_empty_matcher.rb @@ -7,7 +7,9 @@ # # Used in architectural lint rules to verify that no items match # # a forbidden pattern (e.g., no controllers call +.where+ directly). # # -# # @param custom_message [String, nil] optional failure message +# # @param custom_message [String, nil] optional 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, nil] items to permanently ignore # # @param baseline [Array, nil] known violations for gradual adoption # # @@ -16,17 +18,24 @@ # # # # @example With baseline for gradual adoption # # expect(violations).to zen_empty(baseline: ['LegacyController']) -# def zen_empty(custom_message = nil, allowlist: nil, baseline: nil); end +# # +# # @example With a rule that explains the failure (see {Rubyzen::Rule}) +# # expect(controllers.all_methods.if_statements).to zen_empty(rule: NO_IFS_IN_CONTROLLERS) +# def zen_empty(custom_message = nil, rule: nil, allowlist: nil, baseline: nil); end # end # end -RSpec::Matchers.define :zen_empty do |custom_message=nil, allowlist: nil, baseline: nil| +RSpec::Matchers.define :zen_empty do |custom_message=nil, rule: nil, allowlist: nil, baseline: nil| include Rubyzen::ExpectationHelpers match do |subject_collection| options = custom_message.is_a?(Hash) ? custom_message : {} resolved_allowlist = allowlist || options[:allowlist] || options['allowlist'] resolved_baseline = baseline || options[:baseline] || options['baseline'] - @custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash)) + positional_message = custom_message unless custom_message.is_a?(Hash) + assign_failure_explanation( + options[:message] || options['message'] || positional_message, + rule || options[:rule] || options['rule'] + ) @classified_items = classify_items( subject_collection, diff --git a/lib/rubyzen/matchers/zen_false_matcher.rb b/lib/rubyzen/matchers/zen_false_matcher.rb index a362e94..cf5f968 100644 --- a/lib/rubyzen/matchers/zen_false_matcher.rb +++ b/lib/rubyzen/matchers/zen_false_matcher.rb @@ -6,7 +6,9 @@ # # Supports +allowlist:+ and +baseline:+ for gradual adoption, matching items # # where the block returns true against exception lists. # # -# # @param custom_message [String, nil] optional failure message +# # @param custom_message [String, nil] optional 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, nil] items to permanently ignore # # @param baseline [Array, nil] known violations for gradual adoption # # @yield [item] block that should return false for each item @@ -16,17 +18,24 @@ # # # # @example With a baseline for gradual adoption # # expect(classes).to zen_false(baseline: ['LegacyModel']) { |k| k.lines_of_code > 200 } -# def zen_false(custom_message = nil, allowlist: nil, baseline: nil, &block); end +# # +# # @example With a rule that explains the failure (see {Rubyzen::Rule}) +# # expect(models).to zen_false(rule: NO_STATIC_MODEL_METHODS) { |m| m.class_methods.any? } +# def zen_false(custom_message = nil, rule: nil, allowlist: nil, baseline: nil, &block); end # end # end -RSpec::Matchers.define :zen_false do |custom_message=nil, allowlist: nil, baseline: nil| +RSpec::Matchers.define :zen_false do |custom_message=nil, rule: nil, allowlist: nil, baseline: nil| include Rubyzen::ExpectationHelpers match do |subject_collection| options = custom_message.is_a?(Hash) ? custom_message : {} resolved_allowlist = allowlist || options[:allowlist] || options['allowlist'] resolved_baseline = baseline || options[:baseline] || options['baseline'] - @custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash)) + positional_message = custom_message unless custom_message.is_a?(Hash) + assign_failure_explanation( + options[:message] || options['message'] || positional_message, + rule || options[:rule] || options['rule'] + ) @offenders = [] if block_arg != nil diff --git a/lib/rubyzen/matchers/zen_true_matcher.rb b/lib/rubyzen/matchers/zen_true_matcher.rb index 5a771f1..393d905 100644 --- a/lib/rubyzen/matchers/zen_true_matcher.rb +++ b/lib/rubyzen/matchers/zen_true_matcher.rb @@ -3,7 +3,9 @@ # module Matchers # # Asserts that a block returns true for every item in a collection. # # -# # @param custom_message [String, nil] optional failure message +# # @param custom_message [String, nil] optional 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, nil] items to permanently ignore # # @param baseline [Array, nil] known violations for gradual adoption # # @yield [item] block that should return true for each item @@ -13,17 +15,24 @@ # # # # @example With a custom failure message # # expect(services).to zen_true("All services must inherit from BaseService") { |s| s.superclass_name == 'BaseService' } -# def zen_true(custom_message = nil, allowlist: nil, baseline: nil, &block); end +# # +# # @example With a rule that explains the failure (see {Rubyzen::Rule}) +# # expect(repos).to zen_true(rule: REPOS_IN_REPOS_MODULE) { |r| r.top_level_module == 'Repos' } +# def zen_true(custom_message = nil, rule: nil, allowlist: nil, baseline: nil, &block); end # end # end -RSpec::Matchers.define :zen_true do |custom_message=nil, allowlist: nil, baseline: nil| +RSpec::Matchers.define :zen_true do |custom_message=nil, rule: nil, allowlist: nil, baseline: nil| include Rubyzen::ExpectationHelpers match do |subject_collection| options = custom_message.is_a?(Hash) ? custom_message : {} resolved_allowlist = allowlist || options[:allowlist] || options['allowlist'] resolved_baseline = baseline || options[:baseline] || options['baseline'] - @custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash)) + positional_message = custom_message unless custom_message.is_a?(Hash) + assign_failure_explanation( + options[:message] || options['message'] || positional_message, + rule || options[:rule] || options['rule'] + ) @offenders = [] if block_arg != nil diff --git a/lib/rubyzen/rule.rb b/lib/rubyzen/rule.rb new file mode 100644 index 0000000..bd3f4df --- /dev/null +++ b/lib/rubyzen/rule.rb @@ -0,0 +1,171 @@ +module Rubyzen + # Describes an architectural lint rule: what it requires, why, and how to fix a violation. + # + # Pass a +Rule+ to any Rubyzen matcher (+rule:+) or assertion (+rule:+) and the failure + # message explains the rule instead of just listing the violations. This gives whoever reads + # the failure — a developer or an AI agent — the context needed to fix the code rather than + # to silence the rule. + # + # Define the sections in a block (see {Builder}) or pass them as keywords. Only +description+ + # is required; the other sections are omitted from the message when +nil+. + # + # @example Defining a rule with the block DSL and attaching it to an RSpec matcher + # let(:rule) do + # Rubyzen::Rule.new do + # description 'Controllers do not contain if statements.' + # rationale <<~TEXT + # Controllers only orchestrate; branching is business logic in the wrong layer. + # TEXT + # fix_hint 'Move the condition into a Service step and return a Failure.' + # bad_example <<~RUBY + # return error(:forbidden) if profile.banned? + # RUBY + # good_example <<~RUBY + # Services::Profiles::Update.new.call(profile:) + # RUBY + # end + # end + # + # it 'has no if statements' do + # expect(controllers.all_methods.if_statements).to zen_empty(rule:) + # end + # + # @example Defining a short rule with keywords and attaching it to a Minitest assertion + # RULE = Rubyzen::Rule.new(description: 'Controllers do not contain if statements.') + # + # assert_zen_empty(controllers.all_methods.if_statements, rule: RULE) + # + class Rule + # @return [String] one-line statement of what the rule requires + attr_reader :description + + # @return [String, nil] why the rule exists — the architectural reasoning + attr_reader :rationale + + # @return [String, nil] how to bring violating code into compliance + attr_reader :fix_hint + + # @return [String, nil] a short snippet that violates the rule + attr_reader :bad_example + + # @return [String, nil] a short snippet that satisfies the rule + attr_reader :good_example + + # @param description [String] one-line statement of what the rule requires + # @param rationale [String, nil] why the rule exists + # @param fix_hint [String, nil] how to fix a violation + # @param bad_example [String, nil] a snippet that violates the rule + # @param good_example [String, nil] a snippet that satisfies the rule + # @yield the block DSL, evaluated by a {Builder}; use it instead of the keywords + # @yieldparam builder [Builder] passed when the block declares a parameter + # @raise [ArgumentError] if +description+ is blank, or if both keywords and a block are given + def initialize(description: nil, rationale: nil, fix_hint: nil, bad_example: nil, good_example: nil, &definition) + sections = { description:, rationale:, fix_hint:, bad_example:, good_example: } + if definition + raise ArgumentError, 'Rubyzen::Rule takes its sections as keywords or in a block, not both' if sections.values.any? + + sections = Builder.build(&definition) + end + + @description = normalize_text(sections[:description]) + raise ArgumentError, 'Rubyzen::Rule requires a description' unless @description + + @rationale = normalize_text(sections[:rationale]) + @fix_hint = normalize_text(sections[:fix_hint]) + @bad_example = normalize_code(sections[:bad_example]) + @good_example = normalize_code(sections[:good_example]) + freeze + end + + # Returns the human-readable failure text: the description followed by every + # provided section, separated by blank lines. + # + # @return [String] + # + # @example + # rule.message + # #=> "RULE: Controllers do not contain if statements.\n\nWHY: ...\n\nHOW TO FIX: ..." + def message + [ + "RULE: #{description}", + rationale && "WHY: #{rationale}", + fix_hint && "HOW TO FIX: #{fix_hint}", + bad_example && "BAD:\n#{bad_example}", + good_example && "GOOD:\n#{good_example}" + ].compact.join("\n\n") + end + alias to_s message + + private + + def normalize_text(value) + text = value.to_s.strip + text.empty? ? nil : text + end + + # Keeps the indentation of code snippets and only drops surrounding blank lines, + # so heredoc examples render as written. + def normalize_code(value) + code = value.to_s.sub(/\A(\s*\n)+/, '').rstrip + code.empty? ? nil : code + end + + # The block DSL of {Rule#initialize}: one method per section. The block is evaluated + # with the builder as +self+, or receives it as a parameter (+do |rule| ... end+) when the + # block needs to reach methods of the surrounding scope. Calling a method again replaces + # the earlier value. + # + # @example + # Rubyzen::Rule.new do + # description 'Jobs are namespaced in the Jobs module.' + # fix_hint 'Wrap the class in `module Jobs`.' + # end + class Builder + # Evaluates a rule definition block. + # + # @return [Hash{Symbol => String}] the sections that the block defined + def self.build(&definition) + builder = new + definition.arity == 1 ? definition.call(builder) : builder.instance_eval(&definition) + builder.sections + end + + # @return [Hash{Symbol => String}] the sections defined so far + attr_reader :sections + + def initialize + @sections = {} + end + + # @param text [String] one-line statement of what the rule requires + # @return [void] + def description(text) + @sections[:description] = text + end + + # @param text [String] why the rule exists — the architectural reasoning + # @return [void] + def rationale(text) + @sections[:rationale] = text + end + + # @param text [String] how to bring violating code into compliance + # @return [void] + def fix_hint(text) + @sections[:fix_hint] = text + end + + # @param code [String] a short snippet that violates the rule + # @return [void] + def bad_example(code) + @sections[:bad_example] = code + end + + # @param code [String] a short snippet that satisfies the rule + # @return [void] + def good_example(code) + @sections[:good_example] = code + end + end + end +end diff --git a/lib/rubyzen/version.rb b/lib/rubyzen/version.rb index 17e47ce..1d014f8 100644 --- a/lib/rubyzen/version.rb +++ b/lib/rubyzen/version.rb @@ -1,4 +1,4 @@ module Rubyzen # @return [String] the current gem version - VERSION = '0.3.0' + VERSION = '0.4.0' end diff --git a/sample_project/spec/controllers/no_if_statements_in_controllers_lint_spec.rb b/sample_project/spec/controllers/no_if_statements_in_controllers_lint_spec.rb index a42989e..32157d3 100644 --- a/sample_project/spec/controllers/no_if_statements_in_controllers_lint_spec.rb +++ b/sample_project/spec/controllers/no_if_statements_in_controllers_lint_spec.rb @@ -11,19 +11,42 @@ context "given controller classes" do it "has no if statements in methods" do - expect(target_classes.all_methods.if_statements).to zen_empty + expect(target_classes.all_methods.if_statements).to zen_empty(rule:) end it "has no if statements in methods, using true with a block" do - expect(target_classes.all_methods).to zen_true { |m| + expect(target_classes.all_methods).to zen_true(rule:) { |m| m.if_statements.count.zero? } end it "has no if statements in methods, using false with a block" do - expect(target_classes.all_methods).to zen_false { |m| + expect(target_classes.all_methods).to zen_false(rule:) { |m| !m.if_statements.count.zero? } end end + + let(:rule) do + Rubyzen::Rule.new do + description 'Controllers and presenters do not contain if statements.' + rationale <<~TEXT + Controllers only orchestrate a request and presenters only format a response. + Branching in them hides business logic where it cannot be reused or unit-tested. + TEXT + fix_hint 'Move the condition into a Service (or a Question) and call it from the controller.' + bad_example <<~RUBY + def index + if rand > 0.5 + render_success + end + end + RUBY + good_example <<~RUBY + def index + Services::Index.new.call + end + RUBY + end + end end diff --git a/sample_project/test/controllers/no_if_statements_in_controllers_test.rb b/sample_project/test/controllers/no_if_statements_in_controllers_test.rb index 1bfb3b1..5433633 100644 --- a/sample_project/test/controllers/no_if_statements_in_controllers_test.rb +++ b/sample_project/test/controllers/no_if_statements_in_controllers_test.rb @@ -2,6 +2,27 @@ # Minitest equivalent of spec/controllers/no_if_statements_in_controllers_lint_spec.rb. class NoIfStatementsInControllersTest < LintTestCase + RULE = Rubyzen::Rule.new do + description 'Controllers and presenters do not contain if statements.' + rationale <<~TEXT + Controllers only orchestrate a request and presenters only format a response. + Branching in them hides business logic where it cannot be reused or unit-tested. + TEXT + fix_hint 'Move the condition into a Service (or a Question) and call it from the controller.' + bad_example <<~RUBY + def index + if rand > 0.5 + render_success + end + end + RUBY + good_example <<~RUBY + def index + Services::Index.new.call + end + RUBY + end + def baseline [] end @@ -11,14 +32,14 @@ def target_classes end def test_has_no_if_statements_in_methods - assert_zen_empty(target_classes.all_methods.if_statements) + assert_zen_empty(target_classes.all_methods.if_statements, rule: RULE) end def test_has_no_if_statements_in_methods_using_true_with_a_block - assert_zen_true(target_classes.all_methods) { |m| m.if_statements.count.zero? } + assert_zen_true(target_classes.all_methods, rule: RULE) { |m| m.if_statements.count.zero? } end def test_has_no_if_statements_in_methods_using_false_with_a_block - assert_zen_false(target_classes.all_methods) { |m| !m.if_statements.count.zero? } + assert_zen_false(target_classes.all_methods, rule: RULE) { |m| !m.if_statements.count.zero? } end end diff --git a/spec/matchers/zen_empty_matcher_spec.rb b/spec/matchers/zen_empty_matcher_spec.rb index 53c420f..ebd7706 100644 --- a/spec/matchers/zen_empty_matcher_spec.rb +++ b/spec/matchers/zen_empty_matcher_spec.rb @@ -72,4 +72,71 @@ class BarController; end }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) end end + + describe 'with a rule' do + let(:rule) do + Rubyzen::Rule.new( + description: 'No Foo classes.', + rationale: 'Foo is deprecated.', + fix_hint: 'Rename Foo to Bar.' + ) + end + + it 'shows the rule sections followed by the violations' do + expect { + expect(non_empty_collection).to zen_empty(rule:) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError) { |error| + expect(error.message).to eq(<<~MESSAGE.chomp) + RULE: No Foo classes. + + WHY: Foo is deprecated. + + HOW TO FIX: Rename Foo to Bar. + + Violations: + - element: Foo + - class: Foo + - file: test.rb:1 + MESSAGE + } + end + + it 'shows stale baseline entries after the rule' do + expect { + expect(empty_collection).to zen_empty(rule:, baseline: ['OldClass']) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /No Foo classes\.\n\n.*Stale baseline entries:\n - OldClass/m) + end + + it 'passes when the collection is empty' do + expect(empty_collection).to zen_empty(rule:) + end + + it 'accepts the rule as the positional argument' do + expect { + expect(non_empty_collection).to zen_empty(rule) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /\ARULE: No Foo classes\./) + end + + it 'prefers a custom message over the rule' do + expect { + expect(non_empty_collection).to zen_empty('Custom message', rule:) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError) { |error| + expect(error.message).to start_with('Custom message') + expect(error.message).not_to include('RULE:') + } + end + + it 'keeps the negation reason after the rule' do + expect { + expect(empty_collection).not_to zen_empty(rule:) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, + /\ARULE: No Foo classes\..*\n\nExpected not to be empty, but had no elements\.\z/m) + end + + it 'rejects a rule that is not a Rubyzen::Rule' do + expect { + expect(non_empty_collection).to zen_empty(rule: 'No Foo classes.') + }.to raise_error(ArgumentError, /Expected rule: to be a Rubyzen::Rule, got String/) + end + end end diff --git a/spec/matchers/zen_false_matcher_spec.rb b/spec/matchers/zen_false_matcher_spec.rb index f983ffa..f567d3c 100644 --- a/spec/matchers/zen_false_matcher_spec.rb +++ b/spec/matchers/zen_false_matcher_spec.rb @@ -104,4 +104,52 @@ def baz; end }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) end end + + describe 'with a rule' do + let(:rule) { Rubyzen::Rule.new(description: 'Methods take parameters.', fix_hint: 'Add a parameter.') } + let(:methods) do + parse_ruby(<<~SOURCE).classes.first.instance_methods + class Foo + def bar(x); end + def baz; end + end + SOURCE + end + + it 'shows the rule sections followed by the violations' do + failing = methods.with_name('bar') + + expect { + expect(failing).to zen_false(rule:) { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError) { |error| + expect(error.message).to eq(<<~MESSAGE.chomp) + RULE: Methods take parameters. + + HOW TO FIX: Add a parameter. + + Violations: + - element: bar + - class: Foo + - file: test.rb:2 + MESSAGE + } + end + + it 'baselines violations like any other message' do + expect(methods).to zen_false(rule:, baseline: ['bar']) { |m| m.parameters? } + end + + it 'prefers a custom message over the rule' do + expect { + expect(methods).to zen_false('Custom message', rule:) { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /\ACustom message\n/) + end + + it 'keeps the missing block reason after the rule' do + expect { + expect(methods).to zen_false(rule:) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, + /\ARULE: Methods take parameters\..*\n\nExpected a block, but got nil\.\z/m) + end + end end diff --git a/spec/matchers/zen_true_matcher_spec.rb b/spec/matchers/zen_true_matcher_spec.rb index a4028a2..cc321d1 100644 --- a/spec/matchers/zen_true_matcher_spec.rb +++ b/spec/matchers/zen_true_matcher_spec.rb @@ -104,4 +104,61 @@ def baz(y); end }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) end end + + describe 'with a rule' do + let(:rule) { Rubyzen::Rule.new(description: 'Methods take parameters.', fix_hint: 'Add a parameter.') } + let(:methods) do + parse_ruby(<<~SOURCE).classes.first.instance_methods + class Foo + def bar(x); end + def baz; end + end + SOURCE + end + + it 'shows the rule sections followed by the violations' do + failing = methods.with_name('baz') + + expect { + expect(failing).to zen_true(rule:) { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError) { |error| + expect(error.message).to eq(<<~MESSAGE.chomp) + RULE: Methods take parameters. + + HOW TO FIX: Add a parameter. + + Violations: + - element: baz + - class: Foo + - file: test.rb:3 + MESSAGE + } + end + + it 'baselines violations like any other message' do + expect(methods).to zen_true(rule:, baseline: ['baz']) { |m| m.parameters? } + end + + it 'prefers a custom message over the rule' do + expect { + expect(methods).to zen_true('Custom message', rule:) { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /\ACustom message\n/) + end + + it 'keeps the missing block reason after the rule' do + expect { + expect(methods).to zen_true(rule:) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, + /\ARULE: Methods take parameters\..*\n\nExpected a block, but got nil\.\z/m) + end + + it 'keeps the negation reason after the rule' do + passing = methods.with_name('bar') + + expect { + expect(passing).not_to zen_true(rule:) { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, + /\ARULE: Methods take parameters\..*\n\nExpected to return false for at least one element/m) + end + end end diff --git a/spec/rule_spec.rb b/spec/rule_spec.rb new file mode 100644 index 0000000..2bc4ec9 --- /dev/null +++ b/spec/rule_spec.rb @@ -0,0 +1,134 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Rule do + let(:full_rule) do + described_class.new( + description: 'Controllers do not contain if statements.', + rationale: 'Controllers only orchestrate; branching belongs in a Service.', + fix_hint: 'Move the condition into a Service step.', + bad_example: 'return error if banned?', + good_example: 'Services::Update.new.call' + ) + end + + describe '#initialize' do + it 'requires a description' do + expect { described_class.new(rationale: 'Why') }.to raise_error(ArgumentError, /description/) + end + + it 'rejects a blank description' do + expect { described_class.new(description: " \n") }.to raise_error(ArgumentError, /requires a description/) + end + + it 'is frozen' do + expect(full_rule).to be_frozen + end + + it 'strips surrounding whitespace from text sections' do + rule = described_class.new(description: " Short rule.\n", rationale: "Because.\n") + + expect(rule.description).to eq('Short rule.') + expect(rule.rationale).to eq('Because.') + end + + it 'treats blank optional sections as nil' do + rule = described_class.new(description: 'Short rule.', rationale: '', fix_hint: " \n") + + expect(rule.rationale).to be_nil + expect(rule.fix_hint).to be_nil + end + + it 'keeps the indentation of heredoc examples' do + rule = described_class.new(description: 'Short rule.', bad_example: <<~RUBY) + def call + return if banned? + end + RUBY + + expect(rule.bad_example).to eq("def call\n return if banned?\nend") + end + end + + describe 'block DSL' do + it 'builds the same rule as the keywords' do + rule = described_class.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 step.' + bad_example 'return error if banned?' + good_example 'Services::Update.new.call' + end + + expect(rule.message).to eq(full_rule.message) + end + + it 'keeps the indentation of heredoc examples' do + rule = described_class.new do + description 'Short rule.' + bad_example <<~RUBY + def call + return if banned? + end + RUBY + end + + expect(rule.bad_example).to eq("def call\n return if banned?\nend") + end + + it 'yields the builder to a block that declares a parameter' do + fix = 'Use the method from the surrounding scope.' + rule = described_class.new do |r| + r.description 'Short rule.' + r.fix_hint fix + end + + expect(rule.fix_hint).to eq(fix) + end + + it 'requires a description' do + expect { described_class.new { rationale 'Why' } }.to raise_error(ArgumentError, /requires a description/) + end + + it 'rejects keywords and a block together' do + expect { + described_class.new(description: 'Short rule.') { fix_hint 'Do this.' } + }.to raise_error(ArgumentError, /keywords or in a block, not both/) + end + + it 'rejects unknown sections' do + expect { described_class.new { description 'Short rule.'; why 'Because.' } }.to raise_error(NoMethodError, /why/) + end + + it 'is frozen' do + expect(described_class.new { description 'Short rule.' }).to be_frozen + end + end + + describe '#message' do + it 'renders every provided section, separated by blank lines' do + expect(full_rule.message).to eq(<<~MESSAGE.chomp) + RULE: Controllers do not contain if statements. + + WHY: Controllers only orchestrate; branching belongs in a Service. + + HOW TO FIX: Move the condition into a Service step. + + BAD: + return error if banned? + + GOOD: + Services::Update.new.call + MESSAGE + end + + it 'omits sections that were not provided' do + rule = described_class.new(description: 'Short rule.', fix_hint: 'Do this.') + + expect(rule.message).to eq("RULE: Short rule.\n\nHOW TO FIX: Do this.") + end + + it 'is also the string form of the rule' do + expect(full_rule.to_s).to eq(full_rule.message) + end + end +end diff --git a/test/assertions/assert_zen_empty_test.rb b/test/assertions/assert_zen_empty_test.rb index 0aa5876..81901fd 100644 --- a/test/assertions/assert_zen_empty_test.rb +++ b/test/assertions/assert_zen_empty_test.rb @@ -66,4 +66,63 @@ def test_zen_empty_fails_on_stale_baseline_entries end assert_match(/stale/i, error.message) end + + def rule + Rubyzen::Rule.new(description: 'No Foo classes.', rationale: 'Foo is deprecated.', fix_hint: 'Rename Foo to Bar.') + end + + def test_zen_empty_shows_the_rule_followed_by_the_violations + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(single_class_collection, rule: rule) + end + assert_equal(<<~MESSAGE.chomp, error.message) + RULE: No Foo classes. + + WHY: Foo is deprecated. + + HOW TO FIX: Rename Foo to Bar. + + Violations: + - element: Foo + - class: Foo + - file: test.rb:1 + MESSAGE + end + + def test_zen_empty_shows_stale_baseline_entries_after_the_rule + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(empty_collection, rule: rule, baseline: %w[OldClass]) + end + assert_match(/No Foo classes\.\n\n.*Stale baseline entries:\n - OldClass/m, error.message) + end + + def test_zen_empty_accepts_the_rule_as_the_message + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(single_class_collection, message: rule) + end + assert_match(/\ARULE: No Foo classes\./, error.message) + end + + def test_zen_empty_prefers_a_custom_message_over_the_rule + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(single_class_collection, message: 'Custom message', rule: rule) + end + assert_match(/\ACustom message\n/, error.message) + refute_match(/RULE:/, error.message) + end + + def test_zen_empty_does_not_reuse_the_rule_of_a_previous_assertion + assert_zen_empty(empty_collection, rule: rule) + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(single_class_collection) + end + refute_match(/RULE:/, error.message) + end + + def test_zen_empty_rejects_a_rule_that_is_not_a_rubyzen_rule + error = assert_raises(ArgumentError) do + assert_zen_empty(single_class_collection, rule: 'No Foo classes.') + end + assert_match(/Expected rule: to be a Rubyzen::Rule, got String/, error.message) + end end diff --git a/test/assertions/assert_zen_false_test.rb b/test/assertions/assert_zen_false_test.rb index 01f9107..f032579 100644 --- a/test/assertions/assert_zen_false_test.rb +++ b/test/assertions/assert_zen_false_test.rb @@ -30,4 +30,35 @@ def test_zen_false_requires_a_block def test_zen_false_supports_baseline assert_zen_false(two_controllers, baseline: %w[FooController]) { |c| c.name == 'FooController' } end + + def rule + Rubyzen::Rule.new(description: 'Only FooController is allowed.', fix_hint: 'Rename it to FooController.') + end + + def test_zen_false_shows_the_rule_followed_by_the_violations + error = assert_raises(Minitest::Assertion) do + assert_zen_false(two_controllers, rule: rule) { |c| c.name == 'BarController' } + end + assert_equal(<<~MESSAGE.chomp, error.message) + RULE: Only FooController is allowed. + + HOW TO FIX: Rename it to FooController. + + Violations: + - element: BarController + - class: BarController + - file: /app/controllers/foo_controller.rb:2 + MESSAGE + end + + def test_zen_false_supports_a_rule_with_a_baseline + assert_zen_false(two_controllers, rule: rule, baseline: %w[BarController]) { |c| c.name == 'BarController' } + end + + def test_zen_false_prefers_a_custom_message_over_the_rule + error = assert_raises(Minitest::Assertion) do + assert_zen_false(two_controllers, message: 'Custom message', rule: rule) { |c| c.name == 'BarController' } + end + assert_match(/\ACustom message\n/, error.message) + end end diff --git a/test/assertions/assert_zen_true_test.rb b/test/assertions/assert_zen_true_test.rb index ecf3812..047ecce 100644 --- a/test/assertions/assert_zen_true_test.rb +++ b/test/assertions/assert_zen_true_test.rb @@ -37,4 +37,35 @@ def test_zen_true_supports_custom_message end assert_match(/All must be Foo/, error.message) end + + def rule + Rubyzen::Rule.new(description: 'Only FooController is allowed.', fix_hint: 'Rename it to FooController.') + end + + def test_zen_true_shows_the_rule_followed_by_the_violations + error = assert_raises(Minitest::Assertion) do + assert_zen_true(two_controllers, rule: rule) { |c| c.name == 'FooController' } + end + assert_equal(<<~MESSAGE.chomp, error.message) + RULE: Only FooController is allowed. + + HOW TO FIX: Rename it to FooController. + + Violations: + - element: BarController + - class: BarController + - file: /app/controllers/foo_controller.rb:2 + MESSAGE + end + + def test_zen_true_supports_a_rule_with_a_baseline + assert_zen_true(two_controllers, rule: rule, baseline: %w[BarController]) { |c| c.name == 'FooController' } + end + + def test_zen_true_prefers_a_custom_message_over_the_rule + error = assert_raises(Minitest::Assertion) do + assert_zen_true(two_controllers, message: 'Custom message', rule: rule) { |c| c.name == 'FooController' } + end + assert_match(/\ACustom message\n/, error.message) + end end