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.
@@ -1,15 +1,26 @@
1
- # Custom RSpec matcher that asserts a Rubyzen collection is empty.
2
- #
3
- # Used in architectural lint rules to verify that no items match
4
- # a forbidden pattern (e.g., no controllers call +.where+ directly).
5
- #
6
- # @example Ensure no controllers use .where
7
- # expect(controllers.all_methods.call_sites.with_name('where')).to zen_empty
8
- #
9
- # @example With a custom failure message
10
- # expect(violations).to zen_empty("Controllers should not call .where directly")
1
+ # @!parse
2
+ # module Rubyzen
3
+ # # Custom RSpec matchers for asserting on Rubyzen collections.
4
+ # module Matchers
5
+ # # Asserts that a Rubyzen collection is empty.
6
+ # #
7
+ # # Used in architectural lint rules to verify that no items match
8
+ # # a forbidden pattern (e.g., no controllers call +.where+ directly).
9
+ # #
10
+ # # @param custom_message [String, nil] optional failure message
11
+ # # @param allowlist [Array<String>, nil] items to permanently ignore
12
+ # # @param baseline [Array<String>, nil] known violations for gradual adoption
13
+ # #
14
+ # # @example Ensure no controllers use .where
15
+ # # expect(controllers.all_methods.call_sites.with_name('where')).to zen_empty
16
+ # #
17
+ # # @example With baseline for gradual adoption
18
+ # # expect(violations).to zen_empty(baseline: ['LegacyController'])
19
+ # def zen_empty(custom_message = nil, allowlist: nil, baseline: nil); end
20
+ # end
21
+ # end
11
22
  RSpec::Matchers.define :zen_empty do |custom_message=nil, allowlist: nil, baseline: nil|
12
- include Rubyzen::Matchers::MatcherHelpers
23
+ include Rubyzen::ExpectationHelpers
13
24
 
14
25
  match do |subject_collection|
15
26
  options = custom_message.is_a?(Hash) ? custom_message : {}
@@ -1,18 +1,26 @@
1
- # Custom RSpec matcher that asserts a block returns false for every item in a collection.
2
- #
3
- # Supports +allowlist:+ and +baseline:+ for gradual adoption, matching items
4
- # where the block returns true against exception lists.
5
- #
6
- # @example Ensure no methods have more than 5 parameters
7
- # expect(methods).to zen_false { |m| m.parameters.size > 5 }
8
- #
9
- # @example With a custom failure message
10
- # expect(controllers.all_methods.call_sites).to zen_false("Controllers must not call .where directly") { |cs| cs.name == 'where' }
11
- #
12
- # @example With a baseline for gradual adoption
13
- # expect(classes).to zen_false(baseline: ['LegacyModel']) { |k| k.lines_of_code > 200 }
1
+ # @!parse
2
+ # module Rubyzen
3
+ # module Matchers
4
+ # # Asserts that a block returns false for every item in a collection.
5
+ # #
6
+ # # Supports +allowlist:+ and +baseline:+ for gradual adoption, matching items
7
+ # # where the block returns true against exception lists.
8
+ # #
9
+ # # @param custom_message [String, nil] optional failure message
10
+ # # @param allowlist [Array<String>, nil] items to permanently ignore
11
+ # # @param baseline [Array<String>, nil] known violations for gradual adoption
12
+ # # @yield [item] block that should return false for each item
13
+ # #
14
+ # # @example Ensure no methods have more than 5 parameters
15
+ # # expect(methods).to zen_false { |m| m.parameters.size > 5 }
16
+ # #
17
+ # # @example With a baseline for gradual adoption
18
+ # # expect(classes).to zen_false(baseline: ['LegacyModel']) { |k| k.lines_of_code > 200 }
19
+ # def zen_false(custom_message = nil, allowlist: nil, baseline: nil, &block); end
20
+ # end
21
+ # end
14
22
  RSpec::Matchers.define :zen_false do |custom_message=nil, allowlist: nil, baseline: nil|
