rubocop-rspec_parity 1.5.0 → 1.7.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: 4c9e27c87b24c7df00daa4eb0a88c31644a7f9986b384d5c875d42bb8358d071
4
- data.tar.gz: aaeab2c5bccca24e24c39250299235ec3a46950f48bee5181a575b2cf45ef683
3
+ metadata.gz: f70e06a85873dc993a895443df9bab8892de60d0cc75573ffdc6487245c09dcf
4
+ data.tar.gz: 7d8baed7d58b13d98fb7b7e14084c3c81dcb1905df3bacfc52772dcb65ba7dbd
5
5
  SHA512:
6
- metadata.gz: 858ae886ee9a32d83cd0a8695f0e5d0acc8758af31802ef5fdfe3d4686860dc40991adfa29cacb5a24a4191a9d94011e531b35edccbc40b8300b83c2c46b2996
7
- data.tar.gz: 7d1184d3ea750869fcf9d655865e3a1d7b93a3d30f2292bc8e57fbd6cb4166e8da9a8ee274c54ccb9d2f825039911b230ede34731be80f9ade8c98dd3a59e8df
6
+ metadata.gz: 66ae9ef49f40f4323af10a733c9c1eb6bbc7a49883b3f00619fda6610c22b035ca53aa9e6b259259df8c7a5cb49aded12d56e8b96082074036f4fd9dc960baf3
7
+ data.tar.gz: 18635158b5155856049fdbfd33aaeb732b013d729467f1aefd8751873f997411b78a4d1489fa692ef64b0030691a6141b8ab6c6ca28e95398ea5d87201d0b3e7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.7.0] - 2026-06-11
4
+
5
+ Fixed: `SufficientContexts` no longer over-counts chains of guard clauses (`return`/`raise`/`next ... if/unless`). A sequence of guards shares a single "all guards pass" fall-through, so that happy path is counted once for the whole method instead of once per guard. Each `&&`/`||` inside a guard condition is still counted as a distinct way for the guard to fire (one scenario per operand).
6
+
7
+ ## [1.6.0] - 2026-06-11
8
+
9
+ Fixed: `SufficientContexts` now counts each `it`/`example` within a single context as a separate scenario, so specs that cover branches with multiple examples (instead of separate contexts) no longer trigger false violations
10
+ Updated: `SufficientContexts` violation message now explains that each branch needs one `context` or `it`, and that compound conditions like `a && b` need a scenario per operand
11
+
3
12
  ## [1.5.0] - 2026-05-21
4
13
 
5
14
  Added: `SufficientContexts` now inlines branches from private/protected helpers that are called from exactly one site in the same class (controlled by the new `TraceSingleUsePrivateMethods` config key, default `true`). Violation messages list the helpers whose branches were counted.
@@ -8,7 +8,9 @@ module RuboCop
8
8
  # private/protected helpers that are called from exactly one place in
9
9
  # the same container.
10
10
  class PrivateMethodCallGraph # rubocop:disable Metrics/ClassLength
11
- Result = Struct.new(:branches, :traced_methods)
11
+ # `branch_tally` is whatever the branch_counter returns (a BranchTally),
12
+ # or nil when nothing was inlined. It must respond to `+` and `total`.
13
+ Result = Struct.new(:branch_tally, :traced_methods)
12
14
 
13
15
  DYNAMIC_DISPATCH_SENDS = %i[send public_send __send__].freeze
14
16
  EVAL_METHODS = %i[class_eval instance_eval module_eval].freeze
@@ -28,13 +30,13 @@ module RuboCop
28
30
  end
29
31
 
30
32
  def inlinable_from(method_node, branch_counter)
31
- return Result.new(0, []) unless @container
32
- return Result.new(0, []) if dynamic_dispatch?
33
+ return Result.new(nil, []) unless @container
34
+ return Result.new(nil, []) if dynamic_dispatch?
33
35
 
34
36
  build! unless @built
35
37
 
36
38
  key = key_for(method_node)
37
- return Result.new(0, []) unless @methods.key?(key)
39
+ return Result.new(nil, []) unless @methods.key?(key)
38
40
 
39
41
  traverse(key, branch_counter)
40
42
  end
@@ -42,7 +44,7 @@ module RuboCop
42
44
  private
43
45
 
44
46
  def traverse(start_key, branch_counter)
