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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +128 -0
- data/config/default.yml +69 -0
- data/exe/why-classes +6 -0
- data/lib/why_classes/ast/class_shape.rb +106 -0
- data/lib/why_classes/ast/node_helpers.rb +186 -0
- data/lib/why_classes/cli.rb +97 -0
- data/lib/why_classes/config_loader.rb +51 -0
- data/lib/why_classes/configuration.rb +68 -0
- data/lib/why_classes/correction.rb +44 -0
- data/lib/why_classes/correctors/data_bucket_corrector.rb +79 -0
- data/lib/why_classes/disable_comments.rb +59 -0
- data/lib/why_classes/file_finder.rb +43 -0
- data/lib/why_classes/formatters/base_formatter.rb +30 -0
- data/lib/why_classes/formatters/clang_formatter.rb +19 -0
- data/lib/why_classes/formatters/diff_formatter.rb +107 -0
- data/lib/why_classes/formatters/json_formatter.rb +42 -0
- data/lib/why_classes/formatters/progress_formatter.rb +38 -0
- data/lib/why_classes/offense.rb +29 -0
- data/lib/why_classes/options.rb +32 -0
- data/lib/why_classes/parser.rb +32 -0
- data/lib/why_classes/registry.rb +39 -0
- data/lib/why_classes/rule.rb +89 -0
- data/lib/why_classes/rules/data_bucket.rb +79 -0
- data/lib/why_classes/rules/function_bucket.rb +55 -0
- data/lib/why_classes/rules/inheritance_for_mixins.rb +37 -0
- data/lib/why_classes/rules/invalid_initial_state.rb +56 -0
- data/lib/why_classes/rules/single_attribute_reader.rb +38 -0
- data/lib/why_classes/rules/stateless_singleton_methods.rb +50 -0
- data/lib/why_classes/runner.rb +115 -0
- data/lib/why_classes/source_file.rb +45 -0
- data/lib/why_classes/version.rb +5 -0
- data/lib/why_classes.rb +32 -0
- 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.
|
data/config/default.yml
ADDED
|
@@ -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,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
|