rubocop-constable 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +260 -0
- data/config/default.yml +150 -0
- data/lib/rubocop/constable/inject.rb +23 -0
- data/lib/rubocop/constable/version.rb +7 -0
- data/lib/rubocop/constable.rb +31 -0
- data/lib/rubocop/cop/constable/case_scope.rb +134 -0
- data/lib/rubocop/cop/constable/helpers.rb +54 -0
- data/lib/rubocop/cop/constable/no_conditional_assertions.rb +132 -0
- data/lib/rubocop/cop/constable/no_network_without_stub.rb +143 -0
- data/lib/rubocop/cop/constable/no_retry_helpers.rb +153 -0
- data/lib/rubocop/cop/constable/no_shared_mutable_state.rb +148 -0
- data/lib/rubocop/cop/constable/no_sleep.rb +68 -0
- data/lib/rubocop/cop/constable/no_unfrozen_time.rb +156 -0
- data/lib/rubocop/cop/constable/unsafe_block_visibility.rb +98 -0
- data/lib/rubocop/cop/constable_cops.rb +13 -0
- data/lib/rubocop-constable.rb +17 -0
- metadata +95 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4c912fddff14925ab4b86ed12735d37dce21f02b6d3fdedbfd5d86a4cf842b05
|
|
4
|
+
data.tar.gz: e81930e509b082e3acc5014f47769a48c310032bfd7d9e905c4f46aa45ed36f7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0d48aedd13025539938ee08952e99da6833a8e40f73bfe5b7875d69d9126dd563deff331833613f6cf251c742d52d704d28773335922563f7b6edb32826c55ec
|
|
7
|
+
data.tar.gz: da375578877020106791b8ee52776866fb180b93eb683d7c568f87cc78847e3d7b4a72e751584df8beaeef8b8be014ed154f31107ca2a1efe800a0c74fbff7d0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `rubocop-constable` are documented here.
|
|
4
|
+
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - unreleased
|
|
7
|
+
|
|
8
|
+
Initial release. Seven cops, all enabled by default, all scoped to native
|
|
9
|
+
`Constable::Case` files:
|
|
10
|
+
|
|
11
|
+
- `Constable/NoSleep`
|
|
12
|
+
- `Constable/NoUnfrozenTime`
|
|
13
|
+
- `Constable/NoNetworkWithoutStub`
|
|
14
|
+
- `Constable/NoSharedMutableState`
|
|
15
|
+
- `Constable/NoConditionalAssertions`
|
|
16
|
+
- `Constable/NoRetryHelpers`
|
|
17
|
+
- `Constable/UnsafeBlockVisibility`
|
|
18
|
+
|
|
19
|
+
Cold cases (`Constable::ColdCase::RSpec`, `Constable::ColdCase::Minitest`) are
|
|
20
|
+
exempt from every one of them, by design.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ray Hughes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# rubocop-constable
|
|
2
|
+
|
|
3
|
+
The companion RuboCop extension for [Constable](https://github.com/Ray-Hughes/constable),
|
|
4
|
+
the opinionated Rails testing gem published as `constable-rails`.
|
|
5
|
+
|
|
6
|
+
Constable's second principle is that **nondeterminism is caught by the linter, not
|
|
7
|
+
discovered in CI**. This gem is that linter. Seven cops, all on by default, each one
|
|
8
|
+
aimed at a specific way a suite stops being trustworthy: a bare `sleep`, an unfrozen
|
|
9
|
+
clock, a live HTTP call, class-level shared state, an assertion hiding behind a
|
|
10
|
+
branch, a retry helper papering over a real failure, and an `unsafe` block that never
|
|
11
|
+
says why it exists.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Gemfile
|
|
17
|
+
group :development, :test do
|
|
18
|
+
gem "constable-rails"
|
|
19
|
+
gem "rubocop-constable", require: false
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
# .rubocop.yml
|
|
25
|
+
require:
|
|
26
|
+
- rubocop-constable
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That is the whole setup. Every cop arrives enabled, with `Include` globs already
|
|
30
|
+
pointed at case files — nothing to copy into your own config.
|
|
31
|
+
|
|
32
|
+
On RuboCop 1.72 and newer you can use the `plugins:` key instead of `require:`;
|
|
33
|
+
both work.
|
|
34
|
+
|
|
35
|
+
## Scope: native cases only
|
|
36
|
+
|
|
37
|
+
**Every cop here is scoped to native `Constable::Case` files.** A file whose class
|
|
38
|
+
inherits from `Constable::ColdCase::RSpec` or `Constable::ColdCase::Minitest` is
|
|
39
|
+
exempt from all of them.
|
|
40
|
+
|
|
41
|
+
That is a design decision, not an oversight. Constable's third principle is that
|
|
42
|
+
adoption never requires a rewrite: an existing RSpec or Minitest file becomes a cold
|
|
43
|
+
case with a one-line superclass swap and runs verbatim from day one. Linting those
|
|
44
|
+
files would punish exactly the people who took the on-ramp, and would turn a
|
|
45
|
+
zero-risk import into a thousand-offense wall. Cold cases already announce
|
|
46
|
+
themselves — the runner emits one warning per cold-case file, every run, in the
|
|
47
|
+
summary's own WARNINGS section — so they are visible without being blocked.
|
|
48
|
+
|
|
49
|
+
### The heuristic
|
|
50
|
+
|
|
51
|
+
For each file, in order:
|
|
52
|
+
|
|
53
|
+
1. **Cold case wins.** If any class in the file inherits from a constant with a
|
|
54
|
+
`ColdCase` segment (`Constable::ColdCase::RSpec`, `Constable::ColdCase::Minitest`,
|
|
55
|
+
or a project-local `ColdCase` base class), the file is exempt. This runs first, so
|
|
56
|
+
a cold case is never dragged back into scope by a path glob — and a file holding
|
|
57
|
+
both a cold case and a native case gets the benefit of the doubt.
|
|
58
|
+
2. **Native case.** If any class inherits from a constant whose last segment ends in
|
|
59
|
+
`Case` — `Constable::Case` itself, or the tier base classes the install generator
|
|
60
|
+
writes (`UnitCase`, `IntegrationCase`, `SystemCase`) — the file is in scope.
|
|
61
|
+
Matching the `Case` suffix rather than `Constable::Case` literally is deliberate:
|
|
62
|
+
the recommended pattern is subclassing a tier base class, so the literal
|
|
63
|
+
superclass of a real case file usually *isn't* `Constable::Case`.
|
|
64
|
+
3. **Path fallback.** Otherwise, a file under one of the cop's `Include` globs is
|
|
65
|
+
treated as in scope. This keeps the cops useful for a shared module living under
|
|
66
|
+
`test/cases/`, or a case file whose class definition a static parser can't see.
|
|
67
|
+
It is safe precisely because rule 1 already took cold cases off the table.
|
|
68
|
+
4. Anything else reports nothing.
|
|
69
|
+
|
|
70
|
+
The default `Include` globs are:
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
Include:
|
|
74
|
+
- 'test/cases/**/*.rb'
|
|
75
|
+
- 'spec/cases/**/*.rb'
|
|
76
|
+
- 'test/**/*_case.rb'
|
|
77
|
+
- 'spec/**/*_case.rb'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## The escape hatch
|
|
81
|
+
|
|
82
|
+
Six of the seven cops go quiet inside an `unsafe { }` block, because that is
|
|
83
|
+
Constable's designed valve for the genuine edge case — and it is never silent: the
|
|
84
|
+
runtime emits a warning with `file:line` and the adjacent comment for every
|
|
85
|
+
occurrence, every run.
|
|
86
|
+
|
|
87
|
+
The seventh cop, `Constable/UnsafeBlockVisibility`, is what keeps that honest. It
|
|
88
|
+
does not object to `unsafe` at all; it objects only to an `unsafe` that does not say
|
|
89
|
+
why.
|
|
90
|
+
|
|
91
|
+
## The cops
|
|
92
|
+
|
|
93
|
+
### `Constable/NoSleep`
|
|
94
|
+
|
|
95
|
+
A bare `sleep` is the most common way a suite becomes both slow and flaky at once: it
|
|
96
|
+
costs seconds on every green run and is still not long enough on the loaded CI box.
|
|
97
|
+
`sleep(...)` and `Kernel.sleep(...)` are flagged; `some_object.sleep` is not.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# bad
|
|
101
|
+
investigate("expires the session") { sleep(0.2); attest(session).to be_expired }
|
|
102
|
+
|
|
103
|
+
# good
|
|
104
|
+
investigate("expires the session") { travel_to(2.hours.from_now); attest(session).to be_expired }
|
|
105
|
+
|
|
106
|
+
# good -- the timeout is the subject, and it says so
|
|
107
|
+
unsafe { sleep(0.1) } # testing an actual timeout path, not a code smell
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### `Constable/NoUnfrozenTime`
|
|
111
|
+
|
|
112
|
+
Reading the wall clock makes a test a function of when it runs — invisible until the
|
|
113
|
+
suite goes red at midnight, on the last day of a month, or in the one CI region that
|
|
114
|
+
isn't UTC. Flags `Time.now`, `Time.current`, `Time.zone.now`, `Date.today`,
|
|
115
|
+
`Date.current`, `DateTime.now` and `DateTime.current`.
|
|
116
|
+
|
|
117
|
+
Satisfied by any of: a lexically enclosing `freeze_time { }` / `travel_to(...) { }`
|
|
118
|
+
block; a bare `freeze_time` / `travel_to` earlier in the same `investigate`; a
|
|
119
|
+
`freeze_time` / `travel_to` in any `briefing` in the file (a briefing runs before
|
|
120
|
+
every investigation, so it covers all of them); being the argument to a freeze helper
|
|
121
|
+
(`travel_to(Time.now + 1.day)` is fine); or `unsafe { }`.
|
|
122
|
+
|
|
123
|
+
| Option | Default |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `ForbiddenCalls` | `Time.now`, `Time.current`, `Time.zone.now`, `Date.today`, `Date.current`, `DateTime.now`, `DateTime.current` |
|
|
126
|
+
| `FreezeHelpers` | `freeze_time`, `travel_to` |
|
|
127
|
+
|
|
128
|
+
### `Constable/NoNetworkWithoutStub`
|
|
129
|
+
|
|
130
|
+
A case that talks to the real network is not a test of your code, it is a test of
|
|
131
|
+
somebody else's uptime. If the file never calls `stub_network!`, HTTP entry points are
|
|
132
|
+
flagged: `Net::HTTP`, `HTTParty`, `Faraday`, `RestClient`, `Excon`, `Typhoeus`,
|
|
133
|
+
`HTTPClient`, `HTTPX`, `HTTP`, `Curl`, `Patron`, `Mechanize`, `OpenURI`, `Down`, plus
|
|
134
|
+
`URI.open` / `URI.read` and open-uri's `open("https://...")`.
|
|
135
|
+
|
|
136
|
+
One `stub_network!` anywhere in the file — normally in a `briefing`, which runs before
|
|
137
|
+
every investigation — silences the cop for the whole case.
|
|
138
|
+
|
|
139
|
+
The cop matches calls made *directly* on a known entry-point constant, so a chain like
|
|
140
|
+
`Faraday.new(url: url).get("/profile")` reports once, at its entry point. A connection
|
|
141
|
+
object handed around by a `witness` is out of reach of a static check; `stub_network!`
|
|
142
|
+
itself catches that one at runtime.
|
|
143
|
+
|
|
144
|
+
| Option | Default |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| `StubHelpers` | `stub_network!` |
|
|
147
|
+
| `HttpConstants` | the list above |
|
|
148
|
+
|
|
149
|
+
### `Constable/NoSharedMutableState`
|
|
150
|
+
|
|
151
|
+
Isolation is non-negotiable in native code. A class variable or global written from
|
|
152
|
+
inside a case survives the investigation that wrote it, so the suite's result depends
|
|
153
|
+
on its order — and the failure lands on whichever test ran second, not on the one that
|
|
154
|
+
caused it. This is why Constable has no `before(:all)`.
|
|
155
|
+
|
|
156
|
+
Reading `@@x` or `$x` is fine. Writing is not: assignment, `||=`/`+=`, `<<`, `push`,
|
|
157
|
+
`merge!`, `[]=`, and anything else ending in `!` or `=`. Use `witness` for per-test
|
|
158
|
+
memoized data, `briefing` for per-test setup, and an ordinary instance variable for
|
|
159
|
+
whatever an investigation needs to remember about itself.
|
|
160
|
+
|
|
161
|
+
### `Constable/NoConditionalAssertions`
|
|
162
|
+
|
|
163
|
+
An assertion behind a branch is an assertion that might not run. The test goes green
|
|
164
|
+
either way, so nobody notices when the interesting branch stops being taken — the case
|
|
165
|
+
quietly stops testing anything while still counting itself as coverage.
|
|
166
|
+
|
|
167
|
+
Flags `if`, `unless`, modifier forms, ternaries, `case/when` and `case/in` whose
|
|
168
|
+
branch bodies contain `attest` or an `assert_*`/`refute_*` call. Only the outermost
|
|
169
|
+
conditional is reported, so a nested tree yields one offense, not five. The fix is to
|
|
170
|
+
split the branches into separate `investigate` blocks — or separate `docket` blocks —
|
|
171
|
+
so each one asserts unconditionally and each one's name says which world it describes.
|
|
172
|
+
|
|
173
|
+
| Option | Default |
|
|
174
|
+
| --- | --- |
|
|
175
|
+
| `AssertionMethods` | `attest` |
|
|
176
|
+
| `AssertionPrefixes` | `assert`, `refute` |
|
|
177
|
+
|
|
178
|
+
### `Constable/NoRetryHelpers`
|
|
179
|
+
|
|
180
|
+
Retrying is how a flaky test hides. It turns a test that fails some of the time into
|
|
181
|
+
one that passes most of the time — strictly worse, because now nobody is looking at
|
|
182
|
+
it.
|
|
183
|
+
|
|
184
|
+
Flags the `retry` keyword, the helper calls `wait_for`, `eventually`, `with_retries`,
|
|
185
|
+
`try_again`, `retry_until`, `retry_on_failure`, `poll_until`, `keep_trying`, and
|
|
186
|
+
`loop`/`while`/`until` bodies that poll with `sleep`. The loop heuristic is
|
|
187
|
+
deliberately narrow — a `while` doing real work is left alone.
|
|
188
|
+
|
|
189
|
+
Constable's real answer for genuine flakiness is **warrants**: the runner reruns a
|
|
190
|
+
failing test in isolation, records what it finds in the blotter, and reports it in its
|
|
191
|
+
own summary section. Visible and counted, rather than swallowed by a
|
|
192
|
+
`rescue; retry; end`.
|
|
193
|
+
|
|
194
|
+
`wait_for(timeout:, interval:)` does exist in the runtime DSL for genuinely async work
|
|
195
|
+
— but it is legal only inside `unsafe { }`, and raises outside it. This cop enforces
|
|
196
|
+
the same rule statically.
|
|
197
|
+
|
|
198
|
+
| Option | Default |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| `RetryHelpers` | the list above |
|
|
201
|
+
|
|
202
|
+
### `Constable/UnsafeBlockVisibility`
|
|
203
|
+
|
|
204
|
+
Every escape hatch is visible. This cop fails **only** when an `unsafe` block has
|
|
205
|
+
nothing next to it explaining why. Satisfied by a trailing comment on the same line, a
|
|
206
|
+
comment on the line immediately above, or a literal reason argument. That text is what
|
|
207
|
+
the runner quotes in the run summary:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
⚠ spec/controllers/sessions_case.rb:44
|
|
211
|
+
unsafe { sleep(0.1) } — "testing an actual timeout path, not a code smell"
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
# bad
|
|
216
|
+
unsafe { sleep(0.1) }
|
|
217
|
+
|
|
218
|
+
# good
|
|
219
|
+
unsafe { sleep(0.1) } # testing an actual timeout path, not a code smell
|
|
220
|
+
|
|
221
|
+
# good
|
|
222
|
+
# testing an actual timeout path, not a code smell
|
|
223
|
+
unsafe do
|
|
224
|
+
sleep(0.1)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# good
|
|
228
|
+
unsafe("testing an actual timeout path, not a code smell") { sleep(0.1) }
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
| Option | Default |
|
|
232
|
+
| --- | --- |
|
|
233
|
+
| `AllowReasonArgument` | `true` — set to `false` to insist on a comment |
|
|
234
|
+
|
|
235
|
+
## Autocorrection
|
|
236
|
+
|
|
237
|
+
None of these cops autocorrect. Every one of them is reporting a decision a human has
|
|
238
|
+
to make — which investigation to split the branch into, whether the clock or the
|
|
239
|
+
network is genuinely the subject, whether the retry was hiding a real bug. A machine
|
|
240
|
+
guessing at that would be worse than the offense.
|
|
241
|
+
|
|
242
|
+
## Development
|
|
243
|
+
|
|
244
|
+
The suite is plain Minitest, matching the rest of the Constable repo:
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
bundle install
|
|
248
|
+
bundle exec rake test
|
|
249
|
+
# or, without bundler:
|
|
250
|
+
ruby -Ilib -Itest test/no_sleep_test.rb
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Each cop is exercised by building a `RuboCop::ProcessedSource` and running it through
|
|
254
|
+
a `Commissioner` holding just that cop; `test/integration_test.rb` runs the whole
|
|
255
|
+
department through a real `Team` over real files, which is what proves the `Include`
|
|
256
|
+
filtering and the cold-case exemption end to end.
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
|
|
260
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/config/default.yml
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Defaults for rubocop-constable.
|
|
2
|
+
#
|
|
3
|
+
# Every cop here is scoped to native `Constable::Case` files. A file whose class
|
|
4
|
+
# inherits from `Constable::ColdCase::RSpec` or `Constable::ColdCase::Minitest`
|
|
5
|
+
# has explicitly opted out of native rules and is exempt from all of them, no
|
|
6
|
+
# matter which of the `Include` globs below it happens to sit under.
|
|
7
|
+
|
|
8
|
+
Constable:
|
|
9
|
+
Enabled: true
|
|
10
|
+
DocumentationBaseURL: https://github.com/Ray-Hughes/constable/blob/main/rubocop-constable/README.md
|
|
11
|
+
|
|
12
|
+
Constable/NoConditionalAssertions:
|
|
13
|
+
Description: >-
|
|
14
|
+
Do not put `if`/`unless`/ternary/`case` branching around `attest` or
|
|
15
|
+
`assert_*` calls. An assertion behind a branch may never run, so the case
|
|
16
|
+
passes whichever way the branch falls.
|
|
17
|
+
Enabled: true
|
|
18
|
+
VersionAdded: '0.1.0'
|
|
19
|
+
SafeAutoCorrect: false
|
|
20
|
+
AssertionMethods:
|
|
21
|
+
- attest
|
|
22
|
+
AssertionPrefixes:
|
|
23
|
+
- assert
|
|
24
|
+
- refute
|
|
25
|
+
Include:
|
|
26
|
+
- 'test/cases/**/*.rb'
|
|
27
|
+
- 'spec/cases/**/*.rb'
|
|
28
|
+
- 'test/**/*_case.rb'
|
|
29
|
+
- 'spec/**/*_case.rb'
|
|
30
|
+
|
|
31
|
+
Constable/NoNetworkWithoutStub:
|
|
32
|
+
Description: >-
|
|
33
|
+
Do not make real HTTP calls from a case that never calls `stub_network!`.
|
|
34
|
+
A test that depends on somebody else's uptime is not testing your code.
|
|
35
|
+
Enabled: true
|
|
36
|
+
VersionAdded: '0.1.0'
|
|
37
|
+
SafeAutoCorrect: false
|
|
38
|
+
StubHelpers:
|
|
39
|
+
- stub_network!
|
|
40
|
+
HttpConstants:
|
|
41
|
+
- Net::HTTP
|
|
42
|
+
- Net::HTTPS
|
|
43
|
+
- HTTParty
|
|
44
|
+
- Faraday
|
|
45
|
+
- RestClient
|
|
46
|
+
- Excon
|
|
47
|
+
- Typhoeus
|
|
48
|
+
- HTTPClient
|
|
49
|
+
- HTTPX
|
|
50
|
+
- HTTP
|
|
51
|
+
- Curl
|
|
52
|
+
- Patron
|
|
53
|
+
- Mechanize
|
|
54
|
+
- OpenURI
|
|
55
|
+
- Down
|
|
56
|
+
Include:
|
|
57
|
+
- 'test/cases/**/*.rb'
|
|
58
|
+
- 'spec/cases/**/*.rb'
|
|
59
|
+
- 'test/**/*_case.rb'
|
|
60
|
+
- 'spec/**/*_case.rb'
|
|
61
|
+
|
|
62
|
+
Constable/NoRetryHelpers:
|
|
63
|
+
Description: >-
|
|
64
|
+
Do not retry. `retry`, `eventually`, `wait_for` and `sleep`-driven polling
|
|
65
|
+
loops all hide a flaky test instead of fixing it -- that is what warrants
|
|
66
|
+
are for.
|
|
67
|
+
Enabled: true
|
|
68
|
+
VersionAdded: '0.1.0'
|
|
69
|
+
SafeAutoCorrect: false
|
|
70
|
+
RetryHelpers:
|
|
71
|
+
- wait_for
|
|
72
|
+
- eventually
|
|
73
|
+
- with_retries
|
|
74
|
+
- try_again
|
|
75
|
+
- retry_until
|
|
76
|
+
- retry_on_failure
|
|
77
|
+
- poll_until
|
|
78
|
+
- keep_trying
|
|
79
|
+
Include:
|
|
80
|
+
- 'test/cases/**/*.rb'
|
|
81
|
+
- 'spec/cases/**/*.rb'
|
|
82
|
+
- 'test/**/*_case.rb'
|
|
83
|
+
- 'spec/**/*_case.rb'
|
|
84
|
+
|
|
85
|
+
Constable/NoSharedMutableState:
|
|
86
|
+
Description: >-
|
|
87
|
+
Do not assign or mutate class variables or globals inside a case. State that
|
|
88
|
+
outlives an investigation makes the suite order-dependent, which is the
|
|
89
|
+
hardest class of flake to debug. Use `witness` or `briefing`.
|
|
90
|
+
Enabled: true
|
|
91
|
+
VersionAdded: '0.1.0'
|
|
92
|
+
SafeAutoCorrect: false
|
|
93
|
+
Include:
|
|
94
|
+
- 'test/cases/**/*.rb'
|
|
95
|
+
- 'spec/cases/**/*.rb'
|
|
96
|
+
- 'test/**/*_case.rb'
|
|
97
|
+
- 'spec/**/*_case.rb'
|
|
98
|
+
|
|
99
|
+
Constable/NoSleep:
|
|
100
|
+
Description: >-
|
|
101
|
+
Do not use a bare `sleep` in a case. It costs seconds on every green run and
|
|
102
|
+
is still not long enough on the red one. Wrap it in `unsafe { }` when the
|
|
103
|
+
delay itself is under test.
|
|
104
|
+
Enabled: true
|
|
105
|
+
VersionAdded: '0.1.0'
|
|
106
|
+
SafeAutoCorrect: false
|
|
107
|
+
Include:
|
|
108
|
+
- 'test/cases/**/*.rb'
|
|
109
|
+
- 'spec/cases/**/*.rb'
|
|
110
|
+
- 'test/**/*_case.rb'
|
|
111
|
+
- 'spec/**/*_case.rb'
|
|
112
|
+
|
|
113
|
+
Constable/NoUnfrozenTime:
|
|
114
|
+
Description: >-
|
|
115
|
+
Do not read the wall clock in a case without freezing it first. Call
|
|
116
|
+
`freeze_time` or `travel_to`, otherwise the test is a function of when it
|
|
117
|
+
runs.
|
|
118
|
+
Enabled: true
|
|
119
|
+
VersionAdded: '0.1.0'
|
|
120
|
+
SafeAutoCorrect: false
|
|
121
|
+
ForbiddenCalls:
|
|
122
|
+
- Time.now
|
|
123
|
+
- Time.current
|
|
124
|
+
- Time.zone.now
|
|
125
|
+
- Date.today
|
|
126
|
+
- Date.current
|
|
127
|
+
- DateTime.now
|
|
128
|
+
- DateTime.current
|
|
129
|
+
FreezeHelpers:
|
|
130
|
+
- freeze_time
|
|
131
|
+
- travel_to
|
|
132
|
+
Include:
|
|
133
|
+
- 'test/cases/**/*.rb'
|
|
134
|
+
- 'spec/cases/**/*.rb'
|
|
135
|
+
- 'test/**/*_case.rb'
|
|
136
|
+
- 'spec/**/*_case.rb'
|
|
137
|
+
|
|
138
|
+
Constable/UnsafeBlockVisibility:
|
|
139
|
+
Description: >-
|
|
140
|
+
An `unsafe` block must say why it exists -- a trailing comment, a comment on
|
|
141
|
+
the line above, or a literal reason argument. Every escape hatch is visible.
|
|
142
|
+
Enabled: true
|
|
143
|
+
VersionAdded: '0.1.0'
|
|
144
|
+
SafeAutoCorrect: false
|
|
145
|
+
AllowReasonArgument: true
|
|
146
|
+
Include:
|
|
147
|
+
- 'test/cases/**/*.rb'
|
|
148
|
+
- 'spec/cases/**/*.rb'
|
|
149
|
+
- 'test/**/*_case.rb'
|
|
150
|
+
- 'spec/**/*_case.rb'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Constable
|
|
5
|
+
# Merges this extension's `config/default.yml` into RuboCop's own default
|
|
6
|
+
# configuration, so users get sensible defaults for every `Constable/*` cop
|
|
7
|
+
# from `require: rubocop-constable` alone -- no copy-pasting of departments
|
|
8
|
+
# into their `.rubocop.yml`.
|
|
9
|
+
#
|
|
10
|
+
# This is the standard RuboCop extension injection pattern, as used by
|
|
11
|
+
# rubocop-rails, rubocop-rspec and friends.
|
|
12
|
+
module Inject
|
|
13
|
+
def self.defaults!
|
|
14
|
+
path = ::RuboCop::Constable.config_default.to_s
|
|
15
|
+
hash = ::RuboCop::ConfigLoader.send(:load_yaml_configuration, path)
|
|
16
|
+
config = ::RuboCop::Config.new(hash, path).tap(&:make_excludes_absolute)
|
|
17
|
+
puts "configuration from #{path}" if ::RuboCop::ConfigLoader.debug?
|
|
18
|
+
config = ::RuboCop::ConfigLoader.merge_with_default(config, path)
|
|
19
|
+
::RuboCop::ConfigLoader.instance_variable_set(:@default_configuration, config)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
# RuboCop cops for Constable, the opinionated Rails testing gem.
|
|
7
|
+
#
|
|
8
|
+
# Constable's promise is that fast and non-flaky are structural properties of a
|
|
9
|
+
# suite, not a matter of discipline. These cops are the half of that promise that
|
|
10
|
+
# runs before the suite does: nondeterminism is caught by the linter, not
|
|
11
|
+
# discovered in CI.
|
|
12
|
+
module Constable
|
|
13
|
+
PROJECT_ROOT = Pathname.new(__dir__).parent.parent.expand_path.freeze
|
|
14
|
+
CONFIG_DEFAULT = PROJECT_ROOT.join("config", "default.yml").freeze
|
|
15
|
+
CONFIG = YAML.safe_load(CONFIG_DEFAULT.read, permitted_classes: [Regexp, Symbol]).freeze
|
|
16
|
+
|
|
17
|
+
private_constant :CONFIG_DEFAULT, :PROJECT_ROOT
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# @return [Pathname] the gem's own root, used to locate config/default.yml.
|
|
21
|
+
def project_root
|
|
22
|
+
PROJECT_ROOT
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Pathname] path to the shipped default configuration.
|
|
26
|
+
def config_default
|
|
27
|
+
CONFIG_DEFAULT
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Constable
|
|
6
|
+
# Scopes every `Constable/*` cop to **native** `Constable::Case` files.
|
|
7
|
+
#
|
|
8
|
+
# Constable's whole adoption story is that an existing RSpec or Minitest file
|
|
9
|
+
# can run untouched from day one as a *cold case*. A cold case has explicitly
|
|
10
|
+
# opted out of native rules, so linting it would punish exactly the people who
|
|
11
|
+
# took the on-ramp. Cold-case files are therefore exempt by design, not by
|
|
12
|
+
# oversight -- their escape hatch is already reported once per file, every run,
|
|
13
|
+
# by the runner itself.
|
|
14
|
+
#
|
|
15
|
+
# == The heuristic
|
|
16
|
+
#
|
|
17
|
+
# In order, for each file:
|
|
18
|
+
#
|
|
19
|
+
# 1. *Cold case wins.* If any class in the file inherits from a constant with a
|
|
20
|
+
# +ColdCase+ segment (+Constable::ColdCase::RSpec+, +Constable::ColdCase::Minitest+,
|
|
21
|
+
# or a project-local +ColdCase+ base class), the file is exempt. This check runs
|
|
22
|
+
# first so a cold case is never dragged back in by a path glob.
|
|
23
|
+
# 2. *Native case.* If any class inherits from a constant whose last segment ends in
|
|
24
|
+
# +Case+ -- +Constable::Case+ itself, or the tier base classes the install
|
|
25
|
+
# generator writes (+UnitCase+, +IntegrationCase+, +SystemCase+) -- the file is in
|
|
26
|
+
# scope. Matching on the +Case+ suffix rather than on +Constable::Case+ literally is
|
|
27
|
+
# deliberate: SPEC.md recommends subclassing a tier base class, so the literal
|
|
28
|
+
# superclass of a real case file usually *isn't* +Constable::Case+.
|
|
29
|
+
# 3. *Path fallback.* Otherwise, a file sitting under one of the cop's own +Include+
|
|
30
|
+
# globs (+test/cases/**/*.rb+ and friends) is treated as in scope. This keeps the
|
|
31
|
+
# cops useful for a shared module under +test/cases/+ or a case file whose class
|
|
32
|
+
# definition the parser can't see, and it is safe precisely because rule 1 already
|
|
33
|
+
# took cold cases off the table.
|
|
34
|
+
# 4. Anything else is out of scope and reports nothing.
|
|
35
|
+
#
|
|
36
|
+
# Cops mix this in and guard their handlers with +#constable_case_file?+, rather than
|
|
37
|
+
# overriding +#relevant_file?+, so the exemption is enforced identically whether the
|
|
38
|
+
# cop runs under a full RuboCop team or a bare Commissioner.
|
|
39
|
+
module CaseScope
|
|
40
|
+
COLD_CASE_SEGMENT = "ColdCase"
|
|
41
|
+
NATIVE_CASE_SUFFIX = /Case\z/.freeze
|
|
42
|
+
|
|
43
|
+
def on_new_investigation
|
|
44
|
+
@constable_case_file = nil
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] whether this file is a native Constable case file.
|
|
49
|
+
def constable_case_file?
|
|
50
|
+
return @constable_case_file unless @constable_case_file.nil?
|
|
51
|
+
|
|
52
|
+
@constable_case_file = compute_constable_case_file
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] whether the file opted out of native rules.
|
|
56
|
+
def cold_case_file?
|
|
57
|
+
superclass_names.any? { |name| cold_case_superclass?(name) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def compute_constable_case_file
|
|
63
|
+
names = superclass_names
|
|
64
|
+
return false if names.any? { |name| cold_case_superclass?(name) }
|
|
65
|
+
return true if names.any? { |name| native_case_superclass?(name) }
|
|
66
|
+
|
|
67
|
+
include_path_scope?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Every `class Foo < Bar` superclass constant in the file, as dotless
|
|
71
|
+
# `::`-joined strings. Non-constant superclasses (`Class.new(x)`, dynamic
|
|
72
|
+
# superclass expressions) are ignored -- they can't be resolved statically.
|
|
73
|
+
def superclass_names
|
|
74
|
+
@superclass_names ||= begin
|
|
75
|
+
ast = processed_source&.ast
|
|
76
|
+
if ast.nil?
|
|
77
|
+
[]
|
|
78
|
+
else
|
|
79
|
+
ast.each_node(:class).filter_map { |node| constant_name(node.parent_class) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def constant_name(node)
|
|
85
|
+
return nil unless node.respond_to?(:const_type?) && node.const_type?
|
|
86
|
+
|
|
87
|
+
name = node.const_name
|
|
88
|
+
name && name.sub(/\A::/, "")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cold_case_superclass?(name)
|
|
92
|
+
name.split("::").include?(COLD_CASE_SEGMENT)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def native_case_superclass?(name)
|
|
96
|
+
segments = name.split("::")
|
|
97
|
+
return false if segments.include?(COLD_CASE_SEGMENT)
|
|
98
|
+
|
|
99
|
+
NATIVE_CASE_SUFFIX.match?(segments.last.to_s)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def include_path_scope?
|
|
103
|
+
path = current_file_path
|
|
104
|
+
return false if path.nil?
|
|
105
|
+
|
|
106
|
+
patterns = Array(cop_config["Include"])
|
|
107
|
+
return false if patterns.empty?
|
|
108
|
+
|
|
109
|
+
path_candidates(path).any? do |candidate|
|
|
110
|
+
patterns.any? { |pattern| ::RuboCop::PathUtil.match_path?(pattern, candidate) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def current_file_path
|
|
115
|
+
path = processed_source&.file_path
|
|
116
|
+
return nil if path.nil? || path.empty?
|
|
117
|
+
# `(string)` -- source handed to the cop without a path at all.
|
|
118
|
+
return nil if path.start_with?("(")
|
|
119
|
+
|
|
120
|
+
path
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def path_candidates(path)
|
|
124
|
+
candidates = [path, path.delete_prefix("#{Dir.pwd}/")]
|
|
125
|
+
begin
|
|
126
|
+
candidates << config.path_relative_to_config(path)
|
|
127
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
128
|
+
end
|
|
129
|
+
candidates.compact.uniq
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|