rubocop-rspec_parity 1.7.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: f70e06a85873dc993a895443df9bab8892de60d0cc75573ffdc6487245c09dcf
4
- data.tar.gz: 7d8baed7d58b13d98fb7b7e14084c3c81dcb1905df3bacfc52772dcb65ba7dbd
3
+ metadata.gz: 2bda6248c84f44ce1e039baac11b9e6b3075c3feb13bb4e06a9e804ff26436a6
4
+ data.tar.gz: ebc0e696c427bd033637609d7f1ad4aaa66c603a9ca1ed82adb09d2857c99161
5
5
  SHA512:
6
- metadata.gz: 66ae9ef49f40f4323af10a733c9c1eb6bbc7a49883b3f00619fda6610c22b035ca53aa9e6b259259df8c7a5cb49aded12d56e8b96082074036f4fd9dc960baf3
7
- data.tar.gz: 18635158b5155856049fdbfd33aaeb732b013d729467f1aefd8751873f997411b78a4d1489fa692ef64b0030691a6141b8ab6c6ca28e95398ea5d87201d0b3e7
6
+ metadata.gz: 102aceedd06b6db82be97c0a45d95a57e22cb1b1ab7427875c6137f98dcfcc87b862cd2cc26dfc14d8f5604a24702166db12b6f94c1835f7e0395fd98082d556
7
+ data.tar.gz: ca83d65bb73322941c49160de66943b2e1c6c93f35db372ef8cb559e5ea2cf3bdf1d3cc31151e654c8d19b620ceb1f4ee2f6dbf5ef8a8213277bd243c7104111
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## [1.7.0] - 2026-06-11
4
8
 
5
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).
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
@@ -65,10 +65,18 @@ module RuboCop
65
65
  tally = branch_counter.call(@methods[key][:node])
66
66
  return unless tally.total.positive?
67
67
 
68
- state[:tally] = state[:tally] ? state[:tally] + tally : tally
68
+ state[:tally] = combine_tally(state[:tally], tally, @methods[key][:name])
69
69
  state[:traced] << key
70
70
  end
71
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
+
72
80
  def inlinable?(key, visited)
73
81
  return false if visited.include?(key)
74
82
  return false unless @methods.key?(key)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
4
+
3
5
  require_relative "spec_file_finder"
4
6
  require_relative "private_method_call_graph"
5
7
 
@@ -42,12 +44,31 @@ module RuboCop
42
44
  include DepartmentConfig
43
45
  include SpecFileFinder
44
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
+ # Used when CoversAnnotations is on: names one still-uncovered branch and
48
+ # gives the exact context to add. Re-running advances to the next gap as
49
+ # branches get annotated, so the message stays short instead of listing all.
50
+ COVERAGE_MSG = "Missing coverage for `%<token>s` (%<location>s) — " \
51
+ "%<missing>d of %<branches>d %<branch_word>s untested. " \
52
+ "Add `context '...' do # rspec_parity:covers %<token>s` to mark it covered."
53
+
54
+ # Used when many branches are missing: too many to walk one at a time, so
55
+ # point at the CLI that lists them all instead.
56
+ MANY_MSG = "%<missing>d of %<branches>d %<branch_word>s untested. " \
57
+ "Run `bundle exec rspec-parity-cover %<location>s` for the full list."
58
+
59
+ # Above this many missing branches, switch from the per-branch message to
60
+ # the CLI pointer.
61
+ MANY_UNCOVERED_BRANCHES = 3
62
+
63
+ # Used when CoversAnnotations is off (no annotation guidance to give).
64
+ COUNT_MSG = "Method `%<method_name>s` is missing coverage for %<missing>d of %<branches>d %<branch_word>s."
48
65
 
49
66
  TRACED_SUFFIX = " (including branches from: %<traced>s)"
50
67
 
68
+ ORPHAN_SUFFIX = " `rspec_parity:covers` annotation `%<label>s` matches no branch%<hint>s"
69
+
70
+ ANNOTATION_PATTERN = /#\s*rspec_parity:covers\s+(.+?)\s*\z/
71
+
51
72
  APP_DIR_PATTERN = %r{/app/}
52
73
 
53
74
  EXCLUDED_METHODS = %w[initialize].freeze
@@ -61,20 +82,180 @@ module RuboCop
61
82
  ].freeze
62
83
 
63
84
  # 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
