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,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: []