rubocop-metz 0.5.3
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 +21 -0
- data/config/default.yml +76 -0
- data/lib/metz/cop_metadata.rb +50 -0
- data/lib/metz/erb_ruby_extractor.rb +107 -0
- data/lib/metz/file_classifier.rb +46 -0
- data/lib/metz/haml_ruby_extractor.rb +33 -0
- data/lib/metz/slim_ruby_extractor.rb +34 -0
- data/lib/metz/template_ruby_extractor.rb +153 -0
- data/lib/rubocop/cop/metz/classes_too_long.rb +59 -0
- data/lib/rubocop/cop/metz/controllers_too_many_direct_collaborators.rb +235 -0
- data/lib/rubocop/cop/metz/demeter_train_wreck/type_inference.rb +234 -0
- data/lib/rubocop/cop/metz/demeter_train_wreck.rb +144 -0
- data/lib/rubocop/cop/metz/methods_too_long.rb +62 -0
- data/lib/rubocop/cop/metz/methods_too_many_parameters.rb +62 -0
- data/lib/rubocop/cop/metz/on_send_csend_bridge.rb +23 -0
- data/lib/rubocop/cop/metz/views_deep_navigation.rb +57 -0
- data/lib/rubocop/cop/metz_cops.rb +12 -0
- data/lib/rubocop/formatter/metz_json_formatter.rb +50 -0
- data/lib/rubocop/metz/plugin.rb +36 -0
- data/lib/rubocop/metz/version.rb +7 -0
- data/lib/rubocop-metz.rb +19 -0
- data/rubocop-metz.gemspec +42 -0
- metadata +111 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
require_relative "../../../metz/file_classifier"
|
|
5
|
+
require_relative "../../../metz/cop_metadata"
|
|
6
|
+
|
|
7
|
+
module RuboCop
|
|
8
|
+
module Cop
|
|
9
|
+
module Metz
|
|
10
|
+
class ControllerClassConstants
|
|
11
|
+
def initialize(method_node)
|
|
12
|
+
@class_node = method_node.each_ancestor(:class).first
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def names
|
|
16
|
+
return [] unless class_node
|
|
17
|
+
|
|
18
|
+
(class_names + own_names + qualified_own_names).uniq
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :class_node
|
|
24
|
+
|
|
25
|
+
def class_names
|
|
26
|
+
return [] unless own_name
|
|
27
|
+
|
|
28
|
+
[own_name, lexically_qualified_name].compact.uniq
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def own_name
|
|
32
|
+
@own_name ||= constant_name(class_node.children.first)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def lexically_qualified_name
|
|
36
|
+
return own_name if own_name.include?("::")
|
|
37
|
+
return nil if namespace.empty?
|
|
38
|
+
|
|
39
|
+
(namespace + [own_name]).join("::")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def namespace
|
|
43
|
+
@namespace ||= class_node.each_ancestor(:class, :module).to_a.reverse.filter_map do |ancestor|
|
|
44
|
+
constant_name(ancestor.children.first)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def own_names
|
|
49
|
+
@own_names ||= direct_body_children.flat_map { |child| own_names_for(child) }.uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def direct_body_children
|
|
53
|
+
body = class_node.children[2]
|
|
54
|
+
return [] unless body
|
|
55
|
+
|
|
56
|
+
body.begin_type? ? body.children : [body]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def own_names_for(node)
|
|
60
|
+
return casgn_name(node) if node.casgn_type?
|
|
61
|
+
return class_or_module_names(node) if node.class_type? || node.module_type?
|
|
62
|
+
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def casgn_name(node)
|
|
67
|
+
return [] unless controller_constant_assignment?(node.children.first)
|
|
68
|
+
|
|
69
|
+
[node.children[1].to_s]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def controller_constant_assignment?(scope)
|
|
73
|
+
return true if scope.nil? || scope.self_type?
|
|
74
|
+
return false unless scope.const_type?
|
|
75
|
+
|
|
76
|
+
class_names.include?(scope.const_name)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def class_or_module_names(node)
|
|
80
|
+
name = constant_name(node.children.first)
|
|
81
|
+
|
|
82
|
+
name ? [name, name.split("::").last] : []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def qualified_own_names
|
|
86
|
+
own_names.flat_map do |name|
|
|
87
|
+
class_names.map { |class_name| "#{class_name}::#{name}" }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def constant_name(node)
|
|
92
|
+
node&.const_type? ? node.const_name : nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class ControllerCollaboratorCollector
|
|
97
|
+
CORE_COLLABORATOR_ALLOWLIST = %w[
|
|
98
|
+
Rails Arel Time Date DateTime File FileUtils Pathname Hash Array String Integer Float
|
|
99
|
+
Symbol Set SecureRandom JSON YAML URI CGI ERB
|
|
100
|
+
].freeze
|
|
101
|
+
|
|
102
|
+
def initialize(method_node)
|
|
103
|
+
@method_node = method_node
|
|
104
|
+
@same_class_constants = ControllerClassConstants.new(method_node).names
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def call
|
|
108
|
+
method_node.each_descendant(:const).with_object({}) do |const_node, ordered|
|
|
109
|
+
record(ordered, const_node)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
attr_reader :method_node, :same_class_constants
|
|
116
|
+
|
|
117
|
+
def record(ordered, const_node)
|
|
118
|
+
name = const_node.const_name
|
|
119
|
+
return if name.nil? || ignored?(const_node, name)
|
|
120
|
+
|
|
121
|
+
ordered[name] ||= const_node
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def ignored?(const_node, name)
|
|
125
|
+
nested_inside_const?(const_node) ||
|
|
126
|
+
rescue_exception_class?(const_node) ||
|
|
127
|
+
raise_exception_class?(const_node) ||
|
|
128
|
+
allowed_core_constant?(name) ||
|
|
129
|
+
same_class_constants.include?(name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def nested_inside_const?(const_node)
|
|
133
|
+
parent = const_node.parent
|
|
134
|
+
parent&.const_type? && parent.children.first.equal?(const_node)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def rescue_exception_class?(const_node)
|
|
138
|
+
resbody_node = const_node.each_ancestor(:resbody).first
|
|
139
|
+
return false unless resbody_node
|
|
140
|
+
|
|
141
|
+
descendant_of?(const_node, resbody_node.children.first)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def raise_exception_class?(const_node)
|
|
145
|
+
send_node = const_node.each_ancestor(:send).find { |ancestor| raise_or_fail?(ancestor) }
|
|
146
|
+
return false unless send_node
|
|
147
|
+
|
|
148
|
+
descendant_of?(const_node, send_node.arguments.first)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def raise_or_fail?(send_node)
|
|
152
|
+
send_node.receiver.nil? && %i[raise fail].include?(send_node.method_name)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def descendant_of?(node, ancestor)
|
|
156
|
+
return false unless ancestor
|
|
157
|
+
|
|
158
|
+
current = node
|
|
159
|
+
current = current.parent while current && !current.equal?(ancestor)
|
|
160
|
+
current.equal?(ancestor)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def allowed_core_constant?(name)
|
|
164
|
+
root_name = name.delete_prefix("::").split("::").first
|
|
165
|
+
|
|
166
|
+
CORE_COLLABORATOR_ALLOWLIST.include?(root_name)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Flags Rails controller methods that reach into more than
|
|
171
|
+
# `MaxCollaborators` distinct top-level collaborators. A "direct
|
|
172
|
+
# collaborator" is an application constant referenced inside a method
|
|
173
|
+
# body -- whether bare (`User`), as the receiver of `.new` (`User.new`),
|
|
174
|
+
# or as the receiver of another message (`Mailer.confirmation(...)`).
|
|
175
|
+
# Exception classes at `rescue`/`raise`/`fail` sites, constants owned by
|
|
176
|
+
# the controller class, and core framework/stdlib constants do not
|
|
177
|
+
# count. Multiple references
|
|
178
|
+
# to the same constant count once. The cop is path-classified through
|
|
179
|
+
# `Metz::FileClassifier.controller?` and is silent on any file that is
|
|
180
|
+
# not under `app/controllers/`.
|
|
181
|
+
class ControllersTooManyDirectCollaborators < RuboCop::Cop::Base
|
|
182
|
+
extend ::Metz::CopMetadata
|
|
183
|
+
|
|
184
|
+
MSG = "Controller method `%<method>s` reaches into %<count>d " \
|
|
185
|
+
"direct collaborators (%<list>s); " \
|
|
186
|
+
"Max is %<max>d. Reduce by funneling work through a single coordinator."
|
|
187
|
+
|
|
188
|
+
why_it_matters "Controllers that touch many collaborators turn into orchestration soup, " \
|
|
189
|
+
"hiding intent and resisting change."
|
|
190
|
+
fix_safety :manual
|
|
191
|
+
suggested_next_moves [
|
|
192
|
+
"Introduce a single coordinator/service object that owns the multi-step workflow.",
|
|
193
|
+
"Push side-effecting calls into a service the controller invokes once.",
|
|
194
|
+
"Move auxiliary lookups into model scopes or named queries on the primary resource."
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
def on_def(node)
|
|
198
|
+
collaborators = collect_collaborators(node)
|
|
199
|
+
return if collaborators.size <= max_collaborators
|
|
200
|
+
|
|
201
|
+
report_offense(node, collaborators)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def relevant_file?(file)
|
|
205
|
+
return super if file.nil? || file.empty? || file == "(string)"
|
|
206
|
+
|
|
207
|
+
!file_name_matches_any?(file, "Exclude", false) &&
|
|
208
|
+
::Metz::FileClassifier.controller?(file)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def collect_collaborators(method_node)
|
|
214
|
+
ControllerCollaboratorCollector.new(method_node).call
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def report_offense(method_node, collaborators)
|
|
218
|
+
add_offense(collaborators.values.first, message: build_message(method_node, collaborators))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def build_message(method_node, collaborators)
|
|
222
|
+
format(MSG,
|
|
223
|
+
method: method_node.method_name,
|
|
224
|
+
count: collaborators.size,
|
|
225
|
+
list: collaborators.keys.join(", "),
|
|
226
|
+
max: max_collaborators)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def max_collaborators
|
|
230
|
+
cop_config["MaxCollaborators"] || 1
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../demeter_train_wreck" unless defined?(RuboCop::Cop::Metz::DemeterTrainWreck)
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Metz
|
|
8
|
+
class DemeterTrainWreck
|
|
9
|
+
# Static type-inference data for `Metz/DemeterTrainWreck`. Hosts the
|
|
10
|
+
# `METHOD_RETURN_TYPES` map (per `docs/demeter-design.md` §5), the
|
|
11
|
+
# AST-node-to-value-object literal map, the pass-through method set,
|
|
12
|
+
# the operator method set, and the reverse-lookup table that powers
|
|
13
|
+
# the unknown-receiver heuristic from §1 of the design doc.
|
|
14
|
+
module TypeInference # rubocop:disable Metrics/ModuleLength
|
|
15
|
+
# The inference tables are conservative: unknown or missing return-type
|
|
16
|
+
# data is treated as advisory noise reduction (`nil`/`:unknown`) so
|
|
17
|
+
# the cop stays quiet when certainty is incomplete.
|
|
18
|
+
|
|
19
|
+
PASS_THROUGH = %i[tap then yield_self itself dup clone freeze].to_set.freeze
|
|
20
|
+
|
|
21
|
+
OPERATOR_METHODS = (
|
|
22
|
+
%i[+ - * / % **] +
|
|
23
|
+
%i[== != < > <= >= <=> === =~ !~] +
|
|
24
|
+
%i[& | ^ << >>]
|
|
25
|
+
).to_set.freeze
|
|
26
|
+
|
|
27
|
+
# rubocop:disable Lint/BooleanSymbol
|
|
28
|
+
LITERAL_MAP = {
|
|
29
|
+
str: :string, dstr: :string, xstr: :string,
|
|
30
|
+
sym: :symbol, dsym: :symbol,
|
|
31
|
+
int: :integer, float: :float,
|
|
32
|
+
array: :array, hash: :hash,
|
|
33
|
+
true: :boolean, false: :boolean, nil: :nil_value,
|
|
34
|
+
regexp: :regexp, irange: :range, erange: :range
|
|
35
|
+
}.freeze
|
|
36
|
+
# rubocop:enable Lint/BooleanSymbol
|
|
37
|
+
|
|
38
|
+
METHOD_RETURN_TYPES = {
|
|
39
|
+
string: {
|
|
40
|
+
upcase: :string, downcase: :string, capitalize: :string,
|
|
41
|
+
swapcase: :string, strip: :string, lstrip: :string, rstrip: :string,
|
|
42
|
+
chomp: :string, chop: :string, reverse: :string,
|
|
43
|
+
sub: :string, gsub: :string, tr: :string, tr_s: :string,
|
|
44
|
+
squeeze: :string, delete: :string, delete_prefix: :string,
|
|
45
|
+
delete_suffix: :string, slice: :string, succ: :string, next: :string,
|
|
46
|
+
center: :string, ljust: :string, rjust: :string,
|
|
47
|
+
inspect: :string, dump: :string, encode: :string, force_encoding: :string,
|
|
48
|
+
to_s: :string, freeze: :string, dup: :string, clone: :string,
|
|
49
|
+
:+ => :string, :* => :string,
|
|
50
|
+
:[] => :string,
|
|
51
|
+
to_sym: :symbol, intern: :symbol,
|
|
52
|
+
to_i: :integer, hex: :integer, oct: :integer, ord: :integer,
|
|
53
|
+
to_f: :float,
|
|
54
|
+
split: :array, chars: :array, bytes: :array, lines: :array,
|
|
55
|
+
scan: :array, unpack: :array, codepoints: :array, grapheme_clusters: :array,
|
|
56
|
+
each_char: :enumerator, each_byte: :enumerator, each_line: :enumerator,
|
|
57
|
+
size: :integer, length: :integer, bytesize: :integer, count: :integer,
|
|
58
|
+
start_with?: :boolean, end_with?: :boolean, include?: :boolean,
|
|
59
|
+
empty?: :boolean, match?: :boolean, ascii_only?: :boolean,
|
|
60
|
+
valid_encoding?: :boolean, frozen?: :boolean,
|
|
61
|
+
:== => :boolean, :=== => :boolean, :!= => :boolean,
|
|
62
|
+
:< => :boolean, :> => :boolean, :<= => :boolean, :>= => :boolean,
|
|
63
|
+
:<=> => :integer, casecmp: :integer, casecmp?: :boolean,
|
|
64
|
+
hash: :integer
|
|
65
|
+
}.freeze,
|
|
66
|
+
symbol: {
|
|
67
|
+
to_s: :string, id2name: :string, inspect: :string,
|
|
68
|
+
upcase: :symbol, downcase: :symbol, capitalize: :symbol,
|
|
69
|
+
swapcase: :symbol, succ: :symbol, next: :symbol,
|
|
70
|
+
to_proc: :proc, to_sym: :symbol,
|
|
71
|
+
length: :integer, size: :integer,
|
|
72
|
+
empty?: :boolean, match?: :boolean,
|
|
73
|
+
:== => :boolean, :=== => :boolean, :<=> => :integer,
|
|
74
|
+
:[] => :string
|
|
75
|
+
}.freeze,
|
|
76
|
+
integer: {
|
|
77
|
+
to_s: :string, inspect: :string, chr: :string,
|
|
78
|
+
to_i: :integer, to_int: :integer, abs: :integer, succ: :integer, pred: :integer,
|
|
79
|
+
magnitude: :integer, bit_length: :integer, digits: :array,
|
|
80
|
+
to_f: :float, fdiv: :float,
|
|
81
|
+
zero?: :boolean, positive?: :boolean, negative?: :boolean,
|
|
82
|
+
even?: :boolean, odd?: :boolean, integer?: :boolean,
|
|
83
|
+
:+ => :integer, :- => :integer, :* => :integer, :/ => :integer,
|
|
84
|
+
:% => :integer, :** => :integer, :<=> => :integer,
|
|
85
|
+
:== => :boolean, :=== => :boolean,
|
|
86
|
+
:< => :boolean, :> => :boolean, :<= => :boolean, :>= => :boolean,
|
|
87
|
+
divmod: :array, coerce: :array,
|
|
88
|
+
times: :enumerator, upto: :enumerator, downto: :enumerator
|
|
89
|
+
}.freeze,
|
|
90
|
+
float: {
|
|
91
|
+
to_s: :string, inspect: :string,
|
|
92
|
+
to_i: :integer, to_int: :integer, truncate: :integer,
|
|
93
|
+
ceil: :integer, floor: :integer, round: :integer,
|
|
94
|
+
to_f: :float, abs: :float, magnitude: :float,
|
|
95
|
+
zero?: :boolean, positive?: :boolean, negative?: :boolean,
|
|
96
|
+
nan?: :boolean, infinite?: :boolean, finite?: :boolean,
|
|
97
|
+
:+ => :float, :- => :float, :* => :float, :/ => :float,
|
|
98
|
+
:== => :boolean, :<=> => :integer
|
|
99
|
+
}.freeze,
|
|
100
|
+
array: {
|
|
101
|
+
first: :unknown, last: :unknown, sample: :unknown, min: :unknown, max: :unknown,
|
|
102
|
+
sum: :unknown, fetch: :unknown, dig: :unknown,
|
|
103
|
+
:[] => :unknown, at: :unknown,
|
|
104
|
+
map: :array, collect: :array, select: :array, filter: :array,
|
|
105
|
+
reject: :array, sort: :array, sort_by: :array, reverse: :array,
|
|
106
|
+
compact: :array, flatten: :array, uniq: :array, take: :array,
|
|
107
|
+
drop: :array, take_while: :array, drop_while: :array,
|
|
108
|
+
each_slice: :enumerator, each_cons: :enumerator,
|
|
109
|
+
zip: :array, product: :array, combination: :enumerator, permutation: :enumerator,
|
|
110
|
+
partition: :array, group_by: :hash, tally: :hash, to_h: :hash,
|
|
111
|
+
chunk_while: :enumerator, slice_when: :enumerator,
|
|
112
|
+
min_by: :unknown, max_by: :unknown, find: :unknown, detect: :unknown,
|
|
113
|
+
join: :string, inspect: :string, to_s: :string, pack: :string,
|
|
114
|
+
size: :integer, length: :integer, count: :integer,
|
|
115
|
+
empty?: :boolean, any?: :boolean, all?: :boolean, none?: :boolean,
|
|
116
|
+
one?: :boolean, include?: :boolean, member?: :boolean, frozen?: :boolean,
|
|
117
|
+
:== => :boolean, :<=> => :integer,
|
|
118
|
+
:+ => :array, :- => :array,
|
|
119
|
+
:& => :array, :| => :array,
|
|
120
|
+
to_a: :array, to_ary: :array, dup: :array, clone: :array, freeze: :array,
|
|
121
|
+
each: :enumerator, each_with_index: :enumerator
|
|
122
|
+
}.freeze,
|
|
123
|
+
hash: {
|
|
124
|
+
keys: :array, values: :array, to_a: :array, entries: :array,
|
|
125
|
+
map: :array, collect: :array, flat_map: :array, sort: :array, sort_by: :array,
|
|
126
|
+
select: :hash, filter: :hash, reject: :hash, compact: :hash,
|
|
127
|
+
merge: :hash, merge!: :hash, transform_keys: :hash, transform_values: :hash,
|
|
128
|
+
invert: :hash, to_h: :hash, slice: :hash, except: :hash, dup: :hash, clone: :hash,
|
|
129
|
+
fetch: :unknown, dig: :unknown, :[] => :unknown, store: :unknown, assoc: :unknown,
|
|
130
|
+
min_by: :unknown, max_by: :unknown, find: :unknown,
|
|
131
|
+
size: :integer, length: :integer, count: :integer,
|
|
132
|
+
empty?: :boolean, any?: :boolean, all?: :boolean,
|
|
133
|
+
include?: :boolean, member?: :boolean,
|
|
134
|
+
key?: :boolean, has_key?: :boolean, value?: :boolean, has_value?: :boolean,
|
|
135
|
+
frozen?: :boolean,
|
|
136
|
+
:== => :boolean,
|
|
137
|
+
inspect: :string, to_s: :string,
|
|
138
|
+
each: :enumerator, each_pair: :enumerator, each_key: :enumerator,
|
|
139
|
+
each_value: :enumerator, each_with_index: :enumerator,
|
|
140
|
+
keys_at: :array, values_at: :array
|
|
141
|
+
}.freeze,
|
|
142
|
+
boolean: {
|
|
143
|
+
:& => :boolean, :| => :boolean, :^ => :boolean, :! => :boolean,
|
|
144
|
+
inspect: :string, to_s: :string,
|
|
145
|
+
:== => :boolean, :=== => :boolean
|
|
146
|
+
}.freeze,
|
|
147
|
+
regexp: {
|
|
148
|
+
match?: :boolean, source: :string, options: :integer,
|
|
149
|
+
casefold?: :boolean, named_captures: :hash, names: :array,
|
|
150
|
+
inspect: :string, to_s: :string
|
|
151
|
+
}.freeze,
|
|
152
|
+
range: {
|
|
153
|
+
first: :unknown, last: :unknown, min: :unknown, max: :unknown,
|
|
154
|
+
begin: :unknown, end: :unknown,
|
|
155
|
+
size: :integer, count: :integer,
|
|
156
|
+
include?: :boolean, cover?: :boolean, exclude_end?: :boolean,
|
|
157
|
+
to_a: :array, to_ary: :array, entries: :array,
|
|
158
|
+
map: :array, select: :array, reject: :array,
|
|
159
|
+
each: :enumerator, step: :enumerator,
|
|
160
|
+
inspect: :string, to_s: :string
|
|
161
|
+
}.freeze,
|
|
162
|
+
nil_value: {
|
|
163
|
+
to_s: :string, inspect: :string, to_a: :array, to_h: :hash,
|
|
164
|
+
to_i: :integer, to_f: :float,
|
|
165
|
+
nil?: :boolean, :& => :boolean, :| => :boolean, :^ => :boolean,
|
|
166
|
+
:== => :boolean
|
|
167
|
+
}.freeze,
|
|
168
|
+
enumerator: {
|
|
169
|
+
to_a: :array, to_h: :hash, first: :unknown, next: :unknown, peek: :unknown,
|
|
170
|
+
map: :array, select: :array, reject: :array, sort: :array,
|
|
171
|
+
count: :integer, size: :integer,
|
|
172
|
+
with_index: :enumerator, with_object: :enumerator,
|
|
173
|
+
include?: :boolean, any?: :boolean, all?: :boolean
|
|
174
|
+
}.freeze,
|
|
175
|
+
proc: {
|
|
176
|
+
call: :unknown, :[] => :unknown,
|
|
177
|
+
arity: :integer, lambda?: :boolean, curry: :proc, to_proc: :proc
|
|
178
|
+
}.freeze,
|
|
179
|
+
set: {
|
|
180
|
+
add: :set, add?: :set, delete: :set, delete?: :set,
|
|
181
|
+
merge: :set, replace: :set, clear: :set,
|
|
182
|
+
union: :set, intersection: :set, difference: :set,
|
|
183
|
+
subtract: :set, flatten: :set, classify: :hash, divide: :set,
|
|
184
|
+
keep_if: :set, delete_if: :set, reject!: :set, select!: :set, filter!: :set,
|
|
185
|
+
:+ => :set, :- => :set, :& => :set, :| => :set, :^ => :set,
|
|
186
|
+
dup: :set, clone: :set, freeze: :set,
|
|
187
|
+
size: :integer, length: :integer, count: :integer, hash: :integer,
|
|
188
|
+
empty?: :boolean, include?: :boolean, member?: :boolean,
|
|
189
|
+
subset?: :boolean, superset?: :boolean, disjoint?: :boolean,
|
|
190
|
+
proper_subset?: :boolean, proper_superset?: :boolean,
|
|
191
|
+
intersect?: :boolean, frozen?: :boolean,
|
|
192
|
+
any?: :boolean, all?: :boolean, none?: :boolean, one?: :boolean,
|
|
193
|
+
:== => :boolean, :=== => :boolean,
|
|
194
|
+
to_a: :array, to_ary: :array, sort: :array,
|
|
195
|
+
map: :array, collect: :array, select: :array, filter: :array,
|
|
196
|
+
reject: :array, partition: :array, group_by: :hash,
|
|
197
|
+
min: :unknown, max: :unknown, min_by: :unknown, max_by: :unknown,
|
|
198
|
+
first: :unknown, find: :unknown, detect: :unknown,
|
|
199
|
+
to_set: :set, sort_by: :array, tally: :hash,
|
|
200
|
+
each: :enumerator, each_with_index: :enumerator, each_with_object: :enumerator,
|
|
201
|
+
inspect: :string, to_s: :string, join: :string
|
|
202
|
+
}.freeze
|
|
203
|
+
}.freeze
|
|
204
|
+
|
|
205
|
+
REVERSE_LOOKUP = METHOD_RETURN_TYPES.each_with_object({}) do |(_, methods), table|
|
|
206
|
+
methods.each do |method, return_type|
|
|
207
|
+
table[method] = table.key?(method) && table[method] != return_type ? :unknown : return_type
|
|
208
|
+
end
|
|
209
|
+
end.freeze
|
|
210
|
+
|
|
211
|
+
def self.next_type(type, method)
|
|
212
|
+
METHOD_RETURN_TYPES.dig(type, method)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def self.reverse_lookup(method)
|
|
216
|
+
REVERSE_LOOKUP[method]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.literal_type(node)
|
|
220
|
+
LITERAL_MAP[node.type]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.pass_through?(method)
|
|
224
|
+
PASS_THROUGH.include?(method)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def self.operator?(method)
|
|
228
|
+
OPERATOR_METHODS.include?(method)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
require_relative "../../../metz/cop_metadata"
|
|
5
|
+
require_relative "on_send_csend_bridge"
|
|
6
|
+
|
|
7
|
+
module RuboCop
|
|
8
|
+
module Cop
|
|
9
|
+
module Metz
|
|
10
|
+
# Flags Law-of-Demeter-violating object-graph traversal chains while
|
|
11
|
+
# staying quiet on long value-object chains. Walks the send chain
|
|
12
|
+
# downward via `node.receiver`, skipping pass-through methods, allowed
|
|
13
|
+
# constant receivers, and operator/setter sends. Recognises core
|
|
14
|
+
# value-object methods through `TypeInference::METHOD_RETURN_TYPES`
|
|
15
|
+
# and uses a reverse-lookup heuristic on the method name to escape
|
|
16
|
+
# an unknown innermost receiver.
|
|
17
|
+
class DemeterTrainWreck < RuboCop::Cop::Base
|
|
18
|
+
extend ::Metz::CopMetadata
|
|
19
|
+
include OnSendCsendBridge
|
|
20
|
+
|
|
21
|
+
MSG = "Object-graph traversal of %<count>d exceeds Max (%<max>d). " \
|
|
22
|
+
"Consider delegating or wrapping intermediate calls."
|
|
23
|
+
|
|
24
|
+
why_it_matters "Long object-graph chains tighten coupling and break the Law of Demeter."
|
|
25
|
+
fix_safety :unsafe
|
|
26
|
+
suggested_next_moves [
|
|
27
|
+
"Introduce `delegate :name, to: :account` (or equivalent) to flatten the chain.",
|
|
28
|
+
"Wrap intermediate links in a small query/decorator object that exposes the value you actually need.",
|
|
29
|
+
"Push the behaviour onto the inner collaborator so callers ask for one message instead of traversing four."
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def on_send(node)
|
|
33
|
+
return if part_of_outer_chain?(node)
|
|
34
|
+
return if outermost_skippable?(node)
|
|
35
|
+
|
|
36
|
+
analyze(node)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def analyze(node)
|
|
42
|
+
links = chain_links(node)
|
|
43
|
+
return if links.size <= max
|
|
44
|
+
return if allowed_receiver_root?(links.first)
|
|
45
|
+
|
|
46
|
+
hops = count_graph_traversals(links)
|
|
47
|
+
add_offense(node, message: build_message(hops)) if hops > max
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_message(hops)
|
|
51
|
+
format(self.class::MSG, count: hops, max: max)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def chain_links(node)
|
|
55
|
+
links = []
|
|
56
|
+
collect_chain(links, node)
|
|
57
|
+
links.reject { |send_node| skip_link?(send_node) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def collect_chain(links, start)
|
|
61
|
+
current = start
|
|
62
|
+
while current
|
|
63
|
+
links.unshift(current) if current.type?(:send, :csend)
|
|
64
|
+
current = inner_node(current)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def inner_node(node)
|
|
69
|
+
return node.receiver if node.type?(:send, :csend)
|
|
70
|
+
|
|
71
|
+
node.send_node if node.type?(:block, :numblock, :itblock)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def skip_link?(send_node)
|
|
75
|
+
operator_send?(send_node) || send_node.setter_method?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def outermost_skippable?(node)
|
|
79
|
+
operator_send?(node) || node.setter_method?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def operator_send?(node)
|
|
83
|
+
node.type?(:send, :csend) && TypeInference.operator?(node.method_name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def part_of_outer_chain?(node)
|
|
87
|
+
unit = wrapping_block(node) || node
|
|
88
|
+
parent = unit.parent
|
|
89
|
+
parent&.type?(:send, :csend) && parent.receiver.equal?(unit)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def wrapping_block(node)
|
|
93
|
+
parent = node.parent
|
|
94
|
+
return nil unless parent&.type?(:block, :numblock, :itblock)
|
|
95
|
+
|
|
96
|
+
parent.send_node.equal?(node) ? parent : nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def count_graph_traversals(links)
|
|
100
|
+
type = infer_initial_type(links.first.receiver)
|
|
101
|
+
hops = 0
|
|
102
|
+
links.each { |send_node| type, hops = advance(type, hops, send_node) }
|
|
103
|
+
hops
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def advance(type, hops, send_node)
|
|
107
|
+
method = send_node.method_name
|
|
108
|
+
return [type, hops] if TypeInference.pass_through?(method)
|
|
109
|
+
|
|
110
|
+
resolve(method, type, hops)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve(method, type, hops)
|
|
114
|
+
next_type = TypeInference.next_type(type, method) ||
|
|
115
|
+
TypeInference.reverse_lookup(method)
|
|
116
|
+
next_type ? [next_type, hops] : [:unknown, hops + 1]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def infer_initial_type(node)
|
|
120
|
+
return :unknown if node.nil?
|
|
121
|
+
|
|
122
|
+
TypeInference.literal_type(node) || :unknown
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def allowed_receiver_root?(innermost_send)
|
|
126
|
+
receiver = innermost_send.receiver
|
|
127
|
+
return false unless receiver&.const_type?
|
|
128
|
+
|
|
129
|
+
allowed_receivers.include?(receiver.const_name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def max
|
|
133
|
+
cop_config["Max"] || 4
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def allowed_receivers
|
|
137
|
+
@allowed_receivers ||= Array(cop_config["AllowedReceivers"]).to_set
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
require_relative "demeter_train_wreck/type_inference"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
require_relative "../../../metz/cop_metadata"
|
|
5
|
+
|
|
6
|
+
module RuboCop
|
|
7
|
+
module Cop
|
|
8
|
+
module Metz
|
|
9
|
+
# Flags methods whose body exceeds the configured `Max` line count.
|
|
10
|
+
# Wraps the semantics of core's `Metrics/MethodLength` with a stricter
|
|
11
|
+
# default of 5 lines.
|
|
12
|
+
class MethodsTooLong < RuboCop::Cop::Base
|
|
13
|
+
include RuboCop::Cop::CodeLength
|
|
14
|
+
include RuboCop::Cop::AllowedMethods
|
|
15
|
+
include RuboCop::Cop::AllowedPattern
|
|
16
|
+
extend ::Metz::CopMetadata
|
|
17
|
+
|
|
18
|
+
why_it_matters "Long methods hide multiple responsibilities and resist understanding at a glance."
|
|
19
|
+
fix_safety :manual
|
|
20
|
+
suggested_next_moves [
|
|
21
|
+
"Extract cohesive blocks into private helper methods named after their intent.",
|
|
22
|
+
"Replace conditional branches with polymorphism or small lookup objects.",
|
|
23
|
+
"Move data shaping onto value objects or query methods so the action reads as a list of steps."
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
LABEL = "Method"
|
|
27
|
+
|
|
28
|
+
def on_def(node)
|
|
29
|
+
return if allowed?(node.method_name)
|
|
30
|
+
|
|
31
|
+
check_code_length(node)
|
|
32
|
+
end
|
|
33
|
+
alias on_defs on_def
|
|
34
|
+
|
|
35
|
+
def on_block(node)
|
|
36
|
+
return unless node.method?(:define_method)
|
|
37
|
+
return if defined_method_allowed?(node.send_node.first_argument)
|
|
38
|
+
|
|
39
|
+
check_code_length(node)
|
|
40
|
+
end
|
|
41
|
+
alias on_numblock on_block
|
|
42
|
+
alias on_itblock on_block
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def cop_label
|
|
47
|
+
LABEL
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def allowed?(method_name)
|
|
51
|
+
allowed_method?(method_name) || matches_allowed_pattern?(method_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def defined_method_allowed?(method_name)
|
|
55
|
+
method_name.respond_to?(:basic_literal?) &&
|
|
56
|
+
method_name.basic_literal? &&
|
|
57
|
+
allowed?(method_name.value)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|