15
- include Rubyzen::Matchers::MatcherHelpers
23
+ include Rubyzen::ExpectationHelpers
16
24
 
17
25
  match do |subject_collection|
18
26
  options = custom_message.is_a?(Hash) ? custom_message : {}
@@ -1,12 +1,23 @@
1
- # Custom RSpec matcher that asserts a block returns true for every item in a collection.
2
- #
3
- # @example Ensure all methods have parameters
4
- # expect(methods).to zen_true { |m| m.parameters? }
5
- #
6
- # @example With a custom failure message
7
- # expect(services).to zen_true("All services must inherit from BaseService") { |s| s.superclass_name == 'BaseService' }
1
+ # @!parse
2
+ # module Rubyzen
3
+ # module Matchers
4
+ # # Asserts that a block returns true for every item in a collection.
5
+ # #
6
+ # # @param custom_message [String, nil] optional failure message
7
+ # # @param allowlist [Array<String>, nil] items to permanently ignore
8
+ # # @param baseline [Array<String>, nil] known violations for gradual adoption
9
+ # # @yield [item] block that should return true for each item
10
+ # #
11
+ # # @example Ensure all methods have parameters
12
+ # # expect(methods).to zen_true { |m| m.parameters? }
13
+ # #
14
+ # # @example With a custom failure message
15
+ # # expect(services).to zen_true("All services must inherit from BaseService") { |s| s.superclass_name == 'BaseService' }
16
+ # def zen_true(custom_message = nil, allowlist: nil, baseline: nil, &block); end
17
+ # end
18
+ # end
8
19
  RSpec::Matchers.define :zen_true do |custom_message=nil, allowlist: nil, baseline: nil|
9
- include Rubyzen::Matchers::MatcherHelpers
20
+ include Rubyzen::ExpectationHelpers
10
21
 
11
22
  match do |subject_collection|
12
23
  options = custom_message.is_a?(Hash) ? custom_message : {}
@@ -0,0 +1,33 @@
1
+ # Minitest entry point for Rubyzen.
2
+ #
3
+ # Loads the framework-agnostic core plus the Minitest assertions.
4
+ # Require it from your test/test_helper.rb:
5
+ #
6
+ # # Gemfile
7
+ # group :test do
8
+ # gem 'rubyzen-lint'
9
+ # gem 'minitest'
10
+ # end
11
+ #
12
+ # # test/test_helper.rb
13
+ # require 'rubyzen/minitest'
14
+ #
15
+ # The assertions (+assert_zen_empty+, +assert_zen_true+, +assert_zen_false+) are
16
+ # mixed into +Minitest::Assertions+, so they are available in every Minitest test
17
+ # class and spec-style block automatically.
18
+ require_relative 'core'
19
+
20
+ begin
21
+ require 'minitest'
22
+ rescue LoadError
23
+ raise LoadError, "Rubyzen's Minitest assertions require the 'minitest' gem. " \
24
+ "Add `gem 'minitest'` to your Gemfile, or use the RSpec matchers via `require 'rubyzen/rspec'`."
25
+ end
26
+
27
+ require_relative 'assertions/zen_assertions'
28
+
29
+ # Call +include+ via +send+ so YARD's static parser doesn't try to document a
30
+ # mixin into the external Minitest::Assertions namespace (the constant only
31
+ # exists at runtime, after +require 'minitest'+, so YARD would warn). +include+
32
+ # is public on Module, so this is behaviourally identical to a plain call.
33
+ Minitest::Assertions.send(:include, Rubyzen::Assertions)
@@ -1,6 +1,7 @@
1
1
  require 'rubocop-ast'
2
2
 
3
3
  module Rubyzen
4
+ # Ruby source file parsing utilities.
4
5
  module Parsers
5
6
  # Singleton parser that converts Ruby source files into Rubyzen declarations
6
7
  # using RuboCop's AST processing. Results are cached via {Cache::ParseCache}.
@@ -1,6 +1,7 @@
1
1
  module Rubyzen
