From a85a1d8fc057db98389261b9572e3749c72ec4d7 Mon Sep 17 00:00:00 2001 From: Stelios Frantzeskakis Date: Wed, 3 Jun 2026 00:57:27 +0300 Subject: [PATCH 1/3] [GH-7502] Add Minitest support --- .claude/skills/add-rubyzen-tests/SKILL.md | 37 +++- .claude/skills/expand-rubyzen/SKILL.md | 12 +- .claude/skills/run-lint-rules/SKILL.md | 39 +++- .claude/skills/run-tests/SKILL.md | 17 +- .claude/skills/write-lint-rule/SKILL.md | 57 +++++- .github/ISSUE_TEMPLATE/bug_report.md | 4 +- .github/PULL_REQUEST_TEMPLATE.md | 3 +- .github/workflows/tests.yml | 7 +- CHANGELOG.md | 24 +++ CLAUDE.md | 78 ++++++-- CONTRIBUTING.md | 22 ++- README.md | 51 ++++- Rakefile | 16 ++ lib/rubyzen.rb | 103 +--------- lib/rubyzen/assertions/assert_zen_empty.rb | 51 +++++ lib/rubyzen/assertions/assert_zen_false.rb | 49 +++++ lib/rubyzen/assertions/assert_zen_true.rb | 49 +++++ lib/rubyzen/assertions/zen_assertions.rb | 29 +++ lib/rubyzen/core.rb | 112 +++++++++++ lib/rubyzen/expectation_helpers.rb | 184 ++++++++++++++++++ lib/rubyzen/matchers/matcher_helpers.rb | 183 ----------------- lib/rubyzen/matchers/zen_empty_matcher.rb | 3 +- lib/rubyzen/matchers/zen_false_matcher.rb | 2 +- lib/rubyzen/matchers/zen_true_matcher.rb | 2 +- lib/rubyzen/minitest.rb | 26 +++ lib/rubyzen/providers/blocks_provider.rb | 2 +- lib/rubyzen/rspec.rb | 29 +++ lib/rubyzen/version.rb | 2 +- rubyzen-lint.gemspec | 12 +- ..._public_attr_writer_in_models_lint_spec.rb | 2 - .../no_top_level_constants_lint_spec.rb | 2 - .../models/no_requires_in_models_lint_spec.rb | 2 - .../module_file_path_consistency_lint_spec.rb | 2 - sample_project/spec/spec_helper.rb | 2 +- .../no_if_statements_in_controllers_test.rb | 24 +++ ...active_record_models_outside_repos_test.rb | 39 ++++ .../models/no_arguments_named_biz_test.rb | 8 + sample_project/test/repos/repos_test.rb | 8 + sample_project/test/test_helper.rb | 56 ++++++ spec/spec_helper.rb | 2 +- test/assertions/assert_zen_empty_test.rb | 69 +++++++ test/assertions/assert_zen_false_test.rb | 33 ++++ test/assertions/assert_zen_true_test.rb | 40 ++++ test/minitest_adapter_test.rb | 21 ++ test/test_helper.rb | 12 ++ 45 files changed, 1176 insertions(+), 351 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 Rakefile create mode 100644 lib/rubyzen/assertions/assert_zen_empty.rb create mode 100644 lib/rubyzen/assertions/assert_zen_false.rb create mode 100644 lib/rubyzen/assertions/assert_zen_true.rb create mode 100644 lib/rubyzen/assertions/zen_assertions.rb create mode 100644 lib/rubyzen/core.rb create mode 100644 lib/rubyzen/expectation_helpers.rb delete mode 100644 lib/rubyzen/matchers/matcher_helpers.rb create mode 100644 lib/rubyzen/minitest.rb create mode 100644 lib/rubyzen/rspec.rb create mode 100644 sample_project/test/controllers/no_if_statements_in_controllers_test.rb create mode 100644 sample_project/test/database/do_not_call_active_record_models_outside_repos_test.rb create mode 100644 sample_project/test/models/no_arguments_named_biz_test.rb create mode 100644 sample_project/test/repos/repos_test.rb create mode 100644 sample_project/test/test_helper.rb create mode 100644 test/assertions/assert_zen_empty_test.rb create mode 100644 test/assertions/assert_zen_false_test.rb create mode 100644 test/assertions/assert_zen_true_test.rb create mode 100644 test/minitest_adapter_test.rb create mode 100644 test/test_helper.rb diff --git a/.claude/skills/add-rubyzen-tests/SKILL.md b/.claude/skills/add-rubyzen-tests/SKILL.md index 24bfd3e..a06b607 100644 --- a/.claude/skills/add-rubyzen-tests/SKILL.md +++ b/.claude/skills/add-rubyzen-tests/SKILL.md @@ -1,14 +1,16 @@ --- name: add-rubyzen-tests -description: Write unit tests for Rubyzen's own API components — declarations, providers, collections, or matchers. Use this skill when the user wants to add tests for an existing or newly added Rubyzen component, increase test coverage, or write specs for untested methods. Also trigger when the user says "test this declaration", "add specs for", or "write tests for the X collection". +description: Write unit tests for Rubyzen's own API components — declarations, providers, collections, RSpec matchers, or Minitest assertions. Use this skill when the user wants to add tests for an existing or newly added Rubyzen component, increase test coverage, or write specs for untested methods. Also trigger when the user says "test this declaration", "add specs for", or "write tests for the X collection". --- # Writing Unit Tests for Rubyzen -You are writing unit tests for Rubyzen's internal API — the declarations, providers, collections, and matchers that make up the library. These tests verify that Rubyzen's own code works correctly. +You are writing unit tests for Rubyzen's internal API — the declarations, providers, collections, RSpec matchers, and Minitest assertions that make up the library. These tests verify that Rubyzen's own code works correctly. **This is NOT about writing lint rules.** Lint rules test user codebases; unit tests test Rubyzen itself. Use the `write-lint-rule` skill for lint rules. +Rubyzen has **two unit-test suites**: RSpec specs in `spec/` cover the core API (declarations, collections, providers) and the RSpec matchers; Minitest tests in `test/` cover the Minitest assertions (`assert_zen_*`). + ## Step 0: Understand the Test Infrastructure Read these files first: @@ -225,6 +227,34 @@ end **Note:** Do NOT use `fail_with` — it's not available. Use `raise_error(RSpec::Expectations::ExpectationNotMetError, /pattern/)` to test failure messages. +## Writing Minitest Assertion Tests + +The Minitest assertions (`assert_zen_empty` / `assert_zen_true` / `assert_zen_false`) live in `lib/rubyzen/assertions/` and are tested with **Minitest**, not RSpec, under `test/` — the counterpart of the matcher specs above. + +Files: `test/assertions/assert__test.rb` + +You need to require `test/test_helper.rb` (which requires `rubyzen/minitest` and provides the same `parse_ruby` helper via the `ParseHelper` module), subclass `Minitest::Test`, and use `assert_raises(Minitest::Assertion)` to test failures: + +```ruby +require_relative '../test_helper' + +class AssertZenEmptyTest < Minitest::Test + include ParseHelper + + def test_zen_empty_passes_when_collection_is_empty + assert_zen_empty(Rubyzen::Collections::ClassesCollection.new) + end + + def test_zen_empty_fails_when_collection_is_not_empty + collection = parse_ruby('class Foo; end').classes + error = assert_raises(Minitest::Assertion) { assert_zen_empty(collection) } + assert_match(/Expected to be empty/, error.message) + end +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`. + ## When to Use Fixture Files Use `spec/fixtures/` with real `.rb` files only when testing: @@ -251,4 +281,5 @@ For everything else, use inline `parse_ruby` snippets. - [ ] Tests type preservation for collection `filter` - [ ] Snippets have 2+ statements when testing file-level providers - [ ] Does NOT use `fail_with` (uses `raise_error` instead) -- [ ] `bundle exec rspec spec/` passes +- [ ] Minitest assertion tests live in `test/`, require `test_helper`, and use `assert_raises(Minitest::Assertion)` +- [ ] `bundle exec rake` passes (RSpec `spec/` + Minitest `test/`) diff --git a/.claude/skills/expand-rubyzen/SKILL.md b/.claude/skills/expand-rubyzen/SKILL.md index e9e00c2..8a32a97 100644 --- a/.claude/skills/expand-rubyzen/SKILL.md +++ b/.claude/skills/expand-rubyzen/SKILL.md @@ -47,7 +47,7 @@ module Rubyzen # decl.name #=> "example" # class Declaration - # Always include these three for matcher output: + # Always include these three for matcher/assertion failure output: include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::ClassNameProvider @@ -82,7 +82,7 @@ module Rubyzen # so if you use a custom name, add `alias :parent :` to ensure # FilePathProvider can walk the tree. - # REQUIRED: Used by matchers for failure messages. + # REQUIRED: Used by the RSpec matchers and Minitest assertions for failure messages. # @return [String] def name # Return a meaningful identifier for this declaration @@ -97,8 +97,8 @@ end **Rules:** - `node` and `parent` are always `attr_reader` — providers depend on them -- `FilePathProvider`, `LineNumberProvider`, `ClassNameProvider` are **required** — matchers use `file_path`, `line`, and `class_name` for failure messages -- A `name` method is **required** — matchers call it via `element_name` +- `FilePathProvider`, `LineNumberProvider`, `ClassNameProvider` are **required** — the RSpec matchers and Minitest assertions use `file_path`, `line`, and `class_name` (via the shared `ExpectationHelpers`) for failure messages +- A `name` method is **required** — both frameworks call it via `element_name` - Zeitwerk autoloads — no `require` statements needed (exception: if the file is loaded before Zeitwerk, add explicit `require_relative`) ## Step 3: Create the Provider @@ -289,7 +289,7 @@ This affects any provider that uses `each_descendant` on file-level nodes. 1. Add the new declaration to the **Declaration Reference** table in `CLAUDE.md` 2. Add the new collection to the **Data Flow** tree in `CLAUDE.md` -3. Run `bundle exec rspec spec/` to verify all tests pass +3. Run `bundle exec rake` to verify all tests pass (RSpec `spec/` + Minitest `test/`) ## Checklist @@ -308,4 +308,4 @@ Before considering the work complete, verify: - [ ] Collection spec tests `CollectionFilterProvider` methods and domain-specific filters - [ ] Test snippets have 2+ statements (single-statement gotcha) - [ ] `CLAUDE.md` updated -- [ ] `bundle exec rspec spec/` passes +- [ ] `bundle exec rake` passes (RSpec `spec/` + Minitest `test/`) diff --git a/.claude/skills/run-lint-rules/SKILL.md b/.claude/skills/run-lint-rules/SKILL.md index e3673db..a13c8b8 100644 --- a/.claude/skills/run-lint-rules/SKILL.md +++ b/.claude/skills/run-lint-rules/SKILL.md @@ -7,7 +7,7 @@ description: Run the sample project lint rules to verify that Rubyzen detects ar The sample project (`sample_project/`) contains intentional architectural violations. Lint rule specs verify that Rubyzen correctly detects them. -## Run All Lint Rules +## Run RSpec Lint Rules ```bash bundle exec rspec sample_project/spec/ @@ -21,6 +21,21 @@ bundle exec rspec sample_project/spec/ bundle exec rspec sample_project/spec/controllers/no_if_statements_in_controllers_lint_spec.rb ``` +## Run Minitest Lint Rules + +The sample project also has a parallel Minitest suite under `sample_project/test/` (using the `assert_zen_*` assertions). Run it with plain Ruby: + +```bash +# All Minitest lint rules — single process, one aggregated summary +# (mirrors `bundle exec rspec sample_project/spec/`) +cd sample_project && bundle exec ruby -Itest -e 'Dir.glob("test/**/*_test.rb").sort.each { |f| require File.expand_path(f) }' + +# A specific Minitest lint rule +cd sample_project && bundle exec ruby -Itest test/controllers/no_if_statements_in_controllers_test.rb +``` + +**Expected behavior:** same as RSpec — these are **expected to fail**, detecting the sample project's intentional violations. + ## Lint Rule Structure ``` @@ -34,18 +49,26 @@ sample_project/ │ ├── requests/ │ ├── tests/ │ └── config.rb -└── spec/ # Lint rules as RSpec tests - ├── spec_helper.rb # Shared context with collection helpers +├── spec/ # Lint rules as RSpec tests +│ ├── spec_helper.rb # Shared context with collection helpers +│ ├── controllers/ +│ ├── models/ +│ ├── presenters/ +│ ├── tests/ +│ └── ... +└── test/ # Lint rules as Minitest tests (parallel subset) + ├── test_helper.rb # LintTestCase base class with collection accessors ├── controllers/ ├── models/ - ├── presenters/ - ├── tests/ - └── ... + ├── repos/ + └── database/ ``` ## Shared Context -Lint rules use a shared context defined in `sample_project/spec/spec_helper.rb` that provides pre-built collections: +Lint rules use a shared context that provides pre-built collections. + +For RSpec, it's defined in `sample_project/spec/spec_helper.rb`: ```ruby let(:project) { Rubyzen::Project.new([sample_src]) } @@ -53,3 +76,5 @@ let(:controllers) { project.files.with_paths('src/controllers/').classes } let(:models) { project.files.with_paths('src/models/').classes } let(:services) { project.files.with_paths('src/services/').classes } ``` + +For Minitest, the equivalent is the `LintTestCase` base class in `sample_project/test/test_helper.rb`, which exposes the same collections as memoized methods (`controllers`, `models`, `services`, …) — subclass it in your lint rule and call them directly. diff --git a/.claude/skills/run-tests/SKILL.md b/.claude/skills/run-tests/SKILL.md index 57815d9..98f0ea3 100644 --- a/.claude/skills/run-tests/SKILL.md +++ b/.claude/skills/run-tests/SKILL.md @@ -5,12 +5,19 @@ description: Run Rubyzen's unit test suite. Use this skill when the user wants t # Running Rubyzen Unit Tests -Unit tests verify the correctness of Rubyzen's own API — declarations, providers, collections, and matchers. +Unit tests verify the correctness of Rubyzen's own API — declarations, providers, collections, RSpec matchers, and Minitest assertions. Rubyzen has two test suites: RSpec specs in `spec/` and Minitest tests in `test/`. ## Run All Tests ```bash -bundle exec rspec spec/ +# Both suites (RSpec then Minitest) +bundle exec rake + +# RSpec only +bundle exec rspec spec + +# Minitest only +bundle exec rake test ``` ## Run a Specific File @@ -49,6 +56,12 @@ spec/ ├── project_spec.rb # Project class tests └── cache/ └── parse_cache_spec.rb # Caching behavior tests + +test/ +├── test_helper.rb # Loads rubyzen/minitest + parse helper +├── assertions/ +│ └── zen_assertions_test.rb # assert_zen_empty / _true / _false +└── minitest_adapter_test.rb # Entry-point contract test ``` ## Interpreting Failures diff --git a/.claude/skills/write-lint-rule/SKILL.md b/.claude/skills/write-lint-rule/SKILL.md index ccd07ad..2b3937b 100644 --- a/.claude/skills/write-lint-rule/SKILL.md +++ b/.claude/skills/write-lint-rule/SKILL.md @@ -1,6 +1,6 @@ --- name: write-lint-rule -description: Write an architectural lint rule for a Ruby project using the Rubyzen API. Use this skill when the user wants to create a new lint rule, enforce an architectural constraint, add a code quality check, or write an RSpec test that validates code structure. Also trigger when the user says things like "controllers shouldn't do X", "models must always Y", "enforce that Z", or "add a rule for". +description: Write an architectural lint rule for a Ruby project using the Rubyzen API. Use this skill when the user wants to create a new lint rule, enforce an architectural constraint, add a code quality check, or write an RSpec or Minitest test that validates code structure. Also trigger when the user says things like "controllers shouldn't do X", "models must always Y", "enforce that Z", or "add a rule for". --- # Writing a Lint Rule with Rubyzen @@ -45,7 +45,7 @@ SCOPE → FILTER → EXTRACT → ASSERT Lint rules use a shared context in `spec_helper.rb` that provides pre-built collections. Read the existing `spec_helper.rb` in the target project's spec directory. If it doesn't exist, create one: ```ruby -require 'rubyzen' +require 'rubyzen/rspec' RSpec.configure do |config| RSpec.shared_context 'project_config' do @@ -136,6 +136,59 @@ expect(models).to zen_false { |m| m.name.end_with?('Helper') } expect(models).to zen_false(baseline: ['OldHelper']) { |m| m.name.end_with?('Helper') } ``` +## Minitest variant + +Rubyzen includes a Minitest adapter alongside the RSpec matchers. The `SCOPE → FILTER → EXTRACT → ASSERT` model is identical — only the assertion call changes. The assertions mirror the matchers one-to-one, including the same `allowlist:` / `baseline:` support and the same rich failure messages. + +**Shared context** — define a `test/test_helper.rb` that requires the Minitest adapter and provides a base class with pre-built collections (the Minitest counterpart of the RSpec shared context): + +```ruby +# test/test_helper.rb +require 'rubyzen/minitest' +require 'minitest/autorun' + +class LintTestCase < Minitest::Test + def project = @project ||= Rubyzen::Project.new + + # Scope collections by directory + def controllers = @controllers ||= project.files.with_paths('src/controllers/').classes + def models = @models ||= project.files.with_paths('src/models/').classes + def services = @services ||= project.files.with_paths('src/services/').classes + # Add more as needed... +end +``` + +**Assertion equivalents:** + +| RSpec matcher | Minitest assertion | +|---|---| +| `expect(c).to zen_empty` | `assert_zen_empty(c)` | +| `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\| ... }` | +| `expect(c).to zen_false { \|x\| ... }` | `assert_zen_false(c) { \|x\| ... }` | + +Note the custom message is a positional arg in RSpec but the `message:` keyword in Minitest. + +**Example:** + +```ruby +require 'test_helper' + +# `controllers`, `models`, etc. are available from the LintTestCase base class +class ControllersTest < LintTestCase + def test_controllers_do_not_use_where + assert_zen_empty(controllers.all_methods.call_sites.with_name('where')) + end + + def test_controllers_inherit_from_application_controller + assert_zen_true(controllers) { |c| c.superclass_name == 'ApplicationController' } + end +end +``` + +The `{ }`-vs-`do...end` precedence caveat does **not** apply here (the block binds to the assertion method call either way), but `{ }` is still recommended for consistency. + ## Common API Patterns ### Filtering by file path diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b4cb758..aa6e2d2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -23,6 +23,6 @@ What actually happened. Include error messages or test output if applicable. ## Environment -- Ruby version: - Rubyzen version: -- RSpec version: +- Ruby version: +- Test framework & version (RSpec or Minitest): diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 635e0c9..916d3d2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,4 +5,5 @@ ### Checklist - [ ] Tests are added or updated for the changes -- [ ] `bundle exec rspec spec/` passes locally +- [ ] YARD docs are added or updated for the changes +- [ ] `bundle exec rake` passes locally (runs both the RSpec and Minitest suites) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 41ed4e7..dd6c3cc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,5 +20,8 @@ jobs: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - - name: Run unit tests - run: bundle exec rspec spec/ + - name: Run RSpec unit tests + run: bundle exec rake spec + + - name: Run Minitest unit tests + run: bundle exec rake test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2367756 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## [0.2.0] + +### Added + +- **Minitest adapter.** Write architectural lint rules with Minitest using + `require 'rubyzen/minitest'`. This provides the `assert_zen_empty`, `assert_zen_true`, + and `assert_zen_false` assertions, which mirror the RSpec matchers. + +### Changed + +- **`rspec` is no longer a runtime dependency.** Rubyzen no longer depends + on RSpec at runtime; RSpec users should add `gem 'rspec'` (or `rspec-rails`) + to their Gemfile. This allows Minitest users to not depend on RSpec. + +### Migration from 0.1.x + +- Change `require 'rubyzen'` to `require 'rubyzen/rspec'`. +- Ensure `gem 'rspec'` is in your Gemfile's test group. + +## [0.1.0] + +- Initial release diff --git a/CLAUDE.md b/CLAUDE.md index 26de3be..2b1a6e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,11 @@ ## What is Rubyzen -Rubyzen is a Ruby architectural linter that lets you write lint rules as RSpec tests. Inspired by Konsist (Kotlin) and Harmonize (Swift), it wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural rules across a codebase. +Rubyzen is a Ruby architectural linter that lets you write lint rules as RSpec or Minitest tests. Inspired by Konsist (Kotlin) and Harmonize (Swift), it wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural rules across a codebase. -Instead of configuring YAML rules, you write standard RSpec tests: +Instead of configuring YAML rules, you write standard unit tests. + +Using RSpec (`require 'rubyzen/rspec'`): ```ruby it 'controllers do not call ActiveRecord directly' do @@ -12,6 +14,14 @@ it 'controllers do not call ActiveRecord directly' do end ``` +Or using Minitest (`require 'rubyzen/minitest'`): + +```ruby +def test_controllers_do_not_call_active_record_directly + assert_zen_empty(controllers.all_methods.call_sites.with_name('where')) +end +``` + ## Core Concepts Rubyzen has four main building blocks: @@ -21,7 +31,7 @@ Rubyzen has four main building blocks: | **Declarations** | Domain objects wrapping AST nodes | `ClassDeclaration`, `MethodDeclaration`, `CallSiteDeclaration` | | **Collections** | Typed arrays of declarations with filtering/aggregation | `ClassesCollection`, `MethodsCollection` | | **Providers** | Mixins that add capabilities to declarations | `CallSiteProvider`, `BlocksProvider` | -| **Matchers** | RSpec matchers for asserting on collections | `zen_empty`, `zen_true { }`, `zen_false { }` | +| **Matchers / Assertions** | RSpec matchers and Minitest assertions for asserting on collections | `zen_empty` / `assert_zen_empty`, `zen_true { }` / `assert_zen_true { }`, `zen_false { }` / `assert_zen_false { }` | ## Data Flow @@ -53,7 +63,11 @@ Every arrow is a method that returns a typed collection. Collections support cha ``` lib/rubyzen/ -├── rubyzen.rb # Entry point, Zeitwerk loader, configuration, matchers +├── rubyzen.rb # Entry point — loads core only (require 'rubyzen') +├── core.rb # Framework-agnostic core: Zeitwerk loader + Rubyzen module/Configuration +├── 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 ├── project.rb # Parses all .rb files, returns FileCollection ├── declarations/ # Domain objects wrapping AST nodes │ ├── file_declaration.rb @@ -103,11 +117,15 @@ lib/rubyzen/ │ ├── rescues_provider.rb │ ├── visibility_provider.rb │ └── collection_filter_provider.rb -├── matchers/ # RSpec custom matchers -│ ├── matcher_helpers.rb +├── matchers/ # RSpec matchers (loaded by rubyzen/rspec) │ ├── zen_empty_matcher.rb │ ├── zen_true_matcher.rb │ └── zen_false_matcher.rb +├── assertions/ # Minitest assertions (loaded by rubyzen/minitest) +│ ├── zen_assertions.rb +│ ├── assert_zen_empty.rb +│ ├── assert_zen_true.rb +│ └── assert_zen_false.rb ├── parsers/ │ └── a_s_t_parser.rb # Wraps RuboCop AST ProcessedSource ├── cache/ @@ -124,13 +142,19 @@ sample_project/ │ ├── requests/ │ ├── tests/ │ └── config.rb -└── spec/ # Lint rules as RSpec tests - ├── spec_helper.rb # Shared context with common collections +├── spec/ # Lint rules as RSpec tests +│ ├── spec_helper.rb # Shared context with common collections (require 'rubyzen/rspec') +│ ├── controllers/ +│ ├── models/ +│ ├── presenters/ +│ ├── tests/ +│ └── ... +└── test/ # Lint rules as Minitest tests (parallel subset) + ├── test_helper.rb # LintTestCase base class (require 'rubyzen/minitest') ├── controllers/ ├── models/ - ├── presenters/ - ├── tests/ - └── ... + ├── repos/ + └── database/ ``` ## How the Pieces Connect @@ -209,17 +233,35 @@ Each declaration wraps an AST node and exposes domain-specific methods: | `RaiseDeclaration` | `exception_types`, `with_string?`, `message` | FilePathProvider, LineNumberProvider, ClassNameProvider, SourceCodeProvider | | `RescueDeclaration` | `exception_types` | FilePathProvider, LineNumberProvider, ClassNameProvider | -## Matchers +## Matchers (RSpec) and Assertions (Minitest) -All matchers use `MatcherHelpers` for formatting failure messages with element name, class, and file location. +Rubyzen exposes the same three checks for both frameworks. The RSpec matchers (`lib/rubyzen/matchers/`) and the Minitest assertions (`lib/rubyzen/assertions/`) both delegate to the shared `Rubyzen::ExpectationHelpers` (`lib/rubyzen/expectation_helpers.rb`) for allowlist/baseline classification and for formatting failure messages with element name, class, and file location. -| Matcher | Purpose | Usage | +| RSpec matcher | Minitest assertion | Purpose | |---|---|---| -| `zen_empty` | Collection has no elements. Supports `allowlist:` and `baseline:` for gradual adoption. | `expect(violations).to zen_empty` or `expect(violations).to zen_empty(baseline: [...])` | -| `zen_true { \|item\| }` | Block returns true for ALL elements. Supports `allowlist:` and `baseline:`. | `expect(methods).to zen_true { \|m\| m.parameters.any? }` | -| `zen_false { \|item\| }` | Block returns false for ALL elements. Supports `allowlist:` and `baseline:`. | `expect(methods).to zen_false { \|m\| m.name == :biz }` | +| `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:`. | + +### Examples + +RSpec: + +```ruby +expect(violations).to zen_empty(baseline: [...]) +expect(methods).to zen_true { |m| m.parameters.any? } +expect(methods).to zen_false { |m| m.name == :biz } +``` + +Minitest: + +```ruby +assert_zen_empty(violations, baseline: [...]) +assert_zen_true(methods) { |m| m.parameters.any? } +assert_zen_false(methods) { |m| m.name == :biz } +``` -**Important:** Use `{ }` braces (not `do...end`) with `zen_true`/`zen_false` — `do...end` binds to `expect()` instead of the matcher due to Ruby precedence. +**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/CONTRIBUTING.md b/CONTRIBUTING.md index f45b4aa..b16b92f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,26 +10,36 @@ bundle install ## Running Tests +Rubyzen includes both an RSpec adapter and a Minitest adapter, each with its own unit test suite. + ```bash -# Rubyzen's unit tests -bundle exec rspec spec/ +# Rubyzen's unit tests (both test suites) +bundle exec rake + +# Or run them individually +bundle exec rspec spec +bundle exec rake test # Sample project lint rules (expected to fail — intentional violations) +# Using RSpec adapter bundle exec rspec sample_project/spec/ +# Using Minitest adapter +cd sample_project && for f in test/**/*_test.rb; do bundle exec ruby -Itest "$f"; done ``` ## Project Structure -- **`lib/rubyzen/`** — Source code (declarations, collections, providers, matchers, parsers, cache) -- **`spec/`** — Unit tests for Rubyzen's own API -- **`sample_project/`** — Sample app with intentional violations and lint rules demonstrating Rubyzen +- **`lib/rubyzen/`** — Source code (declarations, collections, providers, matchers, assertions, parsers, cache). `core.rb` is the framework-agnostic core loaded by `require 'rubyzen'`; the adapters are `rubyzen/rspec.rb` (RSpec matchers) and `rubyzen/minitest.rb` (Minitest assertions). +- **`spec/`** — RSpec unit tests for Rubyzen's own API +- **`test/`** — Minitest unit tests for Rubyzen's own API +- **`sample_project/`** — Sample app with intentional violations and lint rules (RSpec in `spec/`, Minitest in `test/`) demonstrating Rubyzen ## Making Changes 1. Fork the repository 2. Create a branch (`git checkout -b my-change`) 3. Make your changes -4. Run the tests (`bundle exec rspec spec/`) +4. Run the tests (`bundle exec rake` — runs both the RSpec and Minitest test suites) 5. Commit and push 6. Open a Pull Request diff --git a/README.md b/README.md index 8eeab66..77f4824 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://github.com/perrystreetsoftware/Rubyzen/actions/workflows/tests.yml/badge.svg)](https://github.com/perrystreetsoftware/Rubyzen/actions/workflows/tests.yml) [![Docs](https://img.shields.io/badge/docs-yard-blue)](https://perrystreetsoftware.github.io/Rubyzen) -Rubyzen is an architectural linter for Ruby that lets you write architectural lint rules as unit tests, inspired by [Konsist](https://github.com/LemonAppDev/konsist) (for Kotlin) and [Harmonize](https://github.com/perrystreetsoftware/Harmonize) (for Swift). +Rubyzen is an architectural linter for Ruby that allows you to write architectural lint rules as unit tests, inspired by [Konsist](https://github.com/LemonAppDev/konsist) (for Kotlin) and [Harmonize](https://github.com/perrystreetsoftware/Harmonize) (for Swift). ## Architectural linters in the era of AI-generated code @@ -28,22 +28,26 @@ Traditional linters such as [RuboCop](https://github.com/rubocop/rubocop) requir ## Setup -Add Rubyzen to your Gemfile: +Add Rubyzen to your Gemfile's test group, alongside your test framework. ```ruby -gem 'rubyzen-lint', group: :test +group :test do + gem 'rubyzen-lint' + # gem 'rspec' # if you use RSpec (or rspec-rails) + # gem 'minitest' # if you use Minitest +end ``` Then run `bundle install`. -That's it. Rubyzen auto-discovers your project structure (`app/`, `lib/`, `src/`, `spec/`) from your project root. If you need to lint other directories (e.g., `config/`, `db/`), see [Custom Paths](#custom-paths) below. +Rubyzen auto-discovers your project structure (`app/`, `lib/`, `src/`, `spec/`) from your project root. If you need to lint other directories (e.g., `config/`, `db/`), see [Custom Paths](#custom-paths) below. ## Write your first set of lint rules Create a spec file anywhere in your project (e.g., `spec/architecture/sample_spec.rb`) and start enforcing your architecture: ```ruby -require 'rubyzen' +require 'rubyzen/rspec' RSpec.describe 'Architecture rules' do let(:project) { Rubyzen::Project.new } @@ -68,10 +72,43 @@ end You can find more sample lint rules in the [`sample_project/spec/`](sample_project/spec/) directory. +## Using Minitest + +If you use Minitest instead of RSpec, replace `require 'rubyzen/rspec'` with `require 'rubyzen/minitest'` and use the equivalent Minitest assertions: + +```ruby +require 'rubyzen/minitest' + +class ArchitectureTest < Minitest::Test + def controllers = Rubyzen::Project.new.files.with_paths('app/controllers/').classes + + def test_controllers_do_not_call_active_record_directly + assert_zen_empty(controllers.all_methods.call_sites.with_name('where')) + end +end +``` + +## Matchers and assertions + +Rubyzen provides three checks for your architectural lint rules: + +| Using RSpec | Using Minitest | Checks that | +|---|---|---| +| `zen_empty` | `assert_zen_empty(collection)` | the collection is empty | +| `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. + ## Run your lint rules ```bash +# If you use RSpec: bundle exec rspec spec/architecture/ + +# If you use Minitest: +bundle exec rake lint # via a Rake task +bin/rails test test/architecture # via the Rails test runner ``` ## Custom Paths @@ -94,7 +131,11 @@ Add a step to your existing CI workflow to run your lint rules automatically whe ```yaml - name: Run architecture lint rules + # If you use RSpec: run: bundle exec rspec spec/architecture/ + + # If you use Minitest: + # run: bundle exec rake lint ``` ## AI Agent Skills diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9e00ed3 --- /dev/null +++ b/Rakefile @@ -0,0 +1,16 @@ +require 'rake/testtask' +require 'rspec/core/rake_task' + +# Minitest unit tests (test/**/*_test.rb). +Rake::TestTask.new(:test) do |t| + t.libs << 'test' << 'lib' + t.test_files = FileList['test/**/*_test.rb'] + t.warning = false +end + +# RSpec unit tests (spec/**/*_spec.rb). +RSpec::Core::RakeTask.new(:spec) do |t| + t.pattern = 'spec/**/*_spec.rb' +end + +task default: %i[spec test] diff --git a/lib/rubyzen.rb b/lib/rubyzen.rb index 864893a..509dedc 100644 --- a/lib/rubyzen.rb +++ b/lib/rubyzen.rb @@ -1,98 +1,11 @@ -require 'rubocop-ast' -require 'rspec' -require 'zeitwerk' - -loader = Zeitwerk::Loader.for_gem -loader.ignore("#{__dir__}/rubyzen/matchers") -loader.ignore("#{__dir__}/rubyzen/rspec") -loader.ignore("#{__dir__}/rubyzen/lint.rb") -loader.ignore("#{__dir__}/rubyzen-lint.rb") -loader.setup - -require_relative 'rubyzen/matchers/matcher_helpers' -require_relative 'rubyzen/matchers/zen_empty_matcher' -require_relative 'rubyzen/matchers/zen_true_matcher' -require_relative 'rubyzen/matchers/zen_false_matcher' - -# Rubyzen is a Ruby architectural linter that lets you write lint rules as RSpec tests. -# It wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural -# rules across a codebase. +# Rubyzen entry point — loads the framework-agnostic core only. # -# @example Basic usage -# project = Rubyzen::Project.new(["/path/to/src", "/path/to/spec"]) -# controllers = project.files.with_paths("controllers/").classes +# `require 'rubyzen'` gives you the parsing/analysis API (Rubyzen::Project, +# declarations, collections) without any test framework attached. To write lint +# rules, require the adapter for your test framework instead: # -# # Assert controllers don't call ActiveRecord directly -# expect(controllers.all_methods.call_sites.with_name("where")).to zen_empty +# require 'rubyzen/rspec' # RSpec matchers: zen_empty / zen_true / zen_false +# require 'rubyzen/minitest' # Minitest assertions: assert_zen_empty / _true / _false # -# @example Using auto-discovery (from project root) -# project = Rubyzen::Project.new # scans app/, lib/, src/, spec/ automatically -# -module Rubyzen - # Base error class for all Rubyzen errors. - class Error < StandardError; end - - # Raised when a Ruby file cannot be parsed. - class ParseError < Error; end - - # Yields the global configuration for customization. - # - # @example - # Rubyzen.configure do |config| - # config.paths = ['app', 'lib'] - # end - def self.configure - yield(configuration) - end - - # Returns the global configuration instance. - # - # @return [Configuration] - def self.configuration - @configuration ||= Configuration.new - end - - # Holds project path configuration with auto-discovery support. - # - # Resolution order: - # 1. Explicit paths via {#paths=} (set via +Rubyzen.configure+) - # 2. Auto-discovery of +app/+, +lib/+, +src/+, +spec/+ from +Dir.pwd+ - # - # @example - # Rubyzen.configure { |c| c.paths = ['app/models', 'app/controllers'] } - # Rubyzen.configuration.project_paths #=> ["/full/path/app/models", "/full/path/app/controllers"] - # - class Configuration - # Sets explicit paths to scan. - # Relative paths are resolved against +Dir.pwd+. - # - # @param value [Array] directories to analyze - attr_writer :paths - - # Returns the resolved project paths. - # - # @return [Array] absolute paths to directories to analyze - def project_paths - resolve_paths(@paths) || auto_discover_paths - end - - private - - def resolve_paths(paths) - return nil unless paths&.any? - - root = Dir.pwd - paths.map do |path| - File.expand_path(path, root) - end - end - - def auto_discover_paths - root = Dir.pwd - candidates = %w[app lib src spec].map { |d| File.join(root, d) } - paths = candidates.select { |d| Dir.exist?(d) } - paths = [root] if paths.empty? - paths - end - end -end +# Each adapter loads this core automatically +require_relative 'rubyzen/core' diff --git a/lib/rubyzen/assertions/assert_zen_empty.rb b/lib/rubyzen/assertions/assert_zen_empty.rb new file mode 100644 index 0000000..1c9d90f --- /dev/null +++ b/lib/rubyzen/assertions/assert_zen_empty.rb @@ -0,0 +1,51 @@ +module Rubyzen + module Assertions + # Asserts that a Rubyzen collection is empty (the Minitest counterpart of + # the +zen_empty+ matcher). + # + # Used in architectural lint rules to verify that no items match a forbidden + # 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 allowlist [Array, nil] items to permanently ignore + # @param baseline [Array, nil] known violations for gradual adoption + # @return [true] when the assertion passes + # @raise [Minitest::Assertion] when there are live violations or stale entries + # + # @example Ensure no controllers use .where + # assert_zen_empty(controllers.all_methods.call_sites.with_name('where')) + # + # @example With a baseline for gradual adoption + # assert_zen_empty(violations, baseline: ['LegacyController']) + def assert_zen_empty(collection, message: nil, allowlist: nil, baseline: nil) + @failure_message = nil + @custom_message = message + @classified_items = classify_items(collection, allowlist: allowlist, baseline: baseline) + + violations = @classified_items[:violations] + stale_baseline = @classified_items[:stale_baseline] + stale_allowlist = @classified_items[:stale_allowlist] + + stale_groups = [] + stale_groups << 'baseline entries' if stale_baseline.any? + stale_groups << 'allowlist entries' if stale_allowlist.any? + + reason = + if violations.any? && stale_groups.any? + "Expected to be empty, but found live violations and stale #{stale_groups.join(' and ')}." + elsif violations.any? + if allowlist || baseline + 'Expected to be empty, but found live violations.' + else + 'Expected to be empty, but had elements.' + end + elsif stale_groups.any? + "Expected to be empty, but found stale #{stale_groups.join(' and ')}." + end + + passed = violations.empty? && stale_baseline.empty? && stale_allowlist.empty? + assert(passed, message_for_failure(reason || 'Expected to be empty, but had elements.')) + end + end +end diff --git a/lib/rubyzen/assertions/assert_zen_false.rb b/lib/rubyzen/assertions/assert_zen_false.rb new file mode 100644 index 0000000..f98d999 --- /dev/null +++ b/lib/rubyzen/assertions/assert_zen_false.rb @@ -0,0 +1,49 @@ +module Rubyzen + module Assertions + # Asserts that a block returns false for every item in a collection (the + # Minitest counterpart of the +zen_false+ matcher). + # + # @param collection [Enumerable] a Rubyzen collection of declarations + # @param message [String, nil] optional custom 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 + # @return [true] when the assertion passes + # @raise [ArgumentError] when no block is given + # @raise [Minitest::Assertion] when the block returns truthy for a live item + # + # @example Ensure no methods have more than 5 parameters + # assert_zen_false(methods) { |m| m.parameters.size > 5 } + # + # @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) + raise ArgumentError, 'assert_zen_false requires a block' unless block + + @failure_message = nil + @custom_message = message + failing_items = Array(collection).filter { |item| block.call(item) } + @classified_items = classify_items(failing_items, allowlist: allowlist, baseline: baseline) + + violations = @classified_items[:violations] + stale_baseline = @classified_items[:stale_baseline] + stale_allowlist = @classified_items[:stale_allowlist] + + stale_groups = [] + stale_groups << 'baseline entries' if stale_baseline.any? + stale_groups << 'allowlist entries' if stale_allowlist.any? + + reason = + if violations.any? && stale_groups.any? + "Expected to return false for all elements, but found live violations and stale #{stale_groups.join(' and ')}." + elsif violations.any? + 'Expected to return false for all elements.' + elsif stale_groups.any? + "Expected to return false for all elements, but found stale #{stale_groups.join(' and ')}." + end + + passed = violations.empty? && stale_baseline.empty? && stale_allowlist.empty? + assert(passed, message_for_failure(reason || 'Expected to return false for all elements.')) + end + end +end diff --git a/lib/rubyzen/assertions/assert_zen_true.rb b/lib/rubyzen/assertions/assert_zen_true.rb new file mode 100644 index 0000000..3a60aae --- /dev/null +++ b/lib/rubyzen/assertions/assert_zen_true.rb @@ -0,0 +1,49 @@ +module Rubyzen + module Assertions + # Asserts that a block returns true for every item in a collection (the + # Minitest counterpart of the +zen_true+ matcher). + # + # @param collection [Enumerable] a Rubyzen collection of declarations + # @param message [String, nil] optional custom 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 + # @return [true] when the assertion passes + # @raise [ArgumentError] when no block is given + # @raise [Minitest::Assertion] when the block returns falsey for a live item + # + # @example Ensure all methods have parameters + # assert_zen_true(methods) { |m| m.parameters? } + # + # @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) + raise ArgumentError, 'assert_zen_true requires a block' unless block + + @failure_message = nil + @custom_message = message + failing_items = Array(collection).filter { |item| !block.call(item) } + @classified_items = classify_items(failing_items, allowlist: allowlist, baseline: baseline) + + violations = @classified_items[:violations] + stale_baseline = @classified_items[:stale_baseline] + stale_allowlist = @classified_items[:stale_allowlist] + + stale_groups = [] + stale_groups << 'baseline entries' if stale_baseline.any? + stale_groups << 'allowlist entries' if stale_allowlist.any? + + reason = + if violations.any? && stale_groups.any? + "Expected to return true for all elements, but found live violations and stale #{stale_groups.join(' and ')}." + elsif violations.any? + 'Expected to return true for all elements.' + elsif stale_groups.any? + "Expected to return true for all elements, but found stale #{stale_groups.join(' and ')}." + end + + passed = violations.empty? && stale_baseline.empty? && stale_allowlist.empty? + assert(passed, message_for_failure(reason || 'Expected to return true for all elements.')) + end + end +end diff --git a/lib/rubyzen/assertions/zen_assertions.rb b/lib/rubyzen/assertions/zen_assertions.rb new file mode 100644 index 0000000..8a9f38c --- /dev/null +++ b/lib/rubyzen/assertions/zen_assertions.rb @@ -0,0 +1,29 @@ +require_relative '../expectation_helpers' + +module Rubyzen + # Minitest equivalents of the +zen_empty+ / +zen_true+ / +zen_false+ RSpec + # matchers. Mixed into +Minitest::Assertions+ by +require 'rubyzen/minitest'+, + # so the methods are available in every +Minitest::Test+ (and spec-style block). + # + # All three delegate to the shared {Rubyzen::ExpectationHelpers} for + # violation/allowlist/baseline classification and failure-message formatting. + # The behavior is identical to the RSpec matchers ({Rubyzen::Matchers}). + # + # @example + # class ArchitectureTest < Minitest::Test + # def test_controllers_have_no_if_statements + # assert_zen_empty(controllers.all_methods.if_statements) + # end + # + # def test_repos_live_in_module + # assert_zen_true(repos) { |repo| repo.top_level_module == 'Repos' } + # end + # end + module Assertions + include Rubyzen::ExpectationHelpers + end +end + +require_relative 'assert_zen_empty' +require_relative 'assert_zen_true' +require_relative 'assert_zen_false' diff --git a/lib/rubyzen/core.rb b/lib/rubyzen/core.rb new file mode 100644 index 0000000..21ecc9e --- /dev/null +++ b/lib/rubyzen/core.rb @@ -0,0 +1,112 @@ +require 'rubocop-ast' +require 'zeitwerk' + +# Framework-agnostic core of Rubyzen. +# +# Owns the Zeitwerk autoloading of the parsing API (declarations, collections, +# providers, parsers) and defines the +Rubyzen+ module and its +Configuration+. +# +# This file deliberately does NOT require any test framework (RSpec or Minitest) +# so that adapters can load only what they need: +# * +require 'rubyzen'+ → core only (no test framework) +# * +require 'rubyzen/rspec'+ → core + RSpec matchers +# * +require 'rubyzen/minitest'+ → core + Minitest assertions +lib_dir = File.expand_path('..', __dir__) + +loader = Zeitwerk::Loader.new +loader.tag = 'rubyzen' +loader.push_dir(lib_dir) +# Entry points and adapter directories are loaded explicitly, not autoloaded. +loader.ignore("#{lib_dir}/rubyzen.rb") +loader.ignore("#{lib_dir}/rubyzen-lint.rb") +loader.ignore("#{__dir__}/core.rb") +loader.ignore("#{__dir__}/minitest.rb") +loader.ignore("#{__dir__}/lint.rb") +loader.ignore("#{__dir__}/matchers") +loader.ignore("#{__dir__}/assertions") +loader.ignore("#{__dir__}/expectation_helpers.rb") +loader.ignore("#{__dir__}/rspec.rb") +loader.setup + +# Rubyzen is a Ruby architectural linter that lets you write lint rules as unit tests. +# It wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural +# rules across a codebase. +# +# @example Basic usage +# project = Rubyzen::Project.new(["/path/to/src", "/path/to/spec"]) +# controllers = project.files.with_paths("controllers/").classes +# +# # Assert controllers don't call ActiveRecord directly +# expect(controllers.all_methods.call_sites.with_name("where")).to zen_empty +# +# @example Using auto-discovery (from project root) +# project = Rubyzen::Project.new # scans app/, lib/, src/, spec/ automatically +# +module Rubyzen + # Base error class for all Rubyzen errors. + class Error < StandardError; end + + # Raised when a Ruby file cannot be parsed. + class ParseError < Error; end + + # Yields the global configuration for customization. + # + # @example + # Rubyzen.configure do |config| + # config.paths = ['app', 'lib'] + # end + def self.configure + yield(configuration) + end + + # Returns the global configuration instance. + # + # @return [Configuration] + def self.configuration + @configuration ||= Configuration.new + end + + # Holds project path configuration with auto-discovery support. + # + # Resolution order: + # 1. Explicit paths via {#paths=} (set via +Rubyzen.configure+) + # 2. Auto-discovery of +app/+, +lib/+, +src/+, +spec/+ from +Dir.pwd+ + # + # @example + # Rubyzen.configure { |c| c.paths = ['app/models', 'app/controllers'] } + # Rubyzen.configuration.project_paths #=> ["/full/path/app/models", "/full/path/app/controllers"] + # + class Configuration + # Sets explicit paths to scan. + # Relative paths are resolved against +Dir.pwd+. + # + # @param value [Array] directories to analyze + attr_writer :paths + + # Returns the resolved project paths. + # + # @return [Array] absolute paths to directories to analyze + def project_paths + resolve_paths(@paths) || auto_discover_paths + end + + private + + def resolve_paths(paths) + return nil unless paths&.any? + + root = Dir.pwd + paths.map do |path| + File.expand_path(path, root) + end + end + + def auto_discover_paths + root = Dir.pwd + candidates = %w[app lib src spec].map { |d| File.join(root, d) } + paths = candidates.select { |d| Dir.exist?(d) } + paths = [root] if paths.empty? + paths + end + end +end diff --git a/lib/rubyzen/expectation_helpers.rb b/lib/rubyzen/expectation_helpers.rb new file mode 100644 index 0000000..9db3439 --- /dev/null +++ b/lib/rubyzen/expectation_helpers.rb @@ -0,0 +1,184 @@ +module Rubyzen + # Shared helper methods for Rubyzen's +zen_*+ / +assert_zen_*+ expectations — + # used by both the RSpec matchers ({Rubyzen::Matchers}) and the Minitest + # assertions ({Rubyzen::Assertions}). + # + # Provides utilities for normalizing exception lists, extracting item details, + # matching items against allowlist/baseline entries, and formatting failure + # messages. These methods are framework-agnostic — they operate only on plain + # declaration objects and instance variables set by the caller. + module ExpectationHelpers + # Normalizes a list of exception entries into unique, non-blank strings. + # + # @param entries [Array, String, nil] raw exception entries + # @return [Array] deduplicated, stripped, non-empty strings + def normalize_exception_entries(entries) + Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq + end + + # Extracts identifying details from a declaration item. + # + # @param item [Object] a declaration object (e.g., FileDeclaration, ClassDeclaration) + # @return [Hash{Symbol => String, nil}] hash with :name, :class_name, :file_path, :line + def item_details(item) + { + name: item.respond_to?(:name) ? item.name : nil, + class_name: item.respond_to?(:class_name) ? item.class_name : nil, + file_path: item.respond_to?(:file_path) ? item.file_path : 'Unknown file', + line: item.respond_to?(:line) ? item.line : nil + } + end + + # Returns a list of unique identifier strings for an item, used for matching. + # + # @param item [Object] a declaration object + # @return [Array] identifiers such as name, class name, file path, and file:line + def item_identifiers(item) + details = item_details(item) + identifiers = [details[:name], details[:class_name], details[:file_path]] + + if details[:line] + identifiers << "#{details[:file_path]}:#{details[:line]}" + end + + identifiers.compact.uniq + end + + # Checks whether a given exception entry string matches an item. + # + # @param entry [String] an allowlist or baseline entry + # @param item [Object] a declaration object + # @return [Boolean] true if the entry matches the item by name, class, or path + def exception_entry_matches_item?(entry, item) + normalized_entry = entry.to_s.strip + return false if normalized_entry.empty? + + details = item_details(item) + return true if item_identifiers(item).include?(normalized_entry) + + file_path = details[:file_path] + file_path && (file_path.end_with?(normalized_entry) || file_path.end_with?("/#{normalized_entry}")) + end + + # Classifies items into violations, baseline matches, allowlist matches, + # and detects stale entries in either list. + # + # @param subject_collection [Array, Object] items to classify + # @param allowlist [Array, nil] allowed exception entries + # @param baseline [Array, nil] baseline exception entries + # @return [Hash{Symbol => Array}] keys: :violations, :baseline, :allowlist, + # :stale_baseline, :stale_allowlist + def classify_items(subject_collection, allowlist: nil, baseline: nil) + items = Array(subject_collection).compact + normalized_allowlist = normalize_exception_entries(allowlist) + normalized_baseline = normalize_exception_entries(baseline) + matched_baseline_entries = [] + matched_allowlist_entries = [] + + grouped_items = items.group_by do |item| + matching_baseline_entry = normalized_baseline.find do |entry| + exception_entry_matches_item?(entry, item) + end + + if matching_baseline_entry + matched_baseline_entries << matching_baseline_entry + :baseline + else + matching_allowlist_entry = normalized_allowlist.find do |entry| + exception_entry_matches_item?(entry, item) + end + + if matching_allowlist_entry + matched_allowlist_entries << matching_allowlist_entry + :allowlist + else + :violations + end + end + end + + classifications = { + baseline: Array(grouped_items[:baseline]).map { |item| element_name(item) }, + allowlist: Array(grouped_items[:allowlist]).map { |item| element_name(item) }, + violations: Array(grouped_items[:violations]).map { |item| element_name(item) } + } + + classifications.merge( + stale_baseline: normalized_baseline - matched_baseline_entries.uniq, + stale_allowlist: normalized_allowlist - matched_allowlist_entries.uniq + ) + end + + # Formats a human-readable description of an item for failure messages. + # + # @param item [Object] a declaration object + # @return [String] formatted multi-line description + def element_name(item) + details = item_details(item) + location = [details[:file_path], details[:line]].compact.join(':') + + case + when details[:name] && details[:class_name] + " - element: #{details[:name]}\n - class: #{details[:class_name]}\n - file: #{location}" + when details[:name] + " - element: #{details[:name]}\n - file: #{location}" + when details[:class_name] + " - class: #{details[:class_name]}\n - file: #{location}" + else + " - unknown element in #{location}" + end + end + + # Builds a formatted string of violations and stale entries for failure output. + # + # @return [String, nil] formatted sections or nil if no classified items + def formatted_matcher_groups + return unless defined?(@classified_items) && @classified_items + + sections = [] + + if @classified_items[:violations].any? + sections << "Violations:\n#{@classified_items[:violations].join("\n")}" + end + + if @classified_items[:stale_baseline].any? + stale_entries = @classified_items[:stale_baseline].map { |entry| " - #{entry}" } + sections << "Stale baseline entries:\n#{stale_entries.join("\n")}" + end + + if @classified_items[:stale_allowlist].any? + stale_entries = @classified_items[:stale_allowlist].map { |entry| " - #{entry}" } + sections << "Stale allowlist entries:\n#{stale_entries.join("\n")}" + end + + sections.join("\n") + end + + # Formats the failure message by combining the base message with + # custom messages and classified item details (violations, stale entries). + # + # 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+). + # + # @param base_message [String] the default failure message + # @return [String] formatted failure message + def message_for_failure(base_message) + return @failure_message if @failure_message + + details = formatted_matcher_groups + + if @custom_message + if details && !details.empty? + "#{@custom_message}\n#{details}" + else + @custom_message + end + elsif details && !details.empty? + "#{base_message}\n#{details}" + else + base_message + end + end + end +end diff --git a/lib/rubyzen/matchers/matcher_helpers.rb b/lib/rubyzen/matchers/matcher_helpers.rb deleted file mode 100644 index 391699d..0000000 --- a/lib/rubyzen/matchers/matcher_helpers.rb +++ /dev/null @@ -1,183 +0,0 @@ -module Rubyzen - # Custom RSpec matchers for asserting on Rubyzen collections. - module Matchers - # Shared helper methods used by Rubyzen's custom RSpec matchers. - # - # Provides utilities for normalizing exception lists, extracting item - # details, matching items against allowlist/baseline entries, and - # formatting failure messages. - module MatcherHelpers - # Normalizes a list of exception entries into unique, non-blank strings. - # - # @param entries [Array, String, nil] raw exception entries - # @return [Array] deduplicated, stripped, non-empty strings - def normalize_exception_entries(entries) - Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq - end - - # Extracts identifying details from a declaration item. - # - # @param item [Object] a declaration object (e.g., FileDeclaration, ClassDeclaration) - # @return [Hash{Symbol => String, nil}] hash with :name, :class_name, :file_path, :line - def item_details(item) - { - name: item.respond_to?(:name) ? item.name : nil, - class_name: item.respond_to?(:class_name) ? item.class_name : nil, - file_path: item.respond_to?(:file_path) ? item.file_path : 'Unknown file', - line: item.respond_to?(:line) ? item.line : nil - } - end - - # Returns a list of unique identifier strings for an item, used for matching. - # - # @param item [Object] a declaration object - # @return [Array] identifiers such as name, class name, file path, and file:line - def item_identifiers(item) - details = item_details(item) - identifiers = [details[:name], details[:class_name], details[:file_path]] - - if details[:line] - identifiers << "#{details[:file_path]}:#{details[:line]}" - end - - identifiers.compact.uniq - end - - # Checks whether a given exception entry string matches an item. - # - # @param entry [String] an allowlist or baseline entry - # @param item [Object] a declaration object - # @return [Boolean] true if the entry matches the item by name, class, or path - def exception_entry_matches_item?(entry, item) - normalized_entry = entry.to_s.strip - return false if normalized_entry.empty? - - details = item_details(item) - return true if item_identifiers(item).include?(normalized_entry) - - file_path = details[:file_path] - file_path && (file_path.end_with?(normalized_entry) || file_path.end_with?("/#{normalized_entry}")) - end - - # Classifies items into violations, baseline matches, allowlist matches, - # and detects stale entries in either list. - # - # @param subject_collection [Array, Object] items to classify - # @param allowlist [Array, nil] allowed exception entries - # @param baseline [Array, nil] baseline exception entries - # @return [Hash{Symbol => Array}] keys: :violations, :baseline, :allowlist, - # :stale_baseline, :stale_allowlist - def classify_items(subject_collection, allowlist: nil, baseline: nil) - items = Array(subject_collection).compact - normalized_allowlist = normalize_exception_entries(allowlist) - normalized_baseline = normalize_exception_entries(baseline) - matched_baseline_entries = [] - matched_allowlist_entries = [] - - grouped_items = items.group_by do |item| - matching_baseline_entry = normalized_baseline.find do |entry| - exception_entry_matches_item?(entry, item) - end - - if matching_baseline_entry - matched_baseline_entries << matching_baseline_entry - :baseline - else - matching_allowlist_entry = normalized_allowlist.find do |entry| - exception_entry_matches_item?(entry, item) - end - - if matching_allowlist_entry - matched_allowlist_entries << matching_allowlist_entry - :allowlist - else - :violations - end - end - end - - classifications = { - baseline: Array(grouped_items[:baseline]).map { |item| element_name(item) }, - allowlist: Array(grouped_items[:allowlist]).map { |item| element_name(item) }, - violations: Array(grouped_items[:violations]).map { |item| element_name(item) } - } - - classifications.merge( - stale_baseline: normalized_baseline - matched_baseline_entries.uniq, - stale_allowlist: normalized_allowlist - matched_allowlist_entries.uniq - ) - end - - # Formats a human-readable description of an item for failure messages. - # - # @param item [Object] a declaration object - # @return [String] formatted multi-line description - def element_name(item) - details = item_details(item) - location = [details[:file_path], details[:line]].compact.join(':') - - case - when details[:name] && details[:class_name] - " - element: #{details[:name]}\n - class: #{details[:class_name]}\n - file: #{location}" - when details[:name] - " - element: #{details[:name]}\n - file: #{location}" - when details[:class_name] - " - class: #{details[:class_name]}\n - file: #{location}" - else - " - unknown element in #{location}" - end - end - - # Builds a formatted string of violations and stale entries for failure output. - # - # @return [String, nil] formatted sections or nil if no classified items - def formatted_matcher_groups - return unless defined?(@classified_items) && @classified_items - - sections = [] - - if @classified_items[:violations].any? - sections << "Violations:\n#{@classified_items[:violations].join("\n")}" - end - - if @classified_items[:stale_baseline].any? - stale_entries = @classified_items[:stale_baseline].map { |entry| " - #{entry}" } - sections << "Stale baseline entries:\n#{stale_entries.join("\n")}" - end - - if @classified_items[:stale_allowlist].any? - stale_entries = @classified_items[:stale_allowlist].map { |entry| " - #{entry}" } - sections << "Stale allowlist entries:\n#{stale_entries.join("\n")}" - end - - sections.join("\n") - end - - # @!method message_for_failure(base_message) - # Formats the failure message by combining the base message with - # custom messages and classified item details (violations, stale entries). - # - # @param base_message [String] the default failure message - # @return [String] formatted failure message - def self.included(base) - base.define_method(:message_for_failure) do |base_message| - return @failure_message if @failure_message - - details = formatted_matcher_groups - - if @custom_message - if details && !details.empty? - "#{@custom_message}\n#{details}" - else - @custom_message - end - elsif details && !details.empty? - "#{base_message}\n#{details}" - else - base_message - end - end - end - end - end -end diff --git a/lib/rubyzen/matchers/zen_empty_matcher.rb b/lib/rubyzen/matchers/zen_empty_matcher.rb index f28f0cb..a07740a 100644 --- a/lib/rubyzen/matchers/zen_empty_matcher.rb +++ b/lib/rubyzen/matchers/zen_empty_matcher.rb @@ -1,5 +1,6 @@ # @!parse # module Rubyzen +# # Custom RSpec matchers for asserting on Rubyzen collections. # module Matchers # # Asserts that a Rubyzen collection is empty. # # @@ -19,7 +20,7 @@ # end # end RSpec::Matchers.define :zen_empty do |custom_message=nil, allowlist: nil, baseline: nil| - include Rubyzen::Matchers::MatcherHelpers + include Rubyzen::ExpectationHelpers match do |subject_collection| options = custom_message.is_a?(Hash) ? custom_message : {} diff --git a/lib/rubyzen/matchers/zen_false_matcher.rb b/lib/rubyzen/matchers/zen_false_matcher.rb index 27e8677..a362e94 100644 --- a/lib/rubyzen/matchers/zen_false_matcher.rb +++ b/lib/rubyzen/matchers/zen_false_matcher.rb @@ -20,7 +20,7 @@ # end # end RSpec::Matchers.define :zen_false do |custom_message=nil, allowlist: nil, baseline: nil| - include Rubyzen::Matchers::MatcherHelpers + include Rubyzen::ExpectationHelpers match do |subject_collection| options = custom_message.is_a?(Hash) ? custom_message : {} diff --git a/lib/rubyzen/matchers/zen_true_matcher.rb b/lib/rubyzen/matchers/zen_true_matcher.rb index e6b819a..5a771f1 100644 --- a/lib/rubyzen/matchers/zen_true_matcher.rb +++ b/lib/rubyzen/matchers/zen_true_matcher.rb @@ -17,7 +17,7 @@ # end # end RSpec::Matchers.define :zen_true do |custom_message=nil, allowlist: nil, baseline: nil| - include Rubyzen::Matchers::MatcherHelpers + include Rubyzen::ExpectationHelpers match do |subject_collection| options = custom_message.is_a?(Hash) ? custom_message : {} diff --git a/lib/rubyzen/minitest.rb b/lib/rubyzen/minitest.rb new file mode 100644 index 0000000..d119808 --- /dev/null +++ b/lib/rubyzen/minitest.rb @@ -0,0 +1,26 @@ +# Minitest entry point for Rubyzen. +# +# Loads the framework-agnostic core plus the Minitest assertions. +# Require it from your test/test_helper.rb: +# +# # Gemfile +# group :test do +# gem 'rubyzen-lint' +# gem 'minitest' +# end +# +# # test/test_helper.rb +# require 'rubyzen/minitest' +# +# The assertions (+assert_zen_empty+, +assert_zen_true+, +assert_zen_false+) are +# mixed into +Minitest::Assertions+, so they are available in every Minitest test +# class and spec-style block automatically. +require_relative 'core' +require 'minitest' +require_relative 'assertions/zen_assertions' + +# Call +include+ via +send+ so YARD's static parser doesn't try to document a +# mixin into the external Minitest::Assertions namespace (the constant only +# exists at runtime, after +require 'minitest'+, so YARD would warn). +include+ +# is public on Module, so this is behaviourally identical to a plain call. +Minitest::Assertions.send(:include, Rubyzen::Assertions) diff --git a/lib/rubyzen/providers/blocks_provider.rb b/lib/rubyzen/providers/blocks_provider.rb index 36e4b36..3440f0b 100644 --- a/lib/rubyzen/providers/blocks_provider.rb +++ b/lib/rubyzen/providers/blocks_provider.rb @@ -1,7 +1,7 @@ module Rubyzen # Mixins that add capabilities (call sites, blocks, attributes, etc.) to declarations. module Providers - # Provides access to block expressions (do..end / {..}) within a declaration. + # Provides access to block expressions (do..end and brace blocks) within a declaration. module BlocksProvider # @return [Rubyzen::Collections::BlocksCollection] collection of block declarations def blocks diff --git a/lib/rubyzen/rspec.rb b/lib/rubyzen/rspec.rb new file mode 100644 index 0000000..89bde9e --- /dev/null +++ b/lib/rubyzen/rspec.rb @@ -0,0 +1,29 @@ +# RSpec entry point for Rubyzen. +# +# Loads the framework-agnostic core plus the RSpec matchers. +# Require it from your spec/spec_helper.rb: +# +# # Gemfile +# group :test do +# gem 'rubyzen-lint' +# gem 'rspec' # or rspec-rails +# end +# +# # spec/spec_helper.rb +# require 'rubyzen/rspec' +# +# Every RSpec project should already have the `rspec` gem. +# If it is missing we raise an error. +require_relative 'core' + +begin + require 'rspec' +rescue LoadError + raise LoadError, "Rubyzen's RSpec matchers require the 'rspec' gem. " \ + "Add `gem 'rspec'` to your Gemfile, or use the Minitest matchers via `require 'rubyzen/minitest'`." +end + +require_relative 'expectation_helpers' +require_relative 'matchers/zen_empty_matcher' +require_relative 'matchers/zen_true_matcher' +require_relative 'matchers/zen_false_matcher' diff --git a/lib/rubyzen/version.rb b/lib/rubyzen/version.rb index 52b8900..750d523 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.1.0' + VERSION = '0.2.0' end diff --git a/rubyzen-lint.gemspec b/rubyzen-lint.gemspec index 6fbf77c..44ec1b9 100644 --- a/rubyzen-lint.gemspec +++ b/rubyzen-lint.gemspec @@ -4,7 +4,7 @@ Gem::Specification.new do |spec| spec.name = 'rubyzen-lint' spec.version = Rubyzen::VERSION spec.authors = ['Perry Street Software'] - spec.summary = 'Architectural linter for Ruby — write lint rules as RSpec tests' + spec.summary = 'Architectural linter for Ruby — write lint rules as unit tests' spec.description = 'Rubyzen is a modern linter for Ruby that allows you to write architectural ' \ 'lint rules as unit tests. In the era of AI-generated code, it provides your ' \ 'team with deterministic guardrails to keep your codebase clean, maintainable, ' \ @@ -14,16 +14,20 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 3.1' - spec.files = Dir.glob(%w[lib/**/*.rb rubyzen-lint.gemspec LICENSE README.md]) + spec.files = Dir.glob(%w[lib/**/*.rb rubyzen-lint.gemspec LICENSE README.md CHANGELOG.md]) spec.require_paths = ['lib'] spec.add_dependency 'rubocop-ast', '~> 1.26' spec.add_dependency 'zeitwerk', '~> 2.6' - spec.add_dependency 'rspec', '~> 3.12' + + spec.add_development_dependency 'rspec', '~> 3.12' + spec.add_development_dependency 'minitest', '>= 5.0', '< 7.0' + spec.add_development_dependency 'rake', '~> 13.0' spec.metadata = { 'source_code_uri' => 'https://github.com/perrystreetsoftware/Rubyzen', 'bug_tracker_uri' => 'https://github.com/perrystreetsoftware/Rubyzen/issues', - 'documentation_uri' => 'https://perrystreetsoftware.github.io/Rubyzen' + 'documentation_uri' => 'https://perrystreetsoftware.github.io/Rubyzen', + 'changelog_uri' => 'https://github.com/perrystreetsoftware/Rubyzen/blob/main/CHANGELOG.md' } end diff --git a/sample_project/spec/attributes/no_public_attr_writer_in_models_lint_spec.rb b/sample_project/spec/attributes/no_public_attr_writer_in_models_lint_spec.rb index 36bf7f3..c1c375b 100644 --- a/sample_project/spec/attributes/no_public_attr_writer_in_models_lint_spec.rb +++ b/sample_project/spec/attributes/no_public_attr_writer_in_models_lint_spec.rb @@ -1,5 +1,3 @@ -require 'rspec' -require 'rubyzen' require_relative '../spec_helper' RSpec.describe 'No public attr_writer in domain models' do diff --git a/sample_project/spec/constants/no_top_level_constants_lint_spec.rb b/sample_project/spec/constants/no_top_level_constants_lint_spec.rb index 71ee206..d84401a 100644 --- a/sample_project/spec/constants/no_top_level_constants_lint_spec.rb +++ b/sample_project/spec/constants/no_top_level_constants_lint_spec.rb @@ -1,5 +1,3 @@ -require 'rspec' -require 'rubyzen' require_relative '../spec_helper' RSpec.describe 'No top-level constants' do diff --git a/sample_project/spec/models/no_requires_in_models_lint_spec.rb b/sample_project/spec/models/no_requires_in_models_lint_spec.rb index a348e6a..bd0a206 100644 --- a/sample_project/spec/models/no_requires_in_models_lint_spec.rb +++ b/sample_project/spec/models/no_requires_in_models_lint_spec.rb @@ -1,5 +1,3 @@ -require 'rspec' -require 'rubyzen' require_relative '../spec_helper' RSpec.describe 'No requires in model files' do diff --git a/sample_project/spec/modules/module_file_path_consistency_lint_spec.rb b/sample_project/spec/modules/module_file_path_consistency_lint_spec.rb index 446e58d..d7e70b5 100644 --- a/sample_project/spec/modules/module_file_path_consistency_lint_spec.rb +++ b/sample_project/spec/modules/module_file_path_consistency_lint_spec.rb @@ -1,5 +1,3 @@ -require 'rspec' -require 'rubyzen' require_relative '../spec_helper' RSpec.describe 'Module file path consistency' do diff --git a/sample_project/spec/spec_helper.rb b/sample_project/spec/spec_helper.rb index 5c27e64..2e1c0c0 100644 --- a/sample_project/spec/spec_helper.rb +++ b/sample_project/spec/spec_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'rubyzen' +require 'rubyzen/rspec' sample_src = File.expand_path('../src', __dir__) 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 new file mode 100644 index 0000000..1bfb3b1 --- /dev/null +++ b/sample_project/test/controllers/no_if_statements_in_controllers_test.rb @@ -0,0 +1,24 @@ +require_relative '../test_helper' + +# Minitest equivalent of spec/controllers/no_if_statements_in_controllers_lint_spec.rb. +class NoIfStatementsInControllersTest < LintTestCase + def baseline + [] + end + + def target_classes + (controllers + presenters).without_name(*baseline) + end + + def test_has_no_if_statements_in_methods + assert_zen_empty(target_classes.all_methods.if_statements) + 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? } + 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? } + end +end diff --git a/sample_project/test/database/do_not_call_active_record_models_outside_repos_test.rb b/sample_project/test/database/do_not_call_active_record_models_outside_repos_test.rb new file mode 100644 index 0000000..5cadea2 --- /dev/null +++ b/sample_project/test/database/do_not_call_active_record_models_outside_repos_test.rb @@ -0,0 +1,39 @@ +require_relative '../test_helper' + +# Minitest equivalent of spec/database/do_not_call_active_record_models_outside_repos_lint_spec.rb. +class DoNotCallActiveRecordModelsOutsideReposTest < LintTestCase + ACTIVE_RECORD_METHODS = %i[ + all average calculate count create create! delete_all destroy_all distinct + eager_load find find_by find_by_sql find_each find_in_batches find_or_create_by + find_or_create_by! find_or_initialize_by first group having ids includes joins last + left_outer_joins lock maximum minimum order pick pluck preload reorder select sum + update update_all where first_or_create first_or_create! first_or_initialize + ].freeze + + def baseline + [] + end + + def active_record_models + models.with_parent_prefix('ActiveRecord::BaseAurora').without_name(baseline) + end + + def non_repo_classes + project.files.without_paths('/repos/', '/models/').classes + end + + def test_does_not_call_active_record_methods_on_active_record_models + offending_calls = non_repo_classes.all_methods.call_sites.filter do |cs| + if ACTIVE_RECORD_METHODS.include?(cs.method_name.to_sym) + active_record_models.map(&:name).include?(cs.receiver) + else + false + end + end + + assert_zen_empty( + offending_calls, + message: 'Do not call ActiveRecord methods on ActiveRecord models outside of repos.' + ) + end +end diff --git a/sample_project/test/models/no_arguments_named_biz_test.rb b/sample_project/test/models/no_arguments_named_biz_test.rb new file mode 100644 index 0000000..d1d3abd --- /dev/null +++ b/sample_project/test/models/no_arguments_named_biz_test.rb @@ -0,0 +1,8 @@ +require_relative '../test_helper' + +# Minitest equivalent of spec/models/no_arguments_named_biz_spec.rb. +class NoArgumentsNamedBizTest < LintTestCase + def test_has_no_method_arguments_named_biz + assert_zen_false(models.all_methods.parameters) { |param| param.name == :biz } + end +end diff --git a/sample_project/test/repos/repos_test.rb b/sample_project/test/repos/repos_test.rb new file mode 100644 index 0000000..ce6125a --- /dev/null +++ b/sample_project/test/repos/repos_test.rb @@ -0,0 +1,8 @@ +require_relative '../test_helper' + +# Minitest equivalent of spec/repos/repos_lint_spec.rb. +class ReposTest < LintTestCase + def test_repositories_are_within_a_repos_module + assert_zen_true(repos) { |repo| repo.top_level_module == 'Repos' } + end +end diff --git a/sample_project/test/test_helper.rb b/sample_project/test/test_helper.rb new file mode 100644 index 0000000..d789c23 --- /dev/null +++ b/sample_project/test/test_helper.rb @@ -0,0 +1,56 @@ +# Minitest equivalent of sample_project/spec/spec_helper.rb. +# +# Loads Rubyzen's Minitest adapter and provides a `LintTestCase` base class +# whose memoized accessors mirror the shared `let` collections from the RSpec +# `project_config` shared context. +require 'rubyzen/minitest' +require 'minitest/autorun' + +SAMPLE_SRC = File.expand_path('../src', __dir__) + +# Base class for the sample-project lint rules written with Minitest. +class LintTestCase < Minitest::Test + def project + @project ||= Rubyzen::Project.new([SAMPLE_SRC]) + end + + def files + @files ||= project.files.with_paths('src/') + end + + def all_classes + @all_classes ||= files.classes + end + + def all_modules + @all_modules ||= files.modules + end + + def controllers + @controllers ||= project.files.with_paths('src/controllers/').classes + end + + def presenters + @presenters ||= project.files.with_paths('src/presenters/').classes + end + + def services + @services ||= project.files.with_paths('src/services/').classes + end + + def requests + @requests ||= project.files.with_paths('src/requests/').classes + end + + def models_files + @models_files ||= project.files.with_paths('src/models/') + end + + def models + @models ||= models_files.classes + end + + def repos + @repos ||= project.files.with_paths('src/repos/').without_paths('/spec/').classes + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0d18dbf..9e05d62 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,4 +1,4 @@ -require 'rubyzen' +require 'rubyzen/rspec' require_relative 'support/parse_helper' RSpec.configure do |config| diff --git a/test/assertions/assert_zen_empty_test.rb b/test/assertions/assert_zen_empty_test.rb new file mode 100644 index 0000000..0aa5876 --- /dev/null +++ b/test/assertions/assert_zen_empty_test.rb @@ -0,0 +1,69 @@ +require_relative '../test_helper' + +# Minitest equivalent of spec/matchers/zen_empty_matcher_spec.rb. +class AssertZenEmptyTest < Minitest::Test + include ParseHelper + + def empty_collection + Rubyzen::Collections::ClassesCollection.new + end + + def single_class_collection + parse_ruby('class Foo; end').classes + end + + def two_controllers + file = parse_ruby(<<~RUBY, file_path: '/app/controllers/foo_controller.rb') + class FooController; end + class BarController; end + RUBY + file.classes + end + + def test_zen_empty_passes_when_collection_is_empty + assert_zen_empty(empty_collection) + end + + def test_zen_empty_fails_when_collection_is_not_empty + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(single_class_collection) + end + assert_match(/Expected to be empty/, error.message) + end + + def test_zen_empty_supports_custom_message + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(single_class_collection, message: 'Controllers should not have violations') + end + assert_match(/Controllers should not have violations/, error.message) + end + + def test_zen_empty_passes_when_all_items_allowlisted + assert_zen_empty(two_controllers, allowlist: %w[FooController BarController]) + end + + def test_zen_empty_fails_with_non_allowlisted_items + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(two_controllers, allowlist: %w[FooController]) + end + assert_match(/violations/i, error.message) + end + + def test_zen_empty_fails_on_stale_allowlist_entries + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(two_controllers, allowlist: %w[FooController BarController NonExistent]) + end + assert_match(/stale/i, error.message) + end + + def test_zen_empty_passes_when_all_items_in_baseline + assert_zen_empty(two_controllers, baseline: %w[FooController BarController]) + end + + def test_zen_empty_fails_on_stale_baseline_entries + error = assert_raises(Minitest::Assertion) do + assert_zen_empty(two_controllers, baseline: %w[FooController BarController OldClass]) + end + assert_match(/stale/i, error.message) + end +end diff --git a/test/assertions/assert_zen_false_test.rb b/test/assertions/assert_zen_false_test.rb new file mode 100644 index 0000000..01f9107 --- /dev/null +++ b/test/assertions/assert_zen_false_test.rb @@ -0,0 +1,33 @@ +require_relative '../test_helper' + +# Minitest equivalent of spec/matchers/zen_false_matcher_spec.rb. +class AssertZenFalseTest < Minitest::Test + include ParseHelper + + def two_controllers + file = parse_ruby(<<~RUBY, file_path: '/app/controllers/foo_controller.rb') + class FooController; end + class BarController; end + RUBY + file.classes + end + + def test_zen_false_passes_when_block_false_for_all + assert_zen_false(two_controllers) { |c| c.name == 'BazController' } + end + + def test_zen_false_fails_when_block_true_for_some + error = assert_raises(Minitest::Assertion) do + assert_zen_false(two_controllers) { |c| c.name == 'FooController' } + end + assert_match(/Expected to return false for all elements/, error.message) + end + + def test_zen_false_requires_a_block + assert_raises(ArgumentError) { assert_zen_false(two_controllers) } + end + + def test_zen_false_supports_baseline + assert_zen_false(two_controllers, baseline: %w[FooController]) { |c| c.name == 'FooController' } + end +end diff --git a/test/assertions/assert_zen_true_test.rb b/test/assertions/assert_zen_true_test.rb new file mode 100644 index 0000000..ecf3812 --- /dev/null +++ b/test/assertions/assert_zen_true_test.rb @@ -0,0 +1,40 @@ +require_relative '../test_helper' + +# Minitest equivalent of spec/matchers/zen_true_matcher_spec.rb. +class AssertZenTrueTest < Minitest::Test + include ParseHelper + + def two_controllers + file = parse_ruby(<<~RUBY, file_path: '/app/controllers/foo_controller.rb') + class FooController; end + class BarController; end + RUBY + file.classes + end + + def test_zen_true_passes_when_block_true_for_all + assert_zen_true(two_controllers) { |c| c.name.end_with?('Controller') } + end + + def test_zen_true_fails_when_block_false_for_some + error = assert_raises(Minitest::Assertion) do + assert_zen_true(two_controllers) { |c| c.name == 'FooController' } + end + assert_match(/Expected to return true for all elements/, error.message) + end + + def test_zen_true_requires_a_block + assert_raises(ArgumentError) { assert_zen_true(two_controllers) } + end + + def test_zen_true_supports_allowlist + assert_zen_true(two_controllers, allowlist: %w[BarController]) { |c| c.name == 'FooController' } + end + + def test_zen_true_supports_custom_message + error = assert_raises(Minitest::Assertion) do + assert_zen_true(two_controllers, message: 'All must be Foo') { |c| c.name == 'FooController' } + end + assert_match(/All must be Foo/, error.message) + end +end diff --git a/test/minitest_adapter_test.rb b/test/minitest_adapter_test.rb new file mode 100644 index 0000000..dd91aab --- /dev/null +++ b/test/minitest_adapter_test.rb @@ -0,0 +1,21 @@ +require_relative 'test_helper' + +# Tests the `require 'rubyzen/minitest'` entry-point contract: the Minitest adapter +# exposes the assertions and the core parsing API, without loading RSpec. +class MinitestAdapterTest < Minitest::Test + def test_assertions_are_mixed_into_minitest + assert_respond_to self, :assert_zen_empty + assert_respond_to self, :assert_zen_true + assert_respond_to self, :assert_zen_false + end + + def test_core_parsing_api_is_loaded + assert defined?(Rubyzen::Project), 'Rubyzen::Project should be available' + assert defined?(Rubyzen::Collections::ClassesCollection), + 'Rubyzen collections should be available' + end + + def test_rspec_is_not_loaded_on_the_minitest_path + refute defined?(RSpec), "require 'rubyzen/minitest' must not load RSpec" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..6b89507 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,12 @@ +require 'rubyzen/minitest' +require 'minitest/autorun' +require 'rubocop-ast' + +# Parses a Ruby source string into a FileDeclaration for use in unit tests. +# Mirrors the spec/support/parse_helper.rb. +module ParseHelper + def parse_ruby(source, file_path: 'test.rb') + processed = RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f, file_path) + Rubyzen::Declarations::FileDeclaration.new(file_path, processed.ast) + end +end From 901607690a5f382e783e99cdd7a94b36358ce202 Mon Sep 17 00:00:00 2001 From: Stelios Frantzeskakis Date: Wed, 3 Jun 2026 12:33:40 +0300 Subject: [PATCH 2/3] PR comments --- README.md | 13 ++++++------- lib/rubyzen/rspec.rb | 2 +- ..._active_record_models_outside_repos_lint_spec.rb | 2 +- ..._call_active_record_models_outside_repos_test.rb | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 77f4824..7435ea5 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,12 @@ All three accept an `allowlist:` of exceptions that are permanently exempt from # If you use RSpec: bundle exec rspec spec/architecture/ -# If you use Minitest: -bundle exec rake lint # via a Rake task -bin/rails test test/architecture # via the Rails test runner +# If you use Minitest in Rails: +bin/rails test test/architecture ``` +If you use Minitest outside of Rails, you can use a `Rake::TestTask` to run all lint rules in `test/architecture/`. + ## Custom Paths By default, `Rubyzen::Project.new` scans `app/`, `lib/`, `src/`, and `spec/`. If you need to lint other directories (e.g., `config/`, `db/`), add them explicitly, otherwise those files won't be scanned and queries against them will return empty results. @@ -131,13 +132,11 @@ Add a step to your existing CI workflow to run your lint rules automatically whe ```yaml - name: Run architecture lint rules - # If you use RSpec: run: bundle exec rspec spec/architecture/ - - # If you use Minitest: - # run: bundle exec rake lint ``` +If you use Minitest in Rails, run `bin/rails test test/architecture` instead. For plain Ruby apps, use the `Rake::TestTask` described above. + ## AI Agent Skills Rubyzen includes agent skills in `.claude/skills/` (also symlinked at `.github/skills/`) that work with both Claude Code and GitHub Copilot: diff --git a/lib/rubyzen/rspec.rb b/lib/rubyzen/rspec.rb index 89bde9e..7d9839e 100644 --- a/lib/rubyzen/rspec.rb +++ b/lib/rubyzen/rspec.rb @@ -20,7 +20,7 @@ require 'rspec' rescue LoadError raise LoadError, "Rubyzen's RSpec matchers require the 'rspec' gem. " \ - "Add `gem 'rspec'` to your Gemfile, or use the Minitest matchers via `require 'rubyzen/minitest'`." + "Add `gem 'rspec'` to your Gemfile, or use the Minitest assertions via `require 'rubyzen/minitest'`." end require_relative 'expectation_helpers' diff --git a/sample_project/spec/database/do_not_call_active_record_models_outside_repos_lint_spec.rb b/sample_project/spec/database/do_not_call_active_record_models_outside_repos_lint_spec.rb index 14279d0..e0f9a2d 100644 --- a/sample_project/spec/database/do_not_call_active_record_models_outside_repos_lint_spec.rb +++ b/sample_project/spec/database/do_not_call_active_record_models_outside_repos_lint_spec.rb @@ -5,7 +5,7 @@ let(:baseline) { [] } let(:active_record_models) do models.with_parent_prefix('ActiveRecord::BaseAurora') - .without_name(baseline) + .without_name(*baseline) end context "given a class that is not a repo" do diff --git a/sample_project/test/database/do_not_call_active_record_models_outside_repos_test.rb b/sample_project/test/database/do_not_call_active_record_models_outside_repos_test.rb index 5cadea2..d674896 100644 --- a/sample_project/test/database/do_not_call_active_record_models_outside_repos_test.rb +++ b/sample_project/test/database/do_not_call_active_record_models_outside_repos_test.rb @@ -15,7 +15,7 @@ def baseline end def active_record_models - models.with_parent_prefix('ActiveRecord::BaseAurora').without_name(baseline) + models.with_parent_prefix('ActiveRecord::BaseAurora').without_name(*baseline) end def non_repo_classes From 0c6fe23aae26587d671d024c8bf39fc818ae69d3 Mon Sep 17 00:00:00 2001 From: Stelios Frantzeskakis Date: Wed, 3 Jun 2026 13:04:36 +0300 Subject: [PATCH 3/3] PR comments --- lib/rubyzen/core.rb | 10 ++++++---- lib/rubyzen/minitest.rb | 9 ++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/rubyzen/core.rb b/lib/rubyzen/core.rb index 21ecc9e..2750c09 100644 --- a/lib/rubyzen/core.rb +++ b/lib/rubyzen/core.rb @@ -32,12 +32,14 @@ # It wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural # rules across a codebase. # -# @example Basic usage +# `require 'rubyzen'` loads this framework-agnostic core API only. To make an assertion on a +# collection in a test, require the respective adapter: +rubyzen/rspec+ (the +zen_*+ matchers) or +# +rubyzen/minitest+ (the +assert_zen_*+ assertions). +# +# @example Querying the project # project = Rubyzen::Project.new(["/path/to/src", "/path/to/spec"]) # controllers = project.files.with_paths("controllers/").classes -# -# # Assert controllers don't call ActiveRecord directly -# expect(controllers.all_methods.call_sites.with_name("where")).to zen_empty +# controllers.all_methods.call_sites.with_name("where") # => CallSiteCollection # # @example Using auto-discovery (from project root) # project = Rubyzen::Project.new # scans app/, lib/, src/, spec/ automatically diff --git a/lib/rubyzen/minitest.rb b/lib/rubyzen/minitest.rb index d119808..96c03ce 100644 --- a/lib/rubyzen/minitest.rb +++ b/lib/rubyzen/minitest.rb @@ -16,7 +16,14 @@ # mixed into +Minitest::Assertions+, so they are available in every Minitest test # class and spec-style block automatically. require_relative 'core' -require 'minitest' + +begin + require 'minitest' +rescue LoadError + raise LoadError, "Rubyzen's Minitest assertions require the 'minitest' gem. " \ + "Add `gem 'minitest'` to your Gemfile, or use the RSpec matchers via `require 'rubyzen/rspec'`." +end + require_relative 'assertions/zen_assertions' # Call +include+ via +send+ so YARD's static parser doesn't try to document a