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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +53 -0
- data/config/default.yml +1 -0
- data/docs/superpowers/specs/2026-06-15-covers-branch-annotations-design.md +224 -0
- data/exe/rspec-parity-cover +25 -0
- data/lib/rubocop/cop/rspec_parity/private_method_call_graph.rb +9 -1
- data/lib/rubocop/cop/rspec_parity/sufficient_contexts.rb +524 -98
- data/lib/rubocop/rspec_parity/coverage_reporter.rb +105 -0
- data/lib/rubocop/rspec_parity/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2bda6248c84f44ce1e039baac11b9e6b3075c3feb13bb4e06a9e804ff26436a6
|
|
4
|
+
data.tar.gz: ebc0e696c427bd033637609d7f1ad4aaa66c603a9ca1ed82adb09d2857c99161
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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]
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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)
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
269
|
-
node.method?(:&) || node.method?(:|)
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
context_count += 1
|
|
786
|
+
scan.count_context(line, current_indent)
|
|
357
787
|
elsif nested_example?(line)
|
|
358
|
-
|
|
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
|
-
|
|
791
|
+
scan.count_named_context(line)
|
|
365
792
|
end
|
|
366
793
|
end
|
|
367
794
|
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
context_count += 1
|
|
976
|
+
scan.count_context(line, indent)
|
|
551
977
|
elsif line.match?(/^\s*(?:it|example|specify)\s+/)
|
|
552
|
-
|
|
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
|
-
|
|
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
|
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:
|
|
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
|