45
- state = { visited: Set.new([start_key]), total: 0, traced: [] }
47
+ state = { visited: Set.new([start_key]), tally: nil, traced: [] }
46
48
  stack = callees_of(start_key)
47
49
  until stack.empty?
48
50
  key = stack.shift
@@ -51,7 +53,7 @@ module RuboCop
51
53
  visit_callee(key, branch_counter, state)
52
54
  stack.concat(callees_of(key))
53
55
  end
54
- Result.new(state[:total], sorted_names(state[:traced]))
56
+ Result.new(state[:tally], sorted_names(state[:traced]))
55
57
  end
56
58
 
57
59
  def callees_of(key)
@@ -60,10 +62,10 @@ module RuboCop
60
62
 
61
63
  def visit_callee(key, branch_counter, state)
62
64
  state[:visited] << key
63
- count = branch_counter.call(@methods[key][:node])
64
- return unless count.positive?
65
+ tally = branch_counter.call(@methods[key][:node])
66
+ return unless tally.total.positive?
65
67
 
66
- state[:total] += count
68
+ state[:tally] = state[:tally] ? state[:tally] + tally : tally
67
69
  state[:traced] << key
68
70
  end
69
71
 
@@ -42,8 +42,9 @@ module RuboCop
42
42
  include DepartmentConfig
43
43
  include SpecFileFinder
44
44
 
45
- MSG = "Method `%<method_name>s` has %<branches>d %<branch_word>s but only %<contexts>d %<context_word>s " \
46
- "in spec. Add %<missing>d more %<missing_word>s to cover all branches."
45
+ MSG = "Method `%<method_name>s` has %<branches>d %<branch_word>s but the spec covers only " \
46
+ "%<contexts>d %<scenario_word>s. Add %<missing>d more %<missing_word>s " \
47
+ "(one `context` or `it` per branch; compound conditions like `a && b` need a scenario per operand)."
47
48
 
48
49
  TRACED_SUFFIX = " (including branches from: %<traced>s)"
49
50
 
@@ -59,6 +60,27 @@ module RuboCop
59
60
  /^autosave_/
60
61
  ].freeze
61
62
 
63
+ # Tallies extracted from a spec's text for a single method describe block.
64
+ ParsedSpec = Struct.new(:context_count, :example_count, :has_examples, :has_direct_examples)
65
+
66
+ # Branch tally split into guard-clause "fire" branches vs. every other
67
+ # branch. A sequence of guard clauses (`return/raise/next ... if/unless`)
68
+ # shares a single "all guards pass" fall-through, so that happy path is
69
+ # counted once for the whole method (see #branches_from) rather than once
70
+ # per guard — which previously inflated guard-heavy methods.
71
+ BranchTally = Struct.new(:guard, :regular) do
72
+ def +(other)
73
+ BranchTally.new(guard + other.guard, regular + other.regular)
74
+ end
75
+
76
+ def total
77
+ guard + regular
78
+ end
79
+ end
80
+
81
+ # Node types whose presence as a one-armed `if` body makes it a guard clause.
82
+ GUARD_TERMINATOR_TYPES = %i[return break next redo retry].freeze
83
+
62
84
  def initialize(config = nil, options = nil)
63
85
  super
64
86
  @ignore_memoization = cop_config.fetch("IgnoreMemoization", true)
@@ -80,13 +102,14 @@ module RuboCop
80
102
  return unless in_covered_directory?
81
103
  return if excluded_method?(method_name(node))
82
104
 
83
- branches = count_branches(node)
105
+ tally = branch_tally(node)
84
106
  traced_methods = []
85
107
  if @trace_single_use_private
86
108
  extra = inlined_branches(node)
87
- branches += extra.branches
109
+ tally += extra.branch_tally if extra.branch_tally
88
110
  traced_methods = extra.traced_methods
89
111
  end
112
+ branches = branches_from(tally)
90
113
  return if branches < 2 # Only check methods with branches
91
114
 
92
115
  class_name = extract_class_name(node)
@@ -121,19 +144,19 @@ module RuboCop
121
144
  branches: branches,
122
145
  branch_word: pluralize("branch", branches),
123
146
  contexts: contexts,
124
- context_word: pluralize("context", contexts),
147
+ scenario_word: pluralize("scenario", contexts),
125
148
  missing: missing,
