rubocop-callbacksystems 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.
Files changed (182) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +78 -0
  4. data/config/default.yml +590 -0
  5. data/docs/cops.md +111 -0
  6. data/lib/rubocop/callbacksystems/autocorrection/batch.rb +135 -0
  7. data/lib/rubocop/callbacksystems/autocorrection/comment_rescue.rb +112 -0
  8. data/lib/rubocop/callbacksystems/autocorrection/rehearsal.rb +65 -0
  9. data/lib/rubocop/callbacksystems/autocorrection/rewrite_permit.rb +14 -0
  10. data/lib/rubocop/callbacksystems/class_structure/builder_assignment.rb +48 -0
  11. data/lib/rubocop/callbacksystems/class_structure/class_body.rb +50 -0
  12. data/lib/rubocop/callbacksystems/class_structure/divergence.rb +31 -0
  13. data/lib/rubocop/callbacksystems/class_structure/node_visibility.rb +82 -0
  14. data/lib/rubocop/callbacksystems/class_structure/reference_order.rb +36 -0
  15. data/lib/rubocop/callbacksystems/class_structure/statement_rank.rb +84 -0
  16. data/lib/rubocop/callbacksystems/class_structure/statement_run.rb +42 -0
  17. data/lib/rubocop/callbacksystems/class_structure/visibility.rb +84 -0
  18. data/lib/rubocop/callbacksystems/cops_document.rb +78 -0
  19. data/lib/rubocop/callbacksystems/execution/deferred_local_variables.rb +138 -0
  20. data/lib/rubocop/callbacksystems/execution/discarded_expression.rb +45 -0
  21. data/lib/rubocop/callbacksystems/execution/eager_evaluation_path.rb +73 -0
  22. data/lib/rubocop/callbacksystems/execution/immediate.rb +71 -0
  23. data/lib/rubocop/callbacksystems/execution/local_variable_occurrences.rb +112 -0
  24. data/lib/rubocop/callbacksystems/hashes/key_transform_detection.rb +108 -0
  25. data/lib/rubocop/callbacksystems/hashes/keyword_options.rb +37 -0
  26. data/lib/rubocop/callbacksystems/helpers/bodies.rb +150 -0
  27. data/lib/rubocop/callbacksystems/helpers/builders.rb +103 -0
  28. data/lib/rubocop/callbacksystems/helpers/comments.rb +56 -0
  29. data/lib/rubocop/callbacksystems/helpers/corrections.rb +15 -0
  30. data/lib/rubocop/callbacksystems/helpers/methods.rb +46 -0
  31. data/lib/rubocop/callbacksystems/helpers/names.rb +65 -0
  32. data/lib/rubocop/callbacksystems/helpers/node_types.rb +154 -0
  33. data/lib/rubocop/callbacksystems/helpers/rails.rb +52 -0
  34. data/lib/rubocop/callbacksystems/helpers/ranges.rb +107 -0
  35. data/lib/rubocop/callbacksystems/helpers/recursion.rb +29 -0
  36. data/lib/rubocop/callbacksystems/helpers/variables.rb +44 -0
  37. data/lib/rubocop/callbacksystems/helpers/visibility.rb +121 -0
  38. data/lib/rubocop/callbacksystems/helpers.rb +43 -0
  39. data/lib/rubocop/callbacksystems/methods/calls.rb +38 -0
  40. data/lib/rubocop/callbacksystems/methods/definitions.rb +218 -0
  41. data/lib/rubocop/callbacksystems/methods/delegate_macro.rb +144 -0
  42. data/lib/rubocop/callbacksystems/methods/delegation_target.rb +55 -0
  43. data/lib/rubocop/callbacksystems/methods/domain.rb +77 -0
  44. data/lib/rubocop/callbacksystems/methods/implicit_invocations.rb +17 -0
  45. data/lib/rubocop/callbacksystems/methods/macro_references.rb +238 -0
  46. data/lib/rubocop/callbacksystems/methods/reference_evidence.rb +74 -0
  47. data/lib/rubocop/callbacksystems/methods/siblings.rb +24 -0
  48. data/lib/rubocop/callbacksystems/methods/statement_definitions.rb +68 -0
  49. data/lib/rubocop/callbacksystems/offense.rb +13 -0
  50. data/lib/rubocop/callbacksystems/plugin.rb +27 -0
  51. data/lib/rubocop/callbacksystems/project_index/cache.rb +16 -0
  52. data/lib/rubocop/callbacksystems/project_index/confinement_cache.rb +40 -0
  53. data/lib/rubocop/callbacksystems/project_index/constant_references.rb +74 -0
  54. data/lib/rubocop/callbacksystems/project_index/corrections.rb +35 -0
  55. data/lib/rubocop/callbacksystems/project_index/coverage.rb +56 -0
  56. data/lib/rubocop/callbacksystems/project_index/diagnostics.rb +60 -0
  57. data/lib/rubocop/callbacksystems/project_index/freshness.rb +104 -0
  58. data/lib/rubocop/callbacksystems/project_index/loader.rb +9 -0
  59. data/lib/rubocop/callbacksystems/project_index/location.rb +29 -0
  60. data/lib/rubocop/callbacksystems/project_index/nested_class_confinement.rb +297 -0
  61. data/lib/rubocop/callbacksystems/project_index/nested_class_method_dispatch.rb +50 -0
  62. data/lib/rubocop/callbacksystems/project_index/reading.rb +129 -0
  63. data/lib/rubocop/callbacksystems/project_index/source_file.rb +59 -0
  64. data/lib/rubocop/callbacksystems/project_index/source_file_match.rb +57 -0
  65. data/lib/rubocop/callbacksystems/project_index/support.rb +46 -0
  66. data/lib/rubocop/callbacksystems/source/comment_blocks.rb +43 -0
  67. data/lib/rubocop/callbacksystems/source/comments.rb +141 -0
  68. data/lib/rubocop/callbacksystems/source/file_ast.rb +15 -0
  69. data/lib/rubocop/callbacksystems/source/files_checksum.rb +43 -0
  70. data/lib/rubocop/callbacksystems/source/single_line_call.rb +181 -0
  71. data/lib/rubocop/callbacksystems/source/statement_with_comments.rb +131 -0
  72. data/lib/rubocop/callbacksystems/testing/cop_helpers.rb +42 -0
  73. data/lib/rubocop/callbacksystems/testing/fixture_call.rb +30 -0
  74. data/lib/rubocop/callbacksystems/testing/fixture_names.rb +62 -0
  75. data/lib/rubocop/callbacksystems/testing/path_mapping.rb +104 -0
  76. data/lib/rubocop/cop/callbacksystems/application_controller_method_definition.rb +44 -0
  77. data/lib/rubocop/cop/callbacksystems/assert_with_expected_argument.rb +60 -0
  78. data/lib/rubocop/cop/callbacksystems/base.rb +134 -0
  79. data/lib/rubocop/cop/callbacksystems/body_order.rb +478 -0
  80. data/lib/rubocop/cop/callbacksystems/builder_method_parentheses.rb +74 -0
  81. data/lib/rubocop/cop/callbacksystems/collapse_multiline_expression.rb +140 -0
  82. data/lib/rubocop/cop/callbacksystems/comment_block_length.rb +137 -0
  83. data/lib/rubocop/cop/callbacksystems/complex_conditional.rb +75 -0
  84. data/lib/rubocop/cop/callbacksystems/controller_test_response_assertion.rb +105 -0
  85. data/lib/rubocop/cop/callbacksystems/controller_test_response_first_assertion.rb +123 -0
  86. data/lib/rubocop/cop/callbacksystems/data_clump.rb +339 -0
  87. data/lib/rubocop/cop/callbacksystems/declarative_method_naming.rb +350 -0
  88. data/lib/rubocop/cop/callbacksystems/delegate_requires_private_option.rb +81 -0
  89. data/lib/rubocop/cop/callbacksystems/eager_loading_scope_naming.rb +386 -0
  90. data/lib/rubocop/cop/callbacksystems/early_return.rb +215 -0
  91. data/lib/rubocop/cop/callbacksystems/empty_line_before_method.rb +168 -0
  92. data/lib/rubocop/cop/callbacksystems/empty_line_between_sections.rb +169 -0
  93. data/lib/rubocop/cop/callbacksystems/empty_private_section.rb +107 -0
  94. data/lib/rubocop/cop/callbacksystems/expanded_empty_class_or_module.rb +71 -0
  95. data/lib/rubocop/cop/callbacksystems/inline_memoized_computation.rb +110 -0
  96. data/lib/rubocop/cop/callbacksystems/job_enqueue_methods.rb +93 -0
  97. data/lib/rubocop/cop/callbacksystems/max_safe_navigation_depth.rb +118 -0
  98. data/lib/rubocop/cop/callbacksystems/method_invocation_order.rb +261 -0
  99. data/lib/rubocop/cop/callbacksystems/nested_class_order.rb +255 -0
  100. data/lib/rubocop/cop/callbacksystems/no_abbreviations.rb +197 -0
  101. data/lib/rubocop/cop/callbacksystems/no_anemic_delegation.rb +101 -0
  102. data/lib/rubocop/cop/callbacksystems/no_anemic_record.rb +309 -0
  103. data/lib/rubocop/cop/callbacksystems/no_assert_select.rb +31 -0
  104. data/lib/rubocop/cop/callbacksystems/no_bang_method_without_counterpart.rb +451 -0
  105. data/lib/rubocop/cop/callbacksystems/no_call_method.rb +73 -0
  106. data/lib/rubocop/cop/callbacksystems/no_comments_in_test_class_body.rb +159 -0
  107. data/lib/rubocop/cop/callbacksystems/no_consecutive_appends.rb +261 -0
  108. data/lib/rubocop/cop/callbacksystems/no_custom_route_actions.rb +124 -0
  109. data/lib/rubocop/cop/callbacksystems/no_direct_eager_loading.rb +90 -0
  110. data/lib/rubocop/cop/callbacksystems/no_each_with_early_return.rb +58 -0
  111. data/lib/rubocop/cop/callbacksystems/no_explicit_public_modifier.rb +81 -0
  112. data/lib/rubocop/cop/callbacksystems/no_hash_with_default_block.rb +68 -0
  113. data/lib/rubocop/cop/callbacksystems/no_local_variable_return.rb +173 -0
  114. data/lib/rubocop/cop/callbacksystems/no_manual_accumulation.rb +188 -0
  115. data/lib/rubocop/cop/callbacksystems/no_mixed_memoization.rb +145 -0
  116. data/lib/rubocop/cop/callbacksystems/no_nested_modules_in_classes.rb +78 -0
  117. data/lib/rubocop/cop/callbacksystems/no_nil_at_end_of_method.rb +74 -0
  118. data/lib/rubocop/cop/callbacksystems/no_redundant_validation_name.rb +37 -0
  119. data/lib/rubocop/cop/callbacksystems/no_redundant_wrapper_method.rb +161 -0
  120. data/lib/rubocop/cop/callbacksystems/no_section_divider_comments.rb +105 -0
  121. data/lib/rubocop/cop/callbacksystems/no_send_in_tests.rb +75 -0
  122. data/lib/rubocop/cop/callbacksystems/no_service_objects.rb +64 -0
  123. data/lib/rubocop/cop/callbacksystems/no_single_letter_names.rb +47 -0
  124. data/lib/rubocop/cop/callbacksystems/no_singleton_class_in_modules.rb +43 -0
  125. data/lib/rubocop/cop/callbacksystems/no_tap_collection_building.rb +173 -0
  126. data/lib/rubocop/cop/callbacksystems/no_turbo_stream_in_controllers.rb +52 -0
  127. data/lib/rubocop/cop/callbacksystems/no_typographic_clutter.rb +159 -0
  128. data/lib/rubocop/cop/callbacksystems/no_writes_in_get_actions.rb +149 -0
  129. data/lib/rubocop/cop/callbacksystems/ordered_mixin_arguments.rb +78 -0
  130. data/lib/rubocop/cop/callbacksystems/plural_controller_names.rb +103 -0
  131. data/lib/rubocop/cop/callbacksystems/prefer_attr_reader_over_instance_variable.rb +86 -0
  132. data/lib/rubocop/cop/callbacksystems/prefer_backslash_continuation.rb +478 -0
  133. data/lib/rubocop/cop/callbacksystems/prefer_class_for_state.rb +532 -0
  134. data/lib/rubocop/cop/callbacksystems/prefer_class_new_for_empty_nested_class.rb +247 -0
  135. data/lib/rubocop/cop/callbacksystems/prefer_class_over_builder_block.rb +124 -0
  136. data/lib/rubocop/cop/callbacksystems/prefer_default_argument.rb +225 -0
  137. data/lib/rubocop/cop/callbacksystems/prefer_delegate.rb +242 -0
  138. data/lib/rubocop/cop/callbacksystems/prefer_delegate_over_instance_variable_assignment.rb +175 -0
  139. data/lib/rubocop/cop/callbacksystems/prefer_excluding.rb +204 -0
  140. data/lib/rubocop/cop/callbacksystems/prefer_extract_associated.rb +54 -0
  141. data/lib/rubocop/cop/callbacksystems/prefer_it_block_parameter.rb +156 -0
  142. data/lib/rubocop/cop/callbacksystems/prefer_many.rb +57 -0
  143. data/lib/rubocop/cop/callbacksystems/prefer_method_over_lambda.rb +211 -0
  144. data/lib/rubocop/cop/callbacksystems/prefer_method_over_variable.rb +172 -0
  145. data/lib/rubocop/cop/callbacksystems/prefer_module_for_static_class.rb +286 -0
  146. data/lib/rubocop/cop/callbacksystems/prefer_ordinal_array_access.rb +136 -0
  147. data/lib/rubocop/cop/callbacksystems/prefer_positive_wrap.rb +265 -0
  148. data/lib/rubocop/cop/callbacksystems/prefer_squish.rb +47 -0
  149. data/lib/rubocop/cop/callbacksystems/prefer_stringify_keys.rb +24 -0
  150. data/lib/rubocop/cop/callbacksystems/prefer_symbolize_keys.rb +24 -0
  151. data/lib/rubocop/cop/callbacksystems/prefer_turbo_page_refresh_broadcasts.rb +40 -0
  152. data/lib/rubocop/cop/callbacksystems/private_constants_in_private_section.rb +160 -0
  153. data/lib/rubocop/cop/callbacksystems/private_delegate_placement.rb +342 -0
  154. data/lib/rubocop/cop/callbacksystems/private_methods_in_private_section.rb +70 -0
  155. data/lib/rubocop/cop/callbacksystems/private_nested_class_method_visibility.rb +269 -0
  156. data/lib/rubocop/cop/callbacksystems/private_nested_classes.rb +94 -0
  157. data/lib/rubocop/cop/callbacksystems/public_methods_must_have_tests.rb +190 -0
  158. data/lib/rubocop/cop/callbacksystems/redundant_concern_extend.rb +157 -0
  159. data/lib/rubocop/cop/callbacksystems/redundant_constant_freeze.rb +57 -0
  160. data/lib/rubocop/cop/callbacksystems/redundant_constant_namespace.rb +134 -0
  161. data/lib/rubocop/cop/callbacksystems/reflow_comments.rb +133 -0
  162. data/lib/rubocop/cop/callbacksystems/repeated_fixture_in_tests.rb +106 -0
  163. data/lib/rubocop/cop/callbacksystems/restricted_controller_actions.rb +85 -0
  164. data/lib/rubocop/cop/callbacksystems/routes_module_scope.rb +336 -0
  165. data/lib/rubocop/cop/callbacksystems/single_line_setup_block.rb +107 -0
  166. data/lib/rubocop/cop/callbacksystems/single_singleton_class.rb +49 -0
  167. data/lib/rubocop/cop/callbacksystems/single_top_level_class_or_module.rb +160 -0
  168. data/lib/rubocop/cop/callbacksystems/single_use_fixture_variable.rb +211 -0
  169. data/lib/rubocop/cop/callbacksystems/single_use_setup_variable.rb +684 -0
  170. data/lib/rubocop/cop/callbacksystems/singular_scope_names.rb +47 -0
  171. data/lib/rubocop/cop/callbacksystems/space_inside_percent_literal.rb +73 -0
  172. data/lib/rubocop/cop/callbacksystems/system_test_browser_driver.rb +30 -0
  173. data/lib/rubocop/cop/callbacksystems/test_method_definition.rb +86 -0
  174. data/lib/rubocop/cop/callbacksystems/test_method_order.rb +319 -0
  175. data/lib/rubocop/cop/callbacksystems/too_many_instance_variables.rb +78 -0
  176. data/lib/rubocop/cop/callbacksystems/too_many_local_variables.rb +77 -0
  177. data/lib/rubocop/cop/callbacksystems/too_many_validation_guards.rb +84 -0
  178. data/lib/rubocop/cop/callbacksystems/unnecessary_local_variable.rb +176 -0
  179. data/lib/rubocop/cop/callbacksystems/unused_private_method_in_nested_class.rb +203 -0
  180. data/lib/rubocop-callbacksystems.rb +21 -0
  181. data/rubocop.yml +685 -0
  182. metadata +337 -0
