why-classes 0.1.1 → 0.2.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60d479e6191da73a622209cfcb3e480fc56f0dd56f1c2c327864a9d0ce2386c8
4
- data.tar.gz: c19468580a706d922a3fde0951b126a1ae385a3b1f11a04548ab36cc643a928b
3
+ metadata.gz: 9de2667919d94d511885316c479efec18017e6b4aef44ff77b188a6e6e7b09f3
4
+ data.tar.gz: 72a6e64e8c1e0723c8e1dc40bd7edc6790e442dfb6786b752b22cbd9b75cbae0
5
5
  SHA512:
6
- metadata.gz: f287c5d19cd1d41a297e33fb099815bd689ce7e7fca1d19e6d8135e551d913d680b0b2ed07d67fabc5bf1bbbfe7a7643cb33ba00c8f9156e73ccbdece385842f
7
- data.tar.gz: 82a8a3ed0f4d5e54ac33c30c9cc040b88534b95977bc8a22e01e1c84e13a4fb49217696b59c78359149ec6118a7a0d259a2c35a33d40eae9e75ea86d75cb8d17
6
+ metadata.gz: 4a140aeb0b4d8cefc2dc29e1fe0a401821a8f67d901a8b2b6d85ea4a16da34512f4d14d64db2f33f3e1795f01cf21e897e1ecbede42b0fc92f82e2ebe87f71fa
7
+ data.tar.gz: 5cc2be7da1cf8b8637355fec2be91bb0a92e3bd8dbd9eddd211f9b496b07a3266338808e5a6bd91cc7ee5e0067852ef14f881413ee444db184204791edd814d8
data/README.md CHANGED
@@ -19,12 +19,31 @@ detection and autocorrection share one coordinate system.
19
19
 
20
20
  | Rule | Smell | Suggests | Autocorrect |
21
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 |
22
+ | `DataBucket` | `attr_*` + a field-assigning `initialize`, no behaviour | `Struct.new` / `Data.define` | ✅ `--fix` (safe) |
23
+ | `StatelessSingletonMethods` | only `self.` methods, no state | `module` + `extend self` | ⚠️ `--fix-unsafe` |
24
+ | `FunctionBucket` | a single `call`/`perform`/`run`, no state | `module_function` | ⚠️ `--fix-unsafe` |
25
25
  | `InvalidInitialState` | `initialize` leaves a field `nil`, needs a setter first | pass it in / drop the object | advice |
26
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) |
27
+ | `SingleAttributeReader` | a method that only reads one field | a freestanding function | ⚠️ `--fix-unsafe` (off by default) |
28
+
29
+ **Autocorrect tiers.** `--fix` applies only the *safe* tier (`DataBucket`): a
30
+ self-contained, within-file transform. The *unsafe* tier (`StatelessSingletonMethods`,
31
+ `FunctionBucket`, `SingleAttributeReader`) is mechanically correct in the file but can
32
+ break call sites in files this tool never saw — `Foo.new`, subclassing, `Foo.new.call`,
33
+ or `person.age` becoming `age(person)` — so it is opt-in via `--fix-unsafe` (which
34
+ implies `--fix`). Always review the diff. Run `--list-rules` to see each rule's tier.
35
+
36
+ `SingleAttributeReader` extracts an instance method that only reads one field into a
37
+ freestanding function (Thomas's "decoupled polymorphism"). Two `ParameterStyle`s:
38
+
39
+ ```ruby
40
+ # before # entity (default) # attribute
41
+ class Person def age_in_seconds(entity) def age_in_seconds(date_of_birth)
42
+ def age_in_seconds => Time.now - Time.now - date_of_birth
43
+ Time.now - @date_of_birth entity.date_of_birth end
44
+ end end
45
+ end
46
+ ```
28
47
 
29
48
  `attr_reader`-only buckets become immutable `Data.define`; `attr_accessor`/`attr_writer`
30
49
  buckets (mutation expected) become `Struct.new`.
@@ -44,7 +63,9 @@ why-classes # scan the current directory, report + suggest
44
63
  why-classes app/models/point.rb # scan one file
45
64
  why-classes --diff app/ # preview the rewrites as a unified diff
46
65
  why-classes --fix app/ # apply the safe (DataBucket) rewrites
66
+ why-classes --fix-unsafe app/ # also rewrite stateless classes -> modules
47
67
  why-classes --only DataBucket app # run a single rule
68
+ why-classes --cache app/ # cache results for faster re-scans
48
69
  why-classes --format json app # machine-readable output
49
70
  why-classes --list-rules # list every rule
50
71
  ```
@@ -63,13 +84,17 @@ app/models/point.rb:1:1: [DataBucket] [correctable] Point is a data bucket: it o
63
84
  | Flag | Meaning |
64
85
  |------|---------|
65
86
  | `--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) |