126
- missing_word: pluralize("context", missing))
149
+ missing_word: pluralize("scenario", missing))
127
150
  message += format(TRACED_SUFFIX, traced: traced_methods.join(", ")) if traced_methods.any?
128
151
  message
129
152
  end
130
153
 
131
154
  def inlined_branches(node)
132
155
  container = find_class_or_module(node)
133
- return PrivateMethodCallGraph::Result.new(0, []) unless container
156
+ return PrivateMethodCallGraph::Result.new(nil, []) unless container
134
157
 
135
158
  graph = (@call_graphs[container] ||= PrivateMethodCallGraph.new(container))
136
- graph.inlinable_from(node, method(:count_branches))
159
+ graph.inlinable_from(node, method(:branch_tally))
137
160
  end
138
161
 
139
162
  def method_name(node)
@@ -159,17 +182,75 @@ module RuboCop
159
182
  processed_source.path
160
183
  end
161
184
 
162
- def count_branches(node)
163
- branches = 0
185
+ # Total scenario count from a tally. Guard clauses share one "all guards
186
+ # pass" fall-through; count it once, but only when guards are the sole
187
+ # branching — otherwise the happy path is already represented by the
188
+ # other branches and adding it would over-count.
189
+ def branches_from(tally)
190
+ total = tally.total
191
+ total += 1 if tally.guard.positive? && tally.regular.zero?
192
+ total
193
+ end
194
+
195
+ def branch_tally(node) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
196
+ guard = 0
197
+ regular = 0
164
198
  elsif_nodes = collect_elsif_nodes(node)
199
+ guard_operators = collect_guard_condition_operators(node)
165
200
 
166
201
  node.each_descendant do |descendant|
167
202
  next if elsif_nodes.include?(descendant)
168
203
  next if should_skip_node?(descendant)
169
204
 
170
- branches += branch_count_for_node(descendant)
205
+ case descendant.type
206
+ when :if
207
+ guard_clause?(descendant) ? guard += 1 : regular += count_if_branches(descendant)
208
+ when :case then regular += count_case_branches(descendant)
209
+ when :and, :or then guard_operators.include?(descendant) ? guard += 1 : regular += 1
210
+ when :or_asgn, :and_asgn then regular += 2 # ||= and &&= create 2 branches (set vs already set)
211
+ when :send then regular += send_node_branch_count(descendant)
212
+ end
171
213
  end
172
- branches
214
+
215
+ BranchTally.new(guard, regular)
216
+ end
217
+
218
+ # A guard clause is a one-armed `if`/`unless` whose single body exits the
219
+ # method early (`return`/`raise`/`next`/`break`/...). Its non-exit path is
220
+ # the shared fall-through, so it is not a branch of its own.
221
+ def guard_clause?(node)
222
+ return false unless node.if_type?
223
+
224
+ present = [node.if_branch, node.else_branch].compact
225
+ return false unless present.size == 1
226
+
227
+ guard_terminator?(present.first)
228
+ end
229
+
230
+ def guard_terminator?(node)
231
+ return false unless node
232
+ return true if GUARD_TERMINATOR_TYPES.include?(node.type)
233
+ return true if node.send_type? && (node.method?(:raise) || node.method?(:throw))
234
+ return guard_terminator?(node.children.last) if node.begin_type?
235
+
236
+ false
237
+ end
238
+
239
+ # The `&&`/`||` nodes that live inside a guard clause's condition. Each
240
+ # such operator is a distinct way for the guard to fire (one scenario per
241
+ # operand), so it counts as a guard branch rather than an "other" branch.
242
+ def collect_guard_condition_operators(node)
243
+ operators = Set.new
244
+ node.each_descendant(:if) do |if_node|
245
+ next unless guard_clause?(if_node)
246
+
247
+ condition = if_node.condition
248
+ next unless condition
249
+
250
+ operators.add(condition) if condition.and_type? || condition.or_type?
251
+ condition.each_descendant(:and, :or) { |op| operators.add(op) }
252
+ end
253
+ operators
173
254
  end
174
255
 
175
256
  def collect_elsif_nodes(node)
@@ -184,17 +265,6 @@ module RuboCop
184
265
  @ignore_memoization && memoization_pattern?(node)
185
266
  end
186
267
 
