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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 49efb3c918546952656ce560c450af6b1789e75b2292311bc1775092e736a65c
4
+ data.tar.gz: a5fa304162f3af5851c059ce0e808340abfa9e08b0ea1174d430f7f252a20417
5
+ SHA512:
6
+ metadata.gz: efbf0be84df9e152612e5bb3ea4142260471fbdec61b53487224c499b0b4dbfcd9ac10fbd72494843e4726e13c866391d6dcdbc95091e7fab8fee6117bb9b605
7
+ data.tar.gz: b0bc59250372fb99e49cf12909ed921bc2d86d70d3631b7a3dbb698acf6d8bb2e67249ab93377c6737a6306f67f7c5c63d2f7ebe46e0ba6e8ec3749e518fa83c
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gabriel Quaresma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # why-classes
2
+
3
+ > Not everything needs to be a class.
4
+
5
+ `why-classes` is a linter and refactoring tool for Ruby, inspired by Dave Thomas's
6
+ talk on the over-use of classes. Ruby gives you modules, composition, `Struct`, and
7
+ `Data` — but reflex and framework habits push us toward classes even when a simpler
8
+ construct would be clearer. This gem finds those "class smells" in a codebase (or a
9
+ single file), explains each one, and can rewrite the mechanically-safe ones for you.
10
+
11
+ It targets Rails codebases (with Rails-aware suppression and advice) but works on
12
+ plain Ruby with no dependency on Rails or RuboCop.
13
+
14
+ Under the hood it parses with [Prism](https://github.com/ruby/prism) (via
15
+ `Prism::Translation::Parser`) and rewrites with the `parser` gem's `TreeRewriter`, so
16
+ detection and autocorrection share one coordinate system.
17
+
18
+ ## The rules
19
+
20
+ | Rule | Smell | Suggests | Autocorrect |
21
+ |------|-------|----------|-------------|
22
+ | `DataBucket` | `attr_*` + a field-assigning `initialize`, no behaviour | `Struct.new` / `Data.define` | ✅ `--fix` |
23
+ | `StatelessSingletonMethods` | only `self.` methods, no state | `module` + `extend self` | advice |
24
+ | `FunctionBucket` | a single `call`/`perform`/`run`, no state | `module_function` | advice |
25
+ | `InvalidInitialState` | `initialize` leaves a field `nil`, needs a setter first | pass it in / drop the object | advice |
26
+ | `InheritanceForMixins` | inheriting a fat base (`< ActiveRecord::Base`) for behaviour | include only the mixins you use | advice |
27
+ | `SingleAttributeReader` | a method that only reads one field | a freestanding function | advice (off by default) |
28
+
29
+ `attr_reader`-only buckets become immutable `Data.define`; `attr_accessor`/`attr_writer`
30
+ buckets (mutation expected) become `Struct.new`.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ gem install why-classes
36
+ # or in a Gemfile:
37
+ gem "why-classes", group: :development
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```bash
43
+ why-classes # scan the current directory, report + suggest
44
+ why-classes app/models/point.rb # scan one file
45
+ why-classes --diff app/ # preview the rewrites as a unified diff
46
+ why-classes --fix app/ # apply the safe (DataBucket) rewrites
47
+ why-classes --only DataBucket app # run a single rule
48
+ why-classes --format json app # machine-readable output
49
+ why-classes --list-rules # list every rule
50
+ ```
51
+
52
+ Example:
53
+
54
+ ```
55
+ app/models/point.rb:1:1: [DataBucket] [correctable] Point is a data bucket: it only stores fields and has no behaviour.
56
+ Replace the class with:
57
+ Point = Data.define(:x, :y)
58
+ Data objects are immutable and compare by value; `.new` keeps the same fields.
59
+ ```
60
+
61
+ ### Options
62
+
63
+ | Flag | Meaning |
64
+ |------|---------|
65
+ | `--diff`, `--dry-run` | show a diff of proposed rewrites; write nothing |
66
+ | `-a`, `--fix` | apply suggested (safe-tier) corrections and write files |
67
+ | `--fix-unsafe` | also apply unsafe-tier corrections (reserved for future rules) |
68
+ | `--only A,B` / `--except A,B` | select rules |
69
+ | `--format progress\|clang\|json\|diff` | output format (default `progress`) |
70
+ | `--config PATH` | use a specific `.why-classes.yml` |
71
+ | `--[no-]rails` | toggle Rails awareness |
72
+ | `--fail-level none\|convention\|warning` | CI exit-code threshold (default `convention`) |
73
+
74
+ Exit codes: `0` clean (or below the fail level), `1` offenses found, `2` usage error.
75
+
76
+ ## Configuration
77
+
78
+ Drop a `.why-classes.yml` at your project root; it is deep-merged over the defaults.
79
+
80
+ ```yaml
81
+ AllRules:
82
+ Include: ["**/*.rb"]
83
+ Exclude: ["db/**/*", "vendor/**/*"]
84
+ Rails: true
85
+
86
+ Rules:
87
+ DataBucket:
88
+ Enabled: true
89
+ Autocorrect: true
90
+ PreferData: false # true => always emit Data.define, even for accessors
91
+ FunctionBucket:
92
+ MethodNames: [call, perform, run, execute]
93
+ SingleAttributeReader:
94
+ Enabled: true # opt in to the noisy one
95
+ ```
96
+
97
+ ### Inline suppression
98
+
99
+ ```ruby
100
+ # why-classes:disable DataBucket
101
+ class LegacyThing # not flagged anywhere in this file
102
+ end
103
+
104
+ class CreateUser # why-classes:disable-line FunctionBucket
105
+ def call = :ok
106
+ end
107
+ ```
108
+
109
+ ## Safety
110
+
111
+ Autocorrection is opt-in and reviewable. `--fix` only touches `DataBucket`, the most
112
+ mechanical transform, and even then it **bails to advice-only** when a superclass,
113
+ default arguments, extra `initialize` logic, real behaviour, or metaprogramming is
114
+ present. Every rewrite is re-parsed with Prism and discarded if it would produce
115
+ invalid Ruby. Converting a class changes its public shape (`Data` is immutable, `==`
116
+ becomes value equality) — always review the diff.
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ bin/setup # or: bundle install
122
+ bundle exec rspec
123
+ bundle exec exe/why-classes lib # dogfood
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT.
@@ -0,0 +1,69 @@
1
+ # Default configuration for why-classes.
2
+ # A project `.why-classes.yml` is deep-merged over this file.
3
+
4
+ AllRules:
5
+ Include:
6
+ - "**/*.rb"
7
+ Exclude:
8
+ - "db/**/*"
9
+ - "vendor/**/*"
10
+ - "node_modules/**/*"
11
+ - "tmp/**/*"
12
+ - "spec/fixtures/**/*"
13
+ - "test/fixtures/**/*"
14
+ # Rails-aware suppression and advice. Harmless on plain-Ruby projects.
15
+ Rails: true
16
+
17
+ Rules:
18
+ DataBucket:
19
+ Enabled: true
20
+ Autocorrect: true # Suggested-tier: eligible for --fix
21
+ # attr_reader-only buckets become Data.define; attr_accessor/attr_writer
22
+ # (mutation expected) become Struct.new. Set PreferData: true to always
23
+ # emit Data.define regardless of mutability.
24
+ PreferData: false
25
+
26
+ StatelessSingletonMethods:
27
+ Enabled: true
28
+ Autocorrect: false # Unsafe-tier: advice-only for now (--fix-unsafe later)
29
+
30
+ FunctionBucket:
31
+ Enabled: true
32
+ Autocorrect: false
33
+ MethodNames:
34
+ - call
35
+ - perform
36
+ - run
37
+ - execute
38
+
39
+ InheritanceForMixins:
40
+ Enabled: true # advice only
41
+
42
+ InvalidInitialState:
43
+ Enabled: true # advice only
44
+
45
+ SingleAttributeReader:
46
+ Enabled: false # noisy; opt-in
47
+
48
+ Rails:
49
+ # Subclasses of these legitimately must be classes -> suppress the
50
+ # "shouldn't be a class" family of rules on them.
51
+ FrameworkBaseClasses:
52
+ - ActiveRecord::Base
53
+ - ApplicationRecord
54
+ - ActionController::Base
55
+ - ApplicationController
56
+ - ActionController::API
57
+ - ActiveJob::Base
58
+ - ApplicationJob
59
+ - ActionMailer::Base
60
+ - ApplicationMailer
61
+ - ActiveModel::Serializer
62
+ # Lean mixins suggested by InheritanceForMixins.
63
+ MixinSuggestions:
64
+ ActiveRecord::Base:
65
+ - ActiveModel::Model
66
+ - ActiveModel::Validations
67
+ ApplicationRecord:
68
+ - ActiveModel::Model
69
+ - ActiveModel::Validations
data/exe/why-classes ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "why_classes"
5
+
6
+ exit WhyClasses::CLI.new(ARGV).run
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "node_helpers"
4
+
5
+ module WhyClasses
6
+ module AST
7
+ # A computed-once summary of a single class (or module) node. Rules read a
8
+ # ClassShape instead of re-walking the AST.
9
+ class ClassShape
10
+ H = NodeHelpers
11
+
12
+ attr_reader :node
13
+
14
+ def initialize(node)
15
+ @node = node
16
+ end
17
+
18
+ def module?
19
+ node.type == :module
20
+ end
21
+
22
+ def name
23
+ @name ||= H.class_name(node)
24
+ end
25
+
26
+ def superclass
27
+ return nil if module?
28
+
29
+ @superclass ||= H.superclass_path(node)
30
+ end
31
+
32
+ def superclass?
33
+ !superclass.nil?
34
+ end
35
+
36
+ def instance_methods
37
+ @instance_methods ||= H.instance_methods(node)
38
+ end
39
+
40
+ def instance_method_names
41
+ instance_methods.map { |m| m.children[0] }
42
+ end
43
+
44
+ def singleton_methods
45
+ @singleton_methods ||= H.singleton_methods(node)
46
+ end
47
+
48
+ def singleton_method_names
49
+ singleton_methods.map { |m| m.children[1] || m.children[0] }
50
+ end
51
+
52
+ def initialize_method
53
+ @initialize_method ||= H.initialize_method(node)
54
+ end
55
+
56
+ def attr_macros
57
+ @attr_macros ||= H.attr_macros(node)
58
+ end
59
+
60
+ def attr_fields
61
+ @attr_fields ||= H.attr_fields(node)
62
+ end
63
+
64
+ # Body statements that are neither method defs, attr macros, nor bare
65
+ # comments -- e.g. constants, includes, other macro calls.
66
+ def other_statements
67
+ @other_statements ||= begin
68
+ skip = instance_methods + singleton_methods
69
+ H.body_statements(H.class_body(node)).reject do |s|
70
+ skip.include?(s) ||
71
+ (H.node?(s) && s.type == :send && %i[attr_reader attr_writer attr_accessor].include?(s.children[1]))
72
+ end.select { |s| H.node?(s) }
73
+ end
74
+ end
75
+
76
+ def uses_metaprogramming?
77
+ return @meta if defined?(@meta)
78
+
79
+ @meta = H.uses_metaprogramming?(node)
80
+ end
81
+
82
+ def instance_variables_written
83
+ @ivw ||= H.ivars_assigned(node)
84
+ end
85
+
86
+ def instance_variables_read
87
+ @ivr ||= H.ivars_read(node)
88
+ end
89
+
90
+ # Instance methods other than initialize.
91
+ def behavior_methods
92
+ instance_methods.reject { |m| m.children[0] == :initialize }
93
+ end
94
+
95
+ # A class "has state" if it stores instance variables that are not merely
96
+ # the constructor writing attr-backed fields.
97
+ def stateful?
98
+ !instance_variables_written.empty? || !instance_variables_read.empty?
99
+ end
100
+
101
+ def location
102
+ node.location
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhyClasses
4
+ module AST
5
+ # Predicates over the parser-gem AST. Every helper operates on the *direct*
6
+ # body of a class/module node and never recurses into nested classes, so a
7
+ # rule handling an outer class does not accidentally analyse an inner one.
8
+ module NodeHelpers
9
+ module_function
10
+
11
+ Node = ::Parser::AST::Node
12
+
13
+ def node?(obj)
14
+ obj.is_a?(Node)
15
+ end
16
+
17
+ # The list of top-level statements in a class/module/def body.
18
+ def body_statements(body)
19
+ return [] if body.nil?
20
+ return body.children if body.type == :begin
21
+
22
+ [body]
23
+ end
24
+
25
+ # Body node of a :class (children[2]) or :module (children[1]).
26
+ def class_body(class_node)
27
+ class_node.type == :module ? class_node.children[1] : class_node.children[2]
28
+ end
29
+
30
+ # "Foo::Bar" for a const node, else nil.
31
+ def const_path(const_node)
32
+ return nil unless node?(const_node) && const_node.type == :const
33
+
34
+ scope, name = const_node.children
35
+ scope.nil? ? name.to_s : "#{const_path(scope)}::#{name}"
36
+ end
37
+
38
+ def class_name(class_node)
39
+ const_path(class_node.children[0])
40
+ end
41
+
42
+ # Superclass const path for a :class node, or nil.
43
+ def superclass_path(class_node)
44
+ return nil unless class_node.type == :class
45
+
46
+ const_path(class_node.children[1])
47
+ end
48
+
49
+ # Instance method definitions declared directly in the body (:def).
50
+ def instance_methods(class_node)
51
+ body_statements(class_body(class_node)).select { |n| node?(n) && n.type == :def }
52
+ end
53
+
54
+ # Singleton methods: `def self.foo` (:defs) plus methods inside
55
+ # `class << self` (:sclass) blocks.
56
+ def singleton_methods(class_node)
57
+ stmts = body_statements(class_body(class_node))
58
+ defs = stmts.select { |n| node?(n) && n.type == :defs }
59
+ sclass_defs = stmts.select { |n| node?(n) && n.type == :sclass }
60
+ .flat_map { |sc| body_statements(sc.children[1]) }
61
+ .select { |n| node?(n) && n.type == :def }
62
+ defs + sclass_defs
63
+ end
64
+
65
+ # attr_reader/attr_writer/attr_accessor macro calls -> flat list of field
66
+ # symbols keyed by macro name.
67
+ def attr_macros(class_node)
68
+ result = { attr_reader: [], attr_writer: [], attr_accessor: [] }
69
+ body_statements(class_body(class_node)).each do |stmt|
70
+ next unless node?(stmt) && stmt.type == :send && stmt.children[0].nil?
71
+
72
+ macro = stmt.children[1]
73
+ next unless result.key?(macro)
74
+
75
+ fields = stmt.children[2..].select { |a| node?(a) && a.type == :sym }.map { |a| a.children[0] }
76
+ result[macro].concat(fields)
77
+ end
78
+ result
79
+ end
80
+
81
+ # All field symbols exposed by any attr_* macro.
82
+ def attr_fields(class_node)
83
+ attr_macros(class_node).values.flatten.uniq
84
+ end
85
+
86
+ # Instance variables assigned anywhere in the subtree.
87
+ def ivars_assigned(node)
88
+ collect(node) { |n| n.type == :ivasgn }.map { |n| n.children[0] }.uniq
89
+ end
90
+
91
+ # Instance variables read anywhere in the subtree.
92
+ def ivars_read(node)
93
+ collect(node) { |n| n.type == :ivar }.map { |n| n.children[0] }.uniq
94
+ end
95
+
96
+ # Names of `def initialize` in the body.
97
+ def initialize_method(class_node)
98
+ instance_methods(class_node).find { |m| m.children[0] == :initialize }
99
+ end
100
+
101
+ # True when initialize only assigns instance variables straight from its
102
+ # own parameters (no logic, no defaults, no computed values).
103
+ def initialize_only_assigns_params?(class_node)
104
+ init = initialize_method(class_node)
105
+ return false unless init
106
+
107
+ params = param_names(init.children[1])
108
+ # No default values / splats / blocks allowed.
109
+ return false unless simple_params?(init.children[1])
110
+
111
+ assignments = body_statements(init.children[2])
112
+ return false if assignments.empty?
113
+
114
+ assignments.all? do |stmt|
115
+ node?(stmt) && stmt.type == :ivasgn &&
116
+ (value = stmt.children[1]) &&
117
+ value.type == :lvar &&
118
+ params.include?(value.children[0])
119
+ end
120
+ end
121
+
122
+ # Parameter names of an :args node.
123
+ def param_names(args_node)
124
+ return [] unless node?(args_node)
125
+
126
+ args_node.children.map { |a| a.children[0] }
127
+ end
128
+
129
+ # True when every parameter is a plain required positional (:arg) or
130
+ # required keyword (:kwarg) -- no defaults, splats, or blocks.
131
+ def simple_params?(args_node)
132
+ return true if args_node.nil?
133
+
134
+ args_node.children.all? { |a| node?(a) && %i[arg kwarg].include?(a.type) }
135
+ end
136
+
137
+ # True when all parameters are keyword arguments.
138
+ def keyword_params?(args_node)
139
+ return false if args_node.nil? || args_node.children.empty?
140
+
141
+ args_node.children.all? { |a| node?(a) && %i[kwarg kwoptarg].include?(a.type) }
142
+ end
143
+
144
+ METAPROGRAMMING_SENDS = %i[
145
+ define_method define_singleton_method instance_variable_set
146
+ instance_variable_get method_missing respond_to_missing?
147
+ class_eval instance_eval module_eval const_set
148
+ attr define_attribute_methods
149
+ ].freeze
150
+
151
+ # Static analysis is unreliable in the presence of metaprogramming, so any
152
+ # rule that would rewrite code must bail to advice-only when this is true.
153
+ def uses_metaprogramming?(class_node)
154
+ collect(class_node) do |n|
155
+ (n.type == :send && METAPROGRAMMING_SENDS.include?(n.children[1])) ||
156
+ n.type == :def && %i[method_missing respond_to_missing?].include?(n.children[0])
157
+ end.any?
158
+ end
159
+
160
+ # Recursively collect nodes matching the block. By default the walk stays
161
+ # within the current type definition: it descends into method bodies
162
+ # (def/defs) and `class << self` (sclass) but not into *nested* class or
163
+ # module definitions, so a class's analysis never bleeds into an inner one.
164
+ def collect(node, cross_scopes: false, &block)
165
+ found = []
166
+ walk_local(node, cross_scopes, found, &block)
167
+ found
168
+ end
169
+
170
+ NESTED_SCOPE_TYPES = %i[class module].freeze
171
+
172
+ def walk_local(node, cross_scopes, found, &block)
173
+ return unless node?(node)
174
+
175
+ found << node if block.call(node)
176
+ node.children.each do |child|
177
+ next unless node?(child)
178
+ next if !cross_scopes && NESTED_SCOPE_TYPES.include?(child.type)
179
+
180
+ walk_local(child, cross_scopes, found, &block)
181
+ end
182
+ end
183
+ private_class_method :walk_local
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "version"
5
+ require_relative "options"
6
+ require_relative "runner"
7
+ require_relative "registry"
8
+
9
+ module WhyClasses
10
+ # Command-line entry point. Parses ARGV into Options, runs, returns an exit code.
11
+ class CLI
12
+ VALID_FORMATS = %w[progress clang json diff].freeze
13
+ VALID_FAIL_LEVELS = %w[none convention warning].freeze
14
+
15
+ def initialize(argv, io: $stdout, err: $stderr)
16
+ @argv = argv
17
+ @io = io
18
+ @err = err
19
+ end
20
+
21
+ def run
22
+ overrides = parse_options
23
+ return @early_exit if @early_exit
24
+
25
+ options = Options.defaults(**overrides, paths: @argv)
26
+ Runner.new(options).run(io: @io)
27
+ rescue OptionParser::ParseError => e
28
+ @err.puts "why-classes: #{e.message}"
29
+ 2
30
+ rescue StandardError => e
31
+ @err.puts "why-classes: #{e.class}: #{e.message}"
32
+ 2
33
+ end
34
+
35
+ private
36
+
37
+ def parse_options # rubocop:disable Metrics/MethodLength
38
+ overrides = {}
39
+ parser.parse!(@argv)
40
+ apply_validations(overrides)
41
+ overrides
42
+ rescue SystemExit
43
+ overrides
44
+ end
45
+
46
+ def parser # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
47
+ @collected ||= {}
48
+ OptionParser.new do |o|
49
+ o.banner = "Usage: why-classes [options] [paths...]"
50
+
51
+ o.on("--diff", "--dry-run", "Show a diff of proposed rewrites; write nothing") { @collected[:mode] = :diff }
52
+ o.on("-a", "--fix", "Apply suggested (safe-tier) corrections and write files") { @collected[:mode] = :fix }
53
+ o.on("--fix-unsafe", "Also apply unsafe-tier corrections") { @collected[:fix_unsafe] = true }
54
+ o.on("--only RULES", Array, "Run only these rules") { |v| @collected[:only] = v }
55
+ o.on("--except RULES", Array, "Skip these rules") { |v| @collected[:except] = v }
56
+ o.on("--format FORMAT", "Output format: #{VALID_FORMATS.join(', ')}") { |v| @collected[:format] = v }
57
+ o.on("--config PATH", "Path to a .why-classes.yml") { |v| @collected[:config_path] = v }
58
+ o.on("--[no-]rails", "Toggle Rails awareness") { |v| @collected[:rails] = v }
59
+ o.on("--fail-level LEVEL", "CI exit threshold: #{VALID_FAIL_LEVELS.join(', ')}") { |v| @collected[:fail_level] = v }
60
+ o.on("--list-rules", "List all rules and exit") { list_rules_and_exit }
61
+ o.on("-v", "--version", "Print version and exit") { print_version_and_exit }
62
+ o.on("-h", "--help", "Print this help and exit") { print_help_and_exit(o) }
63
+ end
64
+ end
65
+
66
+ def apply_validations(overrides)
67
+ overrides.merge!(@collected)
68
+ if overrides[:format] && !VALID_FORMATS.include?(overrides[:format])
69
+ raise OptionParser::InvalidArgument, "unknown format #{overrides[:format].inspect}"
70
+ end
71
+ if overrides[:fail_level] && !VALID_FAIL_LEVELS.include?(overrides[:fail_level])
72
+ raise OptionParser::InvalidArgument, "unknown fail-level #{overrides[:fail_level].inspect}"
73
+ end
74
+ end
75
+
76
+ def list_rules_and_exit
77
+ Registry.all.sort_by(&:rule_name).each do |rule|
78
+ mark = rule.autocorrectable? ? "[correctable]" : "[advice] "
79
+ @io.puts "#{mark} #{rule.rule_name.ljust(28)} #{rule.description}"
80
+ end
81
+ @early_exit = 0
82
+ raise SystemExit
83
+ end
84
+
85
+ def print_version_and_exit
86
+ @io.puts WhyClasses::VERSION
87
+ @early_exit = 0
88
+ raise SystemExit
89
+ end
90
+
91
+ def print_help_and_exit(parser)
92
+ @io.puts parser
93
+ @early_exit = 0
94
+ raise SystemExit
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "configuration"
5
+
6
+ module WhyClasses
7
+ # Loads config/default.yml and deep-merges a project .why-classes.yml over it.
8
+ module ConfigLoader
9
+ DEFAULT_PATH = File.expand_path("../../config/default.yml", __dir__)
10
+ PROJECT_FILE = ".why-classes.yml"
11
+
12
+ module_function
13
+
14
+ def load(explicit_path: nil, start_dir: Dir.pwd)
15
+ base = read(DEFAULT_PATH)
16
+ project = explicit_path ? read(explicit_path) : read(discover(start_dir))
17
+ Configuration.new(deep_merge(base, project))
18
+ end
19
+
20
+ # Walk up from start_dir looking for .why-classes.yml.
21
+ def discover(start_dir)
22
+ dir = File.expand_path(start_dir)
23
+ loop do
24
+ candidate = File.join(dir, PROJECT_FILE)
25
+ return candidate if File.file?(candidate)
26
+
27
+ parent = File.dirname(dir)
28
+ break if parent == dir
29
+
30
+ dir = parent
31
+ end
32
+ nil
33
+ end
34
+
35
+ def read(path)
36
+ return {} if path.nil? || !File.file?(path)
37
+
38
+ YAML.safe_load_file(path) || {}
39
+ end
40
+
41
+ def deep_merge(base, override)
42
+ base.merge(override) do |_key, base_val, over_val|
43
+ if base_val.is_a?(Hash) && over_val.is_a?(Hash)
44
+ deep_merge(base_val, over_val)
45
+ else
46
+ over_val
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end