87
+ | `-a`, `--fix` | apply safe-tier corrections (`DataBucket`) and write files |
88
+ | `--fix-unsafe` | also apply unsafe-tier corrections (module / `module_function`); implies `--fix` |
68
89
  | `--only A,B` / `--except A,B` | select rules |
69
90
  | `--format progress\|clang\|json\|diff` | output format (default `progress`) |
70
91
  | `--config PATH` | use a specific `.why-classes.yml` |
71
92
  | `--[no-]rails` | toggle Rails awareness |
72
93
  | `--fail-level none\|convention\|warning` | CI exit-code threshold (default `convention`) |
94
+ | `--cache` / `--cache-dir PATH` | cache results on disk for faster re-scans (report modes only) |
95
+ | `--list-rules` | list every rule with its tier (`[--fix]` / `[--fix-unsafe]` / `[advice]`) |
96
+ | `-v`, `--version` | print the version and exit |
97
+ | `-h`, `--help` | print usage and exit |
73
98
 
74
99
  Exit codes: `0` clean (or below the fail level), `1` offenses found, `2` usage error.
75
100
 
data/config/default.yml CHANGED
@@ -44,6 +44,10 @@ Rules:
44
44
 
45
45
  SingleAttributeReader:
46
46
  Enabled: false # noisy; opt-in
47
+ # Freestanding function shape used by --fix-unsafe:
48
+ # entity -> def age_in_seconds(entity); entity.date_of_birth ...
49
+ # attribute -> def age_in_seconds(date_of_birth); date_of_birth ...
50
+ ParameterStyle: entity
47
51
 
48
52
  Rails:
49
53
  # Subclasses of these legitimately must be classes -> suppress the
Binary file
@@ -50,13 +50,18 @@ module WhyClasses
50
50
 
51
51
  o.on("--diff", "--dry-run", "Show a diff of proposed rewrites; write nothing") { @collected[:mode] = :diff }
52
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 }
53
+ o.on("--fix-unsafe", "Also apply unsafe-tier corrections (implies --fix)") do
54
+ @collected[:fix_unsafe] = true
55
+ @collected[:mode] ||= :fix
56
+ end
54
57
  o.on("--only RULES", Array, "Run only these rules") { |v| @collected[:only] = v }
55
58
  o.on("--except RULES", Array, "Skip these rules") { |v| @collected[:except] = v }
56
59
  o.on("--format FORMAT", "Output format: #{VALID_FORMATS.join(', ')}") { |v| @collected[:format] = v }
57
60
  o.on("--config PATH", "Path to a .why-classes.yml") { |v| @collected[:config_path] = v }
58
61
  o.on("--[no-]rails", "Toggle Rails awareness") { |v| @collected[:rails] = v }
59
62
  o.on("--fail-level LEVEL", "CI exit threshold: #{VALID_FAIL_LEVELS.join(', ')}") { |v| @collected[:fail_level] = v }
63
+ o.on("--cache", "Cache analysis results on disk (report modes only)") { @collected[:cache] = true }
64
+ o.on("--cache-dir PATH", "Directory for the cache (default .why-classes-cache)") { |v| @collected[:cache_dir] = v }
60
65
  o.on("--list-rules", "List all rules and exit") { list_rules_and_exit }
