From 94a9e4e06776fd3346ff6acd22042743c11b90fe Mon Sep 17 00:00:00 2001 From: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:53:04 +0530 Subject: [PATCH] chore: Add custom RuboCop cop to enforce one class per file (#12947) --- .rubocop.yml | 4 + ...0230515051424_update_article_image_keys.rb | 2 + rubocop/one_class_per_file.rb | 86 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 rubocop/one_class_per_file.rb diff --git a/.rubocop.yml b/.rubocop.yml index ea688792b..4b32991d1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ plugins: require: - ./rubocop/use_from_email.rb - ./rubocop/custom_cop_location.rb + - ./rubocop/one_class_per_file.rb Layout/LineLength: Max: 150 @@ -205,6 +206,9 @@ UseFromEmail: CustomCopLocation: Enabled: true +Style/OneClassPerFile: + Enabled: true + AllCops: NewCops: enable Exclude: diff --git a/db/migrate/20230515051424_update_article_image_keys.rb b/db/migrate/20230515051424_update_article_image_keys.rb index db208470f..3e8b9f77d 100644 --- a/db/migrate/20230515051424_update_article_image_keys.rb +++ b/db/migrate/20230515051424_update_article_image_keys.rb @@ -44,6 +44,7 @@ class ArticleKeyConverter end end +# rubocop:disable Style/OneClassPerFile class UpdateArticleImageKeys < ActiveRecord::Migration[7.0] def change # Iterate through all articles @@ -53,3 +54,4 @@ class UpdateArticleImageKeys < ActiveRecord::Migration[7.0] end end end +# rubocop:enable Style/OneClassPerFile diff --git a/rubocop/one_class_per_file.rb b/rubocop/one_class_per_file.rb new file mode 100644 index 000000000..47fe9a481 --- /dev/null +++ b/rubocop/one_class_per_file.rb @@ -0,0 +1,86 @@ +require 'rubocop' + +# Enforces having only one class definition per file +# Excludes common Ruby patterns: +# - Nested Scope classes (Pundit pattern) +# - Single-line exception classes inheriting from StandardError or other exceptions +# - Nested classes within exception/error hierarchies +class RuboCop::Cop::Style::OneClassPerFile < RuboCop::Cop::Base + MSG = 'Define only one class per file.'.freeze + + def on_new_investigation + return unless processed_source.ast + + class_nodes = processed_source.ast.each_node(:class).to_a + return if class_nodes.size <= 1 + + class_nodes[1..].each do |node| + next if allowed_nested_class?(node) + + add_offense(node, message: MSG) + end + end + + private + + def allowed_nested_class?(node) + # Allow nested Scope classes (Pundit pattern) + return true if scope_class?(node) + + # Allow nested Request classes (Rack::Attack pattern) + return true if rack_attack_request_class?(node) + + # Allow exception classes (single-line or multi-line) + return true if exception_class?(node) + + # Allow classes within CustomExceptions modules + return true if in_custom_exceptions_module?(node) + + # Allow common nested patterns: Builder, Factory, Result, Response, Params, etc. + return true if common_nested_pattern?(node) + + false + end + + def scope_class?(node) + class_name = node.identifier.source + class_name == 'Scope' + end + + def rack_attack_request_class?(node) + class_name = node.identifier.source + return false unless class_name == 'Request' + + # Check if we're inside a Rack::Attack class + node.each_ancestor(:class).any? do |ancestor| + ancestor.identifier.source.include?('Rack::Attack') + end + end + + def exception_class?(node) + # Check if the class inherits from StandardError or ends with 'Error' + return false unless node.parent_class + + parent_class = node.parent_class.source + parent_class.include?('Error') || parent_class.include?('StandardError') + end + + def in_custom_exceptions_module?(node) + # Check if any parent node is a module containing 'CustomExceptions' + node.each_ancestor(:module).any? do |ancestor| + ancestor.identifier.source.include?('CustomExceptions') + end + end + + def common_nested_pattern?(node) + class_name = node.identifier.source + + # Common nested class patterns in Ruby/Rails + nested_patterns = %w[Builder Factory Result Response Params Config Configuration + Context Query Form Validator Serializer Presenter Decorator + Command Handler] + + # Check if class name ends with any of these patterns + nested_patterns.any? { |pattern| class_name.end_with?(pattern) } + end +end