85
+ # `annotations` holds raw `# rspec_parity:covers` label strings found in the
86
+ # block (empty unless CoversAnnotations is on).
87
+ ParsedSpec = Struct.new(:context_count, :example_count, :has_examples, :has_direct_examples, :annotations)
88
+
89
+ # A spec's contribution to coverage: scenarios counted from un-annotated
90
+ # contexts/examples, plus the raw annotation labels gathered from it.
91
+ SpecCoverage = Struct.new(:scenarios, :annotations)
92
+
93
+ # Line-by-line scanner state shared by both spec-counting paths. Tracks
94
+ # the scenario tallies (mirroring the prior behaviour) and, when
95
+ # CoversAnnotations is on, gathers `# rspec_parity:covers` labels and
96
+ # collapses an annotated context's body so its examples count as the one
97
+ # annotated branch rather than as separate scenarios.
98
+ class ScanState
99
+ def initialize(annotations_enabled)
100
+ @annotations_enabled = annotations_enabled
101
+ @context_count = 0
102
+ @example_count = 0
103
+ @has_examples = false
104
+ @has_direct_examples = false
105
+ @child_indent = nil
106
+ @annotations = []
107
+ @annotated_indent = nil
108
+ @last_context_indent = nil
109
+ @last_context_counted = false
110
+ end
111
+
112
+ def reset_child_indent
113
+ @child_indent = nil
114
+ end
115
+
116
+ def inside_annotated_context?
117
+ !@annotated_indent.nil?
118
+ end
119
+
120
+ def exit_annotated_context(indent)
121
+ @annotated_indent = nil if @annotated_indent && indent <= @annotated_indent
122
+ end
123
+
124
+ def count_context(line, indent)
125
+ @last_context_indent = indent
126
+ labels = annotation_labels(line)
127
+ return annotate_context_line(indent, labels) if labels.any?
128
+
129
+ @child_indent ||= indent
130
+ @context_count += 1
131
+ @last_context_counted = true
132
+ end
133
+
134
+ # A comment-only annotation line, typically placed just inside a context
135
+ # block when a trailing comment would overflow the line length. It is
136
+ # attributed to the enclosing context, which then collapses like a
137
+ # context annotated on its opening line. Returns the labels (truthy)
138
+ # when it consumes the line, else nil. Place it before the context's
139
+ # examples; an annotation after an example only collapses what follows.
140
+ def collect_standalone_annotation(line)
141
+ return unless @annotations_enabled
142
+ return unless line.lstrip.start_with?("#")
143
+
144
+ labels = annotation_labels(line)
145
+ return if labels.empty?
146
+
147
+ @annotations.concat(labels)
148
+ annotate_enclosing_context
149
+ labels
150
+ end
151
+
152
+ # Coarse path for method-named context lines (e.g. `context '.call'`):
153
+ # count the block and gather any annotation, but don't collapse — this
154
+ # path doesn't track the block's interior.
155
+ def count_named_context(line)
156
+ @context_count += 1
157
+ @annotations.concat(annotation_labels(line))
158
+ end
159
+
160
+ def count_example(line, indent)
161
+ labels = annotation_labels(line)
162
+ if labels.any?
163
+ @annotations.concat(labels)
164
+ else
165
+ @has_examples = true
166
+ @example_count += 1
167
+ @child_indent ||= indent
168
+ @has_direct_examples = true if indent == @child_indent
169
+ end
170
+ end
171
+
172
+ def to_parsed_spec
173
+ ParsedSpec.new(@context_count, @example_count, @has_examples, @has_direct_examples, @annotations)
174
+ end
175
+
176
+ private
177
+
178
+ # Annotation found on a context's own opening line: record the labels
179
+ # and collapse the block from this indent. Nothing was counted yet.
180
+ def annotate_context_line(indent, labels)
181
+ @annotations.concat(labels)
182
+ @annotated_indent = indent
183
+ @last_context_counted = false
184
+ end
185
+
186
+ # Mark the most-recently-opened context as annotated: undo its generic
187
+ # scenario count (it now counts as its labels) and collapse the rest of
188
+ # its body. No-op if that context is already annotated.
189
+ def annotate_enclosing_context
190
+ return if @annotated_indent && @annotated_indent == @last_context_indent
191
+
192
+ if @last_context_counted
193
+ @context_count -= 1
194
+ @last_context_counted = false
195
+ end
196
+ @annotated_indent = @last_context_indent
197
+ end
198
+
199
+ def annotation_labels(line)
200
+ return [] unless @annotations_enabled
201
+
202
+ match = line.match(ANNOTATION_PATTERN)
203
+ return [] unless match
204
+
205
+ match[1].split(";").map(&:strip).reject(&:empty?)
206
+ end
207
+ end
208
+
209
+ # A single counted branch. `kind` is :guard or :regular (preserving the
210
+ # guard fall-through accounting in #branches_from). `label` is the
211
+ # normalized condition/operator source used both for display and for
212
+ # matching `# rspec_parity:covers` annotations. `origin` is nil for the
213
+ # method's own branches, or the helper method name when inlined from a
214
+ # single-use private method. `detail` is an optional semantic qualifier
215
+ # (e.g. "b decides", "assigns") applied only when the plain label collides
216
+ # with another branch — see #disambiguate.
217
+ Branch = Struct.new(:kind, :label, :line, :origin, :detail) do
218
+ # The string an author types after `# rspec_parity:covers` to claim this
219
+ # branch. The origin prefix disambiguates identical conditions in
220
+ # different helpers; it is optional when the condition is unique.
221
+ def annotation_token
222
+ origin ? "#{origin}: #{label}" : label
223
+ end
224
+ end
225
+
226
+ # Collection of Branch descriptors. Exposes the guard/regular/total/+
227
+ # interface the rest of the cop (and PrivateMethodCallGraph) relies on —
228
+ # a sequence of guard clauses (`return/raise/next ... if/unless`) shares
229
+ # a single "all guards pass" fall-through counted once per method (see
230
+ # #branches_from) rather than once per guard. `with_origin` tags inlined
231
+ # branches as the call graph descends into single-use helpers.
232
+ class BranchTally
233
+ attr_reader :branches
234
+
235
+ def initialize(branches = [])
236
+ @branches = branches
237
+ end
238
+
72
239
  def +(other)
