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 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).
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Constable
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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