why-classes 0.1.0 → 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 +4 -4
- data/README.md +31 -6
- data/config/default.yml +4 -0
- data/lib/why_classes/cache.rb +0 -0
- data/lib/why_classes/cli.rb +13 -2
- data/lib/why_classes/correction_plan.rb +33 -0
- data/lib/why_classes/correctors/function_bucket_corrector.rb +40 -0
- data/lib/why_classes/correctors/polymorphic_function_corrector.rb +127 -0
- data/lib/why_classes/correctors/stateless_module_corrector.rb +52 -0
- data/lib/why_classes/offense.rb +4 -1
- data/lib/why_classes/options.rb +6 -2
- data/lib/why_classes/registry.rb +10 -3
- data/lib/why_classes/rule.rb +10 -1
- data/lib/why_classes/rules/data_bucket.rb +2 -3
- data/lib/why_classes/rules/function_bucket.rb +14 -2
- data/lib/why_classes/rules/single_attribute_reader.rb +30 -3
- data/lib/why_classes/rules/stateless_singleton_methods.rb +14 -3
- data/lib/why_classes/runner.rb +28 -4
- data/lib/why_classes/version.rb +1 -1
- data/lib/why_classes.rb +1 -0
- metadata +11 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9de2667919d94d511885316c479efec18017e6b4aef44ff77b188a6e6e7b09f3
|
|
4
|
+
data.tar.gz: 72a6e64e8c1e0723c8e1dc40bd7edc6790e442dfb6786b752b22cbd9b75cbae0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` |
|
|
24
|
-
| `FunctionBucket` | a single `call`/`perform`/`run`, no state | `module_function` |
|
|
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 |
|
|
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
|
|
67
|
-
| `--fix-unsafe` | also apply unsafe-tier corrections (
|
|
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
|
data/lib/why_classes/cli.rb
CHANGED
|
@@ -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")
|
|
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.
|
|
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
|
data/lib/why_classes/offense.rb
CHANGED
|
@@ -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
|
|
24
|
+
correctable
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
# Sort key: by position within a file.
|
data/lib/why_classes/options.rb
CHANGED
|
@@ -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
|
data/lib/why_classes/registry.rb
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
selected =
|
|
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
|
data/lib/why_classes/rule.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
17
|
-
|
|
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
|
-
|
|
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:
|
|
31
|
-
|
|
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
|
|
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)
|
data/lib/why_classes/runner.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
78
|
-
|
|
79
|
-
|
|
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
|
data/lib/why_classes/version.rb
CHANGED
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
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gabriel Quaresma
|
|
@@ -46,7 +46,7 @@ description: |
|
|
|
46
46
|
mechanically-safe refactorings (notably data bucket -> Struct/Data) can be applied
|
|
47
47
|
automatically with --fix.
|
|
48
48
|
email:
|
|
49
|
-
-
|
|
49
|
+
- j.quaresmasantos_98@hotmail.com
|
|
50
50
|
executables:
|
|
51
51
|
- why-classes
|
|
52
52
|
extensions: []
|
|
@@ -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
|
|
@@ -85,12 +90,12 @@ files:
|
|
|
85
90
|
- lib/why_classes/runner.rb
|
|
86
91
|
- lib/why_classes/source_file.rb
|
|
87
92
|
- lib/why_classes/version.rb
|
|
88
|
-
homepage: https://github.com/
|
|
93
|
+
homepage: https://github.com/joaoGabriel55/why-classes
|
|
89
94
|
licenses:
|
|
90
95
|
- MIT
|
|
91
96
|
metadata:
|
|
92
|
-
homepage_uri: https://github.com/
|
|
93
|
-
source_code_uri: https://github.com/
|
|
97
|
+
homepage_uri: https://github.com/joaoGabriel55/why-classes
|
|
98
|
+
source_code_uri: https://github.com/joaoGabriel55/why-classes
|
|
94
99
|
rubygems_mfa_required: 'true'
|
|
95
100
|
rdoc_options: []
|
|
96
101
|
require_paths:
|
|
@@ -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.
|
|
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.
|