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.
@@ -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