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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Constable
|
|
6
|
+
# Reading the wall clock inside a case makes the test a function of when it
|
|
7
|
+
# runs. Most of the time that is invisible; then the suite goes red at
|
|
8
|
+
# midnight, on the last day of a month, or in the one CI region that isn't
|
|
9
|
+
# UTC. Freeze the clock and the whole class of failure disappears.
|
|
10
|
+
#
|
|
11
|
+
# The cop is satisfied by any of:
|
|
12
|
+
#
|
|
13
|
+
# * a lexically enclosing `freeze_time { }` / `travel_to(...) { }` block,
|
|
14
|
+
# * a bare `freeze_time` / `travel_to` earlier in the same `investigate`,
|
|
15
|
+
# * a `freeze_time` / `travel_to` in any `briefing` in the file (a briefing
|
|
16
|
+
# runs before every investigation, so it covers all of them),
|
|
17
|
+
# * being the argument to a freeze helper (`freeze_time(Time.now)` is fine),
|
|
18
|
+
# * the `unsafe { }` escape hatch.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# # bad
|
|
22
|
+
# investigate "stamps the record" do
|
|
23
|
+
# attest(record.created_at).to eq(Time.now)
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # good
|
|
27
|
+
# investigate "stamps the record" do
|
|
28
|
+
# freeze_time
|
|
29
|
+
# attest(record.created_at).to eq(Time.now)
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # good
|
|
33
|
+
# briefing { freeze_time }
|
|
34
|
+
#
|
|
35
|
+
# investigate "stamps the record" do
|
|
36
|
+
# attest(record.created_at).to eq(Time.current)
|
|
37
|
+
# end
|
|
38
|
+
class NoUnfrozenTime < Base
|
|
39
|
+
include CaseScope
|
|
40
|
+
include Helpers
|
|
41
|
+
|
|
42
|
+
MSG = "`%<call>s` reads the wall clock, which makes this investigation a " \
|
|
43
|
+
"function of when it runs. Call `freeze_time` (or `travel_to`) first, " \
|
|
44
|
+
"or reach for `unsafe { }` if the real clock is genuinely the subject."
|
|
45
|
+
|
|
46
|
+
DEFAULT_FORBIDDEN_CALLS = %w[
|
|
47
|
+
Time.now Time.current Time.zone.now
|
|
48
|
+
Date.today Date.current
|
|
49
|
+
DateTime.now DateTime.current
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
DEFAULT_FREEZE_HELPERS = %w[freeze_time travel_to].freeze
|
|
53
|
+
|
|
54
|
+
# Blocks whose body is one investigation's worth of scope.
|
|
55
|
+
SCOPE_METHODS = %i[investigate briefing docket witness].freeze
|
|
56
|
+
|
|
57
|
+
def on_new_investigation
|
|
58
|
+
@freeze_calls = nil
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def on_send(node)
|
|
63
|
+
return unless constable_case_file?
|
|
64
|
+
|
|
65
|
+
name = qualified_call_name(node)
|
|
66
|
+
return unless name && forbidden_calls.include?(name)
|
|
67
|
+
return if exempt?(node)
|
|
68
|
+
|
|
69
|
+
add_offense(node, message: format(MSG, call: name))
|
|
70
|
+
end
|
|
71
|
+
alias on_csend on_send
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def exempt?(node)
|
|
76
|
+
inside_unsafe_block?(node) ||
|
|
77
|
+
freeze_helper_argument?(node) ||
|
|
78
|
+
inside_freeze_block?(node) ||
|
|
79
|
+
frozen_by_briefing? ||
|
|
80
|
+
frozen_earlier_in_scope?(node)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# `freeze_time(Time.now)` / `travel_to(Time.now + 1.day)` -- the clock read
|
|
84
|
+
# is what gets frozen, so it cannot itself be unfrozen.
|
|
85
|
+
def freeze_helper_argument?(node)
|
|
86
|
+
# A freeze helper takes no receiver, so anything below it in the tree is
|
|
87
|
+
# necessarily one of its arguments.
|
|
88
|
+
node.each_ancestor(:send, :csend).any? { |ancestor| freeze_helper?(ancestor) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def inside_freeze_block?(node)
|
|
92
|
+
node.each_ancestor(:block, :numblock).any? do |ancestor|
|
|
93
|
+
freeze_helper?(ancestor.send_node)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def frozen_by_briefing?
|
|
98
|
+
freeze_calls.any? { |call| inside_briefing?(call) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A bare `freeze_time` earlier in the same investigation covers everything
|
|
102
|
+
# after it. When the call isn't inside a recognisable scope block at all
|
|
103
|
+
# (a plain helper method, say), fall back to "anywhere earlier in the file".
|
|
104
|
+
def frozen_earlier_in_scope?(node)
|
|
105
|
+
scope = enclosing_scope(node)
|
|
106
|
+
|
|
107
|
+
freeze_calls.any? do |call|
|
|
108
|
+
next false if call.loc.line > node.loc.line
|
|
109
|
+
|
|
110
|
+
scope.nil? || enclosing_scope(call).equal?(scope)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def enclosing_scope(node)
|
|
115
|
+
node.each_ancestor(:block, :numblock).find do |ancestor|
|
|
116
|
+
send_node = ancestor.send_node
|
|
117
|
+
send_node.receiver.nil? && SCOPE_METHODS.include?(send_node.method_name)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inside_briefing?(node)
|
|
122
|
+
node.each_ancestor(:block, :numblock).any? do |ancestor|
|
|
123
|
+
send_node = ancestor.send_node
|
|
124
|
+
send_node.receiver.nil? && send_node.method_name == :briefing
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def freeze_helper?(node)
|
|
129
|
+
return false unless node.respond_to?(:method_name)
|
|
130
|
+
return false unless node.receiver.nil?
|
|
131
|
+
|
|
132
|
+
freeze_helpers.include?(node.method_name.to_s)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def freeze_calls
|
|
136
|
+
@freeze_calls ||= begin
|
|
137
|
+
ast = processed_source&.ast
|
|
138
|
+
if ast.nil?
|
|
139
|
+
[]
|
|
140
|
+
else
|
|
141
|
+
ast.each_node(:send, :csend).select { |send_node| freeze_helper?(send_node) }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def forbidden_calls
|
|
147
|
+
@forbidden_calls ||= Array(cop_config.fetch("ForbiddenCalls", DEFAULT_FORBIDDEN_CALLS)).map(&:to_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def freeze_helpers
|
|
151
|
+
@freeze_helpers ||= Array(cop_config.fetch("FreezeHelpers", DEFAULT_FREEZE_HELPERS)).map(&:to_s)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Constable
|
|
6
|
+
# Every escape hatch is visible. `unsafe { }` is allowed to exist -- it is the
|
|
7
|
+
# honest way to say "this one call really does need to bend a rule" -- but an
|
|
8
|
+
# `unsafe` with nothing next to it is just a rule being broken quietly, and
|
|
9
|
+
# the next person to read it has no way to tell whether it is still needed.
|
|
10
|
+
#
|
|
11
|
+
# So this cop does not object to `unsafe` at all. It objects only to an
|
|
12
|
+
# `unsafe` that does not say why. A trailing comment on the same line, or a
|
|
13
|
+
# comment on the line immediately above, satisfies it -- and that comment is
|
|
14
|
+
# what the runner quotes in the WARNINGS section of every run summary:
|
|
15
|
+
#
|
|
16
|
+
# ⚠ spec/controllers/sessions_case.rb:44
|
|
17
|
+
# unsafe { sleep(0.1) } -- "testing an actual timeout path, not a code smell"
|
|
18
|
+
#
|
|
19
|
+
# A literal reason passed to `unsafe("...")` counts as well, since the runtime
|
|
20
|
+
# DSL reports that string the same way. Set `AllowReasonArgument: false` to
|
|
21
|
+
# insist on a comment.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# # bad
|
|
25
|
+
# unsafe { sleep(0.1) }
|
|
26
|
+
#
|
|
27
|
+
# # good
|
|
28
|
+
# unsafe { sleep(0.1) } # testing an actual timeout path, not a code smell
|
|
29
|
+
#
|
|
30
|
+
# # good
|
|
31
|
+
# # testing an actual timeout path, not a code smell
|
|
32
|
+
# unsafe do
|
|
33
|
+
# sleep(0.1)
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# # good (with AllowReasonArgument: true, the default)
|
|
37
|
+
# unsafe("testing an actual timeout path, not a code smell") { sleep(0.1) }
|
|
38
|
+
class UnsafeBlockVisibility < Base
|
|
39
|
+
include CaseScope
|
|
40
|
+
include Helpers
|
|
41
|
+
|
|
42
|
+
MSG = "This `unsafe` block has nothing saying why. Every escape hatch is " \
|
|
43
|
+
"reported in the run summary, so give it a reason: a trailing comment, " \
|
|
44
|
+
"a comment on the line above, or `unsafe(\"reason\") { }`."
|
|
45
|
+
|
|
46
|
+
def on_block(node)
|
|
47
|
+
return unless constable_case_file?
|
|
48
|
+
return unless unsafe_block?(node)
|
|
49
|
+
return if justified?(node)
|
|
50
|
+
|
|
51
|
+
add_offense(node.send_node)
|
|
52
|
+
end
|
|
53
|
+
alias on_numblock on_block
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def justified?(node)
|
|
58
|
+
reason_argument?(node.send_node) || adjacent_comment?(node)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reason_argument?(send_node)
|
|
62
|
+
return false unless allow_reason_argument?
|
|
63
|
+
|
|
64
|
+
argument = send_node.first_argument
|
|
65
|
+
return false if argument.nil?
|
|
66
|
+
return false unless argument.respond_to?(:type)
|
|
67
|
+
|
|
68
|
+
case argument.type
|
|
69
|
+
when :str then !argument.value.to_s.strip.empty?
|
|
70
|
+
when :dstr, :sym then true
|
|
71
|
+
else false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# "Adjacent" means the same line (a trailing comment) or the line directly
|
|
76
|
+
# above. A comment three lines up is documenting something else.
|
|
77
|
+
def adjacent_comment?(node)
|
|
78
|
+
line = node.send_node.loc.line
|
|
79
|
+
|
|
80
|
+
comment_text?(line) || comment_text?(line - 1)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def comment_text?(line)
|
|
84
|
+
return false if line < 1
|
|
85
|
+
|
|
86
|
+
comment = processed_source.comment_at_line(line)
|
|
87
|
+
return false if comment.nil?
|
|
88
|
+
|
|
89
|
+
!comment.text.to_s.sub(/\A#+/, "").strip.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def allow_reason_argument?
|
|
93
|
+
cop_config.fetch("AllowReasonArgument", true)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared mixins first -- every cop includes them.
|
|
4
|
+
require_relative "constable/case_scope"
|
|
5
|
+
require_relative "constable/helpers"
|
|
6
|
+
|
|
7
|
+
require_relative "constable/no_conditional_assertions"
|
|
8
|
+
require_relative "constable/no_network_without_stub"
|
|
9
|
+
require_relative "constable/no_retry_helpers"
|
|
10
|
+
require_relative "constable/no_shared_mutable_state"
|
|
11
|
+
require_relative "constable/no_sleep"
|
|
12
|
+
require_relative "constable/no_unfrozen_time"
|
|
13
|
+
require_relative "constable/unsafe_block_visibility"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The entry point users name in `.rubocop.yml`:
|
|
4
|
+
#
|
|
5
|
+
# require:
|
|
6
|
+
# - rubocop-constable
|
|
7
|
+
#
|
|
8
|
+
require "yaml"
|
|
9
|
+
require "rubocop"
|
|
10
|
+
|
|
11
|
+
require_relative "rubocop/constable"
|
|
12
|
+
require_relative "rubocop/constable/version"
|
|
13
|
+
require_relative "rubocop/constable/inject"
|
|
14
|
+
|
|
15
|
+
RuboCop::Constable::Inject.defaults!
|
|
16
|
+
|
|
17
|
+
require_relative "rubocop/cop/constable_cops"
|
metadata
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rubocop-constable
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ray Hughes
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rubocop
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.50'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.50'
|
|
27
|
+
description: |
|
|
28
|
+
The companion RuboCop extension for Constable, the opinionated Rails testing gem.
|
|
29
|
+
|
|
30
|
+
Constable's second principle is that nondeterminism is caught by the linter, not
|
|
31
|
+
discovered in CI. These seven cops are that linter: bare `sleep`, unfrozen
|
|
32
|
+
`Time.now`, unstubbed HTTP, class-level shared state, assertions hidden behind a
|
|
33
|
+
branch, retry and eventually helpers, and an `unsafe` block that never says why.
|
|
34
|
+
|
|
35
|
+
Every cop is scoped to native `Constable::Case` files. Cold cases -- untouched
|
|
36
|
+
RSpec or Minitest files running through `Constable::ColdCase::*` -- are exempt by
|
|
37
|
+
design, because the whole point of the adoption story is that taking the on-ramp
|
|
38
|
+
costs nothing.
|
|
39
|
+
|
|
40
|
+
Install it alongside `constable-rails` and add `require: rubocop-constable` to
|
|
41
|
+
`.rubocop.yml`.
|
|
42
|
+
email:
|
|
43
|
+
- r.hughes2136@gmail.com
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- CHANGELOG.md
|
|
49
|
+
- LICENSE.txt
|
|
50
|
+
- README.md
|
|
51
|
+
- config/default.yml
|
|
52
|
+
- lib/rubocop-constable.rb
|
|
53
|
+
- lib/rubocop/constable.rb
|
|
54
|
+
- lib/rubocop/constable/inject.rb
|
|
55
|
+
- lib/rubocop/constable/version.rb
|
|
56
|
+
- lib/rubocop/cop/constable/case_scope.rb
|
|
57
|
+
- lib/rubocop/cop/constable/helpers.rb
|
|
58
|
+
- lib/rubocop/cop/constable/no_conditional_assertions.rb
|
|
59
|
+
- lib/rubocop/cop/constable/no_network_without_stub.rb
|
|
60
|
+
- lib/rubocop/cop/constable/no_retry_helpers.rb
|
|
61
|
+
- lib/rubocop/cop/constable/no_shared_mutable_state.rb
|
|
62
|
+
- lib/rubocop/cop/constable/no_sleep.rb
|
|
63
|
+
- lib/rubocop/cop/constable/no_unfrozen_time.rb
|
|
64
|
+
- lib/rubocop/cop/constable/unsafe_block_visibility.rb
|
|
65
|
+
- lib/rubocop/cop/constable_cops.rb
|
|
66
|
+
homepage: https://github.com/Ray-Hughes/constable
|
|
67
|
+
licenses:
|
|
68
|
+
- MIT
|
|
69
|
+
metadata:
|
|
70
|
+
homepage_uri: https://github.com/Ray-Hughes/constable
|
|
71
|
+
source_code_uri: https://github.com/Ray-Hughes/constable/tree/main/rubocop-constable
|
|
72
|
+
changelog_uri: https://github.com/Ray-Hughes/constable/blob/main/rubocop-constable/CHANGELOG.md
|
|
73
|
+
bug_tracker_uri: https://github.com/Ray-Hughes/constable/issues
|
|
74
|
+
documentation_uri: https://github.com/Ray-Hughes/constable/blob/main/rubocop-constable/README.md
|
|
75
|
+
rubygems_mfa_required: 'true'
|
|
76
|
+
post_install_message:
|
|
77
|
+
rdoc_options: []
|
|
78
|
+
require_paths:
|
|
79
|
+
- lib
|
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: 3.1.0
|
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
requirements: []
|
|
91
|
+
rubygems_version: 3.4.10
|
|
92
|
+
signing_key:
|
|
93
|
+
specification_version: 4
|
|
94
|
+
summary: RuboCop cops that catch test nondeterminism before CI does.
|
|
95
|
+
test_files: []
|