61
66
  o.on("-v", "--version", "Print version and exit") { print_version_and_exit }
62
67
  o.on("-h", "--help", "Print this help and exit") { print_help_and_exit(o) }
@@ -73,9 +78,15 @@ module WhyClasses
73
78
  end
74
79
  end
75
80
 
81
+ TIER_LABELS = {
82
+ suggested: "[--fix] ",
83
+ unsafe: "[--fix-unsafe]",
84
+ none: "[advice] "
85
+ }.freeze
86
+
76
87
  def list_rules_and_exit
77
88
  Registry.all.sort_by(&:rule_name).each do |rule|
78
- mark = rule.autocorrectable? ? "[correctable]" : "[advice] "
89
+ mark = TIER_LABELS.fetch(rule.tier, "[advice] ")
79
90
  @io.puts "#{mark} #{rule.rule_name.ljust(28)} #{rule.description}"
80
91
  end
81
92
  @early_exit = 0
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+
5
+ module WhyClasses
6
+ # Decides which correctable offenses should actually be applied, given the
7
+ # configuration and whether --fix-unsafe was requested. Detection ("is there a
8
+ # corrector?") is separate from application ("should we run it?"): a rule can
9
+ # report a rewrite as available while the plan declines to apply it.
10
+ module CorrectionPlan
11
+ module_function
12
+
13
+ def applicable(offenses, configuration:, fix_unsafe: false)
14
+ offenses.select { |offense| apply?(offense, configuration, fix_unsafe) }
15
+ end
16
+
17
+ def apply?(offense, configuration, fix_unsafe)
18
+ return false if offense.corrector.nil?
19
+
20
+ # No Enabled re-check here: offenses only exist for rules that were active
21
+ # (see Registry.active), so a --only-forced rule can still autocorrect.
22
+ tier = Registry.fetch(offense.rule_name).tier
23
+ case tier
24
+ when :suggested
25
+ configuration.autocorrect?(offense.rule_name)
26
+ when :unsafe
27
+ fix_unsafe
28
+ else
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast/node_helpers"
4
+
5
+ module WhyClasses
6
+ module Correctors
7
+ # Rewrites a single-function "service object" class into a module function:
8
+ #
9
+ # class CreateUser => module CreateUser
10
+ # def call module_function
11
+ # ...
12
+ # end def call
13
+ # end ...
14
+ # end
15
+ # end
16
+ #
17
+ # Unsafe-tier: `CreateUser.new.call` call sites elsewhere would break.
18
+ module FunctionBucketCorrector
19
+ module_function
20
+
21
+ def correctable?(shape)
22
+ return false if shape.module?
23
+ return false if shape.superclass?
24
+ return false if shape.uses_metaprogramming?
25
+ return false unless shape.initialize_method.nil?
26
+ return false unless shape.singleton_methods.empty?
27
+
28
+ shape.behavior_methods.size == 1
29
+ end
30
+
31
+ def corrector(class_node, method_node)
32
+ lambda do |rewriter|
33
+ rewriter.replace(class_node.location.keyword, "module")
34
+ inner = " " * (class_node.location.keyword.column + 2)
35
+ rewriter.insert_before(method_node.location.expression, "module_function\n\n#{inner}")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast/node_helpers"
4
+
5
+ module WhyClasses
6
+ module Correctors
7
+ # Decoupled polymorphism (Dave Thomas): an instance method that only reads a
8
+ # single field is more flexible as a freestanding function. This corrector
9
+ # *extracts* such a method out of the class:
10
+ #
11
+ # class Person def age_in_seconds(entity)
12
+ # def initialize(dob) => Time.now - entity.date_of_birth
13
+ # @date_of_birth = dob end
14
+ # end
15
+ # # class keeps only what remains
16
+ # def age_in_seconds
17
+ # Time.now - @date_of_birth
18
+ # end
19
+ # end
20
+ #
21
+ # Two parameter styles:
22
+ # :entity -> `def age_in_seconds(entity)` using `entity.date_of_birth`
23
+ # (works for anything responding to the reader)
24
+ # :attribute -> `def age_in_seconds(date_of_birth)` using `date_of_birth`
25
+ # (the "even more generic" form -- just the value)
26
+ #
27
+ # Unsafe-tier: every `person.age_in_seconds` call site elsewhere must become
28
+ # `age_in_seconds(person)`, which this single-file tool cannot rewrite.
29
+ module PolymorphicFunctionCorrector
30
+ H = AST::NodeHelpers
31
+
32
+ module_function
33
+
34
+ def correctable?(shape, method_node)
35
+ return false if shape.uses_metaprogramming?
36
+ return false unless no_parameters?(method_node)
37
+
38
+ read = H.ivars_read(method_node)
39
+ return false unless read.size == 1
40
+ return false unless H.ivars_assigned(method_node).empty?
41
+ return false if leans_on_self?(method_node, shape)
42
+
43
+ true
44
+ end
45
+
46
+ def corrector(class_node, method_node, style:)
47
+ field = H.ivars_read(method_node).first.to_s.delete_prefix("@")
48
+ lambda do |rewriter|
49
+ buffer = class_node.location.expression.source_buffer
50
+ rewriter.remove(removal_range(buffer, method_node))
51
+ rewriter.insert_after(class_node.location.end, "\n\n#{freestanding(class_node, method_node, field, style)}")
52
+ end
53
+ end
54
+
55
+ # --- preconditions -------------------------------------------------------
56
+
57
+ def no_parameters?(method_node)
58
+ args = method_node.children[1]
59
+ args.nil? || args.children.empty?
60
+ end
61
+
62
+ # Bail when the body depends on the receiver in ways extraction would break:
63
+ # `self`, `super`, blocks (`yield`), or a bare call to a sibling method.
64
+ def leans_on_self?(method_node, shape)
65
+ siblings = shape.instance_method_names - [method_node.children[0]]
66
+ H.collect(method_node) do |n|
67
+ %i[self zsuper super yield].include?(n.type) ||
68
+ (n.type == :send && n.children[0].nil? && siblings.include?(n.children[1]))
69
+ end.any?
70
+ end
71
+
72
+ # --- rewriting -----------------------------------------------------------
73
+
74
+ def freestanding(class_node, method_node, field, style)
75
+ param = style == :attribute ? field : "entity"
76
+ replacement = style == :attribute ? field : "#{param}.#{field}"
77
+ body = rewritten_source(method_node, field, param: param, replacement: replacement)
78
+ reindent(body, class_node.location.keyword.column, method_node.location.keyword.column)
79
+ end
80
+
81
+ # Splice the method's own source: add the parameter and rewrite each ivar
82
+ # read. Offsets are relative to the method's start.
83
+ def rewritten_source(method_node, field, param:, replacement:)
84
+ base = method_node.location.expression.begin_pos
85
+ src = method_node.location.expression.source
86
+ edits = []
87
+ name_end = method_node.location.name.end_pos - base
88
+ edits << [name_end, name_end, "(#{param})"]
89
+ H.collect(method_node) { |n| n.type == :ivar && n.children[0] == :"@#{field}" }.each do |ivar|
90
+ edits << [ivar.location.expression.begin_pos - base, ivar.location.expression.end_pos - base, replacement]
91
+ end
92
+ splice(src, edits)
93
+ end
94
+
95
+ def splice(str, edits)
96
+ edits.sort_by { |start, _stop, _text| -start }.each do |start, stop, text|
97
+ str = str[0...start] + text + str[stop..]
98
+ end
99
+ str
100
+ end
101
+
102
+ # The method source has no leading indent on line 1 and absolute indent on
103
+ # the rest. Shift it to sit at the class's indentation level: line 1 gets
104
+ # the target indent; the remaining lines are dedented by the difference
105
+ # between the method's indent and the target.
106
+ def reindent(source, target_col, method_col)
107
+ delta = [method_col - target_col, 0].max
108
+ pad = " " * target_col
109
+ source.lines.each_with_index.map do |line, i|
110
+ i.zero? ? pad + line : line.sub(/\A {0,#{delta}}/, "")
111
+ end.join
112
+ end
113
+
114
+ # Range covering the method's whole line(s) plus its trailing newline, and
115
+ # one preceding blank line if present, for a tidy removal.
116
+ def removal_range(buffer, method_node)
117
+ expr = method_node.location.expression
118
+ source = buffer.source
119
+ line_start = expr.begin_pos - method_node.location.keyword.column
120
+ stop = expr.end_pos
121
+ stop += 1 if source[stop] == "\n"
122
+ line_start -= 1 if source[line_start - 1] == "\n" && source[line_start - 2] == "\n"
123
+ ::Parser::Source::Range.new(buffer, line_start, stop)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast/node_helpers"
4
+
5
+ module WhyClasses
6
+ module Correctors
7
+ # Rewrites a stateless "class of class-methods" into a module that exposes the
8
+ # same methods via `extend self`:
9
+ #
10
+ # class Checksum => module Checksum
11
+ # def self.calculate(id) def calculate(id)
12
+ # ... ...
13
+ # end end
14
+ # end
15
+ # extend self
16
+ # end
17
+ #
18
+ # Unsafe-tier: mechanical within the file, but `Checksum.new`, subclassing,
19
+ # or `Checksum.superclass` elsewhere would break.
20
+ module StatelessModuleCorrector
21
+ H = AST::NodeHelpers
22
+
23
+ module_function
24
+
25
+ # Convertible only when every class method uses the `def self.x` form.
26
+ # A `class << self` block is bailed on (kept as advice).
27
+ def correctable?(shape)
28
+ return false if shape.module?
29
+ return false if shape.superclass?
30
+ return false if shape.uses_metaprogramming?
31
+ return false unless shape.instance_methods.empty?
32
+ return false if shape.singleton_methods.empty?
33
+
34
+ body = H.body_statements(H.class_body(shape.node))
35
+ body.none? { |n| H.node?(n) && n.type == :sclass }
36
+ end
37
+
38
+ def corrector(class_node)
39
+ lambda do |rewriter|
40
+ rewriter.replace(class_node.location.keyword, "module")
41
+ H.singleton_methods(class_node).each do |defs|
42
+ receiver = defs.children[0]
43
+ self_dot = receiver.location.expression.join(defs.location.operator)
44
+ rewriter.remove(self_dot)
45
+ end
46
+ inner = " " * (class_node.location.keyword.column + 2)
47
+ rewriter.insert_before(class_node.location.end, "\n#{inner}extend self\n")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -17,8 +17,11 @@ module WhyClasses
17
17
  :correctable,
18
18
  :corrector
19
19
  ) do
20
+ # Whether a corrector is available (display marker). Stays true across cache
21
+ # round-trips even though the non-serializable corrector proc is dropped;
22
+ # actually applying an edit checks #corrector directly (see CorrectionPlan).
20
23
  def correctable?
21
- correctable && !corrector.nil?
24
+ correctable
22
25
  end
23
26
 
24
27
  # Sort key: by position within a file.
@@ -13,7 +13,9 @@ module WhyClasses
13
13
  :format,
14
14
  :config_path,
15
15
  :rails,
16
- :fail_level
16
+ :fail_level,
17
+ :cache,
18
+ :cache_dir
17
19
  ) do
