why-classes 0.1.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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +128 -0
  4. data/config/default.yml +69 -0
  5. data/exe/why-classes +6 -0
  6. data/lib/why_classes/ast/class_shape.rb +106 -0
  7. data/lib/why_classes/ast/node_helpers.rb +186 -0
  8. data/lib/why_classes/cli.rb +97 -0
  9. data/lib/why_classes/config_loader.rb +51 -0
  10. data/lib/why_classes/configuration.rb +68 -0
  11. data/lib/why_classes/correction.rb +44 -0
  12. data/lib/why_classes/correctors/data_bucket_corrector.rb +79 -0
  13. data/lib/why_classes/disable_comments.rb +59 -0
  14. data/lib/why_classes/file_finder.rb +43 -0
  15. data/lib/why_classes/formatters/base_formatter.rb +30 -0
  16. data/lib/why_classes/formatters/clang_formatter.rb +19 -0
  17. data/lib/why_classes/formatters/diff_formatter.rb +107 -0
  18. data/lib/why_classes/formatters/json_formatter.rb +42 -0
  19. data/lib/why_classes/formatters/progress_formatter.rb +38 -0
  20. data/lib/why_classes/offense.rb +29 -0
  21. data/lib/why_classes/options.rb +32 -0
  22. data/lib/why_classes/parser.rb +32 -0
  23. data/lib/why_classes/registry.rb +39 -0
  24. data/lib/why_classes/rule.rb +89 -0
  25. data/lib/why_classes/rules/data_bucket.rb +79 -0
  26. data/lib/why_classes/rules/function_bucket.rb +55 -0
  27. data/lib/why_classes/rules/inheritance_for_mixins.rb +37 -0
  28. data/lib/why_classes/rules/invalid_initial_state.rb +56 -0
  29. data/lib/why_classes/rules/single_attribute_reader.rb +38 -0
  30. data/lib/why_classes/rules/stateless_singleton_methods.rb +50 -0
  31. data/lib/why_classes/runner.rb +115 -0
  32. data/lib/why_classes/source_file.rb +45 -0
  33. data/lib/why_classes/version.rb +5 -0
  34. data/lib/why_classes.rb +32 -0
  35. metadata +113 -0
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "offense"
4
+ require_relative "registry"
5
+ require_relative "ast/node_helpers"
6
+ require_relative "ast/class_shape"
7
+
8
+ module WhyClasses
9
+ # Base class for every detector. A rule walks the parser-style AST, dispatches
10
+ # to `on_<type>` handlers a subclass defines, and emits Offenses via
11
+ # #add_offense. Rules never mutate source; a correctable offense carries a
12
+ # `corrector` callable that queues edits on a Parser::Source::TreeRewriter.
13
+ class Rule
14
+ H = AST::NodeHelpers
15
+
16
+ class << self
17
+ def rule_name
18
+ name.split("::").last
19
+ end
20
+
21
+ # Human-readable one-liner shown by `--list-rules`. Override in subclasses.
22
+ def description
23
+ ""
24
+ end
25
+
26
+ # Whether this rule can ever autocorrect. Advice-only rules return false.
27
+ def autocorrectable?
28
+ false
29
+ end
30
+
31
+ def inherited(subclass)
32
+ super
33
+ # Anonymous subclasses (in specs) are not registered.
34
+ Registry.register(subclass) unless subclass.name.nil?
35
+ end
36
+ end
37
+
38
+ def initialize(configuration)
39
+ @configuration = configuration
40
+ end
41
+
42
+ # Run this rule over a SourceFile and return its offenses.
43
+ def call(source_file)
44
+ @source_file = source_file
45
+ @offenses = []
46
+ walk(source_file.ast)
47
+ @offenses
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :configuration, :source_file
53
+
54
+ def rails?
55
+ configuration.rails?
56
+ end
57
+
58
+ def rule_option(key, default = nil)
59
+ configuration.rule_option(self.class.rule_name, key, default)
60
+ end
61
+
62
+ def shape(class_node)
63
+ AST::ClassShape.new(class_node)
64
+ end
65
+
66
+ def walk(node)
67
+ return unless node.is_a?(::Parser::AST::Node)
68
+
69
+ handler = "on_#{node.type}"
70
+ send(handler, node) if respond_to?(handler, true)
71
+ node.children.each { |child| walk(child) }
72
+ end
73
+
74
+ def add_offense(node, message:, suggestion:, severity: :convention, corrector: nil)
75
+ range = node.location.expression
76
+ @offenses << Offense.new(
77
+ rule_name: self.class.rule_name,
78
+ message: message,
79
+ suggestion: suggestion,
80
+ path: source_file.path,
81
+ line: range.line,
82
+ column: range.column,
83
+ severity: severity,
84
+ correctable: !corrector.nil?,
85
+ corrector: corrector
86
+ )
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+ require_relative "../correctors/data_bucket_corrector"
5
+
6
+ module WhyClasses
7
+ module Rules
8
+ # Rule 4 (summary): "If it's just a data bucket, use a Struct, Data object, or
9
+ # a Hash." A class that only declares attr_* fields and an initialize that
10
+ # assigns them, with no behaviour, is a data bucket.
11
+ class DataBucket < Rule
12
+ def self.description
13
+ "Class that only holds data (attr_* + field-assigning initialize) -> use Struct or Data."
14
+ end
15
+
16
+ def self.autocorrectable?
17
+ true
18
+ end
19
+
20
+ def on_class(node)
21
+ s = shape(node)
22
+ return if s.name.nil?
23
+ return if configuration.framework_base_class?(s.superclass)
24
+ return unless data_bucket?(s)
25
+
26
+ analysis = Correctors::DataBucketCorrector.analyze(s, prefer_data: rule_option("PreferData", true))
27
+ corrector = build_corrector(node, s, analysis)
28
+
29
+ add_offense(
30
+ node,
31
+ message: "#{s.name} is a data bucket: it only stores fields and has no behaviour.",
32
+ suggestion: suggestion_for(s, analysis),
33
+ corrector: corrector
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ # Loose detection (for advice): has attr fields, no real behaviour, and
40
+ # either no initialize or an initialize that only assigns fields.
41
+ def data_bucket?(s)
42
+ return false if s.attr_fields.empty? && s.initialize_method.nil?
43
+ return false unless s.behavior_methods.empty?
44
+ return false unless s.singleton_methods.empty?
45
+ return false if s.uses_metaprogramming?
46
+
47
+ init = s.initialize_method
48
+ return true if init.nil? && !s.attr_fields.empty?
49
+
50
+ H.initialize_only_assigns_params?(s.node)
51
+ end
52
+
53
+ def build_corrector(node, _shape, analysis)
54
+ return nil if analysis.nil?
55
+ return nil unless configuration.autocorrect?(self.class.rule_name)
56
+
57
+ name = H.class_name(node)
58
+ replacement = analysis.replacement(name)
59
+ ->(rewriter) { rewriter.replace(node.location.expression, replacement) }
60
+ end
61
+
62
+ def suggestion_for(s, analysis)
63
+ if analysis
64
+ target = analysis.replacement(s.name)
65
+ caveat =
66
+ if analysis.kind == :data
67
+ "Data objects are immutable and compare by value; `.new` keeps the same fields."
68
+ else
69
+ "Struct adds value equality plus `to_a`/`[]`/`each`; it stays mutable."
70
+ end
71
+ "Replace the class with:\n #{target}\n #{caveat}"
72
+ else
73
+ "Consider a Struct or Data.define for these fields instead of a hand-written class " \
74
+ "(couldn't rewrite automatically: a superclass, default args, or extra logic is present)."
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module WhyClasses
6
+ module Rules
7
+ # Rule 2 (summary): "If it's named after a design pattern, it's probably a
8
+ # workaround for a less flexible language." The classic service object -- a
9
+ # class with a single `call`/`perform`/`run` instance method and no state --
10
+ # is a bucket for a function.
11
+ class FunctionBucket < Rule
12
+ def self.description
13
+ "Class with a single call/perform/run method and no state -> use a module function."
14
+ end
15
+
16
+ def on_class(node)
17
+ s = shape(node)
18
+ return if s.name.nil?
19
+ return if configuration.framework_base_class?(s.superclass)
20
+ return if s.superclass? # framework/base subclasses handled above; other inheritance is separate
21
+ return unless function_bucket?(s)
22
+
23
+ method = s.behavior_methods.first.children[0]
24
+ add_offense(
25
+ node,
26
+ message: "#{s.name} is a bucket for a single function (`##{method}`) with no state.",
27
+ suggestion: "Prefer a module function or a plain method:\n" \
28
+ " module #{s.name}\n module_function\n\n" \
29
+ " def #{method}(...)\n ...\n end\n end\n" \
30
+ " Callers become `#{s.name}.#{method}(...)` with no throwaway instance."
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ def function_bucket?(s)
37
+ return false if s.uses_metaprogramming?
38
+
39
+ behavior = s.behavior_methods
40
+ return false unless behavior.size == 1
41
+ return false unless method_names.include?(behavior.first.children[0])
42
+ return false unless s.singleton_methods.empty?
43
+ return false unless s.attr_fields.empty?
44
+
45
+ # No real state: either no initialize, or an initialize that only stashes
46
+ # collaborators (still a smell, but we require truly stateless here).
47
+ s.initialize_method.nil?
48
+ end
49
+
50
+ def method_names
51
+ Array(rule_option("MethodNames", %w[call perform run execute])).map(&:to_sym)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module WhyClasses
6
+ module Rules
7
+ # Thomas criticises inheriting from a fat base class purely to inject
8
+ # behaviour (e.g. `< ActiveRecord::Base` pulls in hundreds of methods) rather
9
+ # than to specialise. Advice-only: safe rewriting needs whole-program
10
+ # knowledge of which inherited methods are actually used.
11
+ class InheritanceForMixins < Rule
12
+ def self.description
13
+ "Inheriting a fat base class to inject methods -> include only the mixins you use."
14
+ end
15
+
16
+ def on_class(node)
17
+ return unless rails?
18
+
19
+ s = shape(node)
20
+ return if s.name.nil?
21
+ return unless s.superclass?
22
+
23
+ mixins = configuration.mixin_suggestions_for(s.superclass)
24
+ return if mixins.nil?
25
+
26
+ add_offense(
27
+ node,
28
+ message: "#{s.name} < #{s.superclass} inherits a large surface just to gain behaviour.",
29
+ suggestion: "If you only need part of it, include the specific mixins instead:\n" \
30
+ " class #{s.name}\n" +
31
+ mixins.map { |m| " include #{m}" }.join("\n") +
32
+ "\n end\n Inherit only when you are genuinely specialising #{s.superclass}."
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module WhyClasses
6
+ module Rules
7
+ # A red-flag pattern: initialize leaves a field nil, so the object is invalid
8
+ # until a setter is called after `.new`. A signal the "object" is really a
9
+ # sequence of function calls in disguise.
10
+ class InvalidInitialState < Rule
11
+ def self.description
12
+ "Object constructed in an invalid state (initialize sets a field to nil, needs a setter first)."
13
+ end
14
+
15
+ def on_class(node)
16
+ s = shape(node)
17
+ return if s.name.nil?
18
+
19
+ init = s.initialize_method
20
+ return if init.nil?
21
+
22
+ writers = s.attr_macros[:attr_writer] + s.attr_macros[:attr_accessor]
23
+ nils = nil_assigned_ivars(init)
24
+ return if nils.empty?
25
+
26
+ nils.each do |ivar|
27
+ field = ivar.to_s.delete_prefix("@").to_sym
28
+ next unless writers.include?(field)
29
+ next unless field_read_in_behavior?(s, ivar)
30
+
31
+ add_offense(
32
+ node,
33
+ message: "#{s.name}#initialize sets #{ivar} = nil and exposes a setter, so a " \
34
+ "#{s.name} is invalid until `#{field}=` is called.",
35
+ suggestion: "Require #{field} up front instead:\n" \
36
+ " def initialize(#{field})\n @#{field} = #{field}\n end\n" \
37
+ " or drop the object entirely and pass #{field} to a module function."
38
+ )
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def nil_assigned_ivars(init)
45
+ H.body_statements(init.children[2]).select do |stmt|
46
+ H.node?(stmt) && stmt.type == :ivasgn &&
47
+ (val = stmt.children[1]) && H.node?(val) && val.type == :nil
48
+ end.map { |stmt| stmt.children[0] }
49
+ end
50
+
51
+ def field_read_in_behavior?(s, ivar)
52
+ s.behavior_methods.any? { |m| H.ivars_read(m).include?(ivar) }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module WhyClasses
6
+ module Rules
7
+ # Decoupled polymorphism: an instance method that only reads a single field
8
+ # could be a freestanding function that works on anything responding to that
9
+ # field. Off by default -- it is a stylistic nudge and can be noisy.
10
+ class SingleAttributeReader < Rule
11
+ def self.description
12
+ "Instance method that only reads one field -> consider a freestanding polymorphic function."
13
+ end
14
+
15
+ def on_class(node)
16
+ s = shape(node)
17
+ return if s.name.nil?
18
+ return if s.uses_metaprogramming?
19
+
20
+ s.behavior_methods.each do |method|
21
+ read = H.ivars_read(method)
22
+ written = H.ivars_assigned(method)
23
+ next unless written.empty?
24
+ next unless read.size == 1
25
+
26
+ field = read.first.to_s.delete_prefix("@")
27
+ add_offense(
28
+ method,
29
+ message: "#{s.name}##{method.children[0]} only reads @#{field}; it isn't tied to #{s.name}.",
30
+ suggestion: "A freestanding function is more flexible and works on any object " \
31
+ "exposing `#{field}`:\n" \
32
+ " def #{method.children[0]}(#{field})\n # use #{field} directly\n end"
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module WhyClasses
6
+ module Rules
7
+ # Rule 3 (summary): "If it has no state, it should not be a class." A class
8
+ # whose body is only `self.` methods is a namespace for functions, not an
9
+ # object factory -- it should be a module.
10
+ class StatelessSingletonMethods < Rule
11
+ def self.description
12
+ "Class with only class methods and no state -> use a module with `extend self`."
13
+ end
14
+
15
+ def on_class(node)
16
+ s = shape(node)
17
+ return if s.name.nil?
18
+ return if s.superclass? # inheritance is a separate concern (see InheritanceForMixins)
19
+ return if configuration.framework_base_class?(s.superclass)
20
+ return unless only_singleton_methods?(s)
21
+
22
+ add_offense(
23
+ node,
24
+ message: "#{s.name} has only class methods and no state; it's a bucket of functions, " \
25
+ "not an object factory.",
26
+ suggestion: "Make it a module and expose the methods with `extend self`:\n" \
27
+ " module #{s.name}\n def #{example_method(s)}(...)\n ...\n end\n\n" \
28
+ " private\n # helper methods here are now genuinely private\n\n" \
29
+ " extend self\n end"
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def only_singleton_methods?(s)
36
+ return false if s.singleton_methods.empty?
37
+ return false unless s.instance_methods.empty?
38
+ return false unless s.attr_fields.empty?
39
+ return false if s.uses_metaprogramming?
40
+
41
+ # Allow only constants / comments as other statements.
42
+ s.other_statements.all? { |n| %i[casgn].include?(n.type) }
43
+ end
44
+
45
+ def example_method(s)
46
+ s.singleton_method_names.first || "call"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config_loader"
4
+ require_relative "registry"
5
+ require_relative "source_file"
6
+ require_relative "file_finder"
7
+ require_relative "disable_comments"
8
+ require_relative "correction"
9
+ require_relative "formatters/progress_formatter"
10
+ require_relative "formatters/clang_formatter"
11
+ require_relative "formatters/json_formatter"
12
+ require_relative "formatters/diff_formatter"
13
+
14
+ module WhyClasses
15
+ # Orchestrates a run: find files, analyse each with the active rules, optionally
16
+ # apply corrections, hand the results to a formatter, and compute an exit code.
17
+ class Runner
18
+ FileReport = Data.define(:path, :offenses, :original_source, :corrected_source, :applied) do
19
+ def changed?
20
+ corrected_source != original_source
21
+ end
22
+ end
23
+
24
+ SEVERITY_RANK = { none: 0, convention: 1, warning: 2 }.freeze
25
+
26
+ FORMATTERS = {
27
+ "progress" => Formatters::ProgressFormatter,
28
+ "clang" => Formatters::ClangFormatter,
29
+ "json" => Formatters::JsonFormatter,
30
+ "diff" => Formatters::DiffFormatter
31
+ }.freeze
32
+
33
+ def initialize(options, configuration: nil)
34
+ @options = options
35
+ @configuration = configuration || build_configuration
36
+ end
37
+
38
+ attr_reader :configuration
39
+
40
+ def run(io: $stdout)
41
+ reports = analyze_files(FileFinder.find(@options.paths, @configuration))
42
+ formatter_for(@options.mode, io).call(reports)
43
+ exit_code(reports)
44
+ end
45
+
46
+ # Analyse a single already-built SourceFile and return its filtered,
47
+ # position-sorted offenses. Handy for specs.
48
+ def offenses_for(source_file)
49
+ return [] unless source_file.parsed?
50
+
51
+ rules = Registry.active(configuration: @configuration, only: @options.only, except: @options.except)
52
+ raw = rules.flat_map { |rule_class| rule_class.new(@configuration).call(source_file) }
53
+ DisableComments.filter(raw, source_file.comments).sort_by(&:position)
54
+ end
55
+
56
+ private
57
+
58
+ def analyze_files(paths)
59
+ paths.map { |path| process(SourceFile.from_path(path)) }
60
+ end
61
+
62
+ def process(source_file)
63
+ offenses = offenses_for(source_file)
64
+ corrected, applied = maybe_correct(source_file, offenses)
65
+ FileReport.new(
66
+ path: source_file.path,
67
+ offenses: offenses,
68
+ original_source: source_file.source,
69
+ corrected_source: corrected,
70
+ applied: applied
71
+ )
72
+ end
73
+
74
+ def maybe_correct(source_file, offenses)
75
+ return [source_file.source, 0] unless correcting?
76
+
77
+ correctable = offenses.select do |offense|
78
+ offense.correctable? && @configuration.autocorrect?(offense.rule_name)
79
+ end
80
+ result = Correction.apply(source_file, correctable)
81
+ write_file(source_file.path, result.source) if @options.mode == :fix && result.source != source_file.source
82
+ [result.source, result.applied]
83
+ end
84
+
85
+ def correcting?
86
+ %i[fix diff].include?(@options.mode)
87
+ end
88
+
89
+ def write_file(path, source)
90
+ File.write(path, source)
91
+ end
92
+
93
+ def formatter_for(mode, io)
94
+ klass = mode == :diff ? Formatters::DiffFormatter : FORMATTERS.fetch(@options.format, Formatters::ProgressFormatter)
95
+ klass.new(io)
96
+ end
97
+
98
+ def exit_code(reports)
99
+ threshold = SEVERITY_RANK.fetch(@options.fail_level.to_sym, 1)
100
+ return 0 if threshold.zero?
101
+
102
+ offending = reports.any? do |report|
103
+ report.offenses.any? { |o| SEVERITY_RANK.fetch(o.severity, 1) >= threshold }
104
+ end
105
+ offending ? 1 : 0
106
+ end
107
+
108
+ def build_configuration
109
+ config = ConfigLoader.load(explicit_path: @options.config_path)
110
+ return config if @options.rails.nil?
111
+
112
+ Configuration.new(ConfigLoader.deep_merge(config.raw, { "AllRules" => { "Rails" => @options.rails } }))
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+
5
+ module WhyClasses
6
+ # A single Ruby source file: its path, raw source and lazily-parsed AST.
7
+ class SourceFile
8
+ attr_reader :path
9
+
10
+ def self.from_path(path)
11
+ new(path, File.read(path))
12
+ end
13
+
14
+ def initialize(path, source)
15
+ @path = path
16
+ @source = source
17
+ end
18
+
19
+ attr_reader :source
20
+
21
+ def result
22
+ @result ||= Parser.parse(@source, @path)
23
+ end
24
+
25
+ def ast
26
+ result.ast
27
+ end
28
+
29
+ def buffer
30
+ result.buffer
31
+ end
32
+
33
+ def comments
34
+ result.comments
35
+ end
36
+
37
+ def parsed?
38
+ !ast.nil?
39
+ end
40
+
41
+ def parse_error
42
+ result.error
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhyClasses
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "why_classes/version"
4
+ require_relative "why_classes/parser"
5
+ require_relative "why_classes/source_file"
6
+ require_relative "why_classes/offense"
7
+ require_relative "why_classes/ast/node_helpers"
8
+ require_relative "why_classes/ast/class_shape"
9
+ require_relative "why_classes/registry"
10
+ require_relative "why_classes/rule"
11
+ require_relative "why_classes/configuration"
12
+ require_relative "why_classes/config_loader"
13
+ require_relative "why_classes/disable_comments"
14
+ require_relative "why_classes/correction"
15
+ require_relative "why_classes/file_finder"
16
+
17
+ # Rules self-register with the Registry when required.
18
+ require_relative "why_classes/rules/data_bucket"
19
+ require_relative "why_classes/rules/stateless_singleton_methods"
20
+ require_relative "why_classes/rules/function_bucket"
21
+ require_relative "why_classes/rules/invalid_initial_state"
22
+ require_relative "why_classes/rules/inheritance_for_mixins"
23
+ require_relative "why_classes/rules/single_attribute_reader"
24
+
25
+ require_relative "why_classes/options"
26
+ require_relative "why_classes/runner"
27
+ require_relative "why_classes/cli"
28
+
29
+ # why-classes detects the over-use of classes in Ruby (per Dave Thomas's talk)
30
+ # and suggests/applies refactorings toward modules, composition, Struct and Data.
31
+ module WhyClasses
32
+ end