2
+ # Mixins that add capabilities (call sites, blocks, attributes, etc.) to declarations.
2
3
  module Providers
3
- # Provides access to block expressions (do..end / {..}) within a declaration.
4
+ # Provides access to block expressions (do..end and brace blocks) within a declaration.
4
5
  module BlocksProvider
5
6
  # @return [Rubyzen::Collections::BlocksCollection] collection of block declarations
6
7
  def blocks
@@ -0,0 +1,29 @@
1
+ # RSpec entry point for Rubyzen.
2
+ #
3
+ # Loads the framework-agnostic core plus the RSpec matchers.
4
+ # Require it from your spec/spec_helper.rb:
5
+ #
6
+ # # Gemfile
7
+ # group :test do
8
+ # gem 'rubyzen-lint'
9
+ # gem 'rspec' # or rspec-rails
10
+ # end
11
+ #
12
+ # # spec/spec_helper.rb
13
+ # require 'rubyzen/rspec'
14
+ #
15
+ # Every RSpec project should already have the `rspec` gem.
16
+ # If it is missing we raise an error.
17
+ require_relative 'core'
18
+
19
+ begin
20
+ require 'rspec'
21
+ rescue LoadError
22
+ raise LoadError, "Rubyzen's RSpec matchers require the 'rspec' gem. " \
23
+ "Add `gem 'rspec'` to your Gemfile, or use the Minitest assertions via `require 'rubyzen/minitest'`."
24
+ end
25
+
26
+ require_relative 'expectation_helpers'
27
+ require_relative 'matchers/zen_empty_matcher'
28
+ require_relative 'matchers/zen_true_matcher'
29
+ require_relative 'matchers/zen_false_matcher'
@@ -1,3 +1,4 @@
1
1
  module Rubyzen
2
- VERSION = '0.1.0'
2
+ # @return [String] the current gem version
3
+ VERSION = '0.2.0'
3
4
  end
data/lib/rubyzen.rb CHANGED
@@ -1,98 +1,11 @@
1
- require 'rubocop-ast'
2
- require 'rspec'
3
- require 'zeitwerk'
4
-
5
- loader = Zeitwerk::Loader.for_gem
6
- loader.ignore("#{__dir__}/rubyzen/matchers")
7
- loader.ignore("#{__dir__}/rubyzen/rspec")
8
- loader.ignore("#{__dir__}/rubyzen/lint.rb")
9
- loader.ignore("#{__dir__}/rubyzen-lint.rb")
10
- loader.setup
11
-
12
- require_relative 'rubyzen/matchers/matcher_helpers'
13
- require_relative 'rubyzen/matchers/zen_empty_matcher'
14
- require_relative 'rubyzen/matchers/zen_true_matcher'
15
- require_relative 'rubyzen/matchers/zen_false_matcher'
16
-
17
- # Rubyzen is a Ruby architectural linter that lets you write lint rules as RSpec tests.
18
- # It wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural
19
- # rules across a codebase.
1
+ # Rubyzen entry point — loads the framework-agnostic core only.
20
2
  #
21
- # @example Basic usage
22
- # project = Rubyzen::Project.new(["/path/to/src", "/path/to/spec"])
23
- # controllers = project.files.with_paths("controllers/").classes
3
+ # `require 'rubyzen'` gives you the parsing/analysis API (Rubyzen::Project,
4
+ # declarations, collections) without any test framework attached. To write lint
5
+ # rules, require the adapter for your test framework instead:
24
6
  #
25
- # # Assert controllers don't call ActiveRecord directly
26
- # expect(controllers.all_methods.call_sites.with_name("where")).to zen_empty
7
+ # require 'rubyzen/rspec' # RSpec matchers: zen_empty / zen_true / zen_false
8
+ # require 'rubyzen/minitest' # Minitest assertions: assert_zen_empty / _true / _false
27
9
  #