73
- BranchTally.new(guard + other.guard, regular + other.regular)
240
+ BranchTally.new(branches + other.branches)
241
+ end
242
+
243
+ def guard
244
+ branches.count { |branch| branch.kind == :guard }
245
+ end
246
+
247
+ def regular
248
+ branches.count { |branch| branch.kind == :regular }
74
249
  end
75
250
 
76
251
  def total
77
- guard + regular
252
+ branches.size
253
+ end
254
+
255
+ def with_origin(name)
256
+ BranchTally.new(branches.map do |branch|
257
+ branch.origin ? branch : Branch.new(branch.kind, branch.label, branch.line, name, branch.detail)
258
+ end)
78
259
  end
79
260
  end
80
261
 
@@ -85,6 +266,7 @@ module RuboCop
85
266
  super
86
267
  @ignore_memoization = cop_config.fetch("IgnoreMemoization", true)
87
268
  @trace_single_use_private = cop_config.fetch("TraceSingleUsePrivateMethods", true)
269
+ @covers_annotations = cop_config.fetch("CoversAnnotations", true)
88
270
  @call_graphs = {}.compare_by_identity
89
271
  end
90
272
 
@@ -96,6 +278,50 @@ module RuboCop
96
278
  check_method(node)
97
279
  end
98
280
 
281
+ # ---- Tooling API (used by CoverageReporter / the rspec-parity-cover CLI) ----
282
+
283
+ # Every counted branch for a method node, including those traced from
284
+ # single-use private helpers. Empty when the method has fewer than two
285
+ # branches or is excluded. Also aliased as +all_branches+ for callers that
286
+ # want the full set regardless of spec coverage.
287
+ def branch_inventory_for(method_node)
288
+ return [] if excluded_method?(method_name(method_node))
289
+
290
+ tally = branch_tally(method_node)
291
+ if @trace_single_use_private
292
+ extra = inlined_branches(method_node)
293
+ tally += extra.branch_tally if extra.branch_tally
294
+ end
295
+ return [] if branches_from(tally) < 2
296
+
297
+ branch_inventory(tally, method_node)
298
+ end
299
+ alias all_branches branch_inventory_for
300
+
301
+ # What's left to cover for a method, given the spec text:
302
+ # uncovered — branches with no covering annotation
303
+ # annotated — branches an annotation already claims (known-covered)
304
+ # unannotated_specs — count of plain (un-annotated) examples/contexts whose
305
+ # branch we can't determine
306
+ # Nil when the method has no spec describe block or is already fully covered.
307
+ CoverageGap = Struct.new(:uncovered, :annotated, :unannotated_specs, keyword_init: true)
308
+
309
+ def coverage_gap(method_node, spec_content)
310
+ inventory = branch_inventory_for(method_node)
311
+ return nil if inventory.empty?
312
+
313
+ build_gap(inventory, count_contexts_for_method(spec_content.to_s, method_name(method_node)))
314
+ end
315
+
316
+ def build_gap(inventory, coverage)
317
+ matched, = match_annotations(inventory, coverage.annotations)
318
+ covered = coverage.scenarios + matched.size
319
+ return nil if covered.zero? || covered >= inventory.size
320
+
321
+ CoverageGap.new(uncovered: inventory.reject { |branch| matched.include?(branch) },
322
+ annotated: matched, unannotated_specs: coverage.scenarios)
323
+ end
324
+
99
325
  private
100
326
 
101
327
  def check_method(node) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -120,35 +346,179 @@ module RuboCop
120
346
 
121
347
  dual_access = node.def_type? && module_with_dual_access?(node)
122
348
 