@@ -0,0 +1,684 @@
1
+ # Detects an instance variable assigned in `setup` that at most one test reads.
2
+ # A `setup` assignment says the tests share the value, so a reader keeps it in
3
+ # mind for every test, where one read by a single test belongs in that test,
4
+ # and one no test reads is a record the file loads for nothing.
5
+ #
6
+ # @example
7
+ # # bad - @order is only used in one test
8
+ # setup do
9
+ # @order = orders(:one)
10
+ # end
11
+ #
12
+ # test "order is valid" do
13
+ # assert @order.valid?
14
+ # end
15
+ #
16
+ # test "something else" do
17
+ # assert true
18
+ # end
19
+ #
20
+ # # bad - @order is not used in any test
21
+ # setup do
22
+ # @order = orders(:one)
23
+ # end
24
+ #
25
+ # # good - variable used in multiple tests
26
+ # setup do
27
+ # @order = orders(:one)
28
+ # end
29
+ #
30
+ # test "order is valid" do
31
+ # assert @order.valid?
32
+ # end
33
+ #
34
+ # test "order has items" do
35
+ # assert @order.items.any?
36
+ # end
37
+ #
38
+ class RuboCop::Cop::Callbacksystems::SingleUseSetupVariable < RuboCop::Cop::Callbacksystems::Base
39
+ include RuboCop::Callbacksystems::Testing::CopHelpers
40
+ extend RuboCop::Cop::AutoCorrector
41
+
42
+ def on_new_investigation
43
+ test_domains.each { report_each it } if processed_source.ast
44
+ end
45
+
46
+ private
47
+ def test_domains
48
+ TestDomains.new(processed_source.ast, processed_source:, setups: setup_blocks, tests: test_blocks)
49
+ end
50
+
51
+ class TestContext
52
+ attr_reader :tests, :writes, :setup_callbacks, :processed_source
53
+
54
+ def initialize(tests:, reads:, writes:, setup_callbacks:, processed_source:)
55
+ @tests = tests
56
+ @writes = writes
57
+ @setup_callbacks = setup_callbacks
58
+ @processed_source = processed_source
59
+ @variable_usages = InstanceVariableUsages.new(reads, tests:)
60
+ end
61
+
62
+ def usage_of(variable_name)
63
+ variable_usages.for(variable_name)
64
+ end
65
+
66
+ private
67
+ attr_reader :variable_usages
68
+ end
69
+
70
+ # Each class or concern owns its setup, tests, and reads; names repeated in another container are unrelated.
71
+ class TestDomains
72
+ include Enumerable
73
+ include RuboCop::Callbacksystems::Helpers
74
+
75
+ def initialize(ast, processed_source:, setups:, tests:)
76
+ @ast = ast
77
+ @processed_source = processed_source
78
+ @setups = setups
79
+ @tests = tests
80
+ end
81
+
82
+ def each
83
+ setup_groups.each do |scope, grouped_setups|
84
+ context = TestContext.new(tests: test_groups.fetch(scope, []), reads: read_groups.fetch(scope, []),
85
+ writes: write_groups.fetch(scope, []), setup_callbacks: setup_callback_groups.fetch(scope, []),
86
+ processed_source:)
87
+
88
+ yield TestDomain.new(scope, setups: grouped_setups, context:)
89
+ end
90
+ end
91
+
92
+ private
93
+ attr_reader :ast, :processed_source, :setups, :tests
94
+
95
+ def setup_groups
96
+ @setup_groups ||= groups_of(setups)
97
+ end
98
+
99
+ def groups_of(nodes)
100
+ {}.compare_by_identity.tap do |groups|
101
+ nodes.each { (groups[scope_of(it)] ||= []) << it }
102
+ end
103
+ end
104
+
105
+ def scope_of(node)
106
+ RuboCop::Callbacksystems::Methods::Domain.new(node).container || ast
107
+ end
108
+
109
+ def test_groups
110
+ @test_groups ||= groups_of(tests)
111
+ end
112
+
113
+ def read_groups
114
+ @read_groups ||= content_groups_of(nodes_in(ast, :ivar))
115
+ end
116
+
117
+ def content_groups_of(nodes)
118
+ {}.compare_by_identity.tap do |groups|
119
+ nodes.each do |node|
120
+ content_scopes_of(node).each { (groups[it] ||= []) << node }
121
+ end
122
+ end
123
+ end
124
+
125
+ def content_scopes_of(node)
126
+ ContentDomains.new(node, domain_scope_index:, included_domains: included_domain_scopes).to_a
127
+ end
128
+
129
+ def domain_scope_index
130
+ @domain_scope_index ||= {}.compare_by_identity.tap do |scopes|
131
+ [ *setup_groups.keys, *test_groups.keys ].each { scopes[it] = true }
132
+ end
133
+ end
134
+
135
+ def included_domain_scopes
136
+ @included_domain_scopes ||= domain_scope_index.keys.select do |scope|
137
+ any_block_type?(scope) && call_on_self?(call_of(scope)) && scope.method?(:included)
138
+ end
139
+ end
140
+
141
+ def write_groups
142
+ @write_groups ||= content_groups_of(nodes_in(ast, :ivasgn))
143
+ end
144
+
145
+ def setup_callback_groups
146
+ @setup_callback_groups ||= groups_of(setup_callbacks)
147
+ end
148
+
149
+ def setup_callbacks
150
+ nodes_in(ast, :send, :csend).select { call_on_self?(it) && it.method?(:setup) }
151
+ .map { callback_node_for(it) }.sort_by { it.source_range.begin_pos }
152
+ end
153
+
154
+ def callback_node_for(call)
155
+ if any_block_type?(call.parent) && call_of(call.parent).equal?(call)
156
+ call.parent
157
+ else
158
+ call
159
+ end
160
+ end
161
+
162
+ # A read or write belongs to the nearest active test domain, but an unopened class-like boundary owns its state
163
+ # too. Concern helpers outside `included` share that block's runtime target without crossing nested owners.
164
+ class ContentDomains
165
+ include RuboCop::Callbacksystems::Helpers
166
+
167
+ def initialize(node, domain_scope_index:, included_domains:)
168
+ @node = node
169
+ @domain_scope_index = domain_scope_index
170
+ @included_domains = included_domains
171
+ end
172
+
173
+ def to_a
174
+ {}.compare_by_identity.tap do |domains|
175
+ primary_domain&.then { domains[it] = true }
176
+ related_included_domains.each { domains[it] = true }
177
+ end.keys
178
+ end
179
+
180
+ private
181
+ attr_reader :node, :domain_scope_index, :included_domains
182
+
183
+ def primary_domain
184
+ nearest_domain_or_boundary if domain_scope_index.key?(nearest_domain_or_boundary)
185
+ end
186
+
187
+ def nearest_domain_or_boundary
188
+ @nearest_domain_or_boundary ||= node.each_ancestor.find do |ancestor|
189
+ domain_scope_index.key?(ancestor) || test_domain_boundary?(ancestor)
190
+ end
191
+ end
192
+
193
+ def test_domain_boundary?(candidate)
194
+ candidate.type?(:class, :module, :sclass) ||
195
+ (any_block_type?(candidate) && self_rebinding_boundary?(candidate))
196
+ end
197
+
198
+ def related_included_domains
199
+ included_domains.select do |scope|
200
+ direct_owner_of(scope).equal?(direct_owner) &&
201
+ (!primary_domain || primary_domain.equal?(scope) || primary_domain.equal?(direct_owner))
202
+ end
203
+ end
204
+
205
+ def direct_owner_of(subject)
206
+ subject.each_ancestor.find { test_domain_boundary?(it) }
207
+ end
208
+
209
+ def direct_owner
210
+ @direct_owner ||= direct_owner_of(node)
211
+ end
212
+ end
213
+ end
214
+
215
+ # One lexical test container, which excludes inherited setup and same-named variables in its neighbours.
216
+ class TestDomain
217
+ include RuboCop::Callbacksystems::Helpers
218
+
219
+ def initialize(scope, setups:, context:)
220
+ @scope = scope
221
+ @setups = setups
222
+ @context = context
223
+ end
224
+
225
+ def each_offense
226
+ return if abstract_base_class?
227
+
228
+ setups.each { setup_analysis_for(it).each_offense { yield it } }
229
+ end
230
+
231
+ private
232
+ attr_reader :scope, :setups, :context
233
+
234
+ delegate :tests, to: :context, private: true
235
+
236
+ # A base class with no tests of its own hands its setup to subclasses that may live in other files.
237
+ def abstract_base_class?
238
+ scope.class_type? && rails_test_base_class?(scope.parent_class) && tests.empty?
239
+ end
240
+
241
+ def setup_analysis_for(setup)
242
+ SetupBlock.new(setup, assignments: unambiguous_assignments.fetch(setup), context:)
243
+ end
244
+
245
+ def unambiguous_assignments
246
+ @unambiguous_assignments ||= assignments_by_setup.transform_values do |assignments|
247
+ assignments.reject { repeated_names.include?(it.name) }
248
+ end
249
+ end
250
+
251
+ def assignments_by_setup
252
+ @assignments_by_setup ||= {}.compare_by_identity.tap do |grouped|
253
+ setups.each { grouped[it] = SetupAssignments.new(it.body, boundary: it).to_a }
254
+ end
255
+ end
256
+
257
+ def repeated_names
258
+ @repeated_names ||= assignments_by_setup.values.flatten.group_by(&:name)
259
+ .select { |_name, assignments| assignments.many? }.keys
260
+ end
261
+ end
262
+
263
+ class SetupBlock
264
+ include RuboCop::Callbacksystems::Helpers
265
+
266
+ def initialize(setup, assignments:, context:)
267
+ @setup = setup
268
+ @assignments = assignments
269
+ @context = context
270
+ @tooling_protection = ToolingProtection.new(setup, assignments, context.processed_source)
271
+ end
272
+
273
+ def each_offense
274
+ offending.each { yield offense_for(it) }
275
+ end
276
+
277
+ private
278
+ attr_reader :setup, :assignments, :context, :tooling_protection
279
+
280
+ def offending
281
+ @offending ||= assignments.map { assignment_for(it) }.select(&:offending?)
282
+ end
283
+
284
+ def assignment_for(node)
285
+ SetupAssignment.new(node, setup:, context:, tooling_protection:)
286
+ end
287
+
288
+ def offense_for(assignment)
289
+ if assignment.correctable?
290
+ RuboCop::Callbacksystems::Offense.new(assignment.node, assignment.message) { correct(assignment, it) }
291
+ else
292
+ RuboCop::Callbacksystems::Offense.new(assignment.node, assignment.message)
293
+ end
294
+ end
295
+
296
+ def correct(assignment, corrector)
297
+ assignment.correct(corrector)
298
+ removal_range_for(assignment)&.then { corrector.remove(it) }
299
+ end
300
+
301
+ def removal_range_for(assignment)
302
+ if cleared?
303
+ block_removal_range if assignment.equal?(first_removable)
304
+ elsif assignment.removable?
305
+ statement_removal_range_for(assignment.node)
306
+ end
307
+ end
308
+
309
+ def cleared?
310
+ removable.size == statements_in(setup.body).size
311
+ end
312
+
313
+ def removable
314
+ @removable ||= offending.select(&:removable?)
315
+ end
316
+
317
+ def first_removable
318
+ removable.min_by { it.node.source_range.begin_pos }
319
+ end
320
+
321
+ # The blank line below the block goes too, so the first test keeps its distance from the line above, and the
322
+ # removal of a block written right below takes its own blank line instead of sharing this one.
323
+ def block_removal_range
324
+ inline_statement_removal_range_for(setup) || standalone_block_removal_range
325
+ end
326
+
327
+ def standalone_block_removal_range
328
+ setup.source_range.with(begin_pos: line_start_position_of(setup), end_pos: position_past_trailing_blank)
329
+ end
330
+
331
+ def position_past_trailing_blank
332
+ position_past_newline_at(position_past_newline_at(setup.source_range.end_pos))
333
+ end
334
+
335
+ def position_past_newline_at(position)
336
+ newline_at?(position) ? position + 1 : position
337
+ end
338
+
339
+ def newline_at?(position)
340
+ setup.source_range.source_buffer.source[position] == "\n"
341
+ end
342
+ end
343
+
344
+ # Tooling comments cannot move with an assignment. A comment belonging to one assignment protects that assignment;
345
+ # one belonging to the setup itself protects them all because removing the callback would change its scope.
346
+ class ToolingProtection
347
+ include RuboCop::Callbacksystems::Helpers
348
+
349
+ def initialize(setup, assignments, processed_source)
350
+ @setup = setup
351
+ @assignments = assignments
352
+ @processed_source = processed_source
353
+ @source_comments = RuboCop::Callbacksystems::Source::Comments.for(processed_source)
354
+ end
355
+
356
+ def protects?(assignment)
357
+ unassigned_tooling? || assignment_block_for(assignment).contains_tooling_comment?
358
+ end
359
+
360
+ private
361
+ attr_reader :setup, :assignments, :processed_source, :source_comments
362
+
363
+ def unassigned_tooling?
364
+ tooling_comments.any? do |comment|
365
+ assignment_blocks.values.none? { it.range.contains?(comment.source_range) }
366
+ end
367
+ end
368
+
369
+ def tooling_comments
370
+ @tooling_comments ||= source_comments.within(setup_block.range).select { tooling_comment?(it) }
371
+ end
372
+
373
+ def setup_block
374
+ @setup_block ||= RuboCop::Callbacksystems::Source::StatementWithComments.new(setup, processed_source)
375
+ end
376
+
377
+ def assignment_blocks
378
+ @assignment_blocks ||= {}.compare_by_identity.tap do |blocks|
379
+ assignments.each do |assignment|
380
+ blocks[assignment] = RuboCop::Callbacksystems::Source::StatementWithComments.new \
381
+ assignment, processed_source
382
+ end
383
+ end
384
+ end
385
+
386
+ def assignment_block_for(assignment)
387
+ assignment_blocks.fetch(assignment)
388
+ end
389
+ end
390
+
391
+ class SetupAssignment
392
+ include RuboCop::Callbacksystems::Helpers
393
+
394
+ USED_ONCE_MESSAGE = "Instance variable `%<variable>s` is only used in one test. Inline it instead of assigning " \
395
+ "in `setup`."
396
+ UNUSED_MESSAGE = "Instance variable `%<variable>s` assigned in `setup` is not used by any test. Remove the " \
397
+ "assignment."
398
+ INLINABLE_TYPES = %i[
399
+ int float rational complex str dstr sym dsym regexp true false nil self array hash const lvar ivar cvar gvar
400
+ ]
401
+
402
+ attr_reader :node
403
+
404
+ def initialize(node, setup:, context:, tooling_protection:)
405
+ @node = node
406
+ @setup = setup
407
+ @context = context
408
+ @tooling_protection = tooling_protection
409
+ end
410
+
411
+ def offending?
412
+ usage.unused? || usage.single_use?
413
+ end
414
+
415
+ def message
416
+ format(usage.unused? ? UNUSED_MESSAGE : USED_ONCE_MESSAGE, variable: node.name)
417
+ end
418
+
419
+ def correct(corrector)
420
+ if inline_target
421
+ replace_expression(corrector, inline_target, with: value.source)
422
+ elsif usage.unused? && !discardable_value?
423
+ corrector.replace(node, value.source)
424
+ end
425
+ end
426
+
427
+ def correctable?
428
+ removable? || replaceable_unused_value?
429
+ end
430
+
431
+ def removable?
432
+ source_self_contained? && !tooling_protection.protects?(node) && direct_statement? &&
433
+ (inline_target || (usage.unused? && discardable_value?))
434
+ end
435
+
436
+ private
437
+ attr_reader :setup, :context, :tooling_protection
438
+
439
+ delegate :tests, :writes, to: :context, private: true
440
+
441
+ def usage
442
+ @usage ||= context.usage_of(node.name)
443
+ end
444
+
445
+ def inline_target
446
+ usage.sole_direct_read if directly_inlinable?
447
+ end
448
+
449
+ def directly_inlinable?
450
+ direct_statement? && stable_single_use? && relocation_safe?
451
+ end
452
+
453
+ def direct_statement?
454
+ statements_in(setup.body).any? { it.equal?(node) }
455
+ end
456
+
457
+ def stable_single_use?
458
+ sole_write? && single_use_of_inlinable_value?
459
+ end
460
+
461
+ def sole_write?
462
+ writes.one? { it.name == node.name }
463
+ end
464
+
465
+ def single_use_of_inlinable_value?
466
+ usage.single_use? && inlinable_value?
467
+ end
468
+
469
+ def inlinable_value?
470
+ INLINABLE_TYPES.include?(value.type) || primary_call?
471
+ end
472
+
473
+ def value
474
+ node.expression
475
+ end
476
+
477
+ def primary_call?
478
+ value.call_type? && plain_method? && primary_shape?
479
+ end
480
+
481
+ def plain_method?
482
+ !value.operator_method? && !value.comparison_method?
483
+ end
484
+
485
+ def primary_shape?
486
+ value.arguments.empty? || value.parenthesized?
487
+ end
488
+
489
+ def relocation_safe?
490
+ value.recursive_basic_literal? || relocation.safe?
491
+ end
492
+
493
+ def relocation
494
+ @relocation ||= ValueRelocation.new(node, setup:, context:, read: usage.sole_direct_read)
495
+ end
496
+
497
+ def discardable_value?
498
+ value.recursive_basic_literal?
499
+ end
500
+
501
+ def source_self_contained?
502
+ !carries_heredoc?(node)
503
+ end
504
+
505
+ def replaceable_unused_value?
506
+ direct_statement? && usage.unused? && !discardable_value?
507
+ end
508
+
509
+ # Moving an evaluated value between callbacks is only offered when every ordering fact visible in this file is
510
+ # preserved. Calls remain intrinsically unsafe because external callbacks and their side effects are unknowable.
511
+ class ValueRelocation
512
+ include RuboCop::Callbacksystems::Helpers
513
+
514
+ LAZY_ANCESTOR_TYPES = %i[
515
+ and case case_match csend defined? ensure for if in_pattern or rescue resbody
516
+ until until_post while while_post
517
+ ]
518
+ LOCAL_BINDING_TYPES = %i[ lvar lvasgn match_var ]
519
+
520
+ def initialize(assignment, setup:, context:, read:)
521
+ @assignment = assignment
522
+ @setup = setup
523
+ @context = context
524
+ @read = read
525
+ end
526
+
527
+ def safe?
528
+ read && keeps_scope? && follows_visible_setup_work? && eagerly_leads_test_work?
529
+ end
530
+
531
+ private
532
+ attr_reader :assignment, :setup, :context, :read
533
+
534
+ delegate :setup_callbacks, :tests, to: :context, private: true
535
+
536
+ def keeps_scope?
537
+ nodes_in(assignment.expression, *LOCAL_BINDING_TYPES).empty?
538
+ end
539
+
540
+ def follows_visible_setup_work?
541
+ setup_callbacks.last.equal?(setup) && statements_in(setup.body).last.equal?(assignment)
542
+ end
543
+
544
+ def eagerly_leads_test_work?
545
+ tests.any? { it.equal?(test) } && lazy_ancestors.empty? && prior_effects.empty?
546
+ end
547
+
548
+ def test
549
+ @test ||= read.each_ancestor(:any_block).first
550
+ end
551
+
552
+ def lazy_ancestors
553
+ evaluation_path.select { it.type?(*LAZY_ANCESTOR_TYPES) }
554
+ end
555
+
556
+ def evaluation_path
557
+ @evaluation_path ||= read.each_ancestor.take_while { !it.equal?(test) }
558
+ end
559
+
560
+ def prior_effects
561
+ current = read
562
+ evaluation_path.flat_map do |ancestor|
563
+ ancestor.child_nodes.take_while { !it.equal?(current) }.reject(&:recursive_basic_literal?).tap do
564
+ current = ancestor
565
+ end
566
+ end
567
+ end
568
+ end
569
+ end
570
+
571
+ # All reads in a test domain are indexed once, then every setup assignment asks only for its own variable.
572
+ class InstanceVariableUsages
573
+ def initialize(reads, tests:)
574
+ @test_membership = TestMembership.new(tests)
575
+ @reads_by_name = {}
576
+ @usages = {}
577
+
578
+ reads.each do |read|
579
+ (reads_by_name[read.name] ||= []) << read
580
+ test_membership.index(read)
581
+ end
582
+ end
583
+
584
+ def for(variable_name)
585
+ usages[variable_name] ||= InstanceVariableUsage.new(reads_by_name.fetch(variable_name, []), test_membership:)
586
+ end
587
+
588
+ private
589
+ attr_reader :reads_by_name, :test_membership, :usages
590
+
591
+ class TestMembership
592
+ Membership = Data.define(:tests, :direct)
593
+
594
+ def initialize(tests)
595
+ @test_index = {}.compare_by_identity
596
+ @memberships = {}.compare_by_identity
597
+ tests.each { test_index[it] = true }
598
+ end
599
+
600
+ def index(read)
601
+ memberships[read] = membership_of(read)
602
+ end
603
+
604
+ def all_inside?(reads)
605
+ reads.all? { memberships.fetch(it).tests.any? }
606
+ end
607
+
608
+ def one_test_for?(reads)
609
+ reads.each_with_object({}.compare_by_identity) do |read, reading_tests|
610
+ memberships.fetch(read).tests.each { reading_tests[it] = true }
611
+ end.one?
612
+ end
613
+
614
+ def direct?(read)
615
+ memberships.fetch(read).direct
616
+ end
617
+
618
+ private
619
+ attr_reader :memberships, :test_index
620
+
621
+ def membership_of(read)
622
+ nearest_block = nil
623
+ tests = read.each_ancestor(:any_block).with_object([]) do |block, containing_tests|
624
+ nearest_block ||= block
625
+ containing_tests << block if test_index.key?(block)
626
+ end
627
+
628
+ Membership.new(tests, test_index.key?(nearest_block))
629
+ end
630
+ end
631
+ end
632
+
633
+ class InstanceVariableUsage
634
+ def initialize(reads, test_membership:)
635
+ @reads = reads
636
+ @test_membership = test_membership
637
+ end
638
+
639
+ def unused?
640
+ reads.none?
641
+ end
642
+
643
+ # A read outside the tests, in a helper or a teardown, keeps the variable shared however few tests read it.
644
+ def single_use?
645
+ test_membership.all_inside?(reads) && test_membership.one_test_for?(reads)
646
+ end
647
+
648
+ # A read inside a block of its test would run the value again, so only a direct read counts as the one read.
649
+ def sole_direct_read
650
+ sole_read if sole_read && test_membership.direct?(sole_read)
651
+ end
652
+
653
+ private
654
+ attr_reader :reads, :test_membership
655
+
656
+ def sole_read
657
+ reads.first if reads.one?
658
+ end
659
+ end
660
+
661
+ # Instance-variable assignments executed by a setup, without descending through a deferred lexical boundary.
662
+ class SetupAssignments
663
+ include Enumerable
664
+ include RuboCop::Callbacksystems::Helpers
665
+
666
+ def initialize(body, boundary: nil)
667
+ @body = body
668
+ @boundary = boundary
669
+ end
670
+
671
+ def each
672
+ assignments.each { yield it } if block_given?
673
+ to_enum(__method__) unless block_given?
674
+ end
675
+
676
+ private
677
+ attr_reader :body, :boundary
678
+
679
+ def assignments
680
+ RuboCop::Callbacksystems::Execution::Immediate.new(body).nodes_of_type(:ivasgn)
681
+ .select { it.expression && self_preserved_between?(it, boundary:) }
682
+ end
683
+ end
684
+ end