18
20
  def self.defaults(**overrides)
19
21
  new(**{
@@ -25,7 +27,9 @@ module WhyClasses
25
27
  format: "progress",
26
28
  config_path: nil,
27
29
  rails: nil,
28
- fail_level: "convention"
30
+ fail_level: "convention",
31
+ cache: false,
32
+ cache_dir: ".why-classes-cache"
29
33
  }.merge(overrides))
30
34
  end
31
35
  end
@@ -29,10 +29,17 @@ module WhyClasses
29
29
  end
30
30
 
31
31
  # Resolve the rule classes to run given config + CLI selection.
32
+ #
33
+ # An explicit --only forces the listed rules on even if they are disabled by
34
+ # default/config (mirrors RuboCop's --only); otherwise the config's Enabled
35
+ # flag decides.
32
36
  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?
37
+ only = Array(only)
38
+ except = Array(except)
39
+ selected = all.reject { |r| except.include?(r.rule_name) }
40
+
41
+ return selected.select { |r| only.include?(r.rule_name) } unless only.empty?
42
+
36
43
  selected.select { |r| configuration.enabled?(r.rule_name) }
37
44
  end
38
45
  end
@@ -25,7 +25,16 @@ module WhyClasses
25
25
 
26
26
  # Whether this rule can ever autocorrect. Advice-only rules return false.