187
- def branch_count_for_node(node)
188
- case node.type
189
- when :if then count_if_branches(node)
190
- when :case then count_case_branches(node)
191
- when :and, :or then 1
192
- when :or_asgn, :and_asgn then 2 # ||= and &&= create 2 branches (set vs already set)
193
- when :send then send_node_branch_count(node)
194
- else 0
195
- end
196
- end
197
-
198
268
  def send_node_branch_count(node)
199
269
  node.method?(:&) || node.method?(:|) ? 1 : 0
200
270
  end
@@ -231,21 +301,38 @@ module RuboCop
231
301
 
232
302
  def count_contexts_for_method(spec_content, method_name)
233
303
  method_pattern = Regexp.escape(method_name)
234
- context_count, has_examples, has_direct_examples = parse_spec_content(spec_content, method_pattern)
304
+ result = parse_spec_content(spec_content, method_pattern)
305
+
306
+ scenario_count(
307
+ result.context_count, result.example_count,
308
+ has_examples: result.has_examples, has_direct_examples: result.has_direct_examples
309
+ )
310
+ end
311
+
312
+ # A test scenario is the smaller unit between "a context block" and "an
313
+ # `it`/`example`". A single context whose examples each exercise a branch
314
+ # covers as many scenarios as it has examples, so the scenario count is
315
+ # the larger of the context-based count and the raw example count. Empty
316
+ # placeholder contexts (no examples) still count via the context-based
317
+ # path, so this never under-counts relative to the old behaviour.
318
+ def scenario_count(context_count, example_count, has_examples:, has_direct_examples:)
319
+ context_based =
320
+ if context_count.positive? && has_direct_examples
321
+ context_count + 1
322
+ elsif context_count.zero? && has_examples
323
+ 1
324
+ else
325
+ context_count
326
+ end
235
327
 
236
- if context_count.positive? && has_direct_examples
237
- context_count + 1
238
- elsif context_count.zero? && has_examples
239
- 1
240
- else
241
- context_count
242
- end
328
+ [context_based, example_count].max
243
329
  end
244
330
 
245
331
  # rubocop:disable Metrics/MethodLength
246
332
  def parse_spec_content(spec_content, method_pattern) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
247
333
  in_method_block = false
248
334
  context_count = 0
335
+ example_count = 0
249
336
  has_examples = false
250
337
  has_direct_examples = false
251
338
  base_indent = 0
@@ -269,6 +356,7 @@ module RuboCop
269
356
  context_count += 1
270
357
  elsif nested_example?(line)
271
358
  has_examples = true
359
+ example_count += 1
272
360
  child_indent ||= current_indent
273
361
  has_direct_examples = true if current_indent == child_indent
274
362
  end
@@ -277,7 +365,7 @@ module RuboCop
277
365
  end
278
366
  end
279
367
 
280
- [context_count, has_examples, has_direct_examples]
368
+ ParsedSpec.new(context_count, example_count, has_examples, has_direct_examples)
281
369
  end
282
370
  # rubocop:enable Metrics/MethodLength
283
371
 
@@ -307,7 +395,6 @@ module RuboCop
307
395
 
308
396
  case word
309
397
  when "branch" then "branches"
310
- when "context" then "contexts"
311
398
  else "#{word}s"
312
399
  end
313
400
  end
@@ -447,6 +534,7 @@ module RuboCop
447
534
  # Count contexts/describes and examples at the top level (under class describe)
448
535
  base_indent = lines[describe_line_index].match(/^(\s*)/)[1].length
449
536
  context_count = 0
537
+ example_count = 0
450
538
  has_examples = false
451
539
  has_direct_examples = false
452
540
  child_indent = nil
@@ -462,18 +550,13 @@ module RuboCop
462
550
  context_count += 1
463
551
  elsif line.match?(/^\s*(?:it|example|specify)\s+/)
464
552
  has_examples = true
553
+ example_count += 1
465
554
  child_indent ||= indent
466
555
  has_direct_examples = true if indent == child_indent
467
556
  end
468
557
  end
469
558
 
470
- if context_count.positive? && has_direct_examples
471
- context_count + 1
472
- elsif context_count.zero? && has_examples
473
- 1
474
- else
475
- context_count
476
- end
559
+ scenario_count(context_count, example_count, has_examples:, has_direct_examples:)
477
560
  end
478
561
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
479
562
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module RSpecParity
5
- VERSION = "1.5.0"
5
+ VERSION = "1.7.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-rspec_parity
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Povilas Jurcys