123
- # Aggregate contexts from all valid spec files
124
- contexts = if matches_skip_path? && count_public_methods(node) == 1
125
- # For single-method classes, count top-level contexts instead
126
- spec_files.sum { |spec_file| count_top_level_contexts(File.read(spec_file), class_name) }
127
- else
128
- # Normal path: look for method describes
129
- spec_files.sum do |spec_file|
130
- count_method_contexts(File.read(spec_file), method_name(node), dual_access: dual_access)
131
- end
132
- end
349
+ coverage = gather_coverage(node, class_name, spec_files, dual_access)
350
+ inventory = branch_inventory(tally, node)
351
+ matched, orphans = match_annotations(inventory, coverage.annotations)
352
+ # Annotations only ever raise coverage: matched branches + un-annotated
353
+ # scenarios + orphan claims (counted so a typo'd annotation never lowers
354
+ # coverage below the un-annotated baseline).
355
+ covered = coverage.scenarios + matched.size + orphans.size
356
+
357
+ return if covered.zero? # Method has no specs at all - PublicMethodHasSpec handles this
358
+ return if covered >= branches
359
+
360
+ missing = branches - covered
361
+ add_offense(node, message: build_message(
362
+ node: node, branches: branches, missing: missing,
363
+ traced_methods: traced_methods, inventory: inventory, matched: matched, orphans: orphans
364
+ ))
365
+ end
366
+
367
+ def gather_coverage(node, class_name, spec_files, dual_access)
368
+ if matches_skip_path? && count_public_methods(node) == 1
369
+ # For single-method classes, count top-level contexts instead
370
+ aggregate_coverage(spec_files) { |file| count_top_level_contexts(File.read(file), class_name) }
371
+ else
372
+ # Normal path: look for method describes
373
+ aggregate_coverage(spec_files) do |file|
374
+ count_method_contexts(File.read(file), method_name(node), dual_access: dual_access)
375
+ end
376
+ end
377
+ end
378
+
379
+ def aggregate_coverage(spec_files)
380
+ spec_files.reduce(SpecCoverage.new(0, [])) do |acc, file|
381
+ merge_coverage(acc, yield(file))
382
+ end
383
+ end
384
+
385
+ def merge_coverage(first, second)
386
+ SpecCoverage.new(first.scenarios + second.scenarios, first.annotations + second.annotations)
387
+ end
388
+
389
+ # The branches counted, sorted for stable output. Adds the synthetic
390
+ # "all guards pass" fall-through so the inventory size matches the count,
391
+ # then makes every token unique so one annotation covers exactly one branch.
392
+ def branch_inventory(tally, node)
393
+ inventory = tally.branches.dup
394
+ if tally.guard.positive? && tally.regular.zero?
395
+ inventory << Branch.new(:guard, "all guards pass", node.first_line, nil)
396
+ end
397
+ disambiguate(inventory.sort_by { |branch| [branch.line, branch.label] })
398
+ end
399
+
400
+ # Makes every token unique so one annotation covers exactly one branch.
401
+ # First qualifies colliding branches that carry a semantic `detail` (e.g.
402
+ # the operator vs. the whole condition, or `||=`'s two cases); then appends
403
+ # a positional `(N)` to anything still identical (e.g. two `if`s with the
404
+ # same condition). Plain, non-colliding labels are left untouched.
405
+ def disambiguate(inventory)
406
+ inventory = requalify(inventory) { |branch| branch.detail && "#{branch.label} (#{branch.detail})" }
407
+ counter = Hash.new(0)
408
+ requalify(inventory) do |branch|
409
+ counter[branch.annotation_token] += 1
410
+ "#{branch.label} (#{counter[branch.annotation_token]})"
411
+ end
412
+ end
413
+
414
+ # Rewrites the label of each branch whose token still collides, using the
415
+ # block's result (skips the rewrite when the block returns nil).
416
+ def requalify(inventory)
417
+ totals = inventory.group_by(&:annotation_token).transform_values(&:size)
418
+ inventory.map do |branch|
419
+ next branch if totals[branch.annotation_token] == 1
420
+
421
+ new_label = yield(branch)
422
+ new_label ? Branch.new(branch.kind, new_label, branch.line, branch.origin, branch.detail) : branch
423
+ end
424
+ end
425
+
426
+ # Splits the spec's raw annotation labels into the inventory branches they
427
+ # cover and orphans that match nothing. A bare condition matches a traced
428
+ # branch when its label is unambiguous; otherwise the `origin:` prefix is
429
+ # needed. One annotation covers *every* branch sharing that token — a
430
+ # compound condition like `a && b` is counted as several MC/DC branches
431
+ # but reads as a single thing to annotate.
432
+ def match_annotations(inventory, raw_annotations)
433
+ tokens = inventory.map(&:annotation_token).uniq
434
+ label_tokens = inventory.group_by(&:label).transform_values do |branches|
435
+ branches.map(&:annotation_token).uniq
436
+ end
437
+ matched_tokens, orphans = partition_annotations(raw_annotations, tokens, label_tokens)
438
+ [inventory.select { |branch| matched_tokens.include?(branch.annotation_token) }, orphans]
439
+ end
133
440
 
134
- return if contexts.zero? # Method has no specs at all - PublicMethodHasSpec handles this
135
- return if contexts >= branches
441
+ def partition_annotations(raw_annotations, tokens, label_tokens)
442
+ matched_tokens = Set.new
443
+ orphans = []
444
+ raw_annotations.each do |raw|
445
+ label = normalize_label(raw)
446
+ token = resolve_annotation(label, tokens, label_tokens)
447
+ token ? matched_tokens << token : orphans << label
448
+ end
449
+ [matched_tokens, orphans]
450
+ end
451
+
452
+ # The inventory token an annotation label refers to: an exact token match,
453
+ # or a bare condition whose origin is unambiguous. Nil when it matches none.
454
+ def resolve_annotation(label, tokens, label_tokens)
455
+ return label if tokens.include?(label)
456
+ return label_tokens[label].first if label_tokens[label]&.one?
457
+
458
+ nil
459
+ end
460
+
461
+ # rubocop:disable Metrics/ParameterLists
462
+ def build_message(node:, branches:, missing:, traced_methods:, inventory:, matched:, orphans:)
463
+ uncovered = @covers_annotations ? inventory.reject { |branch| matched.include?(branch) } : []
464
+ return count_message(node, branches, missing) + traced_suffix(traced_methods) if uncovered.empty?
465
+
466
+ if missing > MANY_UNCOVERED_BRANCHES
467
+ return many_message(node, branches, missing) + orphan_suffix(orphans, inventory)
468
+ end
469
+
470
+ coverage_message(preferred_example(uncovered), branches, missing) +
471
+ traced_suffix(traced_methods) + orphan_suffix(orphans, inventory)
472
+ end
473
+ # rubocop:enable Metrics/ParameterLists
474
+
475
+ def traced_suffix(traced_methods)
476
+ traced_methods.any? ? format(TRACED_SUFFIX, traced: traced_methods.join(", ")) : ""
477
+ end
478
+
479
+ def count_message(node, branches, missing)
480
+ format(COUNT_MSG, method_name: method_name(node), missing: missing,
481
+ branches: branches, branch_word: pluralize("branch", branches))
482
+ end
483
+
484
+ def many_message(node, branches, missing)
485
+ format(MANY_MSG, missing: missing, branches: branches,
486
+ branch_word: pluralize("branch", missing),
487
+ location: "#{source_file_path}:#{node.first_line}")
488
+ end
489
+
490
+ def coverage_message(branch, branches, missing)
491
+ format(COVERAGE_MSG, token: branch.annotation_token, location: branch_location(branch),
492
+ missing: missing, branches: branches, branch_word: pluralize("branch", branches))
493
+ end
136
494
 