27
27
  def autocorrectable?
28
- false
28
+ tier != :none
29
+ end
30
+
31
+ # Autocorrection tier:
32
+ # :suggested -- mechanical and low-risk; applied by --fix
33
+ # :unsafe -- correct in-file but may break cross-file call sites;
34
+ # applied only by --fix-unsafe
35
+ # :none -- advice only, no corrector
36
+ def tier
37
+ :none
29
38
  end
30
39
 
31
40
  def inherited(subclass)
@@ -13,8 +13,8 @@ module WhyClasses
13
13
  "Class that only holds data (attr_* + field-assigning initialize) -> use Struct or Data."
14
14
  end
15
15
 
16
- def self.autocorrectable?
17
- true
16
+ def self.tier
17
+ :suggested
18
18
  end
19
19
 
20
20
  def on_class(node)
@@ -52,7 +52,6 @@ module WhyClasses
52
52
 
53
53
  def build_corrector(node, _shape, analysis)
54
54
  return nil if analysis.nil?
55
- return nil unless configuration.autocorrect?(self.class.rule_name)
56
55
 
57
56
  name = H.class_name(node)
58
57
  replacement = analysis.replacement(name)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../rule"
4
+ require_relative "../correctors/function_bucket_corrector"
4
5
 
5
6
  module WhyClasses