28
- # @example Using auto-discovery (from project root)
29
- # project = Rubyzen::Project.new # scans app/, lib/, src/, spec/ automatically
30
- #
31
- module Rubyzen
32
- # Base error class for all Rubyzen errors.
33
- class Error < StandardError; end
34
-
35
- # Raised when a Ruby file cannot be parsed.
36
- class ParseError < Error; end
37
-
38
- # Yields the global configuration for customization.
39
- #
40
- # @example
41
- # Rubyzen.configure do |config|
42
- # config.paths = ['app', 'lib']
43
- # end
44
- def self.configure
45
- yield(configuration)
46
- end
47
-
48
- # Returns the global configuration instance.
49
- #
50
- # @return [Configuration]
51
- def self.configuration
52
- @configuration ||= Configuration.new
53
- end
54
-
55
- # Holds project path configuration with auto-discovery support.
56
- #
57
- # Resolution order:
58
- # 1. Explicit paths via {#paths=} (set via +Rubyzen.configure+)
59
- # 2. Auto-discovery of +app/+, +lib/+, +src/+, +spec/+ from +Dir.pwd+
60
- #
61
- # @example
62
- # Rubyzen.configure { |c| c.paths = ['app/models', 'app/controllers'] }
63
- # Rubyzen.configuration.project_paths #=> ["/full/path/app/models", "/full/path/app/controllers"]
64
- #
65
- class Configuration
66
- # Sets explicit paths to scan.
67
- # Relative paths are resolved against +Dir.pwd+.
68
- #
69
- # @param value [Array<String>] directories to analyze
70
- attr_writer :paths
71
-
72
- # Returns the resolved project paths.
73
- #
74
- # @return [Array<String>] absolute paths to directories to analyze
75
- def project_paths
76
- resolve_paths(@paths) || auto_discover_paths
77
- end
78
-
79
- private
80
-
81
- def resolve_paths(paths)
82
- return nil unless paths&.any?
83
-
84
- root = Dir.pwd
85
- paths.map do |path|
86
- File.expand_path(path, root)
87
- end
88
- end
89
-
90
- def auto_discover_paths
91
- root = Dir.pwd
92
- candidates = %w[app lib src spec].map { |d| File.join(root, d) }
93
- paths = candidates.select { |d| Dir.exist?(d) }
94
- paths = [root] if paths.empty?
95
- paths
96
- end
97
- end
98
- end
10
+ # Each adapter loads this core automatically
11
+ require_relative 'rubyzen/core'
data/rubyzen-lint.gemspec CHANGED
@@ -4,7 +4,7 @@ Gem::Specification.new do |spec|
4
4
  spec.name = 'rubyzen-lint'
5
5
  spec.version = Rubyzen::VERSION
6
6
  spec.authors = ['Perry Street Software']
7
- spec.summary = 'Architectural linter for Ruby — write lint rules as RSpec tests'
7
+ spec.summary = 'Architectural linter for Ruby — write lint rules as unit tests'
8
8
  spec.description = 'Rubyzen is a modern linter for Ruby that allows you to write architectural ' \
9
9
  'lint rules as unit tests. In the era of AI-generated code, it provides your ' \
10
10
  'team with deterministic guardrails to keep your codebase clean, maintainable, ' \
@@ -14,15 +14,20 @@ Gem::Specification.new do |spec|
14
14
 
15
15
  spec.required_ruby_version = '>= 3.1'
16
16
 
17
- spec.files = Dir.glob(%w[lib/**/*.rb rubyzen-lint.gemspec LICENSE README.md])
17
+ spec.files = Dir.glob(%w[lib/**/*.rb rubyzen-lint.gemspec LICENSE README.md CHANGELOG.md])
18
18
  spec.require_paths = ['lib']
19
19
 
20
20
  spec.add_dependency 'rubocop-ast', '~> 1.26'
21
21
  spec.add_dependency 'zeitwerk', '~> 2.6'
22
- spec.add_dependency 'rspec', '~> 3.12'
22
+
23
+ spec.add_development_dependency 'rspec', '~> 3.12'
24
+ spec.add_development_dependency 'minitest', '>= 5.0', '< 7.0'
25
+ spec.add_development_dependency 'rake', '~> 13.0'
23
26
 