137
- missing = branches - contexts
138
- add_offense(node, message: build_message(node, branches, contexts, missing, traced_methods))
495
+ # Prefer a branch with a real condition label over an `else`/guard
496
+ # fall-through so the showcased annotation reads clearly; fall back to the
497
+ # first.
498
+ def preferred_example(branches)
499
+ branches.find { |branch| !unhelpful_example?(branch.label) } || branches.first
139
500
  end
140
501
 
141
- def build_message(node, branches, contexts, missing, traced_methods)
142
- message = format(MSG,
143
- method_name: method_name(node),
144
- branches: branches,
145
- branch_word: pluralize("branch", branches),
146
- contexts: contexts,
147
- scenario_word: pluralize("scenario", contexts),
148
- missing: missing,
149
- missing_word: pluralize("scenario", missing))
150
- message += format(TRACED_SUFFIX, traced: traced_methods.join(", ")) if traced_methods.any?
151
- message
502
+ def unhelpful_example?(label)
503
+ label == "all guards pass" || label.start_with?("else")
504
+ end
505
+
506
+ def branch_location(branch)
507
+ branch.origin ? "#{branch.origin} line #{branch.line}" : "line #{branch.line}"
508
+ end
509
+
510
+ def orphan_suffix(orphans, inventory)
511
+ orphans.uniq.map do |label|
512
+ nearest = nearest_label(label, inventory)
513
+ hint = nearest ? " — did you mean `#{nearest}`?" : "."
514
+ format(ORPHAN_SUFFIX, label: label, hint: hint)
515
+ end.join
516
+ end
517
+
518
+ def nearest_label(label, inventory)
519
+ return nil if inventory.empty?
520
+
521
+ DidYouMean::SpellChecker.new(dictionary: inventory.map(&:annotation_token)).correct(label).first
152
522
  end
153
523
 
154
524
  def inlined_branches(node)
@@ -192,27 +562,49 @@ module RuboCop
192
562
  total
193
563
  end
194
564
 
195
- def branch_tally(node) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
196
- guard = 0
197
- regular = 0
565
+ def branch_tally(node)
198
566
  elsif_nodes = collect_elsif_nodes(node)
199
567
  guard_operators = collect_guard_condition_operators(node)
200
568
 
569
+ branches = []
201
570
  node.each_descendant do |descendant|
202
571
  next if elsif_nodes.include?(descendant)
203
572
  next if should_skip_node?(descendant)
204
573
 
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
574
+ branches.concat(branches_for(descendant, guard_operators))
575
+ end
576
+
577
+ BranchTally.new(branches)
578
+ end
579
+
580
+ # Descriptors contributed by a single AST node. Counts match the prior
581
+ # integer tally exactly — each `+= N` became N descriptors — so existing
582
+ # branch totals are unchanged; the descriptors just carry labels/lines.
583
+ def branches_for(descendant, guard_operators)
584
+ case descendant.type
585
+ when :if then if_node_descriptors(descendant)
586
+ when :case then case_branch_descriptors(descendant)
587
+ when :and, :or then [boolean_operator_branch(descendant, guard_operators)]
588
+ when :or_asgn, :and_asgn then asgn_branch_descriptors(descendant)
589
+ when :send then send_branch_descriptors(descendant)
590
+ else []
213
591
  end
592
+ end
214
593
 
215
- BranchTally.new(guard, regular)
594
+ def if_node_descriptors(node)
595
+ if guard_clause?(node)
596
+ [Branch.new(:guard, branch_label(node.condition), node.condition.first_line, nil)]
597
+ else
598
+ if_branch_descriptors(node)
599
+ end
600
+ end
601
+
602
+ # An `&&`/`||` operator's own branch is the MC/DC scenario where its right
603
+ # operand is decisive. Kept as the plain expression unless it collides with
604
+ # the whole-condition branch, in which case `detail` qualifies it.
605
+ def boolean_operator_branch(node, guard_operators)
606
+ kind = guard_operators.include?(node) ? :guard : :regular
607
+ Branch.new(kind, branch_label(node), node.first_line, nil, "#{branch_label(node.rhs)} decides")
216
608
  end