6
7
  module Rules
@@ -13,6 +14,10 @@ module WhyClasses
13
14
  "Class with a single call/perform/run method and no state -> use a module function."
14
15
  end
15
16
 
17
+ def self.tier
18
+ :unsafe
19
+ end
20
+
16
21
  def on_class(node)
17
22
  s = shape(node)
18
23
  return if s.name.nil?
@@ -20,14 +25,21 @@ module WhyClasses
20
25
  return if s.superclass? # framework/base subclasses handled above; other inheritance is separate
21
26
  return unless function_bucket?(s)
22
27
 
23
- method = s.behavior_methods.first.children[0]
28
+ method_node = s.behavior_methods.first
29
+ method = method_node.children[0]
30
+ corrector =
31
+ if Correctors::FunctionBucketCorrector.correctable?(s)
32
+ Correctors::FunctionBucketCorrector.corrector(node, method_node)
33
+ end
34
+
24
35
  add_offense(
25
36
  node,
26
37
  message: "#{s.name} is a bucket for a single function (`##{method}`) with no state.",
27
38
  suggestion: "Prefer a module function or a plain method:\n" \
28
39
  " module #{s.name}\n module_function\n\n" \
29
40
  " def #{method}(...)\n ...\n end\n end\n" \
30
- " Callers become `#{s.name}.#{method}(...)` with no throwaway instance."
41
+ " Callers become `#{s.name}.#{method}(...)` with no throwaway instance.",
42
+ corrector: corrector
31
43
  )
32
44
  end
33
45
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../rule"
4
+ require_relative "../correctors/polymorphic_function_corrector"
4
5
 
5
6
  module WhyClasses
6
7
  module Rules
