rubocop-sorted_methods_by_call 1.1.2 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3cac98639251f1177bd34bb14fb7098b8adbda6eae96d186a202a2ce75d37f77
4
- data.tar.gz: b198db699e4cba49214d9f4188a11ae14965c7ba57b51dfa9dc046c0332762b0
3
+ metadata.gz: 02e794a25b73c4b96c7ec77721e5b5e4c71a3a23e0137011f3d272d27e81121e
4
+ data.tar.gz: 75db5c1df55a2d40a9fdf596b654bafa76eecbb163e16c419b7c0366ef4cd772
5
5
  SHA512:
6
- metadata.gz: 774b4c4bced8dec5236826739aa7bb90a9266fe77a5355d677abbe4a629b2c20928861689f2b00c7063f471f3811f03605d624be6d7e4607d67a624578966d56
7
- data.tar.gz: eb0b7cca4f2d460f4ff2b8c1cd3c70756011b424041f29715e0838a8678cac4192b2ac2e717a2f0a5b4ae6e6736bf60eae1dad6201578ad20bb657fe33033065
6
+ metadata.gz: 7c3e6192757164ad8c877337a9bf60e78ccb2c4b8d8abd2a3f8c15a865cfe7ea4b1440f6c443dd26ca9836951884b229927a718081ab521b64926cc25896bab4
7
+ data.tar.gz: 14f8232209323631fb6a993282e6133907d3937ecf1b639c9e186b79fb835f531a750a4ebdb6e972c3f558921507c7fbb3a954c6c26e7510d19a3ddc3e3cfd7d
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2025-11-12 14:06:36 UTC using RuboCop version 1.81.7.
3
+ # on 2026-01-13 11:42:57 UTC using RuboCop version 1.81.7.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -19,37 +19,47 @@ Gemspec/RequiredRubyVersion:
19
19
  Exclude:
20
20
  - 'rubocop-sorted_methods_by_call.gemspec'
21
21
 
22
- # Offense count: 4
22
+ # Offense count: 5
23
23
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
24
24
  Metrics/AbcSize:
25
- Max: 87
25
+ Max: 78
26
26
 
27
- # Offense count: 2
27
+ # Offense count: 1
28
28
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
29
29
  # AllowedMethods: refine
30
30
  Metrics/BlockLength:
31
31
  Max: 36
32
32
 
33
- # Offense count: 5
33
+ # Offense count: 4
34
34
  # Configuration parameters: AllowedMethods, AllowedPatterns.
35
35
  Metrics/CyclomaticComplexity:
36
- Max: 31
36
+ Max: 35
37
37
 
38
- # Offense count: 7
38
+ # Offense count: 9
39
39
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
40
40
  Metrics/MethodLength:
41
- Max: 59
41
+ Max: 70
42
42
 
43
- # Offense count: 4
43
+ # Offense count: 2
44
+ # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
45
+ Metrics/ParameterLists:
46
+ Max: 6
47
+
48
+ # Offense count: 3
44
49
  # Configuration parameters: AllowedMethods, AllowedPatterns.
45
50
  Metrics/PerceivedComplexity:
46
- Max: 34
51
+ Max: 37
47
52
 
48
- # Offense count: 19
53
+ # Offense count: 21
49
54
  # Configuration parameters: CountAsOne.
50
55
  RSpec/ExampleLength:
51
56
  Max: 37
52
57
 
58
+ # Offense count: 1
59
+ # Configuration parameters: AllowedGroups.
60
+ RSpec/NestedGroups:
61
+ Max: 4
62
+
53
63
  # Offense count: 2
54
64
  # This cop supports safe autocorrection (--autocorrect).
55
65
  # Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rubocop-sorted_methods_by_call (1.1.2)
4
+ rubocop-sorted_methods_by_call (1.2.0)
5
5
  lint_roller
6
6
  rubocop (>= 1.72.0)
7
7
 
