rubocop-rspec_parity 1.6.0 → 2.0.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: 2fc4286fcc69b46b490ae21db9ce9681ca7dd25577521e329d0bc3d89b33940a
4
- data.tar.gz: da7872a16a143f8d44a178c7b1912996c8d5213fb1538a8f4231e9ceedf361dc
3
+ metadata.gz: 2bda6248c84f44ce1e039baac11b9e6b3075c3feb13bb4e06a9e804ff26436a6
4
+ data.tar.gz: ebc0e696c427bd033637609d7f1ad4aaa66c603a9ca1ed82adb09d2857c99161
5
5
  SHA512:
6
- metadata.gz: 3d9f782158d6c053256401c6b687eb8b22dd1780f444b42783609b428ea0f82898a33e5c502818df86be8e56847a7be8012a99ead9c3aaffe91e5420d7be5a66
7
- data.tar.gz: 44b7a03a46444bcacbec096e659fd435dc288278ba1f14d72d87311d1bca1b4feb32643c68c6fcb3ff2fbb5be1118adddddb8e4ec164b4d710cc00efbb287bc5
6
+ metadata.gz: 102aceedd06b6db82be97c0a45d95a57e22cb1b1ab7427875c6137f98dcfcc87b862cd2cc26dfc14d8f5604a24702166db12b6f94c1835f7e0395fd98082d556
7
+ data.tar.gz: ca83d65bb73322941c49160de66943b2e1c6c93f35db372ef8cb559e5ea2cf3bdf1d3cc31151e654c8d19b620ceb1f4ee2f6dbf5ef8a8213277bd243c7104111
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [2.0.0] - 2026-06-18
4
+
5
+ Added: `SufficientContexts` now pinpoints which branch is untested — its message names one uncovered branch and the `# rspec_parity:covers <branch>` annotation to add, and the bundled `rspec-parity-cover` executable lists all of a method's gaps as paste-ready context stubs. Annotations are opt-in (new `CoversAnnotations` config key, default `true`).
6
+
7
+ ## [1.7.0] - 2026-06-11
8
+
9
+ 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).
10
+
3
11
  ## [1.6.0] - 2026-06-11
4
12
 
5
13
  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
data/README.md CHANGED
@@ -230,6 +230,59 @@ end
230
230
  **Configuration options:**
231
231
 
232
232
  - `IgnoreMemoization` (default: `true`) - When enabled, common memoization patterns like `@var ||=` and `return @var if defined?(@var)` are not counted as branches. Set to `false` if you want to count these as branches.