@@ -12,6 +13,10 @@ module WhyClasses
12
13
  "Instance method that only reads one field -> consider a freestanding polymorphic function."
13
14
  end
14
15
 
16
+ def self.tier
17
+ :unsafe
18
+ end
19
+
15
20
  def on_class(node)
16
21
  s = shape(node)
17
22
  return if s.name.nil?
@@ -27,12 +32,34 @@ module WhyClasses
27
32
  add_offense(
28
33
  method,
29
34
  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"
35
+ suggestion: suggestion(method, field),
36
+ corrector: build_corrector(node, s, method)
33
37
  )
34
38
  end
35
39
  end
40
+
41
+ private
42
+
43
+ def build_corrector(class_node, shape, method)
44
+ return nil unless Correctors::PolymorphicFunctionCorrector.correctable?(shape, method)
45
+
46
+ Correctors::PolymorphicFunctionCorrector.corrector(class_node, method, style: parameter_style)
47
+ end
48
+
49
+ def parameter_style
50
+ rule_option("ParameterStyle", "entity").to_sym
51
+ end
52
+
53
+ def suggestion(method, field)
54
+ name = method.children[0]
55
+ if parameter_style == :attribute
56
+ "A freestanding function is more flexible; pass the value directly:\n" \
57
+ " def #{name}(#{field})\n # use #{field} instead of @#{field}\n end"
58
+ else
59
+ "A freestanding function works on any object exposing `#{field}`:\n" \
60
+ " def #{name}(entity)\n # use entity.#{field} instead of @#{field}\n end"
61
+ end
62
+ end
36
63
  end
37
64
  end
38
65
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../rule"
4
+ require_relative "../correctors/stateless_module_corrector"
4
5
 
5
6
  module WhyClasses
6
7
  module Rules
@@ -12,6 +13,10 @@ module WhyClasses
12
13
  "Class with only class methods and no state -> use a module with `extend self`."
13
14
  end
14
15
 
16
+ def self.tier
17
+ :unsafe
18
+ end
19
+
15
20
  def on_class(node)
16
21
  s = shape(node)
17
22
  return if s.name.nil?
@@ -19,6 +24,11 @@ module WhyClasses
19
24
  return if configuration.framework_base_class?(s.superclass)
20
25
  return unless only_singleton_methods?(s)
21
26
 
27
+ corrector =
28
+ if Correctors::StatelessModuleCorrector.correctable?(s)
29
+ Correctors::StatelessModuleCorrector.corrector(node)
30
+ end
31
+
22
32
  add_offense(
23
33
  node,
24
34
  message: "#{s.name} has only class methods and no state; it's a bucket of functions, " \
@@ -26,7 +36,8 @@ module WhyClasses
26
36
  suggestion: "Make it a module and expose the methods with `extend self`:\n" \
27
37
  " module #{s.name}\n def #{example_method(s)}(...)\n ...\n end\n\n" \
28
38
  " private\n # helper methods here are now genuinely private\n\n" \
29
- " extend self\n end"
39
+ " extend self\n end",
40
+ corrector: corrector
30
41
  )
31
42
  end
32
43
 
@@ -38,8 +49,8 @@ module WhyClasses
38
49
  return false unless s.attr_fields.empty?
39
50
  return false if s.uses_metaprogramming?
40
51
 
41
- # Allow only constants / comments as other statements.
42
- s.other_statements.all? { |n| %i[casgn].include?(n.type) }
52
+ # Allow only constants and `class << self` blocks as other statements.
53
+ s.other_statements.all? { |n| %i[casgn sclass].include?(n.type) }
43
54
  end
44
55
 
45
56
  def example_method(s)
@@ -6,6 +6,8 @@ require_relative "source_file"
6
6
  require_relative "file_finder"
7
7
  require_relative "disable_comments"
8
8
  require_relative "correction"
9
+ require_relative "correction_plan"
10
+ require_relative "cache"
9
11
  require_relative "formatters/progress_formatter"
10
12
  require_relative "formatters/clang_formatter"