24
27
  spec.metadata = {
25
28
  'source_code_uri' => 'https://github.com/perrystreetsoftware/Rubyzen',
26
- 'bug_tracker_uri' => 'https://github.com/perrystreetsoftware/Rubyzen/issues'
29
+ 'bug_tracker_uri' => 'https://github.com/perrystreetsoftware/Rubyzen/issues',
30
+ 'documentation_uri' => 'https://perrystreetsoftware.github.io/Rubyzen',
31
+ 'changelog_uri' => 'https://github.com/perrystreetsoftware/Rubyzen/blob/main/CHANGELOG.md'
27
32
  }
28
33
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubyzen-lint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Perry Street Software
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rubocop-ast
@@ -45,13 +45,47 @@ dependencies:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
47
  version: '3.12'
48
- type: :runtime
48
+ type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '3.12'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '5.0'
62
+ - - "<"
63
+ - !ruby/object:Gem::Version
64
+ version: '7.0'
65
+ type: :development
66
+ prerelease: false
67
+ version_requirements: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '5.0'
72
+ - - "<"
73
+ - !ruby/object:Gem::Version
74
+ version: '7.0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rake
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '13.0'
55
89
  description: Rubyzen is a modern linter for Ruby that allows you to write architectural
56
90
  lint rules as unit tests. In the era of AI-generated code, it provides your team
57
91
  with deterministic guardrails to keep your codebase clean, maintainable, and consistent
@@ -61,10 +95,15 @@ executables: []
61
95
  extensions: []
62
96
  extra_rdoc_files: []
63
97
  files:
98
+ - CHANGELOG.md
64
99
  - LICENSE
65
100
  - README.md
66
101
  - lib/rubyzen-lint.rb
67
102
  - lib/rubyzen.rb
103
+ - lib/rubyzen/assertions/assert_zen_empty.rb
104
+ - lib/rubyzen/assertions/assert_zen_false.rb
105
+ - lib/rubyzen/assertions/assert_zen_true.rb
106
+ - lib/rubyzen/assertions/zen_assertions.rb
68
107
  - lib/rubyzen/cache/parse_cache.rb
69
108
  - lib/rubyzen/collections/attributes_collection.rb
70
109
  - lib/rubyzen/collections/base_collection.rb
@@ -81,6 +120,7 @@ files:
81
120
  - lib/rubyzen/collections/raises_collection.rb
82
121
  - lib/rubyzen/collections/requires_collection.rb
83
122
  - lib/rubyzen/collections/rescues_collection.rb
123
+ - lib/rubyzen/core.rb
84
124
  - lib/rubyzen/declarations/attribute_declaration.rb
85
125
  - lib/rubyzen/declarations/block_declaration.rb
86
126
  - lib/rubyzen/declarations/call_site_declaration.rb
@@ -95,11 +135,12 @@ files:
95
135
  - lib/rubyzen/declarations/raise_declaration.rb
96
136
  - lib/rubyzen/declarations/require_declaration.rb
97
137
  - lib/rubyzen/declarations/rescue_declaration.rb
138
+ - lib/rubyzen/expectation_helpers.rb
98
139
  - lib/rubyzen/lint.rb
99
- - lib/rubyzen/matchers/matcher_helpers.rb
100
140
  - lib/rubyzen/matchers/zen_empty_matcher.rb
101
141
  - lib/rubyzen/matchers/zen_false_matcher.rb
102
142
  - lib/rubyzen/matchers/zen_true_matcher.rb
143
+ - lib/rubyzen/minitest.rb
103
144
  - lib/rubyzen/parsers/a_s_t_parser.rb
104
145
  - lib/rubyzen/project.rb
105
146
  - lib/rubyzen/providers/attributes_provider.rb
@@ -118,6 +159,7 @@ files:
118
159
  - lib/rubyzen/providers/rescues_provider.rb
119
160
  - lib/rubyzen/providers/source_code_provider.rb
120
161
  - lib/rubyzen/providers/visibility_provider.rb
