diff --git a/lib/rubyzen.rb b/lib/rubyzen.rb index b8093b0..d26d2ca 100644 --- a/lib/rubyzen.rb +++ b/lib/rubyzen.rb @@ -6,6 +6,7 @@ # Load RSpec matchers manually since they don't follow class/module naming conventions require_relative 'rubyzen/matchers/be_empty_matcher' +require_relative 'rubyzen/matchers/be_empty_with_exceptions_matcher' require_relative 'rubyzen/matchers/be_true_matcher' require_relative 'rubyzen/matchers/be_false_matcher' diff --git a/lib/rubyzen/collections/modules_collection.rb b/lib/rubyzen/collections/modules_collection.rb index d25d945..c79a5da 100644 --- a/lib/rubyzen/collections/modules_collection.rb +++ b/lib/rubyzen/collections/modules_collection.rb @@ -6,6 +6,11 @@ module Collections class ModulesCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider + def all_methods + all_methods = flat_map(&:all_methods) + MethodsCollection.new(all_methods) + end + def classes all_classes = flat_map(&:classes) ClassesCollection.new(all_classes) diff --git a/lib/rubyzen/declarations/class_declaration.rb b/lib/rubyzen/declarations/class_declaration.rb index 1a4b0b7..b18a52b 100644 --- a/lib/rubyzen/declarations/class_declaration.rb +++ b/lib/rubyzen/declarations/class_declaration.rb @@ -41,6 +41,7 @@ def name_without_modules def superclass_name super_node = node.children[1] return nil unless super_node&.type == :const + super_node.const_name end @@ -50,7 +51,7 @@ def superclass_prefix?(prefix) def instance_methods Collections::MethodsCollection.new( - node.each_node(:def).map do |def_node| + instance_method_nodes.map do |def_node| MethodDeclaration.new(def_node, self) end ) @@ -58,8 +59,8 @@ def instance_methods def class_methods Collections::MethodsCollection.new( - node.each_node(:defs).map do |defs_node| - MethodDeclaration.new(defs_node, self) + class_method_nodes.map do |method_node| + MethodDeclaration.new(method_node, self) end ) end @@ -71,6 +72,57 @@ def called_method_names def top_level_module file_declaration.top_level_module_name end + + private + + def class_body_node + node.children[2] + end + + def class_body_children + body = class_body_node + return [] unless body + + body.type == :begin ? body.child_nodes : [body] + end + + def instance_method_nodes + class_body_children.select { |child| child.type == :def } + end + + def class_defs_nodes + class_body_children.select do |child| + child.type == :defs && child.children[0]&.type == :self + end + end + + def class_method_nodes + class_defs_nodes + class_sclass_def_nodes + end + + def class_sclass_def_nodes + class_body_children + .select { |child| singleton_class_node?(child) } + .flat_map do |child| + body_children(child.children[1]).select do |body_child| + method_node?(body_child) + end + end + end + + def singleton_class_node?(child) + child.type == :sclass && child.children[0]&.type == :self + end + + def body_children(body) + return [] unless body + + body.type == :begin ? body.child_nodes : [body] + end + + def method_node?(child) + %i[def defs].include?(child.type) + end end end end diff --git a/lib/rubyzen/declarations/module_declaration.rb b/lib/rubyzen/declarations/module_declaration.rb index 6bbed2e..d42c03a 100644 --- a/lib/rubyzen/declarations/module_declaration.rb +++ b/lib/rubyzen/declarations/module_declaration.rb @@ -38,7 +38,48 @@ def modules end def classes - node.each_node(:class).map { |class_node| ClassDeclaration.new(class_node, file_declaration) } + node.each_node(:class).map do |class_node| + ClassDeclaration.new(class_node, file_declaration) + end + end + + def all_methods + Collections::MethodsCollection.new( + direct_method_nodes.map { |method_node| MethodDeclaration.new(method_node, self) } + ) + end + + private + + def module_body_node + node.children[1] + end + + def module_body_children + body = module_body_node + return [] unless body + + body.type == :begin ? body.child_nodes : [body] + end + + def direct_method_nodes + direct_nodes = module_body_children.select do |child| + %i[def defs].include?(child.type) + end + singleton_nodes = module_body_children.flat_map do |child| + next [] unless child.type == :sclass + next [] unless child.children[0]&.type == :self + + sclass_body = child.children[1] + next [] unless sclass_body + + sclass_children = sclass_body.type == :begin ? sclass_body.child_nodes : [sclass_body] + sclass_children.select do |sclass_child| + %i[def defs].include?(sclass_child.type) + end + end + + direct_nodes + singleton_nodes end end end diff --git a/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb b/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb new file mode 100644 index 0000000..584a418 --- /dev/null +++ b/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb @@ -0,0 +1,32 @@ +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 +end diff --git a/lib/rubyzen/matchers/matcher_helpers.rb b/lib/rubyzen/matchers/matcher_helpers.rb index 467f1cb..6d74d5e 100644 --- a/lib/rubyzen/matchers/matcher_helpers.rb +++ b/lib/rubyzen/matchers/matcher_helpers.rb @@ -1,32 +1,134 @@ module Rubyzen module Matchers module MatcherHelpers + def normalize_exception_entries(entries) + Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq + end + + 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 + + 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 + + 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 + + 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 + def element_name(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' - location = "#{file_path}:#{item.line}" + details = item_details(item) + location = [details[:file_path], details[:line]].compact.join(':') case - when name && class_name - " - element: #{name}\n - class: #{class_name}\n - file: #{location}" - when name - " - element: #{name}\n - file: #{location}" - when class_name - " - class: #{class_name}\n - file: #{location}" + 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 + 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 + 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 @offenders.any? - "#{@custom_message}\nViolations: #{@offenders.join(', ')}" + if details && !details.empty? + "#{@custom_message}\n#{details}" else @custom_message end + elsif details && !details.empty? + "#{base_message}\n#{details}" else base_message end diff --git a/lib/rubyzen/providers/class_name_provider.rb b/lib/rubyzen/providers/class_name_provider.rb index 9503f0d..c1bfc02 100644 --- a/lib/rubyzen/providers/class_name_provider.rb +++ b/lib/rubyzen/providers/class_name_provider.rb @@ -9,9 +9,25 @@ def class_name def class_name_recursive(declaration) return if declaration.nil? - return declaration.name if declaration.is_a?(Rubyzen::Declarations::ClassDeclaration) return nil if declaration.is_a?(Rubyzen::Declarations::FileDeclaration) - return class_name_recursive(declaration.parent) if declaration.respond_to?(:parent) + return declaration.name if declaration.is_a?(Rubyzen::Declarations::ClassDeclaration) + + if declaration.is_a?(Rubyzen::Declarations::ModuleDeclaration) + return module_class_name(declaration) + end + + class_name_recursive(parent_declaration(declaration)) + end + + def module_class_name(declaration) + parent_class_name = class_name_recursive(parent_declaration(declaration)) + parent_class_name || declaration.name + end + + def parent_declaration(declaration) + return unless declaration.respond_to?(:parent) + + declaration.parent end end end diff --git a/sample_project/spec/matchers/be_empty_with_exceptions_matcher_spec.rb b/sample_project/spec/matchers/be_empty_with_exceptions_matcher_spec.rb new file mode 100644 index 0000000..0f2bbfd --- /dev/null +++ b/sample_project/spec/matchers/be_empty_with_exceptions_matcher_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'be_empty_with_exceptions' do + TestItem = 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) + end + + it 'groups baseline, allowlist, and live violations in the failure output' do + baseline_item = build_item( + name: 'index', + class_name: 'Legacy::Controller', + file_path: File.join(root_path, 'src/controllers/legacy_controller.rb'), + line: 12 + ) + allowlisted_item = build_item( + name: 'show', + class_name: 'Allowed::Controller', + file_path: File.join(root_path, 'src/controllers/allowed_controller.rb'), + line: 18 + ) + violating_item = build_item( + name: 'create', + class_name: 'New::Controller', + file_path: File.join(root_path, 'src/controllers/new_controller.rb'), + line: 24 + ) + + matcher = be_empty_with_exceptions( + allowlist: ['Allowed::Controller'], + baseline: ['Legacy::Controller'] + ) + + expect(matcher.matches?([baseline_item, allowlisted_item, violating_item])).to be(false) + expect(matcher.failure_message).to include('Expected to be empty, but found live violations.') + expect(matcher.failure_message).to include('Violations:') + expect(matcher.failure_message).to include('New::Controller') + expect(matcher.failure_message).not_to include('Stale baseline entries:') + expect(matcher.failure_message).not_to include('Stale allowlist entries:') + end + + it 'matches baseline entries against file path suffixes' do + item = build_item( + name: 'call', + class_name: 'Repo::UserRepo', + file_path: File.join(root_path, 'src/repos/user_repo.rb'), + line: 7 + ) + + expect([item]).to be_empty_with_exceptions(baseline: ['src/repos/user_repo.rb']) + end + + it 'fails when a baseline entry is no longer needed' do + matcher = be_empty_with_exceptions(baseline: ['Legacy::Controller']) + + expect(matcher.matches?([])).to be(false) + expect(matcher.failure_message).to include('Expected to be empty, but found stale baseline entries.') + expect(matcher.failure_message).to include('Stale baseline entries:') + expect(matcher.failure_message).to include('Legacy::Controller') + end + + it 'uses a combined failure message when both live violations and stale baseline entries exist' do + violating_item = build_item( + name: 'create', + class_name: 'New::Controller', + file_path: File.join(root_path, 'src/controllers/new_controller.rb'), + line: 24 + ) + + matcher = be_empty_with_exceptions(baseline: ['Legacy::Controller']) + + expect(matcher.matches?([violating_item])).to be(false) + expect(matcher.failure_message).to include('Expected to be empty, but found live violations and stale baseline entries.') + expect(matcher.failure_message).to include('Violations:') + expect(matcher.failure_message).to include('Stale baseline entries:') + end + + it 'fails when an allowlist entry is no longer needed' do + matcher = be_empty_with_exceptions(allowlist: ['Allowed::Controller']) + + expect(matcher.matches?([])).to be(false) + expect(matcher.failure_message).to include('Expected to be empty, but found stale allowlist entries.') + expect(matcher.failure_message).to include('Stale allowlist entries:') + expect(matcher.failure_message).to include('Allowed::Controller') + end + + it 'uses a combined failure message when live violations and stale allowlist entries exist' do + violating_item = build_item( + name: 'create', + class_name: 'New::Controller', + file_path: File.join(root_path, 'src/controllers/new_controller.rb'), + line: 24 + ) + + matcher = be_empty_with_exceptions(allowlist: ['Allowed::Controller']) + + expect(matcher.matches?([violating_item])).to be(false) + expect(matcher.failure_message).to include('Expected to be empty, but found live violations and stale allowlist entries.') + expect(matcher.failure_message).to include('Violations:') + expect(matcher.failure_message).to include('Stale allowlist entries:') + end +end \ No newline at end of file