rubyzen-lint 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e0bc97dbd17202e4245c3c8c6b1d16f7fcdef64eab20ff96ab835113e21fbd9
4
- data.tar.gz: 4fb9b3a30754c283823a44e208bd4de9ca0a035db5733df418ef1e70bd98172d
3
+ metadata.gz: 7ce170df4572f8af2962998ff57160f0027b39288a5f53d566234ff5e1442b43
4
+ data.tar.gz: 1189a743bb4de63887b7f45d4387a3b3fb6571ef5cb4a67763bbc0f69c31bde8
5
5
  SHA512:
6
- metadata.gz: c23bf87e539bdda57d28a5b9f0b5fdbbee847a88ce93bf012c92a1c00eaf48fe1f5e2a99bea6a2e070749c2c74451821026f742a2e4e50e12c5395ed4295b870
7
- data.tar.gz: 189a609b17528f6d0898defda84762df30273f4607f4e97882d21d255b111d719818adb9148d452c4ff5b9d7c29dbcc5c68749f36d0e96c86740451d096dc778
6
+ metadata.gz: c424e1404f091fa5c965a4253f15b853e031b9078b305df0fa770d94fa545524f5f17776100bc7986ff630cb10c9506338886604d3a0c6b11f77058b66725a9b
7
+ data.tar.gz: 4275ca620f94b150758f12222064d2ec5b0314502a4a3da4a8bed40d1d937fc580f8f8d23878dfd74933a59615843c6bc5b35c0ff106fb525c816d2c1419c224
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0]
4
+
5
+ ### Added
6
+
7
+ - **Minitest adapter.** Write architectural lint rules with Minitest using
8
+ `require 'rubyzen/minitest'`. This provides the `assert_zen_empty`, `assert_zen_true`,
9
+ and `assert_zen_false` assertions, which mirror the RSpec matchers.
10
+
11
+ ### Changed
12
+
13
+ - **`rspec` is no longer a runtime dependency.** Rubyzen no longer depends
14
+ on RSpec at runtime; RSpec users should add `gem 'rspec'` (or `rspec-rails`)
15
+ to their Gemfile. This allows Minitest users to not depend on RSpec.
16
+
17
+ ### Migration from 0.1.x
18
+
19
+ - Change `require 'rubyzen'` to `require 'rubyzen/rspec'`.
20
+ - Ensure `gem 'rspec'` is in your Gemfile's test group.
21
+
22
+ ## [0.1.0]
23
+
24
+ - Initial release
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Rubyzen
2
2
 