162
+ - lib/rubyzen/rspec.rb
121
163
  - lib/rubyzen/version.rb
122
164
  - rubyzen-lint.gemspec
123
165
  homepage: https://github.com/perrystreetsoftware/Rubyzen
@@ -126,6 +168,8 @@ licenses:
126
168
  metadata:
127
169
  source_code_uri: https://github.com/perrystreetsoftware/Rubyzen
128
170
  bug_tracker_uri: https://github.com/perrystreetsoftware/Rubyzen/issues
171
+ documentation_uri: https://perrystreetsoftware.github.io/Rubyzen
172
+ changelog_uri: https://github.com/perrystreetsoftware/Rubyzen/blob/main/CHANGELOG.md
129
173
  post_install_message:
130
174
  rdoc_options: []
131
175
  require_paths:
@@ -144,5 +188,5 @@ requirements: []
144
188
  rubygems_version: 3.0.3.1
145
189
  signing_key:
146
190
  specification_version: 4
147
- summary: Architectural linter for Ruby — write lint rules as RSpec tests
191
+ summary: Architectural linter for Ruby — write lint rules as unit tests
148
192
  test_files: []
@@ -1,176 +0,0 @@
1
- module Rubyzen
2
- module Matchers
3
- # Shared helper methods used by Rubyzen's custom RSpec matchers.
4
- #
5
- # Provides utilities for normalizing exception lists, extracting item
6
- # details, matching items against allowlist/baseline entries, and
7
- # formatting failure messages.
8
- module MatcherHelpers
9
- # Normalizes a list of exception entries into unique, non-blank strings.
10
- #
11
- # @param entries [Array<String>, String, nil] raw exception entries
12
- # @return [Array<String>] deduplicated, stripped, non-empty strings
13
- def normalize_exception_entries(entries)
14
- Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
15
- end
16
-
17
- # Extracts identifying details from a declaration item.
18
- #
19
- # @param item [Object] a declaration object (e.g., FileDeclaration, ClassDeclaration)
20
- # @return [Hash{Symbol => String, nil}] hash with :name, :class_name, :file_path, :line
21
- def item_details(item)
22
- {
23
- name: item.respond_to?(:name) ? item.name : nil,
24
- class_name: item.respond_to?(:class_name) ? item.class_name : nil,
25
- file_path: item.respond_to?(:file_path) ? item.file_path : 'Unknown file',
26
- line: item.respond_to?(:line) ? item.line : nil
27
- }
28
- end
29
-
30
- # Returns a list of unique identifier strings for an item, used for matching.
31
- #
32
- # @param item [Object] a declaration object
33
- # @return [Array<String>] identifiers such as name, class name, file path, and file:line
34
- def item_identifiers(item)
35
- details = item_details(item)
36
- identifiers = [details[:name], details[:class_name], details[:file_path]]
37
-
38
- if details[:line]
39
- identifiers << "#{details[:file_path]}:#{details[:line]}"
40
- end
41
-
42
- identifiers.compact.uniq
43
- end
44
-
45
- # Checks whether a given exception entry string matches an item.
46
- #
47
- # @param entry [String] an allowlist or baseline entry
48
- # @param item [Object] a declaration object
49
- # @return [Boolean] true if the entry matches the item by name, class, or path
50
- def exception_entry_matches_item?(entry, item)
51
- normalized_entry = entry.to_s.strip
52
- return false if normalized_entry.empty?
53
-
54
- details = item_details(item)
55
- return true if item_identifiers(item).include?(normalized_entry)
56
-
57
- file_path = details[:file_path]
58
- file_path && (file_path.end_with?(normalized_entry) || file_path.end_with?("/#{normalized_entry}"))
59
- end
60
-
61
- # Classifies items into violations, baseline matches, allowlist matches,
62
- # and detects stale entries in either list.
63
- #
64
- # @param subject_collection [Array, Object] items to classify
65
- # @param allowlist [Array<String>, nil] allowed exception entries
66
- # @param baseline [Array<String>, nil] baseline exception entries
67
- # @return [Hash{Symbol => Array<String>}] keys: :violations, :baseline, :allowlist,
68
- # :stale_baseline, :stale_allowlist
69
- def classify_items(subject_collection, allowlist: nil, baseline: nil)
70
- items = Array(subject_collection).compact
71
- normalized_allowlist = normalize_exception_entries(allowlist)
72
- normalized_baseline = normalize_exception_entries(baseline)
73
- matched_baseline_entries = []
74
- matched_allowlist_entries = []
75
-
76
- grouped_items = items.group_by do |item|
77
- matching_baseline_entry = normalized_baseline.find do |entry|
78
- exception_entry_matches_item?(entry, item)
79
- end
80
-
81
- if matching_baseline_entry
82
- matched_baseline_entries << matching_baseline_entry
83
- :baseline
84
- else
85
- matching_allowlist_entry = normalized_allowlist.find do |entry|
86
- exception_entry_matches_item?(entry, item)
87
- end
88
-
89
- if matching_allowlist_entry
90
- matched_allowlist_entries << matching_allowlist_entry
91
- :allowlist
92
- else
93
- :violations
94
- end
95
- end
96
- end
97
-
98
- classifications = {
99
- baseline: Array(grouped_items[:baseline]).map { |item| element_name(item) },
100
- allowlist: Array(grouped_items[:allowlist]).map { |item| element_name(item) },
101
- violations: Array(grouped_items[:violations]).map { |item| element_name(item) }
102
- }
103
-
104
- classifications.merge(
105
- stale_baseline: normalized_baseline - matched_baseline_entries.uniq,
106
- stale_allowlist: normalized_allowlist - matched_allowlist_entries.uniq
107
- )
108
- end
109
-
110
- # Formats a human-readable description of an item for failure messages.
111
- #
112
- # @param item [Object] a declaration object
113
- # @return [String] formatted multi-line description
114
- def element_name(item)
115
- details = item_details(item)
116
- location = [details[:file_path], details[:line]].compact.join(':')
117
-
118
- case
119
- when details[:name] && details[:class_name]
120
- " - element: #{details[:name]}\n - class: #{details[:class_name]}\n - file: #{location}"
121
- when details[:name]
122
- " - element: #{details[:name]}\n - file: #{location}"
123
- when details[:class_name]
124
- " - class: #{details[:class_name]}\n - file: #{location}"
125
- else
126
- " - unknown element in #{location}"
127
- end
128
- end
129
-
130
- # Builds a formatted string of violations and stale entries for failure output.
131
- #
132
- # @return [String, nil] formatted sections or nil if no classified items
133
- def formatted_matcher_groups
134
- return unless defined?(@classified_items) && @classified_items
135
-
136
- sections = []
137
-
138
- if @classified_items[:violations].any?
139
- sections << "Violations:\n#{@classified_items[:violations].join("\n")}"
140
- end
141
-
142
- if @classified_items[:stale_baseline].any?
143
- stale_entries = @classified_items[:stale_baseline].map { |entry| " - #{entry}" }
144
- sections << "Stale baseline entries:\n#{stale_entries.join("\n")}"
145
- end
146
-
147
- if @classified_items[:stale_allowlist].any?
148
- stale_entries = @classified_items[:stale_allowlist].map { |entry| " - #{entry}" }
149
- sections << "Stale allowlist entries:\n#{stale_entries.join("\n")}"
150
- end
151
-
152
- sections.join("\n")
153
- end
154
-
155
- def self.included(base)
156
- base.define_method(:message_for_failure) do |base_message|
157
- return @failure_message if @failure_message
158
-
159
- details = formatted_matcher_groups
160
-
161
- if @custom_message
162
- if details && !details.empty?
163
- "#{@custom_message}\n#{details}"
164
- else
165
- @custom_message
166
- end
167
- elsif details && !details.empty?
168
- "#{base_message}\n#{details}"
169
- else
170
- base_message
171
- end
172
- end
173
- end
174
- end
175
- end
176
- end