217
609
 
218
610
  # A guard clause is a one-armed `if`/`unless` whose single body exits the
@@ -265,48 +657,87 @@ module RuboCop
265
657
  @ignore_memoization && memoization_pattern?(node)
266
658
  end
267
659
 
268
- def send_node_branch_count(node)
269
- node.method?(:&) || node.method?(:|) ? 1 : 0
660
+ def send_branch_descriptors(node)
661
+ return [] unless node.method?(:&) || node.method?(:|)
662
+
663
+ [Branch.new(:regular, branch_label(node), node.first_line, nil)]
270
664
  end
271
665
 
272
- def count_if_branches(node)
273
- # if/else is 2 branches, each elsif adds 1
274
- branches = 2
666
+ # if/else is 2 branches (the `if` condition + the trailing `else`
667
+ # fall-through), each `elsif` adds one more.
668
+ def if_branch_descriptors(node)
669
+ descriptors = [condition_branch(node)]
275
670
  current = node
276
671
  while current&.if_type? && current.else_branch&.if_type?
277
- branches += 1
278
672
  current = current.else_branch
673
+ descriptors << condition_branch(current)
279
674
  end
280
- branches
675
+ # Tie the else to its `if` condition so two separate if/else blocks get
676
+ # distinct, individually-annotatable labels instead of two bare "else"s.
677
+ descriptors << Branch.new(:regular, "else of #{branch_label(node.condition)}", node.first_line, nil)
678
+ descriptors
281
679
  end
282
680
 
283
- def count_case_branches(node)
284
- # Each when clause is a branch, plus default/else
285
- when_count = node.when_branches.count
286
- has_else = !node.else_branch.nil?
287
- when_count + (has_else ? 1 : 0)
681
+ def condition_branch(if_node)
682
+ Branch.new(:regular, branch_label(if_node.condition), if_node.condition.first_line, nil)
683
+ end
684
+
685
+ # Each `when` clause is a branch, plus a default/else when present.
686
+ def case_branch_descriptors(node)
687
+ descriptors = node.when_branches.map do |when_node|
688
+ label = normalize_label("when #{when_node.conditions.map(&:source).join(", ")}")
689
+ Branch.new(:regular, label, when_node.first_line, nil)
690
+ end
691
+ descriptors << Branch.new(:regular, case_else_label(node), node.first_line, nil) if node.else_branch
692
+ descriptors
693
+ end
694
+
695
+ def case_else_label(node)
696
+ node.condition ? "else of case #{branch_label(node.condition)}" : "else of case"
697
+ end
698
+
699
+ # `||=` / `&&=` create 2 branches sharing the expression's source: the
700
+ # target was already set, or the right-hand side is assigned. `detail`
701
+ # tells them apart once #disambiguate sees the collision.
702
+ def asgn_branch_descriptors(node)
703
+ label = branch_label(node)
704
+ [
705
+ Branch.new(:regular, label, node.first_line, nil, "already set"),
706
+ Branch.new(:regular, label, node.first_line, nil, "assigns")
707
+ ]
708
+ end
709
+
710
+ def branch_label(node)
711
+ normalize_label(node.source)
712
+ end
713
+
714
+ def normalize_label(text)
715
+ text.gsub(/\s+/, " ").strip
288
716
  end
289
717
 
290
718
  def count_method_contexts(spec_content, mname, dual_access: false)