data/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  * [Usage Examples](#usage-examples)
16
16
  * [Good Code (waterfall order)](#good-code-waterfall-order)
17
17
  * [Bad Code (violates waterfall order)](#bad-code-violates-waterfall-order)
18
+ * [Sibling ordering and cycles (why autocorrect can be skipped)](#sibling-ordering-and-cycles-why-autocorrect-can-be-skipped)
18
19
  * [Autocorrection](#autocorrection)
19
20
  * [Testing](#testing)
20
21
  * [Development](#development)
@@ -76,6 +77,12 @@ SortedMethodsByCall/Waterfall:
76
77
  Enabled: true
77
78
  SafeAutoCorrect: false # Autocorrection requires -A flag
78
79
  AllowedRecursion: true # Allow mutual recursion (default: true)
80
+ # If true, the cop will NOT add "called together" sibling-order edges
81
+ # that would introduce a cycle with existing constraints. This reduces
82
+ # impossible-to-fix sibling offenses and makes autocorrect more reliable.
83
+ #
84
+ # Default: false
85
+ SkipCyclicSiblingEdges: false
79
86
  ```
80
87
 
81
88
  ## Usage Examples
@@ -135,6 +142,46 @@ class Service
135
142
  end
136
143
  ```
137
144
 
145
+ ### Sibling ordering and cycles (why autocorrect can be skipped)
146
+
147
+ `SortedMethodsByCall/Waterfall` enforces two kinds of ordering constraints:
148
+
149
+ 1. **Direct call edges**: if `caller` calls `callee`, then `caller` must be defined **before** `callee`.
150
+ 2. **Sibling ("called together") edges**: in orchestration methods (methods not called by others in the same scope),
151
+ consecutive calls imply an intended order (e.g., `a` then `b`), so `a` should be defined before `b`.
152
+
153
+ Sometimes these constraints can conflict and create a **cycle**, which means there is no valid ordering that satisfies
154
+ all constraints. In this situation, autocorrect may be skipped.
155
+
156
+ Example:
157
+
158
+ ```ruby
159
+ class SiblingCycleExample
160
+ def call
161
+ a
162
+ b
163
+ end
164
+
165
+ private
166
+
167
+ def b
168
+ c
169
+ end
170
+
171
+ def c
172
+ a
173
+ end
174
+
175
+ def a; end
176
+ end
177
+ ```
178
+
179
+ Here, the direct dependencies imply `b -> c -> a`, but the orchestration method implies `a -> b`,
180
+ which forms the cycle `a -> b -> c -> a`.
181
+
182
+ If you prefer to keep the warning (to encourage refactoring), leave `SkipCyclicSiblingEdges: false`.
183
+ If you prefer the cop to avoid enforcing sibling edges that create cycles, set `SkipCyclicSiblingEdges: true`.
184
+
138
185
  ### Autocorrection
139
186
 
140
187
  Run with unsafe autocorrection to automatically fix violations:
data/config/default.yml CHANGED
@@ -1,6 +1,7 @@
1
1
  SortedMethodsByCall/Waterfall:
2
2
  Description: "Enforces method ordering based on call relationships."
3
3
  Enabled: true
4
- VersionAdded: "1.1.2"
4
+ VersionAdded: "1.2.0"
5
5
  SafeAutoCorrect: false
6
6
  AllowedRecursion: true
7
+ SkipCyclicSiblingEdges: false
@@ -15,10 +15,15 @@ module RuboCop
15
15
  #
16
16
  # Configuration
17
17
  # - AllowedRecursion [Boolean] (default: true)
18
- # If true, the cop ignores violations that are part of a mutual recursion
19
- # cycle (callee → … → caller). If false, such cycles are reported.
18
+ # If true, the cop ignores violations that are part of a recursion cycle
19
+ # detectable in the direct call graph (callee → … → caller). If false,
20
+ # such cycles are reported.
20
21
  # - SafeAutoCorrect [Boolean] (default: false)
21
22
  # Autocorrection is unsafe and only runs under -A, never under -a.
23
+ # - SkipCyclicSiblingEdges [Boolean] (default: false)
24
+ # If true, the cop will not add "called together" sibling-order edges
25
+ # that would introduce a cycle with existing constraints (direct edges +
26
+ # already accepted sibling edges).
22
27
  #
23
28
  # @example Good (waterfall order)
24
29
  # class Service
@@ -70,9 +75,20 @@ module RuboCop
70
75
  include ::RuboCop::Cop::RangeHelp
71
76
  extend ::RuboCop::Cop::AutoCorrector
72
77
 
78
+ VISIBILITY_METHODS = %i[private protected public].freeze
79
+
73
80
  # Template message for offenses where a callee appears before its caller.
74
81
  MSG = 'Define %<callee>s after its caller %<caller>s (waterfall order).'
75
82
 
83
+ SIBLING_MSG = 'Define %<callee>s after %<caller>s to match the order they are called together.'
84
+
85
+ MSG_CROSS_VISIBILITY_NOTE =
86
+ '%<base>s (Autocorrect not supported across visibility boundaries: ' \
87
+ '%<caller_visibility>s vs %<callee_visibility>s.)'
88
+
89
+ MSG_SIBLING_CYCLE_NOTE =
90
+ '%<base>s (Possible sibling cycle detected; autocorrect may be skipped.)'
91
+
76
92
  # Entry point for root :begin nodes (top-level).
77
93
  #
78
94
  # Whether top-level is analyzed depends on how the code is structured;
@@ -111,149 +127,297 @@ module RuboCop
111
127
 
112
128
  private
113
129
 
114
- # Collects defs in the current scope, builds caller→callee edges for local sends,
115
- # locates the first backward edge (callee defined before caller), and registers
116
- # an offense. If autocorrection is requested, attempts to reorder methods within
117
- # the same visibility section.
118
- #
119
- # - Direct edges are used for recursion checks (AllowedRecursion).
120
- # - “Sibling” edges are added from orchestrator methods (not called by others)
121
- # to reflect the order of consecutive calls (foo then bar).
130
+ # Analyze a single scope node (:begin, :class, :module, :sclass):
131
+ # - Collect method defs in the scope body
132
+ # - Build direct call edges (caller callee)
133
+ # - Optionally build sibling-order edges ("called together")
134
+ # - Find the first ordering violation and register an offense
135
+ # - Attempt autocorrect (under -A) within a contiguous visibility section
136
+ # - Recurse into nested scopes inside the body
122
137
  #
123
138
  # @param scope_node [RuboCop::AST::Node] a :begin, :class, :module, or :sclass node
124
139
  # @return [void]
140
+ # @api private
125
141
  def analyze_scope(scope_node)
126
142
  body_nodes = scope_body_nodes(scope_node)
127
143
  return if body_nodes.empty?
128
144
 
129
- def_nodes = body_nodes.select { |n| %i[def defs].include?(n.type) }
145
+ def_nodes = method_def_nodes(body_nodes)
130
146
  return if def_nodes.size <= 1
131
147
 
132
- names = def_nodes.map(&:method_name)
133
- names_set = names.to_set
134
- index_of = names.each_with_index.to_h
148
+ names, names_set, index_of = method_name_index(def_nodes)
135
149
 
136
- # Phase 1: direct call edges (caller -> callee)
137
- direct_edges = def_nodes.flat_map do |def_node|
138
- calls = local_calls(def_node, names_set)
139
- calls.reject { |callee| callee == def_node.method_name }
140
- .map { |callee| [def_node.method_name, callee] }
150
+ direct_edges = build_direct_edges(def_nodes, names_set)
151
+ sibling_edges = build_sibling_edges(def_nodes, names_set, direct_edges, names)
152
+
153
+ edges_for_sort = direct_edges + sibling_edges
154
+ adj_direct = build_adj(names, direct_edges)
155
+
156
+ violation_type, violation = find_violation(direct_edges, sibling_edges, index_of, adj_direct)
157
+ if violation
158
+ _, callee_name = violation
159
+ callee_node = def_nodes[index_of.fetch(callee_name)]
160
+
161
+ message = build_offense_message(
162
+ violation_type: violation_type,
163
+ violation: violation,
164
+ names: names,
165
+ edges_for_sort: edges_for_sort,
166
+ body_nodes: body_nodes
167
+ )
168
+
169
+ add_offense(callee_node, message: message) do |corrector|
170
+ try_autocorrect(corrector, body_nodes, def_nodes, edges_for_sort, violation)
171
+ end
172
+ end
173
+
174
+ analyze_nested_scopes(body_nodes)
175
+ end
176
+
177
+ # Return the direct "body statements" inside a scope node.
178
+ #
179
+ # @param node [RuboCop::AST::Node]
180
+ # @return [Array<RuboCop::AST::Node>] direct children inside the scope body
181
+ # @api private
182
+ def scope_body_nodes(node)
183
+ case node.type
184
+ when :begin
185
+ node.children
186
+ when :class, :module, :sclass
187
+ body = node.body
188
+ return [] unless body
189
+
190
+ body.begin_type? ? body.children : [body]
191
+ else
192
+ []
141
193
  end
194
+ end
195
+
196
+ # Select only method definition nodes from a scope body.
197
+ #
198
+ # @param body_nodes [Array<RuboCop::AST::Node>]
199
+ # @return [Array<RuboCop::AST::Node>] :def/:defs nodes
200
+ # @api private
201
+ def method_def_nodes(body_nodes)
202
+ body_nodes.select { |n| %i[def defs].include?(n.type) }
203
+ end
204
+
205
+ # Compute helper structures for method names in this scope.
206
+ #
207
+ # @param def_nodes [Array<RuboCop::AST::Node>]
208
+ # @return [Array<(Array<Symbol>, Set<Symbol>, Hash{Symbol=>Integer})>]
209
+ # @api private
210
+ def method_name_index(def_nodes)
211
+ names = def_nodes.map(&:method_name)
212
+ [names, names.to_set, names.each_with_index.to_h]
213
+ end
142
214
 
143
- # Methods that are called by someone else in this scope
215
+ # Build direct call edges (caller -> callee) for local calls within each method body.
216
+ #
217
+ # @param def_nodes [Array<RuboCop::AST::Node>]
218
+ # @param names_set [Set<Symbol>]
219
+ # @return [Array<Array(Symbol, Symbol)>]
220
+ # @api private
221
+ def build_direct_edges(def_nodes, names_set)
222
+ def_nodes.flat_map do |def_node|
223
+ local_calls(def_node, names_set)
224
+ .reject { |callee| callee == def_node.method_name }
225
+ .map { |callee| [def_node.method_name, callee] }
226
+ end
227
+ end
228
+
229
+ # Build sibling-order edges (a -> b) for consecutive calls inside orchestration methods.
230
+ #
231
+ # Orchestration methods are those not called by any other method in this scope.
232
+ #
233
+ # @param def_nodes [Array<RuboCop::AST::Node>]
234
+ # @param names_set [Set<Symbol>]
235
+ # @param direct_edges [Array<Array(Symbol, Symbol)>]
236
+ # @param names [Array<Symbol>]
237
+ # @return [Array<Array(Symbol, Symbol)>]
238
+ # @api private
239
+ def build_sibling_edges(def_nodes, names_set, direct_edges, names)
144
240
  all_callees = direct_edges.to_set(&:last)
241
+ direct_pair_set = direct_edges.to_set
242
+
243
+ skip_cyclic_siblings = skip_cyclic_sibling_edges?
244
+ adj_for_siblings = build_adj(names, direct_edges)
145
245
 
146
- # Phase 2: sibling-order edges from orchestration methods
147
246
  sibling_edges = []
247
+
148
248
  def_nodes.each do |def_node|
149
249
  next if all_callees.include?(def_node.method_name)
150
250
 
151
251
  calls = local_calls(def_node, names_set)
152
252
  calls.each_cons(2) do |a, b|
153
- next if direct_edges.any? { |u, v| (u == a && v == b) || (u == b && v == a) }
253
+ # If there is already a direct relationship between a and b (either direction),
254
+ # do not add a sibling-order edge.
255
+ next if direct_pair_set.include?([a, b]) || direct_pair_set.include?([b, a])
256
+
257
+ # Optional: do not add a sibling edge that would introduce a cycle.
258
+ next if skip_cyclic_siblings && path_exists?(b, a, adj_for_siblings)
154
259
 
155
260
  sibling_edges << [a, b]
261
+ adj_for_siblings[a] << b unless adj_for_siblings[a].include?(b)
156
262
  end
157
263
  end
158
264
 
159
- # Phase 3: combine for sorting, but only use direct edges for recursion checks
160
- edges_for_sort = direct_edges + sibling_edges
161
- allow_recursion = cop_config.fetch('AllowedRecursion') { true }
162
- adj_direct = build_adj(names, direct_edges)
265
+ sibling_edges
266
+ end
267
+
268
+ # Find the first ordering violation. Checks direct edges first, then sibling edges.
269
+ #
270
+ # @param direct_edges [Array<Array(Symbol, Symbol)>]
271
+ # @param sibling_edges [Array<Array(Symbol, Symbol)>]
272
+ # @param index_of [Hash{Symbol=>Integer}]
273
+ # @param adj_direct [Hash{Symbol=>Array<Symbol>}] adjacency list for direct edges
274
+ # @return [Array<(Symbol, Array(Symbol, Symbol))>, Array<(nil, nil)>]
275
+ # @api private
276
+ def find_violation(direct_edges, sibling_edges, index_of, adj_direct)
277
+ allow_recursion = allowed_recursion?
163
278
 
164
279
  violation = first_backward_edge(direct_edges, index_of, adj_direct, allow_recursion)
165
- violation_type = :direct if violation
280
+ return [:direct, violation] if violation
166
281
 
167
- unless violation
168
- violation = first_backward_edge(sibling_edges, index_of, adj_direct, allow_recursion)
169
- violation_type = :sibling if violation
170
- end
282
+ violation = first_backward_edge(sibling_edges, index_of, adj_direct, allow_recursion)
283
+ return [:sibling, violation] if violation
171
284
 
172
- return unless violation
285
+ [nil, nil]
286
+ end
287
+
288
+ # Return the first backward edge found, optionally skipping edges that participate
289
+ # in recursion/cycles detectable in the direct-call graph (AllowedRecursion).
290
+ #
291
+ # @param edges [Array<Array(Symbol, Symbol)>]
292
+ # @param index_of [Hash{Symbol=>Integer}]
293
+ # @param adj_direct [Hash{Symbol=>Array<Symbol>}] direct-call adjacency for path checks
294
+ # @param allow_recursion [Boolean]
295
+ # @return [Array(Symbol, Symbol), nil]
296
+ # @api private
297
+ def first_backward_edge(edges, index_of, adj_direct, allow_recursion)
298
+ edges.find do |caller, callee|
299
+ next unless index_of.key?(caller) && index_of.key?(callee)
300
+ next if allow_recursion && path_exists?(callee, caller, adj_direct)
301
+
302
+ index_of[callee] < index_of[caller]
303
+ end
304
+ end
173
305
 
306
+ # Construct the final offense message, including optional notes:
307
+ # - sibling cycle note (for sibling violations)
308
+ # - cross-visibility note (public/private/protected boundary)
309
+ #
310
+ # @param violation_type [Symbol] :direct or :sibling
311
+ # @param violation [Array(Symbol, Symbol)] (caller_name, callee_name)
312
+ # @param names [Array<Symbol>]
313
+ # @param edges_for_sort [Array<Array(Symbol, Symbol)>]
314
+ # @param body_nodes [Array<RuboCop::AST::Node>]
315
+ # @return [String]
316
+ # @api private
317
+ def build_offense_message(violation_type:, violation:, names:, edges_for_sort:, body_nodes:)
174
318
  caller_name, callee_name = violation
175
- callee_node = def_nodes[index_of[callee_name]]
176
319
 
177
- message =
178
- if violation_type == :sibling
179
- "Define ##{callee_name} after ##{caller_name} to match the order they are called together"
180
- else
181
- format(MSG, callee: "##{callee_name}", caller: "##{caller_name}")
182
- end
320
+ base = base_message_for(violation_type, caller_name, callee_name)
321
+ base = add_sibling_cycle_note_if_needed(base, violation_type, caller_name, callee_name, names, edges_for_sort)
322
+ add_cross_visibility_note_if_needed(base, body_nodes, caller_name, callee_name)
323
+ end
183
324
 
184
- add_offense(callee_node, message: message) do |corrector|
185
- try_autocorrect(corrector, body_nodes, def_nodes, edges_for_sort, violation)
325
+ # @param violation_type [Symbol]
326
+ # @param caller_name [Symbol]
327
+ # @param callee_name [Symbol]
328
+ # @return [String]
329
+ # @api private
330
+ def base_message_for(violation_type, caller_name, callee_name)
331
+ if violation_type == :sibling
332
+ format(SIBLING_MSG, callee: "##{callee_name}", caller: "##{caller_name}")
333
+ else
334
+ format(MSG, callee: "##{callee_name}", caller: "##{caller_name}")
186
335
  end
336
+ end
187
337
 
188
- # Recurse into nested scopes inside this body
189
- body_nodes.each do |n|
190
- analyze_scope(n) if n.class_type? || n.module_type? || n.sclass_type?
338
+ # Add a note when a sibling-order edge is part of a cycle in the combined graph.
339
+ #
340
+ # @param base_message [String]
341
+ # @param violation_type [Symbol]
342
+ # @param caller_name [Symbol]
343
+ # @param callee_name [Symbol]
344
+ # @param names [Array<Symbol>]
345
+ # @param edges_for_sort [Array<Array(Symbol, Symbol)>]
346
+ # @return [String]
347
+ # @api private
348
+ def add_sibling_cycle_note_if_needed(base_message, violation_type, caller_name, callee_name, names,
349
+ edges_for_sort)
350
+ return base_message unless violation_type == :sibling
351
+
352
+ adj_all = build_adj(names, edges_for_sort)
353
+ if path_exists?(callee_name, caller_name, adj_all)
354
+ format(MSG_SIBLING_CYCLE_NOTE, base: base_message)
355
+ else
356
+ base_message
191
357
  end
192
358
  end
193
359
 
194
- # Normalizes a scope node to its immediate "body" items we iterate over.
360
+ # Add a note when the violation crosses visibility boundaries.
195
361
  #
196
- # @param node [RuboCop::AST::Node]
197
- # @return [Array<RuboCop::AST::Node>] direct children inside this scope
362
+ # @param base_message [String]
363
+ # @param body_nodes [Array<RuboCop::AST::Node>]
364
+ # @param caller_name [Symbol]
365
+ # @param callee_name [Symbol]
366
+ # @return [String]
198
367
  # @api private
199
- def scope_body_nodes(node)
200
- case node.type
201
- when :begin
202
- node.children
203
- when :class, :module, :sclass
204
- body = node.body
205
- return [] unless body
206
-
207
- body.begin_type? ? body.children : [body]
368
+ def add_cross_visibility_note_if_needed(base_message, body_nodes, caller_name, callee_name)
369
+ sections = extract_visibility_sections(body_nodes)
370
+ caller_section = section_for_method(sections, caller_name)
371
+ callee_section = section_for_method(sections, callee_name)
372
+
373
+ caller_vis = visibility_label(caller_section)
374
+ callee_vis = visibility_label(callee_section)
375
+
376
+ if caller_section && callee_section && caller_vis != callee_vis
377
+ format(
378
+ MSG_CROSS_VISIBILITY_NOTE,
379
+ base: base_message,
380
+ caller_visibility: caller_vis,
381
+ callee_visibility: callee_vis
382
+ )
208
383
  else
209
- []
384
+ base_message
210
385
  end
211
386
  end
212
387
 
213
- # UNSAFE: Reorders method definitions inside the target visibility section only
214
- # (does not cross private/protected/public boundaries). Skips if defs are not
215
- # contiguous within the section or if a cycle prevents a consistent topo order.
388
+ # UNSAFE autocorrect: reorder method definitions inside one contiguous visibility section only.
216
389
  #
217
- # - Uses direct call edges for recursion checks.
218
- # - If the violation is a direct-call violation, sorts using only direct edges
219
- # inside the section (so sibling edges cannot block the fix).
220
- # - If the violation is a sibling-order violation, includes sibling edges.
221
- # - Rewrites only the exact contiguous section (plus the visibility line if present).
222
- # - Preserves leading doc comments for each method.
390
+ # This method intentionally does NOT reorder across:
391
+ # - `private/protected/public` boundaries
392
+ # - nested scopes
393
+ # - non-visibility statements that break contiguity
223
394
  #
224
395
  # @param corrector [RuboCop::Cop::Corrector]
225
- # @param body_nodes [Array<RuboCop::AST::Node>] raw nodes of the scope body
226
- # @param def_nodes [Array<RuboCop::AST::Node>] all def/defs nodes in this body
396
+ # @param body_nodes [Array<RuboCop::AST::Node>]
397
+ # @param def_nodes [Array<RuboCop::AST::Node>]
227
398
  # @param edges [Array<Array(Symbol, Symbol)>] direct + sibling edges for this scope
228
- # @param initial_violation [Array<(Symbol, Symbol)>, nil] an already-found violating edge
399
+ # @param initial_violation [Array(Symbol, Symbol), nil]
229
400
  # @return [void]
230
401
  # @api private
231
402
  def try_autocorrect(corrector, body_nodes, def_nodes, edges, initial_violation = nil)
232
403
  sections = extract_visibility_sections(body_nodes)
233
404
 
234
- names = def_nodes.map(&:method_name)
235
- idx_of = names.each_with_index.to_h
405
+ names = def_nodes.map(&:method_name)
236
406
  names_set = names.to_set
407
+ idx_of = names.each_with_index.to_h
237
408
 
238
409
  # Recompute direct edges; split edges back into direct vs sibling
239
- direct_edges = def_nodes.flat_map do |def_node|
240
- local_calls(def_node, names_set)
241
- .reject { |callee| callee == def_node.method_name }
242
- .map { |callee| [def_node.method_name, callee] }
243
- end
410
+ direct_edges = build_direct_edges(def_nodes, names_set)
244
411
  sibling_edges = edges - direct_edges
245
412
 
246
- # Recursion check uses only direct edges
247
- allow_recursion = cop_config.fetch('AllowedRecursion') { true }
413
+ allow_recursion = allowed_recursion?
248
414
  adj_direct = build_adj(names, direct_edges)
249
415
 
250
- violation = initial_violation ||
251
- first_backward_edge(edges, idx_of, adj_direct, allow_recursion)
416
+ violation = initial_violation || first_backward_edge(edges, idx_of, adj_direct, allow_recursion)
252
417
  return unless violation
253
418
 
254
419
  caller_name, callee_name = violation
255
420
 
256
- # Find the contiguous section containing both caller and callee
257
421
  target_section = sections.find do |section|
258
422
  section_names = section[:defs].map(&:method_name)
259
423
  section_names.include?(caller_name) && section_names.include?(callee_name)
@@ -263,23 +427,19 @@ module RuboCop
263
427
  defs = target_section[:defs]
264
428
  return if defs.size <= 1
265
429
 
266
- section_names = defs.map(&:method_name)
267
- section_idx_of = section_names.each_with_index.to_h
430
+ section_names = defs.map(&:method_name)
431
+ section_idx_of = section_names.each_with_index.to_h
268
432
 
269
- # Is this a direct-call violation?
270
433
  direct_violation = direct_edges.any? { |u, v| u == caller_name && v == callee_name }
271
434
 
272
- # Restrict edges to this contiguous section
273
- section_direct_edges = direct_edges.select { |u, v| section_names.include?(u) && section_names.include?(v) }
435
+ section_direct_edges = direct_edges.select { |u, v| section_names.include?(u) && section_names.include?(v) }
274
436
  section_sibling_edges = sibling_edges.select { |u, v| section_names.include?(u) && section_names.include?(v) }
275
437
 
276
- # Prune mutual-recursion edges inside the section if allowed
277
438
  if allow_recursion
278
439
  pair_set = section_direct_edges.to_set
279
440
  section_direct_edges = section_direct_edges.reject { |u, v| pair_set.include?([v, u]) }
280
441
  end
281
442
 
282
- # Sorting edges: direct-only for direct violation, otherwise sibling + pruned direct
283
443
  section_edges_for_sort =
284
444
  if direct_violation
285
445
  section_direct_edges
@@ -290,9 +450,8 @@ module RuboCop
290
450
  sorted_names = topo_sort(section_names, section_edges_for_sort, section_idx_of)
291
451
  return if sorted_names.nil? || sorted_names == section_names
292
452
 
293
- # Rebuild section (preserve per-method leading docs)
294
453
  ranges_by_name = defs.to_h { |d| [d.method_name, range_with_leading_comments(d)] }
295
- sorted_def_sources = sorted_names.map { |n| ranges_by_name[n].source }
454
+ sorted_def_sources = sorted_names.map { |n| ranges_by_name.fetch(n).source }
296
455
 
297
456
  visibility_node = target_section[:visibility]
298
457
  visibility_source = visibility_node&.source.to_s
@@ -310,14 +469,14 @@ module RuboCop
310
469
  else
311
470
  defs.map { |d| range_with_leading_comments(d).begin_pos }.min
312
471
  end
313
- section_end = target_section[:end_pos]
314
472
 
315
- region = Parser::Source::Range.new(processed_source.buffer, section_begin, section_end)
473
+ section_end = target_section[:end_pos]
474
+ region = range_between(section_begin, section_end)
316
475
  corrector.replace(region, new_content)
317
476
  end
318
477
 
319
- # Collects local calls (receiver is nil/self) from within a def node
320
- # whose names are present in +names_set+.
478
+ # Collect local method calls (receiver is nil/self) from within a def node,
479
+ # restricted to known method names in this scope.
321
480
  #
322
481
  # @param def_node [RuboCop::AST::Node] :def or :defs
323
482
  # @param names_set [Set<Symbol>] known local method names in this scope
@@ -338,63 +497,48 @@ module RuboCop
338
497
  res.uniq
339
498
  end
340
499
 
341
- # Builds an adjacency list for edges restricted to known names.
500
+ # Build an adjacency list for a set of edges restricted to known names.
342
501
  #
343
- # @param names [Array<Symbol>] method names
344
- # @param edges [Array<Array(Symbol, Symbol)>] caller→callee pairs
502
+ # @param names [Array<Symbol>]
503
+ # @param edges [Array<Array(Symbol, Symbol)>]
345
504
  # @return [Hash{Symbol=>Array<Symbol>}] adjacency list
346
505
  # @api private
347
506
  def build_adj(names, edges)
348
507
  allowed = names.to_set
349
508
  adj = Hash.new { |h, k| h[k] = [] }
509
+
350
510
  edges.each do |u, v|
351
511
  next unless allowed.include?(u) && allowed.include?(v)
352
512
  next if u == v
353
513
 
354
514
  adj[u] << v
355
515
  end
356
- adj
357
- end
358
-
359
- # Returns the first backward edge found, optionally skipping edges
360
- # that participate in mutual recursion (when AllowedRecursion is true).
361
- #
362
- # @param edges [Array<Array(Symbol, Symbol)>] candidate edges to check
363
- # @param index_of [Hash{Symbol=>Integer}] current definition order (name -> index)
364
- # @param adj [Hash{Symbol=>Array<Symbol>}] direct-call adjacency for path checks
365
- # @param allow_recursion [Boolean] whether mutual recursion suppresses a violation
366
- # @return [(Symbol, Symbol), nil] the violating (caller, callee) or nil
367
- # @api private
368
- def first_backward_edge(edges, index_of, adj, allow_recursion)
369
- edges.find do |caller, callee|
370
- next unless index_of.key?(caller) && index_of.key?(callee)
371
- # If mutual recursion allowed and there is a path callee -> caller, skip
372
- next if allow_recursion && path_exists?(callee, caller, adj)
373
516
 
374
- # Violation: callee is defined BEFORE caller (waterfall order)
375
- index_of[callee] < index_of[caller]
376
- end
517
+ adj
377
518
  end
378
519
 
379
- # Breadth-first search to detect if a path exists in the direct-call graph.
520
+ # Breadth-first search to detect whether a path exists from +src+ to +dst+.
380
521
  #
381
- # @param src [Symbol] source method
382
- # @param dst [Symbol] destination method
522
+ # @param src [Symbol]
523
+ # @param dst [Symbol]
383
524
  # @param adj [Hash{Symbol=>Array<Symbol>}] adjacency list
384
525
  # @param limit [Integer] traversal safety limit
385
- # @return [Boolean] true if a path exists
526
+ # @return [Boolean]
386
527
  # @api private
387
528
  def path_exists?(src, dst, adj, limit = 200)
388
529
  return true if src == dst
389
530
 
390
531
  visited = {}
391
532
  q = [src]
533
+ i = 0
392
534
  steps = 0
393
- until q.empty?
535
+
536
+ while i < q.length
394
537
  steps += 1
395
538
  return false if steps > limit
396
539
 
397
- u = q.shift
540
+ u = q[i]
541
+ i += 1
398
542
  next if visited[u]
399
543
 
400
544
  visited[u] = true
@@ -402,22 +546,21 @@ module RuboCop
402
546
 
403
547
  adj[u].each { |v| q << v unless visited[v] }
404
548
  end
549
+
405
550
  false
406
551
  end
407
552
 
408
- # Splits the scope body into contiguous sections of def/defs grouped
553
+ # Split the scope body into contiguous sections of def/defs grouped
409
554
  # by the visibility modifier immediately preceding them (private/protected/public).
410
555
  #
411
556
  # A section is represented as a Hash with:
412
557
  # - :visibility [RuboCop::AST::Node, nil] the bare visibility send, or nil
413
558
  # - :defs [Array<RuboCop::AST::Node>] contiguous def/defs nodes
414
- # - :start_pos [Integer] begin_pos of the first def in the section
415
- # - :end_pos [Integer] end_pos of the last def in the section
416
- #
417
- # Non-visibility sends, constants, and nested scopes break contiguity.
559
+ # - :start_pos [Integer]
560
+ # - :end_pos [Integer]
418
561
  #
419
- # @param body_nodes [Array<RuboCop::AST::Node>] raw nodes in the scope body
420
- # @return [Array<Hash>] list of sections metadata
562
+ # @param body_nodes [Array<RuboCop::AST::Node>]
563
+ # @return [Array<Hash>]
421
564
  # @api private
422
565
  def extract_visibility_sections(body_nodes)
423
566
  sections = []
@@ -430,45 +573,19 @@ module RuboCop
430
573
  when :def, :defs
431
574
  current_defs << node
432
575
  section_start ||= node.source_range.begin_pos
433
-
434
576
  when :send
435
- # Close any running section before processing visibility/non-visibility send
436
- unless current_defs.empty?
437
- sections << {
438
- visibility: current_visibility,
439
- defs: current_defs.dup,
440
- start_pos: section_start,
441
- end_pos: body_nodes[idx - 1].source_range.end_pos
442
- }
443
- current_defs = []
444
- section_start = nil
445
- end
446
-
447
- # Bare visibility modifiers: private/protected/public without args
448
- if node.receiver.nil? && %i[private protected public].include?(node.method_name) && node.arguments.empty?
449
- current_visibility = node
450
- else
451
- # Non-visibility send breaks contiguity and resets visibility context
452
- current_visibility = nil
453
- end
454
-
577
+ flush_visibility_section!(sections, current_visibility, current_defs, section_start, body_nodes, idx - 1)
578
+ current_defs = []
579
+ section_start = nil
580
+ current_visibility = node if bare_visibility_send?(node)
455
581
  else
456
- # Any other node breaks contiguity and resets visibility context
457
- unless current_defs.empty?
458
- sections << {
459
- visibility: current_visibility,
460
- defs: current_defs.dup,
461
- start_pos: section_start,
462
- end_pos: body_nodes[idx - 1].source_range.end_pos
463
- }
464
- current_defs = []
465
- section_start = nil
466
- end
582
+ flush_visibility_section!(sections, current_visibility, current_defs, section_start, body_nodes, idx - 1)
583
+ current_defs = []
584
+ section_start = nil
467
585
  current_visibility = nil
468
586
  end
469
587
  end
470
588
 
471
- # trailing defs
472
589
  unless current_defs.empty?
473
590
  sections << {
474
591
  visibility: current_visibility,
@@ -481,12 +598,66 @@ module RuboCop
481
598
  sections
482
599
  end
483
600
 
484
- # Stable topological sort using the current order as a tie-breaker.
601
+ # Flush a currently-collected contiguous def/defs group into +sections+.
485
602
  #
486
- # @param names [Array<Symbol>] names to sort
487
- # @param edges [Array<Array(Symbol, Symbol)>] caller→callee edges to respect
488
- # @param idx_of [Hash{Symbol=>Integer}] current order (name -> index)
489
- # @return [Array<Symbol>, nil] sorted names or nil if a cycle prevents a full order
603
+ # @param sections [Array<Hash>]
604
+ # @param current_visibility [RuboCop::AST::Node, nil]
605
+ # @param current_defs [Array<RuboCop::AST::Node>]
606
+ # @param section_start [Integer, nil]
607
+ # @param body_nodes [Array<RuboCop::AST::Node>]
608
+ # @param last_idx [Integer]
609
+ # @return [void]
610
+ # @api private
611
+ def flush_visibility_section!(sections, current_visibility, current_defs, section_start, body_nodes, last_idx)
612
+ return if current_defs.empty?
613
+
614
+ sections << {
615
+ visibility: current_visibility,
616
+ defs: current_defs.dup,
617
+ start_pos: section_start,
618
+ end_pos: body_nodes[last_idx].source_range.end_pos
619
+ }
620
+ end
621
+
622
+ # Check if +node+ is a bare visibility modifier send:
623
+ # `private`, `protected`, or `public` (with no args and no receiver).
624
+ #
625
+ # @param node [RuboCop::AST::Node]
626
+ # @return [Boolean]
627
+ # @api private
628
+ def bare_visibility_send?(node)
629
+ node.receiver.nil? &&
630
+ VISIBILITY_METHODS.include?(node.method_name) &&
631
+ node.arguments.empty?
632
+ end
633
+
634
+ # Find the visibility section containing a given method name.
635
+ #
636
+ # @param sections [Array<Hash>]
637
+ # @param method_name [Symbol]
638
+ # @return [Hash, nil]
639
+ # @api private
640
+ def section_for_method(sections, method_name)
641
+ sections.find { |s| s[:defs].any? { |d| d.method_name == method_name } }
642
+ end
643
+
644
+ # Normalize a section to a string visibility label ("public", "private", "protected").
645
+ #
646
+ # @param section [Hash, nil]
647
+ # @return [String]
648
+ # @api private
649
+ def visibility_label(section)
650
+ return 'public' unless section # default visibility
651
+
652
+ (section[:visibility]&.method_name || :public).to_s
653
+ end
654
+
655
+ # Stable topological sort using the current definition order as a tie-breaker.
656
+ #
657
+ # @param names [Array<Symbol>]
658
+ # @param edges [Array<Array(Symbol, Symbol)>]
659
+ # @param idx_of [Hash{Symbol=>Integer}]
660
+ # @return [Array<Symbol>, nil] sorted list, or nil if cycle prevents a full order
490
661
  # @api private
491
662
  def topo_sort(names, edges, idx_of)
492
663
  indegree = Hash.new(0)
@@ -500,6 +671,7 @@ module RuboCop
500
671
  indegree[callee] += 1
501
672
  indegree[caller] ||= 0
502
673
  end
674
+
503
675
  names.each { |n| indegree[n] ||= 0 }
504
676
 
505
677
  queue = names.select { |n| indegree[n].zero? }.sort_by { |n| idx_of[n] }
@@ -508,10 +680,12 @@ module RuboCop
508
680
  until queue.empty?
509
681
  n = queue.shift
510
682
  result << n
683
+
511
684
  adj[n].each do |m|
512
685
  indegree[m] -= 1
513
686
  queue << m if indegree[m].zero?
514
687
  end
688
+
515
689
  queue.sort_by! { |x| idx_of[x] }
516
690
  end
517
691
 
@@ -520,11 +694,11 @@ module RuboCop
520
694
  result
521
695
  end
522
696
 
523
- # Returns a range that starts at the first contiguous comment line immediately
697
+ # Return a range that starts at the first contiguous comment line immediately
524
698
  # above the def/defs node and ends at the end of the def. This preserves
525
- # YARD/RDoc doc comments when methods are moved during autocorrect.
699
+ # doc comments when methods are moved during autocorrect.
526
700
  #
527
- # @param node [RuboCop::AST::Node] :def or :defs to capture with leading comments
701
+ # @param node [RuboCop::AST::Node] :def or :defs
528
702
  # @return [Parser::Source::Range]
529
703
  # @api private
530
704
  def range_with_leading_comments(node)
@@ -533,6 +707,7 @@ module RuboCop
533
707
 
534
708
  start_line = expr.line
535
709
  lineno = start_line - 1
710
+
536
711
  while lineno >= 1
537
712
  line = buffer.source_line(lineno)
538
713
  break unless line =~ /\A\s*#/
@@ -542,7 +717,34 @@ module RuboCop
542
717
  end
543
718
 
544
719
  start_pos = buffer.line_range(start_line).begin_pos
545
- Parser::Source::Range.new(buffer, start_pos, expr.end_pos)
720
+ range_between(start_pos, expr.end_pos)
721
+ end
722
+
723
+ # Recurse into nested scopes inside the current scope body.
724
+ #
725
+ # @param body_nodes [Array<RuboCop::AST::Node>]
726
+ # @return [void]
727
+ # @api private
728
+ def analyze_nested_scopes(body_nodes)
729
+ body_nodes.each do |n|
730
+ analyze_scope(n) if n.class_type? || n.module_type? || n.sclass_type?
731
+ end
732
+ end
733
+
734
+ # Read config: AllowedRecursion (default true).
735
+ #
736
+ # @return [Boolean]
737
+ # @api private
738
+ def allowed_recursion?
739
+ cop_config.fetch('AllowedRecursion') { true }
740
+ end
741
+
742
+ # Read config: SkipCyclicSiblingEdges (default false).
743
+ #
744
+ # @return [Boolean]
745
+ # @api private
746
+ def skip_cyclic_sibling_edges?
747
+ cop_config.fetch('SkipCyclicSiblingEdges') { false }
546
748
  end
547
749
  end
548
750
  end
@@ -3,7 +3,7 @@
3
3
  module RuboCop
4
4
  module SortedMethodsByCall
5
5
  # +RuboCop::SortedMethodsByCall::Compare+ provides helpers to compare
6
- # definition orders and call orders using ordered subsequence semantics.
6
+ # definition orders and call orders using "ordered subsequence" semantics.
7
7
  # It’s used by the cop to check that called methods appear in the same
8
8
  # relative order as they are defined (not necessarily contiguously).
9
9
  module Compare
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module SortedMethodsByCall
5
- VERSION = '1.1.2'
5
+ VERSION = '1.2.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-sorted_methods_by_call
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - unurgunite
@@ -186,7 +186,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
186
186
  - !ruby/object:Gem::Version
187
187
  version: '0'
188
188
  requirements: []
189
- rubygems_version: 3.7.2
189
+ rubygems_version: 4.0.2
190
190
  specification_version: 4
191
191
  summary: RuboCop extension for method sorting in AST by stack trace.
192
192
  test_files: []