diff --git a/Gemfile.lock b/Gemfile.lock index 2b97be8..9369a3d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,47 +1,49 @@ GEM remote: https://rubygems.org/ specs: - ast (2.4.2) + ast (2.4.3) coderay (1.1.3) - diff-lcs (1.5.1) + diff-lcs (1.6.2) method_source (1.1.0) ostruct (0.6.3) - parser (3.3.6.0) + parser (3.3.11.1) ast (~> 2.4.1) racc + prism (1.9.0) pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) racc (1.8.1) - require_all (3.0.0) - rspec (3.13.0) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.2) + rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.2) - rubocop-ast (1.36.2) - parser (>= 3.3.1.0) + rspec-support (3.13.7) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) yaml (0.1.1) + zeitwerk (2.7.5) PLATFORMS - arm64-darwin-23 + aarch64-linux ruby DEPENDENCIES ostruct (~> 0.6.2) pry (~> 0.14.1) - require_all (~> 3.0) rspec (~> 3.12) rubocop-ast (~> 1.26) yaml (~> 0.1.0) + zeitwerk (~> 2.6) BUNDLED WITH - 2.5.18 + 2.5.22 diff --git a/README.md b/README.md index 9134c2d..a94919a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ Rubyzen uses [RuboCop AST](https://github.com/rubocop/rubocop-ast) under the hoo - Confirming that model classes do not use question-mark in methods, enforcing us to use the Ask pattern. ### Running Lint Rules + +You should first start a devcontainer, with `devcontainer open .` run from the `Rubyzen` folder. + To run lint rules, execute RSpec with the path to your rule specifications. The rules will analyze the currently mounted target project: ```bash diff --git a/lib/rubyzen/matchers/be_empty_matcher.rb b/lib/rubyzen/matchers/be_empty_matcher.rb index ad6c38e..97a866b 100644 --- a/lib/rubyzen/matchers/be_empty_matcher.rb +++ b/lib/rubyzen/matchers/be_empty_matcher.rb @@ -1,26 +1,46 @@ -RSpec::Matchers.define :be_empty do |custom_message=nil| +RSpec::Matchers.define :be_empty do |custom_message=nil, allowlist: nil, baseline: nil| include Rubyzen::Matchers::MatcherHelpers match do |subject_collection| - @custom_message = custom_message - @offenders = [] - - items = Array(subject_collection) # to handle one or multiple subjects - - items.each do |item| - @offenders << element_name(item) if item != nil - end - - @offenders.empty? + options = custom_message.is_a?(Hash) ? custom_message : {} + resolved_allowlist = allowlist || options[:allowlist] || options['allowlist'] + resolved_baseline = baseline || options[:baseline] || options['baseline'] + @custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash)) + + @classified_items = classify_items( + subject_collection, + allowlist: resolved_allowlist, + baseline: resolved_baseline + ) + @offenders = @classified_items[:violations] + stale_exception_groups = [] + stale_exception_groups << 'baseline entries' if @classified_items[:stale_baseline].any? + stale_exception_groups << 'allowlist entries' if @classified_items[:stale_allowlist].any? + + @failure_reason = if @classified_items[:violations].any? && stale_exception_groups.any? + "Expected to be empty, but found live violations and stale #{stale_exception_groups.join(' and ')}." + elsif @classified_items[:violations].any? + if resolved_baseline || resolved_allowlist + 'Expected to be empty, but found live violations.' + else + 'Expected to be empty, but had elements.' + end + elsif stale_exception_groups.any? + "Expected to be empty, but found stale #{stale_exception_groups.join(' and ')}." + end + + @classified_items[:violations].empty? && + @classified_items[:stale_baseline].empty? && + @classified_items[:stale_allowlist].empty? end failure_message do |_| - message_for_failure("Expected to be empty, but had elements.\n#{@offenders.join("\n")}") + message_for_failure(@failure_reason || 'Expected to be empty, but had elements.') end failure_message_when_negated do |_| - message_for_failure("Expected not to be empty, but had no elements:\n#{@offenders.join("\n")}") + message_for_failure('Expected not to be empty, but had no elements.') end end diff --git a/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb b/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb index 584a418..3ab5a44 100644 --- a/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb +++ b/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb @@ -1,32 +1,14 @@ RSpec::Matchers.define :be_empty_with_exceptions do |custom_message=nil, allowlist: nil, baseline: nil| - include Rubyzen::Matchers::MatcherHelpers - - match do |subject_collection| - @custom_message = custom_message - @classified_items = classify_items(subject_collection, allowlist: allowlist, baseline: baseline) - @offenders = @classified_items[:violations] - stale_exception_groups = [] - stale_exception_groups << 'baseline entries' if @classified_items[:stale_baseline].any? - stale_exception_groups << 'allowlist entries' if @classified_items[:stale_allowlist].any? - - @failure_reason = if @classified_items[:violations].any? && stale_exception_groups.any? - "Expected to be empty, but found live violations and stale #{stale_exception_groups.join(' and ')}." - elsif @classified_items[:violations].any? - 'Expected to be empty, but found live violations.' - elsif stale_exception_groups.any? - "Expected to be empty, but found stale #{stale_exception_groups.join(' and ')}." - end - - @classified_items[:violations].empty? && - @classified_items[:stale_baseline].empty? && - @classified_items[:stale_allowlist].empty? - end - - failure_message do |_| - message_for_failure(@failure_reason || 'Expected to be empty, but had unmatched elements.') - end - - failure_message_when_negated do |_| - message_for_failure('Expected not to be empty, but had no matched elements.') - end + match do |subject_collection| + @matcher = be_empty(custom_message, allowlist: allowlist, baseline: baseline) + @matcher.matches?(subject_collection) + end + + failure_message do |_| + @matcher.failure_message + end + + failure_message_when_negated do |_| + @matcher.failure_message_when_negated + end end diff --git a/lib/rubyzen/matchers/be_true_matcher.rb b/lib/rubyzen/matchers/be_true_matcher.rb index 41f08e8..bd35cd6 100644 --- a/lib/rubyzen/matchers/be_true_matcher.rb +++ b/lib/rubyzen/matchers/be_true_matcher.rb @@ -1,31 +1,52 @@ -RSpec::Matchers.define :be_true do |custom_message=nil| +RSpec::Matchers.define :be_true do |custom_message=nil, allowlist: nil, baseline: nil| include Rubyzen::Matchers::MatcherHelpers match do |subject_collection| - @custom_message = custom_message + options = custom_message.is_a?(Hash) ? custom_message : {} + resolved_allowlist = allowlist || options[:allowlist] || options['allowlist'] + resolved_baseline = baseline || options[:baseline] || options['baseline'] + @custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash)) @offenders = [] if block_arg != nil items = Array(subject_collection) # to handle one or multiple subjects - items.each do |item| - @offenders << element_name(item) unless block_arg.call(item) - end - - @offenders.empty? + failing_items = items.filter { |item| !block_arg.call(item) } + @classified_items = classify_items( + failing_items, + allowlist: resolved_allowlist, + baseline: resolved_baseline + ) + @offenders = @classified_items[:violations] + + stale_exception_groups = [] + stale_baseline = @classified_items[:stale_baseline] + stale_allowlist = @classified_items[:stale_allowlist] + stale_exception_groups << 'baseline entries' if stale_baseline.any? + stale_exception_groups << 'allowlist entries' if stale_allowlist.any? + + @failure_reason = if @offenders.any? && stale_exception_groups.any? + "Expected to return true for all elements, but found live violations and stale #{stale_exception_groups.join(' and ')}." + elsif @offenders.any? + "Expected to return true for all elements, but returned false for:\n#{@offenders.join("\n")}" + elsif stale_exception_groups.any? + "Expected to return true for all elements, but found stale #{stale_exception_groups.join(' and ')}." + end + + @offenders.empty? && stale_baseline.empty? && stale_allowlist.empty? else - @failure_message = "Expected a block, but got nil." + @failure_reason = "Expected a block, but got nil." false end end failure_message do |_| - message_for_failure("Expected to return true for all elements, but returned false for:\n#{@offenders.join("\n")}") + message_for_failure(@failure_reason || "Expected to return true for all elements.") end failure_message_when_negated do |_| - message_for_failure("Expected to return false for at least one element, but returned true for:\n#{@offenders.join("\n")}") + message_for_failure("Expected to return false for at least one element, but returned true for:\n#{Array(@classified_items&.dig(:baseline)).join("\n")}") end end diff --git a/sample_project/spec/matchers/be_empty_with_exceptions_matcher_spec.rb b/sample_project/spec/matchers/be_empty_matcher_spec.rb similarity index 94% rename from sample_project/spec/matchers/be_empty_with_exceptions_matcher_spec.rb rename to sample_project/spec/matchers/be_empty_matcher_spec.rb index 0f2bbfd..0ca5730 100644 --- a/sample_project/spec/matchers/be_empty_with_exceptions_matcher_spec.rb +++ b/sample_project/spec/matchers/be_empty_matcher_spec.rb @@ -2,13 +2,13 @@ require_relative '../spec_helper' -RSpec.describe 'be_empty_with_exceptions' do - TestItem = Struct.new(:name, :class_name, :file_path, :line, keyword_init: true) +RSpec.describe 'be_empty' do + let(:test_item_class) { Struct.new(:name, :class_name, :file_path, :line, keyword_init: true) } let(:root_path) { File.expand_path('../..', __dir__) } def build_item(name:, class_name:, file_path:, line: 1) - TestItem.new(name: name, class_name: class_name, file_path: file_path, line: line) + test_item_class.new(name: name, class_name: class_name, file_path: file_path, line: line) end it 'groups baseline, allowlist, and live violations in the failure output' do diff --git a/sample_project/spec/matchers/be_true_matcher_spec.rb b/sample_project/spec/matchers/be_true_matcher_spec.rb new file mode 100644 index 0000000..aa10a4d --- /dev/null +++ b/sample_project/spec/matchers/be_true_matcher_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'be_true' do + let(:test_item_class) { Struct.new(:name, :class_name, :file_path, :line, keyword_init: true) } + + let(:root_path) { File.expand_path('../..', __dir__) } + + def build_item(class_name: 'Questions::Albums::Type', file_path: 'src/questions/albums/type.rb') + test_item_class.new( + name: 'private_album?', + class_name: class_name, + file_path: File.join(root_path, file_path), + line: 10 + ) + end + + it 'fails when a baseline entry is stale' do + matcher = be_true(baseline: ['Questions::Albums::Type']) { |_item| true } + + expect(matcher.matches?([build_item])).to be(false) + expect(matcher.failure_message).to include('Expected to return true for all elements, but found stale baseline entries.') + expect(matcher.failure_message).to include('Stale baseline entries:') + expect(matcher.failure_message).to include('Questions::Albums::Type') + end + + it 'treats baseline entries as expected failures when they still fail' do + matcher = be_true(baseline: ['Questions::Albums::Type']) { |_item| false } + + expect(matcher.matches?([build_item])).to be(true) + end + + it 'fails with expect syntax when baseline entry refers to a passing type' do + items = [build_item] + baseline = ['Questions::Albums::Type'] + matcher = be_true(baseline: baseline) { |_item| true } + + expect(matcher.matches?(items)).to be(false) + expect(matcher.failure_message).to include('Stale baseline entries:') + expect(matcher.failure_message).to include('Questions::Albums::Type') + end + + it 'fails with expect syntax when baseline entry refers to a non-existent type' do + items = [build_item] + baseline = ['Questions::Albums::NonExistentTypeThatWasJustDeleted'] + matcher = be_true(baseline: baseline) { |_item| true } + + expect(matcher.matches?(items)).to be(false) + expect(matcher.failure_message).to include('Stale baseline entries:') + expect(matcher.failure_message).to include('Questions::Albums::NonExistentTypeThatWasJustDeleted') + end + + it 'treats allowlisted entries as expected failures when they still fail' do + matcher = be_true(allowlist: ['Questions::Albums::Type']) { |_item| false } + + expect(matcher.matches?([build_item])).to be(true) + end + + it 'fails when an allowlist entry is stale' do + matcher = be_true(allowlist: ['Questions::Albums::Type']) { |_item| true } + + expect(matcher.matches?([build_item])).to be(false) + expect(matcher.failure_message).to include('Expected to return true for all elements, but found stale allowlist entries.') + expect(matcher.failure_message).to include('Stale allowlist entries:') + expect(matcher.failure_message).to include('Questions::Albums::Type') + end + +end