291
- count = count_contexts_for_method(spec_content, mname)
292
- prefixes = dual_access ? ["#", "."] : ["#"]
293
- prefixes.each do |prefix|
294
- describe_aliases_for("#{prefix}#{mname}").each do |alias_desc|
295
- alias_name = alias_desc.sub(/^[#.]/, "")
296
- count += count_contexts_for_method(spec_content, alias_name) if alias_name != mname
297
- end
719
+ base = count_contexts_for_method(spec_content, mname)
720
+ alias_method_names(mname, dual_access).reduce(base) do |coverage, alias_name|
721
+ merge_coverage(coverage, count_contexts_for_method(spec_content, alias_name))
298
722
  end
299
- count
723
+ end
724
+
725
+ def alias_method_names(mname, dual_access)
726
+ prefixes = dual_access ? ["#", "."] : ["#"]
727
+ prefixes.flat_map { |prefix| describe_aliases_for("#{prefix}#{mname}") }
728
+ .map { |alias_desc| alias_desc.sub(/^[#.]/, "") }
729
+ .uniq.reject { |alias_name| alias_name == mname }
300
730
  end
301
731
 
302
732
  def count_contexts_for_method(spec_content, method_name)
303
733
  method_pattern = Regexp.escape(method_name)
304
734
  result = parse_spec_content(spec_content, method_pattern)
305
735
 
306
- scenario_count(
736
+ scenarios = scenario_count(
307
737
  result.context_count, result.example_count,
308
738
  has_examples: result.has_examples, has_direct_examples: result.has_direct_examples
309
739
  )
740
+ SpecCoverage.new(scenarios, result.annotations)
310
741
  end
311
742
 
312
743
  # A test scenario is the smaller unit between "a context block" and "an
@@ -331,12 +762,8 @@ module RuboCop
331
762
  # rubocop:disable Metrics/MethodLength
332
763
  def parse_spec_content(spec_content, method_pattern) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
333
764
  in_method_block = false
334
- context_count = 0
335
- example_count = 0
336
- has_examples = false
337
- has_direct_examples = false
765
+ scan = ScanState.new(@covers_annotations)
338
766
  base_indent = 0
339
- child_indent = nil
340
767
 
341
768
  spec_content.each_line do |line|
342
769
  current_indent = line[/^\s*/].length
@@ -344,28 +771,28 @@ module RuboCop
344
771
  if matches_method_describe?(line, method_pattern)
345
772
  in_method_block = true
346
773
  base_indent = current_indent
347
- child_indent = nil
774
+ scan.reset_child_indent
348
775
  next
349
776
  end
350
777
 
351
778
  if in_method_block
779
+ scan.exit_annotated_context(current_indent)
780
+ next if scan.collect_standalone_annotation(line)
781
+ next if scan.inside_annotated_context? # collapsed: belongs to one annotated branch
782
+
352
783
  if exiting_block?(line, current_indent, base_indent)
353
784
  in_method_block = false
354
785
  elsif nested_context?(line)
355
- child_indent ||= current_indent
356
- context_count += 1
786
+ scan.count_context(line, current_indent)
357
787
  elsif nested_example?(line)
358
- has_examples = true
359
- example_count += 1
360
- child_indent ||= current_indent
361
- has_direct_examples = true if current_indent == child_indent
788
+ scan.count_example(line, current_indent)
362
789
  end
363
790
  elsif matches_context_pattern?(line, method_pattern)
364
- context_count += 1
791
+ scan.count_named_context(line)
365
792
  end
366
793
  end
367
794
 
368
- ParsedSpec.new(context_count, example_count, has_examples, has_direct_examples)
795
+ scan.to_parsed_spec
369
796
  end
370
797
  # rubocop:enable Metrics/MethodLength
371
798
 
@@ -529,15 +956,11 @@ module RuboCop
529
956
 
530
957
  lines = spec_content.lines
531
958
  describe_line_index = lines.index { |line| line.match?(describe_pattern) }
532
- return 0 unless describe_line_index
959
+ return SpecCoverage.new(0, []) unless describe_line_index
533
960
 
534
961
  # Count contexts/describes and examples at the top level (under class describe)
535
962
  base_indent = lines[describe_line_index].match(/^(\s*)/)[1].length
536
- context_count = 0
537
- example_count = 0
538
- has_examples = false
539
- has_direct_examples = false
540
- child_indent = nil
963
+ scan = ScanState.new(@covers_annotations)
541
964
 
542
965
  lines[(describe_line_index + 1)..].each do |line|
543
966
  indent = line.match(/^(\s*)/)[1].length
@@ -545,18 +968,21 @@ module RuboCop
545
968
 
546
969
  next unless indent > base_indent
547
970
 
971
+ scan.exit_annotated_context(indent)
972
+ next if scan.collect_standalone_annotation(line)
973
+ next if scan.inside_annotated_context?
974
+
548
975
  if line.match?(/^\s*(?:context|describe)\s+/)
549
- child_indent ||= indent
550
- context_count += 1
976
+ scan.count_context(line, indent)
551
977
  elsif line.match?(/^\s*(?:it|example|specify)\s+/)
552
- has_examples = true
553
- example_count += 1
554
- child_indent ||= indent
555
- has_direct_examples = true if indent == child_indent
978
+ scan.count_example(line, indent)
556
979
  end
557
980
  end
558
981
 
559
- scenario_count(context_count, example_count, has_examples:, has_direct_examples:)
982
+ parsed = scan.to_parsed_spec
983
+ scenarios = scenario_count(parsed.context_count, parsed.example_count,
984
+ has_examples: parsed.has_examples, has_direct_examples: parsed.has_direct_examples)
985
+ SpecCoverage.new(scenarios, parsed.annotations)
560
986
  end
561
987
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
562
988
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop_rspec_parity"
4
+
5
+ module RuboCop
6
+ module RSpecParity
7
+ # Lists the branches a method still needs covered and renders ready-to-paste
8
+ # `context '...' do # rspec_parity:covers <branch>` stubs. Backs the
9
+ # `rspec-parity-cover` executable, reusing SufficientContexts' branch analysis.
10
+ #
11
+ # When the spec file exists, only branches without a covering annotation are
12
+ # listed (and only for methods that aren't already fully covered). When it is
13
+ # missing, every branch is listed so a new spec can be bootstrapped.
14
+ class CoverageReporter
15
+ Entry = Struct.new(:method_name, :branches, :spec_exists, :notes, keyword_init: true)
16
+
17
+ # +line+ narrows the report to the single method enclosing that source
18
+ # line, so `rspec-parity-cover file.rb:20` targets one method.
19
+ def initialize(source_path, spec_path: nil, line: nil)
20
+ @source_path = source_path
21
+ @spec_path = spec_path || derive_spec_path(source_path)
22
+ @line = line
23
+ @cop = RuboCop::Cop::RSpecParity::SufficientContexts.new
24
+ end
25
+
26
+ def entries
27
+ ast = parse
28
+ return [] unless ast
29
+
30
+ spec_content = File.read(@spec_path) if File.exist?(@spec_path)
31
+ method_nodes(ast).filter_map { |node| entry_for(node, spec_content) }
32
+ end
33
+
34
+ def render
35
+ entries.map { |entry| render_entry(entry) }.join("\n\n")
36
+ end
37
+
38
+ private
39
+
40
+ def method_nodes(ast)
41
+ nodes = ast.each_node(:def, :defs)
42
+ return nodes unless @line
43
+
44
+ # The method enclosing the target line (innermost wins if nested).
45
+ nodes.select { |node| @line.between?(node.first_line, node.last_line) }
46
+ .max_by(&:first_line)
47
+ .then { |node| node ? [node] : [] }
48
+ end
49
+
50
+ def entry_for(node, spec_content)
51
+ method_name = @cop.send(:method_name, node)
52
+ return bootstrap_entry(node, method_name) unless spec_content
53
+
54
+ gap = @cop.coverage_gap(node, spec_content)
55
+ return unless gap
56
+
57
+ Entry.new(method_name: method_name, branches: gap.uncovered, spec_exists: true, notes: notes_for(gap))
58
+ end
59
+
60
+ def bootstrap_entry(node, method_name)
61
+ branches = @cop.all_branches(node)
62
+ return if branches.empty?
63
+
64
+ Entry.new(method_name: method_name, branches: branches, spec_exists: false, notes: [])
65
+ end
66
+
67
+ # FYI lines: what we already know is covered, and a caveat when there are
68
+ # plain examples we can't attribute to a branch.
69
+ def notes_for(gap)
70
+ notes = []
71
+ if gap.annotated.any?
72
+ notes << "# already annotated as covered: #{gap.annotated.map(&:annotation_token).join(", ")}"
73
+ end
74
+ if gap.unannotated_specs.positive?
75
+ notes << "# note: #{gap.unannotated_specs} example(s)/context(s) here aren't annotated, so we can't tell " \
76
+ "which branches they cover — all are listed below; drop or annotate the ones already tested."
77
+ end
78
+ notes
79
+ end
80
+
81
+ def render_entry(entry)
82
+ count = entry.branches.size
83
+ word = count == 1 ? "branch" : "branches"
84
+ qualifier = entry.spec_exists ? "uncovered " : ""
85
+ header = "# #{entry.method_name} — #{count} #{qualifier}#{word}:"
86
+ # Every branch has a unique label, so one stub per branch (one annotation
87
+ # covers exactly one branch).
88
+ stubs = entry.branches.map { |branch| context_stub(branch.annotation_token) }
89
+ [header, *entry.notes, stubs.join("\n\n")].join("\n")
90
+ end
91
+
92
+ def context_stub(token)
93
+ ["context '...' do", " # rspec_parity:covers #{token}", "", " it '...' do", " end", "end"].join("\n")
94
+ end
95
+
96
+ def parse
97
+ RuboCop::ProcessedSource.new(File.read(@source_path), RUBY_VERSION.to_f, @source_path).ast
98
+ end
99
+
100
+ def derive_spec_path(path)
101
+ path.sub(%r{(^|/)app/}, '\1spec/').sub(/\.rb\z/, "_spec.rb")
102
+ end
103
+ end
104
+ end
105
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module RSpecParity
5
- VERSION = "1.7.0"
5
+ VERSION = "2.0.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.7.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Povilas Jurcys
@@ -41,7 +41,8 @@ description: A RuboCop plugin that provides custom cops to ensure RSpec test cov
41
41
  parity and enforce RSpec best practices in your Ruby projects.
42
42
  email:
43
43
  - po.jurcys@gmail.com
44
- executables: []
44
+ executables:
45
+ - rspec-parity-cover
45
46
  extensions: []
46
47
  extra_rdoc_files: []
47
48
  files:
@@ -54,6 +55,8 @@ files:
54
55
  - Rakefile
55
56
  - config/default.yml
56
57
  - docs/superpowers/specs/2026-05-21-trace-single-use-private-methods-design.md
58
+ - docs/superpowers/specs/2026-06-15-covers-branch-annotations-design.md
59
+ - exe/rspec-parity-cover
57
60
  - lib/rubocop-rspec_parity.rb
58
61
  - lib/rubocop/cop/rspec_parity/department_config.rb
59
62
  - lib/rubocop/cop/rspec_parity/file_has_spec.rb
@@ -62,6 +65,7 @@ files:
62
65
  - lib/rubocop/cop/rspec_parity/spec_file_finder.rb
63
66
  - lib/rubocop/cop/rspec_parity/sufficient_contexts.rb
64
67
  - lib/rubocop/rspec_parity.rb
68
+ - lib/rubocop/rspec_parity/coverage_reporter.rb
65
69
  - lib/rubocop/rspec_parity/plugin.rb
66
70
  - lib/rubocop/rspec_parity/version.rb
67
71
  - lib/rubocop_rspec_parity.rb