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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhyClasses
4
+ # A merged configuration (project .why-classes.yml over config/default.yml),
5
+ # exposing per-rule lookups and Rails awareness.
6
+ class Configuration
7
+ attr_reader :raw
8
+
9
+ def initialize(data)
10
+ @data = data
11
+ @raw = data
12
+ end
13
+
14
+ def all_rules
15
+ @data.fetch("AllRules", {})
16
+ end
17
+
18
+ def rules
19
+ @data.fetch("Rules", {})
20
+ end
21
+
22
+ def rails_config
23
+ @data.fetch("Rails", {})
24
+ end
25
+
26
+ def include_globs
27
+ all_rules.fetch("Include", ["**/*.rb"])
28
+ end
29
+
30
+ def exclude_globs
31
+ all_rules.fetch("Exclude", [])
32
+ end
33
+
34
+ def rails?
35
+ all_rules.fetch("Rails", true)
36
+ end
37
+
38
+ def enabled?(rule_name)
39
+ rule_config(rule_name).fetch("Enabled", true)
40
+ end
41
+
42
+ def autocorrect?(rule_name)
43
+ rule_config(rule_name).fetch("Autocorrect", false)
44
+ end
45
+
46
+ def rule_option(rule_name, key, default = nil)
47
+ rule_config(rule_name).fetch(key, default)
48
+ end
49
+
50
+ def rule_config(rule_name)
51
+ rules.fetch(rule_name, {})
52
+ end
53
+
54
+ def framework_base_classes
55
+ rails_config.fetch("FrameworkBaseClasses", [])
56
+ end
57
+
58
+ def framework_base_class?(const_path)
59
+ return false if const_path.nil?
60
+
61
+ framework_base_classes.include?(const_path)
62
+ end
63
+
64
+ def mixin_suggestions_for(const_path)
65
+ rails_config.fetch("MixinSuggestions", {}).fetch(const_path, nil)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parser/source/tree_rewriter"
4
+ require_relative "parser"
5
+
6
+ module WhyClasses
7
+ # Applies correctable offenses to a file's source via Parser::Source::TreeRewriter,
8
+ # which shares the coordinate system used for detection. Every rewrite is
9
+ # re-parsed as a fail-safe: if the result no longer parses, it is discarded and
10
+ # the original source is kept (the offense is still reported as advice).
11
+ module Correction
12
+ Result = Data.define(:source, :applied)
13
+
14
+ module_function
15
+
16
+ def apply(source_file, correctable_offenses)
17
+ return Result.new(source: source_file.source, applied: 0) if correctable_offenses.empty?
18
+
19
+ rewriter = ::Parser::Source::TreeRewriter.new(source_file.buffer)
20
+ applied = 0
21
+ correctable_offenses.each do |offense|
22
+ offense.corrector.call(rewriter)
23
+ applied += 1
24
+ rescue ::Parser::ClobberingError
25
+ # Overlapping edit -- skip this one, keep the rest.
26
+ next
27
+ end
28
+
29
+ new_source = rewriter.process
30
+ return Result.new(source: source_file.source, applied: 0) unless reparses?(new_source, source_file.path)
31
+
32
+ Result.new(source: new_source, applied: applied)
33
+ rescue ::Parser::ClobberingError
34
+ Result.new(source: source_file.source, applied: 0)
35
+ end
36
+
37
+ # Strict validation: the detection parser is intentionally error-tolerant,
38
+ # so use native Prism (which reports errors) to reject any rewrite that
39
+ # produced syntactically invalid Ruby.
40
+ def reparses?(source, _path)
41
+ ::Prism.parse(source).success?
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast/node_helpers"
4
+
5
+ module WhyClasses
6
+ module Correctors
7
+ # Decides whether a data-bucket class can be mechanically rewritten to a
8
+ # Struct/Data one-liner, and generates the replacement source.
9
+ #
10
+ # Conservative by design: anything it is unsure about is reported as
11
+ # advice-only (analysis returns nil) rather than rewritten incorrectly.
12
+ module DataBucketCorrector
13
+ H = AST::NodeHelpers
14
+
15
+ Analysis = Data.define(:fields, :kind, :keyword) do
16
+ def replacement(name)
17
+ list = fields.map { |f| ":#{f}" }.join(", ")
18
+ case kind
19
+ when :data
20
+ "#{name} = Data.define(#{list})"
21
+ when :struct
22
+ keyword ? "#{name} = Struct.new(#{list}, keyword_init: true)" : "#{name} = Struct.new(#{list})"
23
+ end
24
+ end
25
+ end
26
+
27
+ module_function
28
+
29
+ # Returns an Analysis when the class is a safely-convertible data bucket,
30
+ # else nil (caller downgrades to advice-only).
31
+ def analyze(shape, prefer_data:)
32
+ return nil unless viable?(shape)
33
+
34
+ fields = fields_for(shape)
35
+ return nil if fields.empty?
36
+
37
+ init = shape.initialize_method
38
+ # attr fields must be covered by the constructor's parameters.
39
+ return nil unless shape.attr_fields.all? { |f| fields.include?(f) }
40
+
41
+ keyword = init ? H.keyword_params?(init.children[1]) : false
42
+ Analysis.new(fields: fields, kind: kind_for(shape, prefer_data), keyword: keyword)
43
+ end
44
+
45
+ # The structural preconditions for treating a class as a pure data bucket.
46
+ def viable?(shape)
47
+ return false if shape.module?
48
+ return false if shape.uses_metaprogramming?
49
+ return false unless superclass_ok?(shape)
50
+ return false unless shape.behavior_methods.empty?
51
+ return false unless shape.singleton_methods.empty?
52
+ return false unless shape.other_statements.empty?
53
+
54
+ init = shape.initialize_method
55
+ # No initialize: a bare attr_* container still qualifies.
56
+ return !shape.attr_fields.empty? if init.nil?
57
+
58
+ H.initialize_only_assigns_params?(shape.node)
59
+ end
60
+
61
+ def superclass_ok?(shape)
62
+ shape.superclass.nil? || shape.superclass == "Object"
63
+ end
64
+
65
+ def fields_for(shape)
66
+ init = shape.initialize_method
67
+ init ? H.param_names(init.children[1]) : shape.attr_fields
68
+ end
69
+
70
+ # attr_accessor/attr_writer imply mutation -> Struct; otherwise Data.
71
+ def kind_for(shape, prefer_data)
72
+ writable = !(shape.attr_macros[:attr_writer] + shape.attr_macros[:attr_accessor]).empty?
73
+ return :struct if writable && !prefer_data
74
+
75
+ :data
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhyClasses
4
+ # Honours inline suppression comments:
5
+ # # why-classes:disable RuleA, RuleB (whole file)
6
+ # # why-classes:disable-line RuleA (that line only)
7
+ # A bare `disable`/`disable-line` with no rule names (or `all`) suppresses
8
+ # every rule.
9
+ module DisableComments
10
+ DIRECTIVE = /#\s*why-classes:disable(?<line>-line)?\s*(?<rules>[\w:,\s]*)$/
11
+
12
+ Directives = Data.define(:file_rules, :line_rules) do
13
+ def allow?(offense)
14
+ return false if suppressed?(file_rules, offense.rule_name)
15
+
16
+ !suppressed?(line_rules.fetch(offense.line, nil), offense.rule_name)
17
+ end
18
+
19
+ def suppressed?(set, rule_name)
20
+ return false if set.nil?
21
+
22
+ set.include?(:all) || set.include?(rule_name)
23
+ end
24
+ end
25
+
26
+ module_function
27
+
28
+ def parse(comments)
29
+ file_rules = []
30
+ line_rules = Hash.new { |h, k| h[k] = [] }
31
+
32
+ comments.each do |comment|
33
+ match = DIRECTIVE.match(comment.text)
34
+ next unless match
35
+
36
+ names = rule_names(match[:rules])
37
+ if match[:line]
38
+ line_rules[comment.location.line].concat(names)
39
+ else
40
+ file_rules.concat(names)
41
+ end
42
+ end
43
+
44
+ Directives.new(file_rules: file_rules.uniq, line_rules: line_rules.transform_values(&:uniq))
45
+ end
46
+
47
+ def filter(offenses, comments)
48
+ directives = parse(comments)
49
+ offenses.select { |offense| directives.allow?(offense) }
50
+ end
51
+
52
+ def rule_names(raw)
53
+ names = raw.to_s.split(/[,\s]+/).reject(&:empty?)
54
+ return [:all] if names.empty? || names.include?("all")
55
+
56
+ names
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhyClasses
4
+ # Expands CLI paths into a concrete list of Ruby files, honouring the
5
+ # configuration's Include/Exclude globs. Explicitly-named files are always
6
+ # included; Exclude only filters directory expansion.
7
+ module FileFinder
8
+ module_function
9
+
10
+ def find(paths, configuration)
11
+ paths = ["."] if paths.nil? || paths.empty?
12
+ files = []
13
+ paths.each do |path|
14
+ if File.file?(path)
15
+ files << normalize(path)
16
+ elsif File.directory?(path)
17
+ files.concat(expand_dir(path, configuration))
18
+ end
19
+ end
20
+ files.uniq
21
+ end
22
+
23
+ def expand_dir(dir, configuration)
24
+ configuration.include_globs
25
+ .flat_map { |glob| Dir.glob(File.join(dir, glob), File::FNM_EXTGLOB) }
26
+ .select { |f| File.file?(f) }
27
+ .map { |f| normalize(f) }
28
+ .reject { |f| excluded?(f, configuration) }
29
+ .uniq
30
+ end
31
+
32
+ def excluded?(file, configuration)
33
+ configuration.exclude_globs.any? do |glob|
34
+ File.fnmatch?(glob, file, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
35
+ File.fnmatch?("**/#{glob}", file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
36
+ end
37
+ end
38
+
39
+ def normalize(path)
40
+ path.delete_prefix("./")
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhyClasses
4
+ module Formatters
5
+ # Formatters receive the array of FileReport objects produced by the Runner
6
+ # and write output to an IO.
7
+ class BaseFormatter
8
+ def initialize(io = $stdout)
9
+ @io = io
10
+ end
11
+
12
+ # @param reports [Array<Runner::FileReport>]
13
+ def call(reports)
14
+ raise NotImplementedError
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :io
20
+
21
+ def total_offenses(reports)
22
+ reports.sum { |r| r.offenses.size }
23
+ end
24
+
25
+ def total_corrected(reports)
26
+ reports.sum(&:applied)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_formatter"
4
+
5
+ module WhyClasses
6
+ module Formatters
7
+ # One line per offense: `path:line:col: [Rule] message`. Editor/CI friendly.
8
+ class ClangFormatter < BaseFormatter
9
+ def call(reports)
10
+ reports.each do |report|
11
+ report.offenses.each do |offense|
12
+ io.puts "#{report.path}:#{offense.line}:#{offense.column + 1}: " \
13
+ "[#{offense.rule_name}] #{offense.message}"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_formatter"
4
+
5
+ module WhyClasses
6
+ module Formatters
7
+ # Shows a unified diff of the proposed rewrites (for --diff / --dry-run).
8
+ # Only files that actually changed are printed.
9
+ class DiffFormatter < BaseFormatter
10
+ def call(reports)
11
+ changed = reports.select(&:changed?)
12
+ changed.each do |report|
13
+ io.puts "--- #{report.path}"
14
+ io.puts "+++ #{report.path}"
15
+ io.print UnifiedDiff.render(report.original_source, report.corrected_source)
16
+ end
17
+ corrections = total_corrected(reports)
18
+ io.puts "#{changed.size} file#{'s' unless changed.size == 1} would change, " \
19
+ "#{corrections} correction#{'s' unless corrections == 1}."
20
+ end
21
+
22
+ # A small LCS-based unified diff over lines. Sufficient for the localized
23
+ # rewrites this tool produces. Ops carry the actual line text.
24
+ module UnifiedDiff
25
+ Op = Struct.new(:kind, :text, :a_line, :b_line) # kind: :eq/:del/:add
26
+
27
+ module_function
28
+
29
+ def render(before, after, context: 3)
30
+ a = before.lines
31
+ b = after.lines
32
+ ops = diff_ops(a, b)
33
+ hunks(ops, context).map { |slice| format_hunk(slice) }.join
34
+ end
35
+
36
+ def diff_ops(a, b)
37
+ table = lcs_table(a, b)
38
+ ops = []
39
+ i = a.size
40
+ j = b.size
41
+ while i.positive? && j.positive?
42
+ if a[i - 1] == b[j - 1]
43
+ ops.unshift(Op.new(:eq, a[i - 1], i, j))
44
+ i -= 1
45
+ j -= 1
46
+ elsif table[i - 1][j] >= table[i][j - 1]
47
+ ops.unshift(Op.new(:del, a[i - 1], i, nil))
48
+ i -= 1
49
+ else
50
+ ops.unshift(Op.new(:add, b[j - 1], nil, j))
51
+ j -= 1
52
+ end
53
+ end
54
+ ops.unshift(Op.new(:del, a[(i -= 1)], i + 1, nil)) while i.positive?
55
+ ops.unshift(Op.new(:add, b[(j -= 1)], nil, j + 1)) while j.positive?
56
+ ops
57
+ end
58
+
59
+ def lcs_table(a, b)
60
+ table = Array.new(a.size + 1) { Array.new(b.size + 1, 0) }
61
+ a.size.times do |i|
62
+ b.size.times do |j|
63
+ table[i + 1][j + 1] = a[i] == b[j] ? table[i][j] + 1 : [table[i][j + 1], table[i + 1][j]].max
64
+ end
65
+ end
66
+ table
67
+ end
68
+
69
+ # Slice ops into hunks around changes, keeping `context` equal lines.
70
+ def hunks(ops, context)
71
+ change_idx = ops.each_index.select { |k| ops[k].kind != :eq }
72
+ return [] if change_idx.empty?
73
+
74
+ ranges = []
75
+ change_idx.each do |k|
76
+ lo = [k - context, 0].max
77
+ hi = [k + context, ops.size - 1].min
78
+ if ranges.any? && lo <= ranges.last[1] + 1
79
+ ranges.last[1] = hi
80
+ else
81
+ ranges << [lo, hi]
82
+ end
83
+ end
84
+ ranges.map { |lo, hi| ops[lo..hi] }
85
+ end
86
+
87
+ def format_hunk(slice)
88
+ a_lines = slice.select { |op| op.kind != :add }
89
+ b_lines = slice.select { |op| op.kind != :del }
90
+ a_start = a_lines.first&.a_line || 0
91
+ b_start = b_lines.first&.b_line || 0
92
+ header = "@@ -#{a_start},#{a_lines.size} +#{b_start},#{b_lines.size} @@\n"
93
+ body = slice.map { |op| "#{prefix(op.kind)}#{ensure_newline(op.text)}" }.join
94
+ header + body
95
+ end
96
+
97
+ def prefix(kind)
98
+ { eq: " ", del: "-", add: "+" }.fetch(kind)
99
+ end
100
+
101
+ def ensure_newline(text)
102
+ text.end_with?("\n") ? text : "#{text}\n"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base_formatter"
5
+
6
+ module WhyClasses
7
+ module Formatters
8
+ # Machine-readable output (editors, LSP bridges, CI dashboards).
9
+ class JsonFormatter < BaseFormatter
10
+ def call(reports)
11
+ payload = {
12
+ "summary" => {
13
+ "files_inspected" => reports.size,
14
+ "offenses" => total_offenses(reports),
15
+ "corrected" => total_corrected(reports)
16
+ },
17
+ "files" => reports.map { |report| file_payload(report) }
18
+ }
19
+ io.puts JSON.pretty_generate(payload)
20
+ end
21
+
22
+ private
23
+
24
+ def file_payload(report)
25
+ {
26
+ "path" => report.path,
27
+ "offenses" => report.offenses.map do |offense|
28
+ {
29
+ "rule" => offense.rule_name,
30
+ "message" => offense.message,
31
+ "suggestion" => offense.suggestion,
32
+ "line" => offense.line,
33
+ "column" => offense.column + 1,
34
+ "severity" => offense.severity.to_s,
35
+ "correctable" => offense.correctable?
36
+ }
37
+ end
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_formatter"
4
+
5
+ module WhyClasses
6
+ module Formatters
7
+ # The default human-readable formatter: one block per offense with its
8
+ # suggestion, then a summary line.
9
+ class ProgressFormatter < BaseFormatter
10
+ def call(reports)
11
+ reports.each do |report|
12
+ report.offenses.each { |offense| print_offense(report, offense) }
13
+ end
14
+ print_summary(reports)
15
+ end
16
+
17
+ private
18
+
19
+ def print_offense(report, offense)
20
+ marker = offense.correctable? ? " [correctable]" : ""
21
+ io.puts "#{report.path}:#{offense.line}:#{offense.column + 1}: " \
22
+ "[#{offense.rule_name}]#{marker} #{offense.message}"
23
+ offense.suggestion.to_s.each_line { |line| io.puts " #{line.chomp}" }
24
+ io.puts
25
+ end
26
+
27
+ def print_summary(reports)
28
+ files = reports.size
29
+ offenses = total_offenses(reports)
30
+ corrected = total_corrected(reports)
31
+ summary = "#{files} file#{'s' unless files == 1} inspected, " \
32
+ "#{offenses} offense#{'s' unless offenses == 1} detected"
33
+ summary += ", #{corrected} corrected" if corrected.positive?
34
+ io.puts summary
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhyClasses
4
+ # An immutable finding produced by a Rule.
5
+ #
6
+ # +corrector+ is nil for advice-only offenses, or a callable taking a
7
+ # Parser::Source::TreeRewriter and queueing the edits that apply the
8
+ # suggested refactoring.
9
+ Offense = Data.define(
10
+ :rule_name,
11
+ :message,
12
+ :suggestion,
13
+ :path,
14
+ :line,
15
+ :column,
16
+ :severity,
17
+ :correctable,
18
+ :corrector
19
+ ) do
20
+ def correctable?
21
+ correctable && !corrector.nil?
22
+ end
23
+
24
+ # Sort key: by position within a file.
25
+ def position
26
+ [line, column]
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhyClasses
4
+ # Parsed CLI options (dogfooding the gem's own advice: a plain Data value).
5
+ #
6
+ # mode: :report | :diff | :fix
7
+ Options = Data.define(
8
+ :paths,
9
+ :mode,
10
+ :fix_unsafe,
11
+ :only,
12
+ :except,
13
+ :format,
14
+ :config_path,
15
+ :rails,
16
+ :fail_level
17
+ ) do
18
+ def self.defaults(**overrides)
19
+ new(**{
20
+ paths: [],
21
+ mode: :report,
22
+ fix_unsafe: false,
23
+ only: [],
24
+ except: [],
25
+ format: "progress",
26
+ config_path: nil,
27
+ rails: nil,
28
+ fail_level: "convention"
29
+ }.merge(overrides))
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "prism/translation/parser"
5
+ require "parser/source/tree_rewriter"
6
+
7
+ module WhyClasses
8
+ # Wraps Prism's official parser-compatible frontend so that detection and
9
+ # rewriting share a single coordinate system. Prism is the actual parsing
10
+ # engine under the hood; the +parser+ gem supplies the AST node interface,
11
+ # source buffer, ranges and TreeRewriter.
12
+ module Parser
13
+ Result = Data.define(:ast, :comments, :buffer, :error)
14
+
15
+ module_function
16
+
17
+ # Parses +source+ and returns a Result. Parse errors are non-fatal: on
18
+ # failure +ast+ is nil and +error+ carries the exception message, so a
19
+ # single unparseable file never aborts a whole run.
20
+ def parse(source, path = "(source)")
21
+ buffer = ::Parser::Source::Buffer.new(path, source: source)
22
+ parser = ::Prism::Translation::Parser.new
23
+ parser.diagnostics.all_errors_are_fatal = false
24
+ parser.diagnostics.ignore_warnings = true
25
+
26
+ ast, comments = parser.parse_with_comments(buffer)
27
+ Result.new(ast: ast, comments: comments, buffer: buffer, error: nil)
28
+ rescue ::Parser::SyntaxError, EncodingError => e
29
+ Result.new(ast: nil, comments: [], buffer: buffer, error: e.message)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhyClasses
4
+ # Tracks every Rule subclass and resolves the active set from --only/--except
5
+ # and the configuration.
6
+ module Registry
7
+ @rules = {}
8
+
9
+ module_function
10
+
11
+ def register(rule_class)
12
+ @rules[rule_class.rule_name] = rule_class
13
+ end
14
+
15
+ def all
16
+ @rules.values
17
+ end
18
+
19
+ def names
20
+ @rules.keys
21
+ end
22
+
23
+ def fetch(name)
24
+ @rules.fetch(name)
25
+ end
26
+
27
+ def known?(name)
28
+ @rules.key?(name)
29
+ end
30
+
31
+ # Resolve the rule classes to run given config + CLI selection.
32
+ def active(configuration:, only: nil, except: nil)
33
+ selected = all
34
+ selected = selected.select { |r| Array(only).include?(r.rule_name) } if only && !only.empty?
35
+ selected = selected.reject { |r| Array(except).include?(r.rule_name) } if except && !except.empty?
36
+ selected.select { |r| configuration.enabled?(r.rule_name) }
37
+ end
38
+ end
39
+ end