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.
@@ -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