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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f70e06a85873dc993a895443df9bab8892de60d0cc75573ffdc6487245c09dcf
|
|
4
|
+
data.tar.gz: 7d8baed7d58b13d98fb7b7e14084c3c81dcb1905df3bacfc52772dcb65ba7dbd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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(
|
|
32
|
-
return Result.new(
|
|
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(
|
|
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]),
|
|
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[:
|
|
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
|
-
|
|
64
|
-
return unless
|
|
65
|
+
tally = branch_counter.call(@methods[key][:node])
|
|
66
|
+
return unless tally.total.positive?
|
|
65
67
|
|
|
66
|
-
state[:
|
|
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
|
|
46
|
-
"
|
|
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
|
-
|
|
105
|
+
tally = branch_tally(node)
|
|
84
106
|
traced_methods = []
|
|
85
107
|
if @trace_single_use_private
|
|
86
108
|
extra = inlined_branches(node)
|
|
87
|
-
|
|
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
|
-
|
|
147
|
+
scenario_word: pluralize("scenario", contexts),
|
|
125
148
|
missing: missing,
|
|
126
|
-
missing_word: pluralize("
|
|
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(
|
|
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(:
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|