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,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Constable
|
|
6
|
+
# Small AST helpers shared by every `Constable/*` cop: recognising the
|
|
7
|
+
# `unsafe { }` escape hatch, and rendering receiver chains as readable names.
|
|
8
|
+
#
|
|
9
|
+
# `unsafe` is the one legal way to bend a native rule for a single call. It is
|
|
10
|
+
# never silent -- the runtime emits a warning for every occurrence, with
|
|
11
|
+
# `file:line` and the adjacent comment -- so the cops treat anything lexically
|
|
12
|
+
# inside an `unsafe` block as already accounted for, and stay quiet.
|
|
13
|
+
module Helpers
|
|
14
|
+
extend ::RuboCop::AST::NodePattern::Macros
|
|
15
|
+
|
|
16
|
+
# `unsafe { ... }`, `unsafe do ... end`, `unsafe("reason") { ... }`
|
|
17
|
+
def_node_matcher :unsafe_block?, <<~PATTERN
|
|
18
|
+
({block numblock} (send nil? :unsafe ...) ...)
|
|
19
|
+
PATTERN
|
|
20
|
+
|
|
21
|
+
# @return [Boolean] whether the node sits lexically inside an `unsafe` block.
|
|
22
|
+
def inside_unsafe_block?(node)
|
|
23
|
+
node.each_ancestor(:block, :numblock).any? { |ancestor| unsafe_block?(ancestor) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Renders a statically-resolvable receiver chain as a dotted string:
|
|
27
|
+
# `Time.now` -> "Time.now", `Time.zone.now` -> "Time.zone.now",
|
|
28
|
+
# `Net::HTTP.get` -> "Net::HTTP.get". Returns nil when any link in the
|
|
29
|
+
# chain takes arguments or isn't a constant/plain send.
|
|
30
|
+
def qualified_call_name(node)
|
|
31
|
+
return nil if node.nil?
|
|
32
|
+
|
|
33
|
+
case node.type
|
|
34
|
+
when :const
|
|
35
|
+
name = node.const_name
|
|
36
|
+
name && name.sub(/\A::/, "")
|
|
37
|
+
when :send
|
|
38
|
+
return nil unless node.arguments.empty?
|
|
39
|
+
return node.method_name.to_s if node.receiver.nil?
|
|
40
|
+
|
|
41
|
+
base = qualified_call_name(node.receiver)
|
|
42
|
+
base && "#{base}.#{node.method_name}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A constant node's dotless name: `(const (const nil :Net) :HTTP)` -> "Net::HTTP".
|
|
47
|
+
def constant_string(node)
|
|
48
|
+
name = node.const_name
|
|
49
|
+
name && name.sub(/\A::/, "")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Constable
|
|
6
|
+
# An assertion behind a branch is an assertion that might not run. The test
|
|
7
|
+
# goes green either way, so nobody notices when the interesting branch stops
|
|
8
|
+
# being taken -- the case quietly stops testing anything while still counting
|
|
9
|
+
# itself as coverage. Worse, the branch condition is usually the very thing
|
|
10
|
+
# that varies between machines: an environment flag, a record that may or may
|
|
11
|
+
# not exist, a feature toggle.
|
|
12
|
+
#
|
|
13
|
+
# Split the branches into separate investigations (or separate `docket`
|
|
14
|
+
# blocks) so each one asserts unconditionally, and each one's name says which
|
|
15
|
+
# world it describes.
|
|
16
|
+
#
|
|
17
|
+
# Only the outermost conditional wrapping an assertion is reported, so one
|
|
18
|
+
# nested `if` tree yields one offense, not five.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# # bad
|
|
22
|
+
# investigate "creates the user" do
|
|
23
|
+
# if admin?
|
|
24
|
+
# attest(response).to be_created
|
|
25
|
+
# else
|
|
26
|
+
# attest(response).to be_forbidden
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# # bad
|
|
31
|
+
# attest(response).to be_created unless skip_check
|
|
32
|
+
#
|
|
33
|
+
# # good
|
|
34
|
+
# docket "as an admin" do
|
|
35
|
+
# investigate("creates the user") { attest(response).to be_created }
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# docket "as a guest" do
|
|
39
|
+
# investigate("is forbidden") { attest(response).to be_forbidden }
|
|
40
|
+
# end
|
|
41
|
+
class NoConditionalAssertions < Base
|
|
42
|
+
include CaseScope
|
|
43
|
+
include Helpers
|
|
44
|
+
|
|
45
|
+
MSG = "This %<construct>s decides whether an assertion runs at all, so the " \
|
|
46
|
+
"case passes whichever way the branch falls. Split it into separate " \
|
|
47
|
+
"investigations (or `docket` blocks) that each assert unconditionally."
|
|
48
|
+
|
|
49
|
+
DEFAULT_ASSERTION_METHODS = %w[attest].freeze
|
|
50
|
+
DEFAULT_ASSERTION_PREFIXES = %w[assert refute].freeze
|
|
51
|
+
|
|
52
|
+
CONDITIONAL_TYPES = %i[if case case_match].freeze
|
|
53
|
+
|
|
54
|
+
def on_if(node)
|
|
55
|
+
return unless constable_case_file?
|
|
56
|
+
|
|
57
|
+
check_conditional(node, node.ternary? ? "ternary" : "conditional")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def on_case(node)
|
|
61
|
+
return unless constable_case_file?
|
|
62
|
+
|
|
63
|
+
check_conditional(node, "`case`")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def on_case_match(node)
|
|
67
|
+
return unless constable_case_file?
|
|
68
|
+
|
|
69
|
+
check_conditional(node, "`case/in`")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def check_conditional(node, construct)
|
|
75
|
+
return unless assertion_in_branches?(node)
|
|
76
|
+
return if outer_conditional?(node)
|
|
77
|
+
return if inside_unsafe_block?(node)
|
|
78
|
+
|
|
79
|
+
add_offense(offense_range(node), message: format(MSG, construct: construct))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Report only the outermost conditional that guards an assertion.
|
|
83
|
+
def outer_conditional?(node)
|
|
84
|
+
node.each_ancestor(*CONDITIONAL_TYPES).any? { |ancestor| assertion_in_branches?(ancestor) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The condition itself may legitimately call a predicate; only the branch
|
|
88
|
+
# bodies decide whether an assertion runs.
|
|
89
|
+
def assertion_in_branches?(node)
|
|
90
|
+
branch_bodies(node).any? { |body| assertion?(body) || body.each_descendant(:send, :csend).any? { |d| assertion?(d) } }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def branch_bodies(node)
|
|
94
|
+
case node.type
|
|
95
|
+
when :if
|
|
96
|
+
[node.if_branch, node.else_branch].compact
|
|
97
|
+
when :case
|
|
98
|
+
(node.when_branches.map(&:body) + [node.else_branch]).compact
|
|
99
|
+
when :case_match
|
|
100
|
+
(node.in_pattern_branches.map(&:body) + [node.else_branch]).compact
|
|
101
|
+
else
|
|
102
|
+
[]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def assertion?(node)
|
|
107
|
+
return false unless node.respond_to?(:send_type?) && (node.send_type? || node.csend_type?)
|
|
108
|
+
|
|
109
|
+
name = node.method_name.to_s
|
|
110
|
+
assertion_methods.include?(name) ||
|
|
111
|
+
assertion_prefixes.any? { |prefix| name == prefix || name.start_with?("#{prefix}_") }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def offense_range(node)
|
|
115
|
+
loc = node.loc
|
|
116
|
+
return loc.keyword if loc.respond_to?(:keyword) && loc.keyword
|
|
117
|
+
return loc.question if loc.respond_to?(:question) && loc.question
|
|
118
|
+
|
|
119
|
+
node.source_range
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def assertion_methods
|
|
123
|
+
@assertion_methods ||= Array(cop_config.fetch("AssertionMethods", DEFAULT_ASSERTION_METHODS)).map(&:to_s)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def assertion_prefixes
|
|
127
|
+
@assertion_prefixes ||= Array(cop_config.fetch("AssertionPrefixes", DEFAULT_ASSERTION_PREFIXES)).map(&:to_s)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Constable
|
|
6
|
+
# A case that talks to the real network is not a test of your code, it is a
|
|
7
|
+
# test of somebody else's uptime. It fails on a plane, on a flaky DNS
|
|
8
|
+
# resolver, and on the day the third party rate-limits CI -- none of which
|
|
9
|
+
# are your bug.
|
|
10
|
+
#
|
|
11
|
+
# Constable ships `stub_network!`, which installs a guard that raises on any
|
|
12
|
+
# real outbound connection. Call it in a `briefing` and every investigation in
|
|
13
|
+
# the case is covered; this cop then goes quiet for the whole file. If the
|
|
14
|
+
# outbound call itself is the subject, `unsafe { }` says so on the record.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# # bad
|
|
18
|
+
# investigate "fetches the profile" do
|
|
19
|
+
# attest(Net::HTTP.get(uri)).to include("ok")
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # good
|
|
23
|
+
# briefing { stub_network! }
|
|
24
|
+
#
|
|
25
|
+
# investigate "fetches the profile" do
|
|
26
|
+
# attest(Net::HTTP.get(uri)).to include("ok")
|
|
27
|
+
# end
|
|
28
|
+
class NoNetworkWithoutStub < Base
|
|
29
|
+
include CaseScope
|
|
30
|
+
include Helpers
|
|
31
|
+
|
|
32
|
+
MSG = "`%<call>s` reaches the real network, and this case never calls " \
|
|
33
|
+
"`stub_network!`. A test that depends on somebody else's uptime is " \
|
|
34
|
+
"not testing your code -- add `briefing { stub_network! }`, or use " \
|
|
35
|
+
"`unsafe { }` if the live call is the subject."
|
|
36
|
+
|
|
37
|
+
DEFAULT_HTTP_CONSTANTS = %w[
|
|
38
|
+
Net::HTTP
|
|
39
|
+
Net::HTTPS
|
|
40
|
+
HTTParty
|
|
41
|
+
Faraday
|
|
42
|
+
RestClient
|
|
43
|
+
Excon
|
|
44
|
+
Typhoeus
|
|
45
|
+
HTTPClient
|
|
46
|
+
HTTPX
|
|
47
|
+
HTTP
|
|
48
|
+
Curl
|
|
49
|
+
Patron
|
|
50
|
+
Mechanize
|
|
51
|
+
OpenURI
|
|
52
|
+
Down
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
DEFAULT_STUB_HELPERS = %w[stub_network!].freeze
|
|
56
|
+
|
|
57
|
+
# `URI` is a perfectly innocent constant right up until you `.open` it.
|
|
58
|
+
URI_NETWORK_METHODS = %i[open read].freeze
|
|
59
|
+
URL_LITERAL = %r{\Ahttps?://}i.freeze
|
|
60
|
+
|
|
61
|
+
def on_new_investigation
|
|
62
|
+
@network_stubbed = nil
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def on_send(node)
|
|
67
|
+
return unless constable_case_file?
|
|
68
|
+
return if network_stubbed?
|
|
69
|
+
return unless (call = network_call_name(node))
|
|
70
|
+
return if inside_unsafe_block?(node)
|
|
71
|
+
|
|
72
|
+
add_offense(node, message: format(MSG, call: call))
|
|
73
|
+
end
|
|
74
|
+
alias on_csend on_send
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# The stub is a file-level fact: `stub_network!` in a `briefing` runs before
|
|
79
|
+
# every investigation, so one call anywhere covers the case.
|
|
80
|
+
def network_stubbed?
|
|
81
|
+
return @network_stubbed unless @network_stubbed.nil?
|
|
82
|
+
|
|
83
|
+
ast = processed_source&.ast
|
|
84
|
+
@network_stubbed =
|
|
85
|
+
!ast.nil? && ast.each_node(:send, :csend).any? do |send_node|
|
|
86
|
+
send_node.receiver.nil? && stub_helpers.include?(send_node.method_name.to_s)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Matches only calls made *directly* on a known entry-point constant. That
|
|
91
|
+
# is deliberate: it flags the innermost link of a chain exactly once, so
|
|
92
|
+
# `Faraday.new.get(url)` reports on `Faraday.new` rather than twice, and
|
|
93
|
+
# `Net::HTTP.get(uri).to_s` never reports a nonsense `Net::HTTP.to_s`.
|
|
94
|
+
# Calls on an object handed around by a `witness` are out of reach of a
|
|
95
|
+
# static check and are left to `stub_network!` itself to catch at runtime.
|
|
96
|
+
def network_call_name(node)
|
|
97
|
+
receiver = node.receiver
|
|
98
|
+
|
|
99
|
+
if receiver.nil?
|
|
100
|
+
# open-uri patches Kernel#open; `open("https://...")` is a live request.
|
|
101
|
+
return "open" if node.method_name == :open && url_literal_argument?(node)
|
|
102
|
+
|
|
103
|
+
return nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
return nil unless receiver.const_type?
|
|
107
|
+
|
|
108
|
+
root = constant_string(receiver)
|
|
109
|
+
return nil if root.nil?
|
|
110
|
+
|
|
111
|
+
if root == "URI"
|
|
112
|
+
return nil unless URI_NETWORK_METHODS.include?(node.method_name)
|
|
113
|
+
|
|
114
|
+
return "URI.#{node.method_name}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
return nil unless http_constant?(root)
|
|
118
|
+
|
|
119
|
+
"#{root}.#{node.method_name}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def http_constant?(root)
|
|
123
|
+
http_constants.any? { |name| root == name || root.start_with?("#{name}::") }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def url_literal_argument?(node)
|
|
127
|
+
first = node.first_argument
|
|
128
|
+
return false unless first.respond_to?(:str_type?) && (first.str_type? || first.dstr_type?)
|
|
129
|
+
|
|
130
|
+
URL_LITERAL.match?(first.source.delete_prefix('"').delete_prefix("'"))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def http_constants
|
|
134
|
+
@http_constants ||= Array(cop_config.fetch("HttpConstants", DEFAULT_HTTP_CONSTANTS)).map(&:to_s)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def stub_helpers
|
|
138
|
+
@stub_helpers ||= Array(cop_config.fetch("StubHelpers", DEFAULT_STUB_HELPERS)).map(&:to_s)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Constable
|
|
6
|
+
# Retrying is how a flaky test hides. `retry`, `eventually { }`, `wait_for`
|
|
7
|
+
# and hand-rolled polling loops all take a test that fails some of the time
|
|
8
|
+
# and turn it into a test that passes most of the time -- which is strictly
|
|
9
|
+
# worse, because now nobody is looking at it. The real defect stays in the
|
|
10
|
+
# code, and the suite gets slower on every run that has to use the retries.
|
|
11
|
+
#
|
|
12
|
+
# Constable has a first-class answer for genuine flakiness: **warrants**.
|
|
13
|
+
# Turn them on and the runner reruns a failing test in isolation, records
|
|
14
|
+
# what it finds in the blotter, and reports it in its own section -- visible,
|
|
15
|
+
# counted, and never quietly swallowed by a `rescue; retry; end`.
|
|
16
|
+
#
|
|
17
|
+
# `wait_for(timeout:, interval:)` does exist in the runtime DSL for genuinely
|
|
18
|
+
# asynchronous things, but it is legal only inside `unsafe { }`; outside it,
|
|
19
|
+
# the DSL raises. This cop enforces the same rule statically.
|
|
20
|
+
#
|
|
21
|
+
# Polling loops are recognised by their signature: a `loop`/`while`/`until`
|
|
22
|
+
# whose body sleeps. That is a deliberately narrow heuristic -- an ordinary
|
|
23
|
+
# `while` that does real work is left alone.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# # bad
|
|
27
|
+
# begin
|
|
28
|
+
# attest(job).to be_finished
|
|
29
|
+
# rescue Constable::AssertionFailed
|
|
30
|
+
# retry
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# # bad
|
|
34
|
+
# eventually { attest(page).to have_content("Done") }
|
|
35
|
+
#
|
|
36
|
+
# # bad
|
|
37
|
+
# until job.reload.finished?
|
|
38
|
+
# sleep 0.1
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# # good
|
|
42
|
+
# perform_enqueued_jobs
|
|
43
|
+
# attest(job.reload).to be_finished
|
|
44
|
+
#
|
|
45
|
+
# # good -- genuinely async, on the record
|
|
46
|
+
# # the browser drives this repaint on its own schedule
|
|
47
|
+
# unsafe { wait_for(timeout: 2, interval: 0.05) { page.has_content?("Done") } }
|
|
48
|
+
class NoRetryHelpers < Base
|
|
49
|
+
include CaseScope
|
|
50
|
+
include Helpers
|
|
51
|
+
|
|
52
|
+
MSG_RETRY = "`retry` turns a failing investigation into a passing one " \
|
|
53
|
+
"without fixing anything. Remove the retry and let the failure " \
|
|
54
|
+
"stand -- if the test is genuinely flaky, that is what warrants " \
|
|
55
|
+
"(`constable test --warrants`) are for."
|
|
56
|
+
|
|
57
|
+
MSG_HELPER = "`%<name>s` is a retry helper, and retrying is how a flaky " \
|
|
58
|
+
"test hides. Assert on the completed state instead, or wrap it " \
|
|
59
|
+
"in `unsafe { }` with a comment if the work is genuinely async."
|
|
60
|
+
|
|
61
|
+
MSG_LOOP = "This %<construct>s polls with `sleep`, which is a retry loop " \
|
|
62
|
+
"wearing a different hat: slow when it passes, flaky when it " \
|
|
63
|
+
"does not. Assert on the completed state, or wrap it in " \
|
|
64
|
+
"`unsafe { }` with a comment if the work is genuinely async."
|
|
65
|
+
|
|
66
|
+
DEFAULT_RETRY_HELPERS = %w[
|
|
67
|
+
wait_for
|
|
68
|
+
eventually
|
|
69
|
+
with_retries
|
|
70
|
+
try_again
|
|
71
|
+
retry_until
|
|
72
|
+
retry_on_failure
|
|
73
|
+
poll_until
|
|
74
|
+
keep_trying
|
|
75
|
+
].freeze
|
|
76
|
+
|
|
77
|
+
LOOP_TYPES = %i[while until while_post until_post].freeze
|
|
78
|
+
|
|
79
|
+
def on_retry(node)
|
|
80
|
+
return unless constable_case_file?
|
|
81
|
+
return if inside_unsafe_block?(node)
|
|
82
|
+
|
|
83
|
+
add_offense(node, message: MSG_RETRY)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def on_send(node)
|
|
87
|
+
return unless constable_case_file?
|
|
88
|
+
return unless node.receiver.nil?
|
|
89
|
+
|
|
90
|
+
name = node.method_name.to_s
|
|
91
|
+
return unless retry_helpers.include?(name)
|
|
92
|
+
return if inside_unsafe_block?(node)
|
|
93
|
+
|
|
94
|
+
add_offense(node, message: format(MSG_HELPER, name: name))
|
|
95
|
+
end
|
|
96
|
+
alias on_csend on_send
|
|
97
|
+
|
|
98
|
+
def on_while(node)
|
|
99
|
+
return unless constable_case_file?
|
|
100
|
+
return unless sleeps_in_body?(node.body)
|
|
101
|
+
return if inside_unsafe_block?(node)
|
|
102
|
+
|
|
103
|
+
add_offense(offense_range(node), message: format(MSG_LOOP, construct: "`#{node.keyword}`"))
|
|
104
|
+
end
|
|
105
|
+
alias on_until on_while
|
|
106
|
+
alias on_while_post on_while
|
|
107
|
+
alias on_until_post on_while
|
|
108
|
+
|
|
109
|
+
def on_block(node)
|
|
110
|
+
return unless constable_case_file?
|
|
111
|
+
return unless kernel_loop?(node)
|
|
112
|
+
return unless sleeps_in_body?(node.body)
|
|
113
|
+
return if inside_unsafe_block?(node)
|
|
114
|
+
|
|
115
|
+
add_offense(node.send_node, message: format(MSG_LOOP, construct: "`loop`"))
|
|
116
|
+
end
|
|
117
|
+
alias on_numblock on_block
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def kernel_loop?(node)
|
|
122
|
+
send_node = node.send_node
|
|
123
|
+
send_node.receiver.nil? && send_node.method_name == :loop && send_node.arguments.empty?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def sleeps_in_body?(body)
|
|
127
|
+
return false if body.nil?
|
|
128
|
+
|
|
129
|
+
sleep_call?(body) || body.each_descendant(:send, :csend).any? { |node| sleep_call?(node) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def sleep_call?(node)
|
|
133
|
+
return false unless node.respond_to?(:method_name)
|
|
134
|
+
return false unless node.method_name == :sleep
|
|
135
|
+
|
|
136
|
+
receiver = node.receiver
|
|
137
|
+
receiver.nil? || (receiver.const_type? && constant_string(receiver) == "Kernel")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def offense_range(node)
|
|
141
|
+
loc = node.loc
|
|
142
|
+
return loc.keyword if loc.respond_to?(:keyword) && loc.keyword
|
|
143
|
+
|
|
144
|
+
node.source_range
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def retry_helpers
|
|
148
|
+
@retry_helpers ||= Array(cop_config.fetch("RetryHelpers", DEFAULT_RETRY_HELPERS)).map(&:to_s)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Constable
|
|
6
|
+
# Isolation is non-negotiable in native code. A class variable or a global
|
|
7
|
+
# written from inside a case survives the investigation that wrote it, which
|
|
8
|
+
# means the suite's result now depends on its order -- and the failure lands
|
|
9
|
+
# on whichever test happened to run second, not on the one that caused it.
|
|
10
|
+
# This is the single hardest class of flake to debug, so Constable simply
|
|
11
|
+
# does not have a `before(:all)`.
|
|
12
|
+
#
|
|
13
|
+
# Reading `@@x` or `$x` is fine. This cop is about *writing*: assignment,
|
|
14
|
+
# `||=`, `<<`, and the other in-place mutators.
|
|
15
|
+
#
|
|
16
|
+
# Use `witness` for per-test memoized data (never per-process), `briefing`
|
|
17
|
+
# for per-test setup, and an ordinary instance variable for anything an
|
|
18
|
+
# investigation needs to remember about itself.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# # bad
|
|
22
|
+
# class ImportCase < UnitCase
|
|
23
|
+
# @@rows = []
|
|
24
|
+
#
|
|
25
|
+
# investigate "collects a row" do
|
|
26
|
+
# @@rows << build_row
|
|
27
|
+
# attest(@@rows.size).to eq(1)
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# # bad
|
|
32
|
+
# investigate "remembers the token" do
|
|
33
|
+
# $token = issue_token
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# # good
|
|
37
|
+
# class ImportCase < UnitCase
|
|
38
|
+
# witness(:rows) { [] }
|
|
39
|
+
#
|
|
40
|
+
# investigate "collects a row" do
|
|
41
|
+
# rows << build_row
|
|
42
|
+
# attest(rows.size).to eq(1)
|
|
43
|
+
# end
|
|
44
|
+
# end
|
|
45
|
+
class NoSharedMutableState < Base
|
|
46
|
+
include CaseScope
|
|
47
|
+
include Helpers
|
|
48
|
+
|
|
49
|
+
MSG_ASSIGN = "Assigning %<kind>s `%<name>s` leaks state between " \
|
|
50
|
+
"investigations and makes the suite order-dependent. Use " \
|
|
51
|
+
"`witness` for per-test data or `briefing` for per-test setup."
|
|
52
|
+
|
|
53
|
+
MSG_MUTATE = "Mutating %<kind>s `%<name>s` with `%<method>s` leaks state " \
|
|
54
|
+
"between investigations and makes the suite order-dependent. " \
|
|
55
|
+
"Use `witness` for per-test data or `briefing` for per-test setup."
|
|
56
|
+
|
|
57
|
+
ASSIGNMENT_TYPES = %i[cvasgn gvasgn].freeze
|
|
58
|
+
OP_ASSIGNMENT_TYPES = %i[op_asgn or_asgn and_asgn].freeze
|
|
59
|
+
|
|
60
|
+
# In-place mutators. Anything ending in `!` or `=` counts too, which is why
|
|
61
|
+
# this list only needs the ones that break the convention.
|
|
62
|
+
MUTATING_METHODS = %i[
|
|
63
|
+
<< push pop shift unshift concat insert append prepend
|
|
64
|
+
clear delete delete_at delete_if keep_if
|
|
65
|
+
replace fill store update
|
|
66
|
+
].freeze
|
|
67
|
+
|
|
68
|
+
def on_cvasgn(node)
|
|
69
|
+
return unless constable_case_file?
|
|
70
|
+
return if operator_assignment_target?(node)
|
|
71
|
+
return if inside_unsafe_block?(node)
|
|
72
|
+
|
|
73
|
+
add_offense(node, message: format(MSG_ASSIGN, kind: "class variable", name: node.name))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def on_gvasgn(node)
|
|
77
|
+
return unless constable_case_file?
|
|
78
|
+
return if operator_assignment_target?(node)
|
|
79
|
+
return if inside_unsafe_block?(node)
|
|
80
|
+
|
|
81
|
+
add_offense(node, message: format(MSG_ASSIGN, kind: "global", name: node.name))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def on_op_asgn(node)
|
|
85
|
+
return unless constable_case_file?
|
|
86
|
+
return if inside_unsafe_block?(node)
|
|
87
|
+
|
|
88
|
+
target = node.children.first
|
|
89
|
+
return unless target.respond_to?(:type) && shared_state_type?(target.type)
|
|
90
|
+
|
|
91
|
+
add_offense(node, message: format(MSG_ASSIGN, kind: kind_for(target), name: variable_name(target)))
|
|
92
|
+
end
|
|
93
|
+
alias on_or_asgn on_op_asgn
|
|
94
|
+
alias on_and_asgn on_op_asgn
|
|
95
|
+
|
|
96
|
+
def on_send(node)
|
|
97
|
+
return unless constable_case_file?
|
|
98
|
+
return if inside_unsafe_block?(node)
|
|
99
|
+
|
|
100
|
+
receiver = node.receiver
|
|
101
|
+
return unless receiver.respond_to?(:type) && shared_state_read_type?(receiver.type)
|
|
102
|
+
return unless mutating_method?(node.method_name)
|
|
103
|
+
|
|
104
|
+
add_offense(
|
|
105
|
+
node,
|
|
106
|
+
message: format(
|
|
107
|
+
MSG_MUTATE,
|
|
108
|
+
kind: kind_for(receiver),
|
|
109
|
+
name: variable_name(receiver),
|
|
110
|
+
method: node.method_name
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
alias on_csend on_send
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# `@@x ||= []` parses as `(or-asgn (cvasgn :@@x) ...)`; the inner `cvasgn`
|
|
119
|
+
# would otherwise be reported alongside its own operator assignment.
|
|
120
|
+
def operator_assignment_target?(node)
|
|
121
|
+
parent = node.parent
|
|
122
|
+
!parent.nil? && OP_ASSIGNMENT_TYPES.include?(parent.type)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def shared_state_type?(type)
|
|
126
|
+
ASSIGNMENT_TYPES.include?(type) || shared_state_read_type?(type)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def shared_state_read_type?(type)
|
|
130
|
+
%i[cvar gvar].include?(type)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def kind_for(node)
|
|
134
|
+
node.type.to_s.start_with?("cv") ? "class variable" : "global"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def variable_name(node)
|
|
138
|
+
node.children.first
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def mutating_method?(method_name)
|
|
142
|
+
name = method_name.to_s
|
|
143
|
+
MUTATING_METHODS.include?(method_name) || name.end_with?("!", "=")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Constable
|
|
6
|
+
# A bare `sleep` is the single most common way a suite becomes both slow and
|
|
7
|
+
# flaky at once. It buys time the machine may not need and, on a loaded CI
|
|
8
|
+
# box, may not be enough of -- so it costs seconds on every green run and
|
|
9
|
+
# still fails on the red one.
|
|
10
|
+
#
|
|
11
|
+
# Wait on the condition itself instead. If the sleep *is* the subject under
|
|
12
|
+
# test -- you are exercising a real timeout path -- say so out loud with the
|
|
13
|
+
# `unsafe` escape hatch, which is reported in every run summary until someone
|
|
14
|
+
# deals with it.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# # bad
|
|
18
|
+
# investigate "expires the session" do
|
|
19
|
+
# sleep(0.2)
|
|
20
|
+
# attest(session).to be_expired
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # good
|
|
24
|
+
# investigate "expires the session" do
|
|
25
|
+
# travel_to(2.hours.from_now)
|
|
26
|
+
# attest(session).to be_expired
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# # good -- the timeout is the thing under test, and it says so
|
|
30
|
+
# investigate "times out after thirty seconds" do
|
|
31
|
+
# # testing an actual timeout path, not a code smell
|
|
32
|
+
# unsafe { sleep(0.1) }
|
|
33
|
+
# attest(subject).to have_timed_out
|
|
34
|
+
# end
|
|
35
|
+
class NoSleep < Base
|
|
36
|
+
include CaseScope
|
|
37
|
+
include Helpers
|
|
38
|
+
|
|
39
|
+
MSG = "Do not `sleep` in a case. It makes this investigation slow on every " \
|
|
40
|
+
"green run and flaky on the red one -- wait on the condition, freeze " \
|
|
41
|
+
"time, or if the delay itself is under test, wrap it in `unsafe { }` " \
|
|
42
|
+
"with a comment saying why."
|
|
43
|
+
|
|
44
|
+
RESTRICT_ON_SEND = %i[sleep].freeze
|
|
45
|
+
|
|
46
|
+
def on_send(node)
|
|
47
|
+
return unless constable_case_file?
|
|
48
|
+
return unless bare_sleep?(node)
|
|
49
|
+
return if inside_unsafe_block?(node)
|
|
50
|
+
|
|
51
|
+
add_offense(node)
|
|
52
|
+
end
|
|
53
|
+
alias on_csend on_send
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# `sleep(0.1)` and `Kernel.sleep(0.1)` both count. `foo.sleep` does not --
|
|
58
|
+
# that is somebody's own object, not the process going to bed.
|
|
59
|
+
def bare_sleep?(node)
|
|
60
|
+
receiver = node.receiver
|
|
61
|
+
return true if receiver.nil?
|
|
62
|
+
|
|
63
|
+
receiver.const_type? && constant_string(receiver) == "Kernel"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|