rubocop-rspec_parity 1.7.0 → 2.0.1
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 +8 -0
- data/README.md +70 -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/public_method_has_spec.rb +22 -2
- 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: c258389ca56666df76d2865c8fee4ecca3dec132d02b7295d6e245747dbc4896
|
|
4
|
+
data.tar.gz: 02612dbd491de04d9819742f61bcdb47933837a6b3211d9888765e9eea94ef36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6fb8521af37136c1449fc5292fcc7658966f42f538197081d5a9ff53985dc4901c70e5e17c7a93c3204c8be0dc179c10c909ec2499ced966251d7ffc124e14e9
|
|
7
|
+
data.tar.gz: d18fc9c48f6063187c20472ada669bbf374192687dce5ae7e91d5bfceb2dc7b282340e623e72165363ad398d4e8cb43251dc8ef7820306cab73279ef71c92c41
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [2.0.1] - 2026-06-18
|
|
4
|
+
|
|
5
|
+
Fixed: `PublicMethodHasSpec` relaxed validation for single-public-method classes (e.g. service objects) no longer passes when the spec's only method-style `describe`/`context` covers a different method — if a method describe is present it must describe the actual public method.
|
|
6
|
+
|
|
7
|
+
## [2.0.0] - 2026-06-18
|
|
8
|
+
|
|
9
|
+
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`).
|
|
10
|
+
|
|
3
11
|
## [1.7.0] - 2026-06-11
|
|
4
12
|
|
|
5
13
|
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,76 @@ 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. A mistyped label is reported with a did-you-mean suggestion.
|
|
253
|
+
|
|
254
|
+
Each annotation covers exactly one branch, and the normal expectation is one annotation per context — one context, one scenario, one branch. In rare cases a single context genuinely exercises several branches at once; you can then list more than one branch on it, either as separate comments or with a `;`-separated list:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
context 'when fully privileged' do
|
|
258
|
+
# rspec_parity:covers user.admin?
|
|
259
|
+
# rspec_parity:covers user.staff?
|
|
260
|
+
it { is_expected.to be_allowed }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# or, equivalently, on one line:
|
|
264
|
+
context 'when fully privileged' do # rspec_parity:covers user.admin?; user.staff?
|
|
265
|
+
it { is_expected.to be_allowed }
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Reach for this only when the branches really are covered together — multiple annotations on a context that only tests one path inflate coverage and defeat the point of the check.
|
|
270
|
+
|
|
271
|
+
Long conditions can push the comment past `Layout/LineLength`; exempt these comments rather than editing the label:
|
|
272
|
+
|
|
273
|
+
```yaml
|
|
274
|
+
Layout/LineLength:
|
|
275
|
+
AllowedPatterns:
|
|
276
|
+
- 'rspec_parity:covers'
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
##### Listing every uncovered branch (`rspec-parity-cover`)
|
|
280
|
+
|
|
281
|
+
To get all the gaps for a method at once as ready-to-paste stubs:
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
$ bundle exec rspec-parity-cover app/services/classifier.rb
|
|
285
|
+
|
|
286
|
+
# classify — 2 uncovered branches:
|
|
287
|
+
context '...' do
|
|
288
|
+
# rspec_parity:covers user.staff?
|
|
289
|
+
|
|
290
|
+
it '...' do
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
context '...' do
|
|
295
|
+
# rspec_parity:covers else of user.admin?
|
|
296
|
+
|
|
297
|
+
it '...' do
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
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
303
|
|
|
234
304
|
## Assumptions
|
|
235
305
|
|
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)
|
|
@@ -219,8 +219,9 @@ module RuboCop
|
|
|
219
219
|
|
|
220
220
|
# Check if relaxed validation applies
|
|
221
221
|
if matches_skip_path? && count_public_methods(node) == 1
|
|
222
|
-
# For single-method classes in configured paths,
|
|
223
|
-
|
|
222
|
+
# For single-method classes in configured paths, the method describe is optional —
|
|
223
|
+
# but if one is present, it must describe the actual public method, not a private/random one.
|
|
224
|
+
return if relaxed_spec_valid?(spec_paths, class_name, method_name, instance_method, flexible_prefix)
|
|
224
225
|
elsif spec_paths.any? do |sp|
|
|
225
226
|
spec_covers_method?(sp, method_name, instance_method, flexible_prefix: flexible_prefix)
|
|
226
227
|
end
|
|
@@ -233,6 +234,25 @@ module RuboCop
|
|
|
233
234
|
end
|
|
234
235
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
235
236
|
|
|
237
|
+
# Relaxed validation for single-public-method classes in skip paths.
|
|
238
|
+
# Passes when the spec describes the class with examples AND either has no
|
|
239
|
+
# method-style describe/context block at all, or has one that covers the public method.
|
|
240
|
+
def relaxed_spec_valid?(spec_paths, class_name, method_name, instance_method, flexible_prefix)
|
|
241
|
+
return false unless spec_paths.any? { |sp| spec_has_examples?(sp, class_name) }
|
|
242
|
+
return true unless spec_paths.any? { |sp| spec_has_method_describe?(sp) }
|
|
243
|
+
|
|
244
|
+
spec_paths.any? do |sp|
|
|
245
|
+
spec_covers_method?(sp, method_name, instance_method, flexible_prefix: flexible_prefix)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Detects a method-style block, e.g. describe '#foo' / context '.bar'
|
|
250
|
+
# (the char right after the quote is a `#` or `.` prefix), as opposed to a
|
|
251
|
+
# plain descriptive string or a class constant.
|
|
252
|
+
def spec_has_method_describe?(spec_path)
|
|
253
|
+
File.read(spec_path).match?(/(?:describe|context)\s+['"][#.][^'"]+['"]/)
|
|
254
|
+
end
|
|
255
|
+
|
|
236
256
|
def spec_covers_method?(spec_path, method_name, instance_method, flexible_prefix: false)
|
|
237
257
|
return true if method_tested_in_spec?(spec_path, method_name, instance_method)
|
|
238
258
|
return true if flexible_prefix && method_tested_in_spec?(spec_path, method_name, !instance_method)
|