3
- 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).
3
+ [![Gem Version](https://badge.fury.io/rb/rubyzen-lint.svg)](https://badge.fury.io/rb/rubyzen-lint)
4
+ [![CI](https://github.com/perrystreetsoftware/Rubyzen/actions/workflows/tests.yml/badge.svg)](https://github.com/perrystreetsoftware/Rubyzen/actions/workflows/tests.yml)
5
+ [![Docs](https://img.shields.io/badge/docs-yard-blue)](https://perrystreetsoftware.github.io/Rubyzen)
6
+
7
+ 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).
4
8
 
5
9
  ## Architectural linters in the era of AI-generated code
6
10
 
@@ -24,22 +28,26 @@ Traditional linters such as [RuboCop](https://github.com/rubocop/rubocop) requir
24
28
 
25
29
  ## Setup
26
30
 
27
- Add Rubyzen to your Gemfile:
31
+ Add Rubyzen to your Gemfile's test group, alongside your test framework.
28
32
 
29
33
  ```ruby
30
- gem 'rubyzen-lint', group: :test
34
+ group :test do
35
+ gem 'rubyzen-lint'
36
+ # gem 'rspec' # if you use RSpec (or rspec-rails)
37
+ # gem 'minitest' # if you use Minitest
38
+ end
31
39
  ```
32
40
 
33
41
  Then run `bundle install`.
34
42
 
35
- That's it. Rubyzen auto-discovers your project structure (`app/`, `lib/`, `src/`, `spec/`) from your project root. No environment variables or configuration needed.
43
+ 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.
36
44
 
37
45
  ## Write your first set of lint rules
38
46
 
39
47
  Create a spec file anywhere in your project (e.g., `spec/architecture/sample_spec.rb`) and start enforcing your architecture:
40
48
 
41
49
  ```ruby
42
- require 'rubyzen'
50
+ require 'rubyzen/rspec'
43
51
 
44
52
  RSpec.describe 'Architecture rules' do
45
53
  let(:project) { Rubyzen::Project.new }
@@ -64,15 +72,49 @@ end
64
72
 
65
73
  You can find more sample lint rules in the [`sample_project/spec/`](sample_project/spec/) directory.
66
74
 
75
+ ## Using Minitest
76
+
77
+ If you use Minitest instead of RSpec, replace `require 'rubyzen/rspec'` with `require 'rubyzen/minitest'` and use the equivalent Minitest assertions:
78
+
79
+ ```ruby
80
+ require 'rubyzen/minitest'
81
+
82
+ class ArchitectureTest < Minitest::Test
83
+ def controllers = Rubyzen::Project.new.files.with_paths('app/controllers/').classes
84
+
85
+ def test_controllers_do_not_call_active_record_directly
86
+ assert_zen_empty(controllers.all_methods.call_sites.with_name('where'))
87
+ end
88
+ end
89
+ ```
90
+
91
+ ## Matchers and assertions
92
+
93
+ Rubyzen provides three checks for your architectural lint rules:
94
+
95
+ | Using RSpec | Using Minitest | Checks that |
96
+ |---|---|---|
97
+ | `zen_empty` | `assert_zen_empty(collection)` | the collection is empty |
98
+ | `zen_true { \|item\| }` | `assert_zen_true(collection) { \|item\| }` | the block returns true for every element |
99
+ | `zen_false { \|item\| }` | `assert_zen_false(collection) { \|item\| }` | the block returns false for every element |
100
+
101
+ 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.
102
+
67
103
  ## Run your lint rules
68
104
 
69
105
  ```bash
106
+ # If you use RSpec:
70
107
  bundle exec rspec spec/architecture/
108
+
109
+ # If you use Minitest in Rails:
110
+ bin/rails test test/architecture
71
111
  ```
72
112
 
73
- ## Custom Paths (Optional)
113
+ If you use Minitest outside of Rails, you can use a `Rake::TestTask` to run all lint rules in `test/architecture/`.
114
+
115
+ ## Custom Paths
74
116
 
75
- By default, `Rubyzen::Project.new` scans standard directories (`app/`, `lib/`, `src/`, `spec/`) from your project root. You can override this:
117
+ 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.
76
118
 
77
119
  ```ruby
78
120
  # In your spec file — scope to specific directories
@@ -80,7 +122,7 @@ project = Rubyzen::Project.new(['app/models', 'app/controllers'])
80
122
 
81
123
  # Or in spec/spec_helper.rb — configure globally for all specs
82
124
  Rubyzen.configure do |config|
83
- config.paths = ['app', 'lib']
125
+ config.paths = ['app', 'lib', 'config']
84
126
  end
85
127
  ```
86
128
 
@@ -93,6 +135,8 @@ Add a step to your existing CI workflow to run your lint rules automatically whe
93
135
  run: bundle exec rspec spec/architecture/
94
136
  ```
95
137
 
138
+ If you use Minitest in Rails, run `bin/rails test test/architecture` instead. For plain Ruby apps, use the `Rake::TestTask` described above.
139
+
96
140
  ## AI Agent Skills
97
141
 
98
142
  Rubyzen includes agent skills in `.claude/skills/` (also symlinked at `.github/skills/`) that work with both Claude Code and GitHub Copilot:
@@ -107,4 +151,8 @@ Rubyzen includes agent skills in `.claude/skills/` (also symlinked at `.github/s
107
151
 
108
152
  ## Contributing
109
153
 
110
- Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on setting up the project, enhancing the API, and adding tests.
154
+ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on setting up the project, enhancing the API, and adding tests.
155
+
156
+ ## Perry Street Software is hiring
157
+
158
+ If you are a Ruby or DevOps engineer excited about [application architecture](https://itnext.io/a-visual-history-of-web-api-architecture-c36044df2ac7) on a platform running at very large scale, check out our [careers](https://www.perrystreet.com/careers) page! Our company has written extensively about our technical approach on the [Perry Street Software Engineering Blog](https://medium.com/perry-street-software-engineering).
@@ -0,0 +1,51 @@
1
+ module Rubyzen
2
+ module Assertions
3
+ # Asserts that a Rubyzen collection is empty (the Minitest counterpart of
4
+ # the +zen_empty+ matcher).
5
+ #
6
+ # Used in architectural lint rules to verify that no items match a forbidden
7
+ # pattern (e.g., no controllers call +.where+ directly).
8
+ #
9
+ # @param collection [Enumerable] a Rubyzen collection of declarations
10
+ # @param message [String, nil] optional custom failure message
11
+ # @param allowlist [Array<String>, nil] items to permanently ignore
12
+ # @param baseline [Array<String>, nil] known violations for gradual adoption
13
+ # @return [true] when the assertion passes
14
+ # @raise [Minitest::Assertion] when there are live violations or stale entries
15
+ #
16
+ # @example Ensure no controllers use .where
17
+ # assert_zen_empty(controllers.all_methods.call_sites.with_name('where'))
18
+ #
19
+ # @example With a baseline for gradual adoption
20
+ # assert_zen_empty(violations, baseline: ['LegacyController'])
21
+ def assert_zen_empty(collection, message: nil, allowlist: nil, baseline: nil)
22
+ @failure_message = nil
23
+ @custom_message = message
24
+ @classified_items = classify_items(collection, allowlist: allowlist, baseline: baseline)
25
+
26
+ violations = @classified_items[:violations]
27
+ stale_baseline = @classified_items[:stale_baseline]
28
+ stale_allowlist = @classified_items[:stale_allowlist]
29
+
30
+ stale_groups = []
31
+ stale_groups << 'baseline entries' if stale_baseline.any?
32
+ stale_groups << 'allowlist entries' if stale_allowlist.any?
33
+
34
+ reason =
35
+ if violations.any? && stale_groups.any?
36
+ "Expected to be empty, but found live violations and stale #{stale_groups.join(' and ')}."
37
+ elsif violations.any?
38
+ if allowlist || baseline
39
+ 'Expected to be empty, but found live violations.'
40
+ else
41
+ 'Expected to be empty, but had elements.'
42
+ end
43
+ elsif stale_groups.any?
44
+ "Expected to be empty, but found stale #{stale_groups.join(' and ')}."
45
+ end
46
+
47
+ passed = violations.empty? && stale_baseline.empty? && stale_allowlist.empty?
48
+ assert(passed, message_for_failure(reason || 'Expected to be empty, but had elements.'))
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,49 @@
1
+ module Rubyzen
2
+ module Assertions
3
+ # Asserts that a block returns false for every item in a collection (the
4
+ # Minitest counterpart of the +zen_false+ matcher).
5
+ #
6
+ # @param collection [Enumerable] a Rubyzen collection of declarations
7
+ # @param message [String, nil] optional custom failure message
8
+ # @param allowlist [Array<String>, nil] items to permanently ignore
9
+ # @param baseline [Array<String>, nil] known violations for gradual adoption
10
+ # @yield [item] block that should return false for each item
11
+ # @return [true] when the assertion passes
12
+ # @raise [ArgumentError] when no block is given
13
+ # @raise [Minitest::Assertion] when the block returns truthy for a live item
14
+ #
15
+ # @example Ensure no methods have more than 5 parameters
16
+ # assert_zen_false(methods) { |m| m.parameters.size > 5 }
17
+ #
18
+ # @example With a baseline for gradual adoption
19
+ # assert_zen_false(classes, baseline: ['LegacyModel']) { |k| k.lines_of_code > 200 }
20
+ def assert_zen_false(collection, message: nil, allowlist: nil, baseline: nil, &block)
21
+ raise ArgumentError, 'assert_zen_false requires a block' unless block
22
+
23
+ @failure_message = nil
24
+ @custom_message = message
25
+ failing_items = Array(collection).filter { |item| block.call(item) }
26
+ @classified_items = classify_items(failing_items, allowlist: allowlist, baseline: baseline)
27
+
28
+ violations = @classified_items[:violations]
29
+ stale_baseline = @classified_items[:stale_baseline]
30
+ stale_allowlist = @classified_items[:stale_allowlist]
31
+
32
+ stale_groups = []
33
+ stale_groups << 'baseline entries' if stale_baseline.any?
34
+ stale_groups << 'allowlist entries' if stale_allowlist.any?
35
+
36
+ reason =
37
+ if violations.any? && stale_groups.any?
38
+ "Expected to return false for all elements, but found live violations and stale #{stale_groups.join(' and ')}."
39
+ elsif violations.any?
40
+ 'Expected to return false for all elements.'
41
+ elsif stale_groups.any?
42
+ "Expected to return false for all elements, but found stale #{stale_groups.join(' and ')}."
43
+ end
44
+
45
+ passed = violations.empty? && stale_baseline.empty? && stale_allowlist.empty?
46
+ assert(passed, message_for_failure(reason || 'Expected to return false for all elements.'))
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,49 @@
1
+ module Rubyzen
2
+ module Assertions
3
+ # Asserts that a block returns true for every item in a collection (the
4
+ # Minitest counterpart of the +zen_true+ matcher).
5
+ #
6
+ # @param collection [Enumerable] a Rubyzen collection of declarations
7
+ # @param message [String, nil] optional custom failure message
8
+ # @param allowlist [Array<String>, nil] items to permanently ignore
9
+ # @param baseline [Array<String>, nil] known violations for gradual adoption
10
+ # @yield [item] block that should return true for each item
11
+ # @return [true] when the assertion passes
12
+ # @raise [ArgumentError] when no block is given
13
+ # @raise [Minitest::Assertion] when the block returns falsey for a live item
14
+ #
15
+ # @example Ensure all methods have parameters
16
+ # assert_zen_true(methods) { |m| m.parameters? }
17
+ #
18
+ # @example With a custom failure message
19
+ # assert_zen_true(services, message: 'All services must inherit from BaseService') { |s| s.superclass_name == 'BaseService' }
20
+ def assert_zen_true(collection, message: nil, allowlist: nil, baseline: nil, &block)
21
+ raise ArgumentError, 'assert_zen_true requires a block' unless block
22
+
23
+ @failure_message = nil
24
+ @custom_message = message
25
+ failing_items = Array(collection).filter { |item| !block.call(item) }
26
+ @classified_items = classify_items(failing_items, allowlist: allowlist, baseline: baseline)
27
+
28
+ violations = @classified_items[:violations]
29
+ stale_baseline = @classified_items[:stale_baseline]
30
+ stale_allowlist = @classified_items[:stale_allowlist]
31
+
32
+ stale_groups = []
33
+ stale_groups << 'baseline entries' if stale_baseline.any?
34
+ stale_groups << 'allowlist entries' if stale_allowlist.any?
35
+
36
+ reason =
37
+ if violations.any? && stale_groups.any?
38
+ "Expected to return true for all elements, but found live violations and stale #{stale_groups.join(' and ')}."
39
+ elsif violations.any?
40
+ 'Expected to return true for all elements.'
41
+ elsif stale_groups.any?
42
+ "Expected to return true for all elements, but found stale #{stale_groups.join(' and ')}."
43
+ end
44
+
45
+ passed = violations.empty? && stale_baseline.empty? && stale_allowlist.empty?
46
+ assert(passed, message_for_failure(reason || 'Expected to return true for all elements.'))
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,29 @@
1
+ require_relative '../expectation_helpers'
2
+
3
+ module Rubyzen
4
+ # Minitest equivalents of the +zen_empty+ / +zen_true+ / +zen_false+ RSpec
5
+ # matchers. Mixed into +Minitest::Assertions+ by +require 'rubyzen/minitest'+,
6
+ # so the methods are available in every +Minitest::Test+ (and spec-style block).
7
+ #
8
+ # All three delegate to the shared {Rubyzen::ExpectationHelpers} for
9
+ # violation/allowlist/baseline classification and failure-message formatting.
10
+ # The behavior is identical to the RSpec matchers ({Rubyzen::Matchers}).
11
+ #
12
+ # @example
13
+ # class ArchitectureTest < Minitest::Test
14
+ # def test_controllers_have_no_if_statements
15
+ # assert_zen_empty(controllers.all_methods.if_statements)
16
+ # end
17
+ #
18
+ # def test_repos_live_in_module
19
+ # assert_zen_true(repos) { |repo| repo.top_level_module == 'Repos' }
20
+ # end
21
+ # end
22
+ module Assertions
23
+ include Rubyzen::ExpectationHelpers
24
+ end
25
+ end
26
+
27
+ require_relative 'assert_zen_empty'
28
+ require_relative 'assert_zen_true'
29
+ require_relative 'assert_zen_false'
@@ -1,6 +1,7 @@
1
1
  require 'digest'
2
2
 
3
3
  module Rubyzen
4
+ # Caching utilities for parsed AST results.
4
5
  module Cache
5
6
  # In-memory cache for parsed AST results, keyed by file path and SHA256 checksum.
6
7
  # Automatically invalidates entries when file contents change.
@@ -1,4 +1,5 @@
1
1
  module Rubyzen
2
+ # Typed collections that wrap arrays of declarations with filtering and aggregation.
2
3
  module Collections
3
4
  # Base collection class for all Rubyzen collections.
4
5
  # Extends Array and replaces +select+/+reject+ with a single +filter+ method
@@ -0,0 +1,114 @@
1
+ require 'rubocop-ast'
2
+ require 'zeitwerk'
3
+
4
+ # Framework-agnostic core of Rubyzen.
5
+ #
6
+ # Owns the Zeitwerk autoloading of the parsing API (declarations, collections,
7
+ # providers, parsers) and defines the +Rubyzen+ module and its +Configuration+.
8
+ #
9
+ # This file deliberately does NOT require any test framework (RSpec or Minitest)
10
+ # so that adapters can load only what they need:
11
+ # * +require 'rubyzen'+ → core only (no test framework)
12
+ # * +require 'rubyzen/rspec'+ → core + RSpec matchers
13
+ # * +require 'rubyzen/minitest'+ → core + Minitest assertions
14
+ lib_dir = File.expand_path('..', __dir__)
15
+
16
+ loader = Zeitwerk::Loader.new
17
+ loader.tag = 'rubyzen'
18
+ loader.push_dir(lib_dir)
19
+ # Entry points and adapter directories are loaded explicitly, not autoloaded.
20
+ loader.ignore("#{lib_dir}/rubyzen.rb")
21
+ loader.ignore("#{lib_dir}/rubyzen-lint.rb")
22
+ loader.ignore("#{__dir__}/core.rb")
23
+ loader.ignore("#{__dir__}/minitest.rb")
24
+ loader.ignore("#{__dir__}/lint.rb")
25
+ loader.ignore("#{__dir__}/matchers")
26
+ loader.ignore("#{__dir__}/assertions")
27
+ loader.ignore("#{__dir__}/expectation_helpers.rb")
28
+ loader.ignore("#{__dir__}/rspec.rb")
29
+ loader.setup
30
+
31
+ # Rubyzen is a Ruby architectural linter that lets you write lint rules as unit tests.
32
+ # It wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural
33
+ # rules across a codebase.
34
+ #
35
+ # `require 'rubyzen'` loads this framework-agnostic core API only. To make an assertion on a
36
+ # collection in a test, require the respective adapter: +rubyzen/rspec+ (the +zen_*+ matchers) or
37
+ # +rubyzen/minitest+ (the +assert_zen_*+ assertions).
38
+ #
39
+ # @example Querying the project
40
+ # project = Rubyzen::Project.new(["/path/to/src", "/path/to/spec"])
41
+ # controllers = project.files.with_paths("controllers/").classes
42
+ # controllers.all_methods.call_sites.with_name("where") # => CallSiteCollection
43
+ #
44
+ # @example Using auto-discovery (from project root)
45
+ # project = Rubyzen::Project.new # scans app/, lib/, src/, spec/ automatically
46
+ #
47
+ module Rubyzen
48
+ # Base error class for all Rubyzen errors.
49
+ class Error < StandardError; end
50
+
51
+ # Raised when a Ruby file cannot be parsed.
52
+ class ParseError < Error; end
53
+
54
+ # Yields the global configuration for customization.
55
+ #
56
+ # @example
57
+ # Rubyzen.configure do |config|
58
+ # config.paths = ['app', 'lib']
59
+ # end
60
+ def self.configure
61
+ yield(configuration)
62
+ end
63
+
64
+ # Returns the global configuration instance.
65
+ #
66
+ # @return [Configuration]
67
+ def self.configuration
68
+ @configuration ||= Configuration.new
69
+ end
70
+
71
+ # Holds project path configuration with auto-discovery support.
72
+ #
73
+ # Resolution order:
74
+ # 1. Explicit paths via {#paths=} (set via +Rubyzen.configure+)
75
+ # 2. Auto-discovery of +app/+, +lib/+, +src/+, +spec/+ from +Dir.pwd+
76
+ #
77
+ # @example
78
+ # Rubyzen.configure { |c| c.paths = ['app/models', 'app/controllers'] }
79
+ # Rubyzen.configuration.project_paths #=> ["/full/path/app/models", "/full/path/app/controllers"]
80
+ #
81
+ class Configuration
82
+ # Sets explicit paths to scan.
83
+ # Relative paths are resolved against +Dir.pwd+.
84
+ #
85
+ # @param value [Array<String>] directories to analyze
86
+ attr_writer :paths
87
+
88
+ # Returns the resolved project paths.
89
+ #
90
+ # @return [Array<String>] absolute paths to directories to analyze
91
+ def project_paths
92
+ resolve_paths(@paths) || auto_discover_paths
93
+ end
94
+
95
+ private
96
+
97
+ def resolve_paths(paths)
98
+ return nil unless paths&.any?
99
+
100
+ root = Dir.pwd
101
+ paths.map do |path|
102
+ File.expand_path(path, root)
103
+ end
104
+ end
105
+
106
+ def auto_discover_paths
107
+ root = Dir.pwd
108
+ candidates = %w[app lib src spec].map { |d| File.join(root, d) }
109
+ paths = candidates.select { |d| Dir.exist?(d) }
110
+ paths = [root] if paths.empty?
111
+ paths
112
+ end
113
+ end
114
+ end
@@ -1,4 +1,5 @@
1
1
  module Rubyzen
2
+ # Domain objects wrapping AST nodes with high-level accessors.
2
3
  module Declarations
3
4
  # Represents a parsed Ruby source file. This is the root of the declaration
4
5
  # hierarchy — all other declarations are accessed through a FileDeclaration.
@@ -0,0 +1,184 @@
1
+ module Rubyzen
2
+ # Shared helper methods for Rubyzen's +zen_*+ / +assert_zen_*+ expectations —
3
+ # used by both the RSpec matchers ({Rubyzen::Matchers}) and the Minitest
4
+ # assertions ({Rubyzen::Assertions}).
5
+ #
6
+ # Provides utilities for normalizing exception lists, extracting item details,
7
+ # matching items against allowlist/baseline entries, and formatting failure
8
+ # messages. These methods are framework-agnostic — they operate only on plain
9
+ # declaration objects and instance variables set by the caller.
10
+ module ExpectationHelpers
11
+ # Normalizes a list of exception entries into unique, non-blank strings.
12
+ #
13
+ # @param entries [Array<String>, String, nil] raw exception entries
14
+ # @return [Array<String>] deduplicated, stripped, non-empty strings
15
+ def normalize_exception_entries(entries)
16
+ Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
17
+ end
18
+
19
+ # Extracts identifying details from a declaration item.
20
+ #
21
+ # @param item [Object] a declaration object (e.g., FileDeclaration, ClassDeclaration)
22
+ # @return [Hash{Symbol => String, nil}] hash with :name, :class_name, :file_path, :line
23
+ def item_details(item)
24
+ {
25
+ name: item.respond_to?(:name) ? item.name : nil,
26
+ class_name: item.respond_to?(:class_name) ? item.class_name : nil,
27
+ file_path: item.respond_to?(:file_path) ? item.file_path : 'Unknown file',
28
+ line: item.respond_to?(:line) ? item.line : nil
29
+ }
30
+ end
31
+
32
+ # Returns a list of unique identifier strings for an item, used for matching.
33
+ #
34
+ # @param item [Object] a declaration object
35
+ # @return [Array<String>] identifiers such as name, class name, file path, and file:line
36
+ def item_identifiers(item)
37
+ details = item_details(item)
38
+ identifiers = [details[:name], details[:class_name], details[:file_path]]
39
+
40
+ if details[:line]
41
+ identifiers << "#{details[:file_path]}:#{details[:line]}"
42
+ end
43
+
44
+ identifiers.compact.uniq
45
+ end
46
+
47
+ # Checks whether a given exception entry string matches an item.
48
+ #
49
+ # @param entry [String] an allowlist or baseline entry
50
+ # @param item [Object] a declaration object
51
+ # @return [Boolean] true if the entry matches the item by name, class, or path
52
+ def exception_entry_matches_item?(entry, item)
53
+ normalized_entry = entry.to_s.strip
54
+ return false if normalized_entry.empty?
55
+
56
+ details = item_details(item)
57
+ return true if item_identifiers(item).include?(normalized_entry)
58
+
59
+ file_path = details[:file_path]
60
+ file_path && (file_path.end_with?(normalized_entry) || file_path.end_with?("/#{normalized_entry}"))
61
+ end
62
+
63
+ # Classifies items into violations, baseline matches, allowlist matches,
64
+ # and detects stale entries in either list.
65
+ #
66
+ # @param subject_collection [Array, Object] items to classify
67
+ # @param allowlist [Array<String>, nil] allowed exception entries
68
+ # @param baseline [Array<String>, nil] baseline exception entries
69
+ # @return [Hash{Symbol => Array<String>}] keys: :violations, :baseline, :allowlist,
70
+ # :stale_baseline, :stale_allowlist
71
+ def classify_items(subject_collection, allowlist: nil, baseline: nil)
72
+ items = Array(subject_collection).compact
73
+ normalized_allowlist = normalize_exception_entries(allowlist)
74
+ normalized_baseline = normalize_exception_entries(baseline)
75
+ matched_baseline_entries = []
76
+ matched_allowlist_entries = []
77
+
78
+ grouped_items = items.group_by do |item|
79
+ matching_baseline_entry = normalized_baseline.find do |entry|
80
+ exception_entry_matches_item?(entry, item)
81
+ end
82
+
83
+ if matching_baseline_entry
84
+ matched_baseline_entries << matching_baseline_entry
85
+ :baseline
86
+ else
87
+ matching_allowlist_entry = normalized_allowlist.find do |entry|
88
+ exception_entry_matches_item?(entry, item)
89
+ end
90
+
91
+ if matching_allowlist_entry
92
+ matched_allowlist_entries << matching_allowlist_entry
93
+ :allowlist
94
+ else
95
+ :violations
96
+ end
97
+ end
98
+ end
99
+
100
+ classifications = {
101
+ baseline: Array(grouped_items[:baseline]).map { |item| element_name(item) },
102
+ allowlist: Array(grouped_items[:allowlist]).map { |item| element_name(item) },
103
+ violations: Array(grouped_items[:violations]).map { |item| element_name(item) }
104
+ }
105
+
106
+ classifications.merge(
107
+ stale_baseline: normalized_baseline - matched_baseline_entries.uniq,
108
+ stale_allowlist: normalized_allowlist - matched_allowlist_entries.uniq
109
+ )
110
+ end
111
+
112
+ # Formats a human-readable description of an item for failure messages.
113
+ #
114
+ # @param item [Object] a declaration object
115
+ # @return [String] formatted multi-line description
116
+ def element_name(item)
117
+ details = item_details(item)
118
+ location = [details[:file_path], details[:line]].compact.join(':')
119
+
120
+ case
121
+ when details[:name] && details[:class_name]
122
+ " - element: #{details[:name]}\n - class: #{details[:class_name]}\n - file: #{location}"
123
+ when details[:name]
124
+ " - element: #{details[:name]}\n - file: #{location}"
125
+ when details[:class_name]
126
+ " - class: #{details[:class_name]}\n - file: #{location}"
127
+ else
128
+ " - unknown element in #{location}"
129
+ end
130
+ end
131
+
132
+ # Builds a formatted string of violations and stale entries for failure output.
133
+ #
134
+ # @return [String, nil] formatted sections or nil if no classified items
135
+ def formatted_matcher_groups
136
+ return unless defined?(@classified_items) && @classified_items
137
+
138
+ sections = []
139
+
140
+ if @classified_items[:violations].any?
141
+ sections << "Violations:\n#{@classified_items[:violations].join("\n")}"
142
+ end
143
+
144
+ if @classified_items[:stale_baseline].any?
145
+ stale_entries = @classified_items[:stale_baseline].map { |entry| " - #{entry}" }
146
+ sections << "Stale baseline entries:\n#{stale_entries.join("\n")}"
147
+ end
148
+
149
+ if @classified_items[:stale_allowlist].any?
150
+ stale_entries = @classified_items[:stale_allowlist].map { |entry| " - #{entry}" }
151
+ sections << "Stale allowlist entries:\n#{stale_entries.join("\n")}"
152
+ end
153
+
154
+ sections.join("\n")
155
+ end
156
+
157
+ # Formats the failure message by combining the base message with
158
+ # custom messages and classified item details (violations, stale entries).
159
+ #
160
+ # Works unchanged in both RSpec matchers and Minitest assertions, since it
161
+ # only reads instance variables set by the caller (+@failure_message+,
162
+ # +@custom_message+, +@classified_items+).
163
+ #
164
+ # @param base_message [String] the default failure message
165
+ # @return [String] formatted failure message
166
+ def message_for_failure(base_message)
167
+ return @failure_message if @failure_message
168
+
169
+ details = formatted_matcher_groups
170
+
171
+ if @custom_message
172
+ if details && !details.empty?
173
+ "#{@custom_message}\n#{details}"
174
+ else
175
+ @custom_message
176
+ end
177
+ elsif details && !details.empty?
178
+ "#{base_message}\n#{details}"
179
+ else
180
+ base_message
181
+ end
182
+ end
183
+ end
184
+ end