233
+ - `CoversAnnotations` (default: `true`) - Enables the `# rspec_parity:covers` annotations described below. Set to `false` to revert to the plain numeric message.
234
+
235
+ #### Pinpointing the missing branch with `# rspec_parity:covers`
236
+
237
+ Instead of a bare count, the violation names one uncovered branch and gives the exact context to add:
238
+
239
+ ```
240
+ Missing coverage for `user.staff?` (line 4) — 2 of 3 branches untested.
241
+ Add `context '...' do # rspec_parity:covers user.staff?` to mark it covered.
242
+ ```
243
+
244
+ Tag a context with the branch it covers (trailing, or on its own line inside the block):
245
+
246
+ ```ruby
247
+ context 'when staff' do # rspec_parity:covers user.staff?
248
+ it { is_expected.to eq('staff') }
249
+ end
250
+ ```
251
+
252
+ Re-run and the message advances to the next uncovered branch. Annotations are opt-in and only ever raise coverage — they never create a new violation, and each one covers exactly one branch. A mistyped label is reported with a did-you-mean suggestion.
253
+
254
+ Long conditions can push the comment past `Layout/LineLength`; exempt these comments rather than editing the label:
255
+
256
+ ```yaml
257
+ Layout/LineLength:
258
+ AllowedPatterns:
259
+ - 'rspec_parity:covers'
260
+ ```
261
+
262
+ ##### Listing every uncovered branch (`rspec-parity-cover`)
263
+
264
+ To get all the gaps for a method at once as ready-to-paste stubs:
265
+
266
+ ```
267
+ $ bundle exec rspec-parity-cover app/services/classifier.rb
268
+
269
+ # classify — 2 uncovered branches:
270
+ context '...' do
271
+ # rspec_parity:covers user.staff?
272
+
273
+ it '...' do
274
+ end
275
+ end
276
+
277
+ context '...' do
278
+ # rspec_parity:covers else of user.admin?
279
+
280
+ it '...' do
281
+ end
282
+ end
283
+ ```
284
+
285
+ It derives the spec path (or take a second argument), and accepts `app/foo.rb:42` to report only the method at that line. When a method has many untested branches, the cop's message points you straight at this command.
233
286
 
234
287
  ## Assumptions
235
288
 
data/config/default.yml CHANGED
@@ -33,5 +33,6 @@ RSpecParity/SufficientContexts:
33
33
  Enabled: true
34
34
  IgnoreMemoization: true
35
35
  TraceSingleUsePrivateMethods: true
36
+ CoversAnnotations: true
36
37
  SkipMethodDescribeFor: []
37
38
  DescribeAliases: {}
@@ -0,0 +1,224 @@
1
+ # Pinpoint missing branches with `# rspec_parity:covers` annotations
2
+
3
+ ## Problem
4
+
5
+ `RSpecParity/SufficientContexts` reports a bare numeric gap: "method has 21
6
+ branches but the spec covers only 20 scenarios." When the gap is 1 out of 21,
7
+ there is no way to tell *which* branch is uncovered. Both sides of the
8
+ comparison are pure counts — branches are tallied as integers from the AST, and
9
+ scenarios are tallied as integers from spec text — so there is no axis along
10
+ which a specific branch maps to a specific context.
11
+
12
+ ## Goal
13
+
14
+ 1. Make the offense name the branches it counted, so a human can reconcile the
15
+ gap by eye instead of guessing.
16
+ 2. Let authors *opt in* to precise per-branch tracking by tagging contexts with
17
+ a `# rspec_parity:covers <label>` magic comment. Annotations are purely additive —
18
+ they can only ever *help* (raise coverage / narrow the reported gap), never
19
+ create a new violation and never make a passing method fail.
20
+ 3. Report annotations that match no known branch (typos / renamed branches),
21
+ with a did-you-mean suggestion.
22
+
23
+ ## Configuration
24
+
25
+ ```yaml
26
+ RSpecParity/SufficientContexts:
27
+ CoversAnnotations: true # default
28
+ ```
29
+
30
+ Set to `false` to restore the prior numeric-only message and skip all
31
+ annotation handling.
32
+
33
+ ## Concepts
34
+
35
+ ### Branch descriptors
36
+
37
+ `branch_tally` stops returning integer counters and instead collects a list of
38
+ **branch descriptors**, one per counted branch:
39
+
40
+ ```
41
+ Branch = Struct.new(:kind, :label, :line, :origin)
42
+ # kind — :guard or :regular (preserves the guard fall-through logic)
43
+ # label — the normalized condition/operator source (what you annotate with)
44
+ # line — source line of the branch
45
+ # origin — nil for the method's own branches; the helper method name when the
46
+ # branch was inlined from a single-use private method
47
+ ```
48
+
49
+ `BranchTally` becomes a thin wrapper over an array of `Branch`, still exposing
50
+ `guard`, `regular`, `total`, and `+` so `branches_from` and
51
+ `PrivateMethodCallGraph` keep working unchanged. A new `with_origin(name)`
52
+ returns a copy with each not-yet-tagged descriptor's `origin` set — the call
53
+ graph tags descriptors as it inlines each helper.
54
+
55
+ ### Labels
56
+
57
+ A label is the branch's source, whitespace-collapsed (`source.gsub(/\s+/, " ")
58
+ .strip`), case-sensitive. No truncation — the displayed label is exactly the
59
+ string you type into the annotation, so it is always copy-pasteable.
60
+
61
+ Per branch kind:
62
+
63
+ | AST | descriptors |
64
+ |-----|-------------|
65
+ | `if`/`elsif`/`else` (non-guard) | one per condition (`if`, each `elsif`) + `else of <if condition>` |
66
+ | guard `if`/`unless` | one, label = guard condition source, kind `:guard` |
67
+ | `case`/`when` | one per `when` + `else of case <subject>` (only if present) |
68
+ | `&&` / `\|\|` | one, label = operator source; carries `detail` `<rhs> decides` |
69
+ | `\|\|=` / `&&=` | two, label = `<src>`; carry `detail` `already set` / `assigns` |
70
+ | `&` / `\|` send | one, label = node source |
71
+
72
+ **Every branch ends up with a unique token, so one annotation covers exactly one
73
+ branch.** `#disambiguate` enforces this in two passes: branches whose plain token
74
+ still collides get their `detail` applied (`a && b` → `a && b (b decides)`,
75
+ `||=` → `… (assigns)`/`… (already set)`); anything still identical (e.g. two
76
+ `if`s with the same condition) gets a positional `(1)`, `(2)`. Non-colliding
77
+ labels (including standalone `&&`/`||` expressions) keep their plain source, so
78
+ they stay naturally typeable.
79
+
80
+ When a branch was inlined from a private helper, its annotation token is
81
+ prefixed with the origin: `admin_role?: role == 'admin'`. The prefix is
82
+ **optional when unambiguous** — typing just `role == 'admin'` matches when
83
+ exactly one branch has that condition; the prefix is only required to
84
+ disambiguate identical conditions in different helpers.
85
+
86
+ ### Annotations
87
+
88
+ A trailing magic comment on a `context`/`describe`/`it`/`example`/`specify`
89
+ line:
90
+
91
+ ```ruby
92
+ context 'when admin by role' do # rspec_parity:covers admin_role?: role == 'admin'
93
+ it 'returns true'
94
+ end
95
+ ```
96
+
97
+ - An annotated **context** collapses its inner examples: the whole block counts
98
+ as the branch(es) it declares, *not* as N example scenarios. This overrides
99
+ the "multiple `it`s = multiple scenarios" heuristic for that block — the
100
+ author has explicitly said "this block is one branch."
101
+ - An annotated **example** counts as its own branch.
102
+ - One annotation may list several labels separated by `;` (semicolon — commas
103
+ appear inside conditions). Or repeat the comment.
104
+ - The annotation may also be a **standalone comment line inside the context**
105
+ (handy when a trailing comment would overflow the line length); it is
106
+ attributed to the enclosing context. Place it before the context's examples.
107
+
108
+ ## Coverage rule
109
+
110
+ ```
111
+ covered = distinct_matched_annotations
112
+ + numeric_scenarios(from the un-annotated parts only)
113
+
114
+ offense fires when covered < branches # same trigger as before
115
+ ```
116
+
117
+ Annotations only raise `covered`. With zero annotations this reduces exactly to
118
+ today's `scenario_count` over the whole block.
119
+
120
+ ## Messages (single line — RuboCop offense constraint)
121
+
122
+ Rather than listing every branch (unreadable for branchy methods), the message
123
+ names **one** still-uncovered branch and gives the exact context to add. Each
124
+ re-run advances to the next gap as branches get annotated.
125
+
126
+ **With annotations on** (the default):
127
+
128
+ ```
129
+ Missing coverage for `user.staff?` (line 4) — 2 of 3 branches untested.
130
+ Add `context '...' do # rspec_parity:covers user.staff?` to mark it covered.
131
+ ```
132
+
133
+ The showcased branch is the first uncovered one, preferring a real condition
134
+ over `else`/guard fall-through so the example reads clearly. A branch traced
135
+ from a helper shows its origin in the location (`helper line N`) and token
136
+ (`helper: condition`). The existing `(including branches from: …)` suffix still
137
+ appends when branches were inlined.
138
+
139
+ **With annotations off** — just the count, no annotation guidance:
140
+
141
+ ```
142
+ Method `classify` is missing coverage for 9 of 10 branches.
143
+ ```
144
+
145
+ **Orphan annotations** (appended when the method offends):
146
+
147
+ ```
148
+ … `rspec_parity:covers` annotation `params[:admni]` matches no branch — did you
149
+ mean `params[:admin]`?
150
+ ```
151
+
152
+ Nearest match via `DidYouMean::SpellChecker`; omit the "did you mean" clause
153
+ when nothing is close. An orphan annotation alone never triggers an offense
154
+ (opt-in): orphans are only reported when the method is already offending.
155
+
156
+ ## Architecture
157
+
158
+ ### `SufficientContexts`
159
+
160
+ - `initialize`: `@covers_annotations = cop_config.fetch("CoversAnnotations", true)`.
161
+ - `BranchTally` rewritten over `Branch` descriptors (`guard`/`regular`/`total`/
162
+ `+`/`with_origin`/`branches`).
163
+ - `branch_tally`, `count_if_branches`→`if_branch_descriptors`,
164
+ `count_case_branches`→`case_branch_descriptors`, `send_node_branch_count`,
165
+ `or_asgn`/`and_asgn` handling all push descriptors.
166
+ - `branches_from` unchanged (operates on `guard`/`regular`/`total`).
167
+ - Spec parsing (`parse_spec_content`, `count_top_level_contexts`): collect
168
+ trailing `# rspec_parity:covers` labels, suppress example counting inside annotated
169
+ contexts, return labels + un-annotated numeric scenario count.
170
+ - `check_method`: compute `matched`/`unannotated`/`orphans` by intersecting
171
+ annotation labels with the descriptor inventory; `covered = matched.size +
172
+ unannotated_scenarios`; build the enriched message.
173
+ - New helpers: `match_annotations`, `nearest_label` (DidYouMean), message
174
+ builders.
175
+
176
+ ### `PrivateMethodCallGraph`
177
+
178
+ `visit_callee` tags the inlined tally: `tally = tally.with_origin(name) if
179
+ tally.respond_to?(:with_origin)`. No other change; the `Result`/traverse logic
180
+ is untouched.
181
+
182
+ ### `config/default.yml`
183
+
184
+ Add `CoversAnnotations: true` under `RSpecParity/SufficientContexts`.
185
+
186
+ ## Edge cases
187
+
188
+ - **`CoversAnnotations: false`** — message reverts to today's exact
189
+ string; annotation comments are ignored entirely.
190
+ - **No spec annotations** — `matched` is empty, `unannotated_scenarios` equals
191
+ today's `scenario_count`; coverage and trigger are identical to today. Only
192
+ the message gains the inventory suffix.
193
+ - **Duplicate identical labels** (two branches, same source, same origin) — a
194
+ single annotation covers both; acceptable for "good enough" precision and
195
+ logged via the inventory line.
196
+ - **Annotated context with nested contexts** — the whole subtree collapses to
197
+ the declared label(s).
198
+ - **Multi-line / very long condition** — collapsed to one line; long but exact
199
+ and matchable.
200
+
201
+ ## Testing
202
+
203
+ Extend `spec/rubocop/cop/rspec_parity/sufficient_contexts_spec.rb`:
204
+
205
+ - inventory appears in the message when no annotations exist
206
+ - a matching annotation removes a branch from the uncovered list / satisfies the
207
+ count
208
+ - annotated context with several `it`s counts as one branch (collapse)
209
+ - optional origin prefix: bare condition matches when unambiguous
210
+ - orphan annotation reported with did-you-mean
211
+ - `CoversAnnotations: false` reproduces the legacy message
212
+ - interaction with `TraceSingleUsePrivateMethods` (origin-prefixed labels)
213
+
214
+ Existing message assertions are updated to the new output (captured from an
215
+ actual run).
216
+
217
+ ## Open questions resolved
218
+
219
+ - **Default** — `true` (opt-out), consistent with `TraceSingleUsePrivateMethods`.
220
+ The richer message is strictly more informative; annotations are additive.
221
+ - **Message is single-line** — RuboCop offense messages are single-line; the
222
+ inventory is joined with `; ` rather than rendered as a list.
223
+ - **Annotations never trigger** — opt-in means an annotation can only narrow or
224
+ satisfy; it never creates an offense.
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rubocop/rspec_parity/coverage_reporter"
5
+
6
+ target = ARGV[0]
7
+
8
+ if target.nil? || %w[-h --help].include?(target)
9
+ warn "Usage: rspec-parity-cover SOURCE_FILE[:LINE] [SPEC_FILE]"
10
+ warn ""
11
+ warn "Lists a method's uncovered branches as paste-ready"
12
+ warn "`context '...' do # rspec_parity:covers <branch>` stubs."
13
+ warn "Append :LINE (e.g. app/foo.rb:20) to report only the method at that line."
14
+ warn "The spec file is derived from SOURCE_FILE (app/ -> spec/, *.rb -> *_spec.rb)"
15
+ warn "unless SPEC_FILE is given."
16
+ exit(target.nil? ? 1 : 0)
17
+ end
18
+
19
+ source, line = target.match(/\A(?<path>.+):(?<line>\d+)\z/)&.named_captures&.values_at("path", "line") || [target, nil]
20
+
21
+ abort "rspec-parity-cover: no such file: #{source}" unless File.file?(source)
22
+
23
+ reporter = RuboCop::RSpecParity::CoverageReporter.new(source, spec_path: ARGV[1], line: line&.to_i)
24
+ output = reporter.render
25
+ puts output.empty? ? "# No uncovered branches found for #{target}." : output
@@ -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,13 +62,21 @@ 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] = combine_tally(state[:tally], tally, @methods[key][:name])
67
69
  state[:traced] << key
68
70
  end
69
71
 
72
+ # Attribute the inlined branches to the helper they came from so the cop
73
+ # can show "label (helper, line N)" and accept origin-prefixed
74
+ # `# rspec_parity:covers` annotations. Duck-typed: a plain count would skip.
75
+ def combine_tally(existing, tally, name)
76
+ tally = tally.with_origin(name) if tally.respond_to?(:with_origin)
77
+ existing ? existing + tally : tally
78
+ end
79
+
70
80
  def inlinable?(key, visited)
71
81
  return false if visited.include?(key)
72
82
  return false unless @methods.key?(key)