11
13
  require_relative "formatters/json_formatter"
@@ -60,7 +62,7 @@ module WhyClasses
60
62
  end
61
63
 
62
64
  def process(source_file)
63
- offenses = offenses_for(source_file)
65
+ offenses = analyze_with_cache(source_file)
64
66
  corrected, applied = maybe_correct(source_file, offenses)
65
67
  FileReport.new(
66
68
  path: source_file.path,
@@ -74,9 +76,9 @@ module WhyClasses
74
76
  def maybe_correct(source_file, offenses)
75
77
  return [source_file.source, 0] unless correcting?
76
78
 
77
- correctable = offenses.select do |offense|
78
- offense.correctable? && @configuration.autocorrect?(offense.rule_name)
79
- end
79
+ correctable = CorrectionPlan.applicable(
80
+ offenses, configuration: @configuration, fix_unsafe: @options.fix_unsafe
81
+ )
80
82
  result = Correction.apply(source_file, correctable)
81
83
  write_file(source_file.path, result.source) if @options.mode == :fix && result.source != source_file.source
82
84
  [result.source, result.applied]
@@ -86,6 +88,28 @@ module WhyClasses
86
88
  %i[fix diff].include?(@options.mode)
87
89
  end
88
90
 
91
+ # Cache is only consulted in report modes: correcting needs live corrector
92
+ # procs, which are never serialized.
93
+ def analyze_with_cache(source_file)
94
+ return offenses_for(source_file) unless cache
95
+
96
+ cache.fetch(source_file) || cache.store(source_file, offenses_for(source_file))
97
+ end
98
+
99
+ def cache
100
+ return @cache if defined?(@cache)
101
+
102
+ @cache =
103
+ if @options.cache && !correcting?
104
+ require "json"
105
+ Cache.new(
106
+ dir: @options.cache_dir,
107
+ config_digest: Digest::SHA256.hexdigest(@configuration.raw.to_json),
108
+ rule_names: Registry.active(configuration: @configuration, only: @options.only, except: @options.except).map(&:rule_name)
109
+ )
110
+ end
111
+ end
112
+
89
113
  def write_file(path, source)
90
114
  File.write(path, source)
91
115
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WhyClasses
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/why_classes.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "why_classes/configuration"
12
12
  require_relative "why_classes/config_loader"
13
13
  require_relative "why_classes/disable_comments"
14
14
  require_relative "why_classes/correction"
15
+ require_relative "why_classes/correction_plan"
15
16
  require_relative "why_classes/file_finder"
16
17
 
17
18
  # Rules self-register with the Registry when required.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: why-classes
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel Quaresma
@@ -59,11 +59,16 @@ files:
59
59
  - lib/why_classes.rb
60
60
  - lib/why_classes/ast/class_shape.rb
61
61
  - lib/why_classes/ast/node_helpers.rb
62
+ - lib/why_classes/cache.rb
62
63
  - lib/why_classes/cli.rb
63
64
  - lib/why_classes/config_loader.rb
64
65
  - lib/why_classes/configuration.rb
65
66
  - lib/why_classes/correction.rb
67
+ - lib/why_classes/correction_plan.rb
66
68
  - lib/why_classes/correctors/data_bucket_corrector.rb
69
+ - lib/why_classes/correctors/function_bucket_corrector.rb
70
+ - lib/why_classes/correctors/polymorphic_function_corrector.rb
71
+ - lib/why_classes/correctors/stateless_module_corrector.rb
67
72
  - lib/why_classes/disable_comments.rb
68
73
  - lib/why_classes/file_finder.rb
69
74
  - lib/why_classes/formatters/base_formatter.rb
@@ -106,7 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
106
111
  - !ruby/object:Gem::Version
107
112
  version: '0'
108
113
  requirements: []
109
- rubygems_version: 4.0.3
114
+ rubygems_version: 4.0.16
110
115
  specification_version: 4
111
116
  summary: Detect over-use of classes in Ruby and refactor toward modules, Struct and
112
117
  Data.