rubocop-dev_doc 0.7.0 → 0.8.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +138 -1
  3. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
  4. data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
  5. data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
  6. data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
  7. data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
  8. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
  9. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
  10. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
  11. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
  12. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
  13. data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
  14. data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
  15. data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
  16. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
  17. data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
  18. data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
  19. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
  20. data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +5 -3
  21. data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
  22. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
  23. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
  24. data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
  25. data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
  26. data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
  27. data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
  28. data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
  29. data/lib/rubocop/dev_doc/version.rb +1 -1
  30. metadata +11 -2
@@ -47,12 +47,16 @@ module RuboCop
47
47
  # OrderJob.perform_later(self)
48
48
  # end
49
49
  class NoPerformLaterInModel < Base
50
- MSG = 'Avoid `perform_later` in model files. Call it from the controller, ' \
51
- 'or use an explicit method name to signal the side effect.'.freeze
52
- RESTRICT_ON_SEND = %i[perform_later].freeze
50
+ MSG = 'Avoid `%<method>s` in model files it enqueues an ActiveJob (mailer deliveries ' \
51
+ 'included), with the same transaction/stale-data hazard as `perform_later`. Call it ' \
52
+ 'from the controller, or use an explicit method name to signal the side effect.'.freeze
53
+ # deliver_later is ActiveJob under the hood (MailDeliveryJob) and
54
+ # enqueue is the primitive perform_later wraps — omitting either
55
+ # would leave an everyday spelling of the same async enqueue.
56
+ RESTRICT_ON_SEND = %i[perform_later perform_all_later deliver_later deliver_later! enqueue].freeze
53
57
 
54
58
  def on_send(node)
55
- add_offense(node.loc.selector)
59
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
56
60
  end
57
61
  end
58
62
  end
@@ -60,7 +60,9 @@ module RuboCop
60
60
  ].freeze
61
61
 
62
62
  def on_send(node)
63
- return unless node.receiver.nil?
63
+ # `self.head(...)` is the same controller call — a bare receiver
64
+ # check would make explicit-self a one-token dodge.
65
+ return unless node.receiver.nil? || node.receiver.self_type?
64
66
 
65
67
  status_node = node.arguments.first
66
68
  return unless status_node
@@ -62,7 +62,9 @@ module RuboCop
62
62
  class AvoidSend < Base
63
63
  MSG = "Avoid dynamic `%<method>s` — use bracket notation for model attributes, " \
64
64
  "or a prefix (`obj.send(\"export_\#{x}\")`) to restrict callable methods.".freeze
65
- RESTRICT_ON_SEND = %i[send public_send].freeze
65
+ # `__send__` is the canonical alias — omitting it would leave a
66
+ # zero-cost dodge for the exact dynamic dispatch this cop restricts.
67
+ RESTRICT_ON_SEND = %i[send public_send __send__].freeze
66
68
 
67
69
  def on_send(node)
68
70
  return if node.receiver.nil?
@@ -0,0 +1,103 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag `||` between literals in a boolean condition (`if`, `unless`,
6
+ # `while`, `until`, and ternary). The operator folds to one operand
7
+ # immediately (`:a || :b` is just `:a`), so the condition is constant —
8
+ # the branch (or loop body) always runs the same way, and the other
9
+ # literal is dead.
10
+ #
11
+ # ## Rationale
12
+ # `if :billing || :payments` reads like "either is truthy" but compiles
13
+ # to `if :billing` — always truthy. The author almost certainly meant a
14
+ # real comparison (`==`) or a variable, not two literal alternatives.
15
+ # Unlike `DevDoc/Style/LiteralOrInWhenClause`, a condition has no
16
+ # comma-form alternative, so the right fix depends on intent and is NOT
17
+ # autocorrected — the cop only flags.
18
+ #
19
+ # Scoped to `||` only: core `Lint/LiteralAsCondition` already catches
20
+ # literal `&&` chains and single literals, but misses `||` chains and
21
+ # ternaries — this cop fills exactly that gap with no overlap.
22
+ #
23
+ # ❌ always truthy — :payments is dead
24
+ # if :billing || :payments
25
+ # charge
26
+ # end
27
+ #
28
+ # ✔️ a real comparison
29
+ # if status == :billing || status == :payments
30
+ # charge
31
+ # end
32
+ #
33
+ # @example
34
+ # # bad
35
+ # x = (:a || :b) ? one : two
36
+ #
37
+ # # good
38
+ # x = (val == :a || val == :b) ? one : two
39
+ class LiteralOperatorInCondition < Base
40
+ MSG = '`%<source>s` always evaluates to `%<result>s`, so this condition is constant.'.freeze
41
+
42
+ LITERAL_TYPES = %i[sym str int float true false nil].freeze
43
+ FALSY_TYPES = %i[false nil].freeze
44
+
45
+ # `unless` desugars to `if` and the ternary `? :` is an `if` node, so a
46
+ # single on_if covers both (plus modifier forms). `until` mirrors
47
+ # `while` — both expose `condition`.
48
+ def on_if(node)
49
+ check(node.condition)
50
+ end
51
+
52
+ def on_while(node)
53
+ check(node.condition)
54
+ end
55
+
56
+ alias on_until on_while
57
+
58
+ private
59
+
60
+ def check(condition)
61
+ inner = unwrap_begin(condition)
62
+ return unless inner.or_type?
63
+
64
+ leaves = flatten_chain(inner)
65
+ return unless leaves.all? { |leaf| LITERAL_TYPES.include?(leaf.type) }
66
+
67
+ add_offense(
68
+ inner,
69
+ message: format(MSG, source: inner.source, result: fold(leaves).source)
70
+ )
71
+ end
72
+
73
+ # The helpers below parallel DevDoc/Style/LiteralOrInWhenClause
74
+ # (`||`-only here). Lift them into a shared module if a third consumer
75
+ # appears; duplicated for now to keep that cop untouched.
76
+
77
+ # Parentheses wrap a condition in a `begin` node; unwrap it.
78
+ def unwrap_begin(node)
79
+ node = node.children.first while node.begin_type?
80
+ node
81
+ end
82
+
83
+ # Flatten a homogeneous `||` chain into its operand nodes. An operand
84
+ # wrapped in parentheses arrives as a `begin` node (`(:a || :b) || :c`),
85
+ # so unwrap before recursing; otherwise the inner chain reads as a
86
+ # single non-literal leaf and slips past the all-literal check.
87
+ def flatten_chain(node)
88
+ node = unwrap_begin(node)
89
+ return [node] unless node.or_type?
90
+
91
+ flatten_chain(node.lhs) + flatten_chain(node.rhs)
92
+ end
93
+
94
+ # The first truthy literal the `||` chain folds to (or its last
95
+ # operand when the whole chain is nil/false).
96
+ def fold(leaves)
97
+ leaves.find { |leaf| !FALSY_TYPES.include?(leaf.type) } || leaves.last
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,134 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag `||` / `&&` between literals inside a `when` clause. The operator
6
+ # evaluates immediately (`:a || :b` is just `:a`), so the other literal
7
+ # silently never matches — `when` alternatives must be comma-separated.
8
+ #
9
+ # ## Rationale
10
+ # `when :issuing_country || :nationality` reads like "either of these"
11
+ # but compiles to `when :issuing_country`; the `:nationality` branch is
12
+ # dead from day one and nothing at runtime ever hints at it. No core or
13
+ # rubocop-rails cop catches this shape. Restricted to literal operands
14
+ # the pattern is provably wrong, so the false-positive surface is
15
+ # effectively zero (`when SOME_CONST || fallback` and variable operands
16
+ # are left alone — unusual, but potentially intentional).
17
+ #
18
+ # ❌ :nationality can never match
19
+ # when :issuing_country || :nationality
20
+ #
21
+ # ✔️ comma-separated alternatives
22
+ # when :issuing_country, :nationality
23
+ #
24
+ # Autocorrect rewrites `||` chains to a comma list. It is marked unsafe
25
+ # because it deliberately changes behavior: the dead alternative starts
26
+ # matching, which is the fix — but any code relying on the buggy
27
+ # behavior must be re-verified. Chains containing `nil`/`false` and all
28
+ # `&&` forms are flagged without autocorrect (the intent is ambiguous).
29
+ #
30
+ # @example
31
+ # # bad
32
+ # case topic
33
+ # when :billing || :payments
34
+ # route_to_finance
35
+ # end
36
+ #
37
+ # # good
38
+ # case topic
39
+ # when :billing, :payments
40
+ # route_to_finance
41
+ # end
42
+ class LiteralOrInWhenClause < Base
43
+ extend AutoCorrector
44
+
45
+ MSG = '`%<source>s` evaluates immediately to `%<result>s`, so the other ' \
46
+ 'literal(s) in it can never match this `when` clause. List ' \
47
+ 'alternatives with commas instead: `when %<list>s`.'.freeze
48
+
49
+ LITERAL_TYPES = %i[sym str int float true false nil].freeze
50
+ FALSY_TYPES = %i[false nil].freeze
51
+
52
+ def on_case(node)
53
+ node.when_branches.each do |branch|
54
+ branch.conditions.each { |condition| check(condition) }
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def check(condition)
61
+ inner = unwrap_begin(condition)
62
+ return unless inner.or_type? || inner.and_type?
63
+
64
+ leaves = flatten_chain(inner, inner.type)
65
+ return unless leaves.all? { |leaf| LITERAL_TYPES.include?(leaf.type) }
66
+
67
+ add_offense(inner, message: offense_message(inner, leaves)) do |corrector|
68
+ next unless autocorrectable_chain?(inner, leaves)
69
+
70
+ corrector.replace(rewrite_range(condition), comma_list(leaves))
71
+ end
72
+ end
73
+
74
+ # Parentheses wrap a condition in a `begin` node; unwrap it for
75
+ # inspection while keeping the outer node for the autocorrect range.
76
+ def unwrap_begin(node)
77
+ node = node.children.first while node.begin_type?
78
+ node
79
+ end
80
+
81
+ # Replace the full condition including wrapping parentheses, so
82
+ # `when (:a || :b)` becomes `when :a, :b` rather than `when (:a, :b)`.
83
+ def rewrite_range(condition)
84
+ return condition unless condition.begin_type? && condition.loc.begin
85
+
86
+ condition.loc.begin.join(condition.loc.end)
87
+ end
88
+
89
+ def offense_message(condition, leaves)
90
+ format(
91
+ MSG,
92
+ source: condition.source,
93
+ result: fold(leaves, condition.type).source,
94
+ list: comma_list(leaves)
95
+ )
96
+ end
97
+
98
+ def comma_list(leaves)
99
+ leaves.map(&:source).join(', ')
100
+ end
101
+
102
+ # Flatten a homogeneous `||` (or `&&`) chain into its operand nodes.
103
+ # A mixed-operator chain yields operand nodes of differing types,
104
+ # which the caller's literal-type check then filters out. An operand
105
+ # wrapped in parentheses arrives as a `begin` node
106
+ # (`(:a || :b) || :c`), so unwrap before recursing; otherwise the
107
+ # inner chain reads as a single non-literal leaf and evades the check.
108
+ def flatten_chain(node, operator)
109
+ node = unwrap_begin(node)
110
+ return [node] unless node.type == operator
111
+
112
+ flatten_chain(node.lhs, operator) + flatten_chain(node.rhs, operator)
113
+ end
114
+
115
+ # The single literal the operator chain folds to.
116
+ def fold(leaves, operator)
117
+ if operator == :or
118
+ leaves.find { |leaf| !FALSY_TYPES.include?(leaf.type) } || leaves.last
119
+ else
120
+ leaves.find { |leaf| FALSY_TYPES.include?(leaf.type) } || leaves.last
121
+ end
122
+ end
123
+
124
+ # Only rewrite `||` chains of truthy literals: there the comma list is
125
+ # unambiguously what was meant. `nil`/`false` operands and `&&` forms
126
+ # have no clear when-clause intent, so they are flagged only.
127
+ def autocorrectable_chain?(condition, leaves)
128
+ condition.or_type? && leaves.none? { |leaf| FALSY_TYPES.include?(leaf.type) }
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -74,7 +74,9 @@ module RuboCop
74
74
  'or inside a DSL block (e.g. Rake `namespace`). ' \
75
75
  'Methods defined here land on `Object` and can silently collide across files.'.freeze
76
76
 
77
- DEFAULT_SAFE_DSL_RECEIVERS = %w[Struct Class Module].freeze
77
+ # Data (Ruby 3.2 value objects) scopes block-defined methods to the
78
+ # new class exactly like Struct.
79
+ DEFAULT_SAFE_DSL_RECEIVERS = %w[Struct Data Class Module].freeze
78
80
 
79
81
  def on_def(node)
80
82
  add_offense(node.loc.keyword) unless enclosed_in_class_or_module?(node)
@@ -67,17 +67,19 @@ module RuboCop
67
67
  # # good — receiver-less send (calling self's own method)
68
68
  # send(:internal_helper)
69
69
  class PreferPublicSend < Base
70
- MSG = 'Prefer `public_send` over `send` — it respects method ' \
70
+ MSG = 'Prefer `public_send` over `%<method>s` — it respects method ' \
71
71
  'visibility, surfacing typos/misconfig that would otherwise ' \
72
72
  'silently invoke a private method. Disable with a reason ' \
73
73
  'when bypassing visibility is intentional.'.freeze
74
74
 
75
- RESTRICT_ON_SEND = %i[send].freeze
75
+ # `__send__` is the canonical alias — omitting it would leave a
76
+ # zero-cost visibility bypass that dodges this cop entirely.
77
+ RESTRICT_ON_SEND = %i[send __send__].freeze
76
78
 
77
79
  def on_send(node)
78
80
  return if node.receiver.nil?
79
81
 
80
- add_offense(node.loc.selector)
82
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
81
83
  end
82
84
  end
83
85
  end
@@ -0,0 +1,155 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag a nil/presence guard on a variable that was just assigned from a
6
+ # bang method. Bang methods raise on failure instead of returning
7
+ # nil/false, so the guard can never fire — it is dead code that hides
8
+ # the real contract and invites style churn (e.g. `Rails/Presence`
9
+ # "fixing" the guard's shape instead of questioning its existence).
10
+ #
11
+ # ## Rationale
12
+ # By convention a `!` method signals failure by raising (`create!`,
13
+ # `save!`, `find_by!`, custom `do_thing!`). Guarding its result reads
14
+ # as if nil were possible, which misleads reviewers about the failure
15
+ # mode and preserves defensive noise through refactors. The fix is to
16
+ # delete the guard, not to restyle it.
17
+ #
18
+ # Ruby-core mutator bangs (`gsub!`, `uniq!`, `compact!`, ...) are the
19
+ # exception — they return nil when nothing changed, so guarding them is
20
+ # legitimate. Those are excluded via the default `AllowedMethods`; add
21
+ # any app-specific nil-returning bang there too (or better, rename it,
22
+ # since a nil-returning `!` method is itself misleading).
23
+ #
24
+ # Caveat on `present?`/`blank?`: for a bang that returns a possibly-empty
25
+ # collection or string, a `present?` check is emptiness logic rather than
26
+ # a nil-guard, and is NOT dead — the cop cannot distinguish this
27
+ # statically, so such methods also belong in `AllowedMethods`. Plain
28
+ # truthiness (`if x`), `nil?`, and `&.` guards carry no such ambiguity:
29
+ # `[]` and `""` are truthy, so those forms only ever test for nil.
30
+ #
31
+ # ❌ dead guard — create! raises rather than return nil
32
+ # snapshot = create_snapshot!(**attrs)
33
+ # if snapshot.present?
34
+ # snapshot.update_columns(version: version)
35
+ # end
36
+ #
37
+ # ✔️ no guard — the bang contract is the guard
38
+ # snapshot = create_snapshot!(**attrs)
39
+ # snapshot.update_columns(version: version)
40
+ #
41
+ # ✔️ core mutator bang — nil means "no change", guard is real logic
42
+ # cleaned = name.strip!
43
+ # apply(cleaned) if cleaned
44
+ #
45
+ # @example
46
+ # # bad
47
+ # record = Order.create!(params)
48
+ # record&.notify_owner
49
+ #
50
+ # # good
51
+ # record = Order.create!(params)
52
+ # record.notify_owner
53
+ class RedundantGuardAfterBang < Base
54
+ MSG = '`%<method>s` raises on failure instead of returning nil, ' \
55
+ 'so this guard on `%<var>s` is dead; remove it.'.freeze
56
+
57
+ # Value assigned from a plain (non-safe-navigation) send, with or
58
+ # without a block (named or numbered params). `obj&.create!` is
59
+ # deliberately not matched: a nil receiver nils the whole expression,
60
+ # making a guard legitimate.
61
+ def_node_matcher :bang_call, <<~PATTERN
62
+ {
63
+ (send _ $_ ...)
64
+ (block (send _ $_ ...) ...)
65
+ (numblock (send _ $_ ...) ...)
66
+ }
67
+ PATTERN
68
+
69
+ def_node_matcher :truthiness_condition?, <<~PATTERN
70
+ {
71
+ ({lvar ivar} %1)
72
+ (send ({lvar ivar} %1) {:present? :nil? :blank?})
73
+ (send (send ({lvar ivar} %1) :nil?) :!)
74
+ }
75
+ PATTERN
76
+
77
+ def_node_matcher :safe_navigation_on_var?, <<~PATTERN
78
+ {
79
+ (csend ({lvar ivar} %1) ...)
80
+ (csend (send ({lvar ivar} %1) :presence) ...)
81
+ }
82
+ PATTERN
83
+
84
+ def on_lvasgn(node)
85
+ # `a ||= x` wraps a value-less lvasgn in an or_asgn; nothing to check.
86
+ return unless node.expression
87
+
88
+ method_name = bang_call(node.expression)
89
+ return unless bang_method?(method_name)
90
+ return if allowed_method?(method_name)
91
+
92
+ following = next_sibling(node)
93
+ return unless following
94
+
95
+ guard = guard_node(following, node.name)
96
+ return unless guard
97
+
98
+ add_offense(guard, message: format(MSG, method: method_name, var: node.name))
99
+ end
100
+ alias on_ivasgn on_lvasgn
101
+
102
+ private
103
+
104
+ # Only the statement immediately after the assignment is considered:
105
+ # once other code intervenes, the variable may have been reassigned
106
+ # and the deadness is no longer provable.
107
+ def next_sibling(node)
108
+ parent = node.parent
109
+ return nil unless parent&.begin_type?
110
+
111
+ parent.children[node.sibling_index + 1]
112
+ end
113
+
114
+ def guard_node(stmt, var)
115
+ return stmt.condition if stmt.if_type? && guard_condition?(stmt.condition, var)
116
+ return stmt if safe_navigation_on_var?(stmt, var)
117
+
118
+ embedded_safe_navigation(stmt, var)
119
+ end
120
+
121
+ # A dead guard can sit in the condition itself, not only as the
122
+ # whole statement: `if record&.persisted?` is the same dead `&.` as
123
+ # a standalone `record&.foo` (the bang never returned nil).
124
+ def guard_condition?(condition, var)
125
+ truthiness_condition?(condition, var) || safe_navigation_on_var?(condition, var)
126
+ end
127
+
128
+ # y = x&.foo — the guard is embedded in another assignment's value.
129
+ def embedded_safe_navigation(stmt, var)
130
+ return nil unless stmt.respond_to?(:expression)
131
+
132
+ value = stmt.expression
133
+ return nil unless value.is_a?(RuboCop::AST::Node)
134
+
135
+ value if safe_navigation_on_var?(value, var)
136
+ end
137
+
138
+ def allowed_method?(method_name)
139
+ cop_config.fetch('AllowedMethods', []).include?(method_name.to_s)
140
+ end
141
+
142
+ # A trailing `!` signals raise-on-failure by convention. The unary
143
+ # negation operator parses as method `:!`, which also ends in `!` but
144
+ # is not a bang method, so it is excluded.
145
+ def bang_method?(method_name)
146
+ return false unless method_name
147
+
148
+ name = method_name.to_s
149
+ name.end_with?('!') && name != '!'
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -36,15 +36,18 @@ module RuboCop
36
36
  # expect(order.expired?).to be true
37
37
  # end
38
38
  class AvoidGlibTravelFreeze < Base
39
- MSG = 'Avoid `glib_travel_freeze` — use `glib_travel` instead. ' \
39
+ MSG = 'Avoid `%<method>s` — use `glib_travel` instead. ' \
40
40
  'Frozen time masks timing bugs that surface in production. ' \
41
41
  'Use `# rubocop:disable DevDoc/Test/AvoidGlibTravelFreeze` ' \
42
- 'with a comment explaining why `glib_travel` cannot work for this test.'
42
+ 'with a comment explaining why `glib_travel` cannot work for this test.'.freeze
43
43
 
44
- RESTRICT_ON_SEND = %i[glib_travel_freeze].freeze
44
+ # The native ActiveSupport helpers freeze the clock exactly the same
45
+ # way — policing only the glib spelling would ban one name for
46
+ # frozen time while leaving the ones agents know from training data.
47
+ RESTRICT_ON_SEND = %i[glib_travel_freeze freeze_time travel_to travel].freeze
45
48
 
46
49
  def on_send(node)
47
- add_offense(node.loc.selector)
50
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
48
51
  end
49
52
  end
50
53
  end
@@ -44,15 +44,25 @@ module RuboCop
44
44
  # model's own": you usually don't need to — assert the *observable*
45
45
  # guarantee through the real path; that is what matters in production.
46
46
  #
47
- # When a unit test is genuinely necessary, suppress with a reason that
48
- # explains why a controller test can't cover the path. That reason IS the
49
- # required justification keep it specific and reviewable:
47
+ # When a unit test is genuinely necessary, do NOT disable this cop —
48
+ # use the sanctioned form instead: subclass `Glib::LastResortUnitTest`
49
+ # (the name is the policy), place the file in a unit-test directory
50
+ # (test/models|services|helpers/), and open it with a justification
51
+ # header containing the marker phrase "deliberate exception" that
52
+ # explains why no controller path can reach the behaviour. That form is
53
+ # machine-enforced by DevDoc/Test/RequireUnitTestJustification and
54
+ # never trips this cop, so a properly justified unit test carries zero
55
+ # disable directives:
50
56
  #
51
- # # rubocop:disable DevDoc/Test/AvoidUnitTest -- search ranking isn't visible through the controller
52
- # class Ai::Retrieval::PgSearchStrategyTest < ActiveSupport::TestCase
57
+ # # Per best practice, we focus on controller tests. This model test
58
+ # # is a deliberate exception: <why no controller path exists>.
59
+ # class MemberTest < Glib::LastResortUnitTest
53
60
  # # ...
54
61
  # end
55
- # # rubocop:enable DevDoc/Test/AvoidUnitTest
62
+ #
63
+ # Legacy `< ActiveSupport::TestCase` tests that predate the sanctioned
64
+ # form survive via project-config Excludes until migrated; new code has
65
+ # no reason to add one.
56
66
  #
57
67
  # NOTE: The cop matches the *direct* superclass only. A project base
58
68
  # (`class ApplicationServiceTest < ActiveSupport::TestCase`) is flagged
@@ -64,17 +74,20 @@ module RuboCop
64
74
  # end
65
75
  #
66
76
  # # good — exercised through the controller
67
- # class NotesControllerTest < ActionDispatch::IntegrationTest
77
+ # class NotesControllerTest < Glib::IntegrationTest
68
78
  # end
69
79
  #
70
- # # good — genuinely unit-only, justified with a reason
71
- # # rubocop:disable DevDoc/Test/AvoidUnitTest -- <why a controller test can't cover this>
72
- # class SomeServiceTest < ActiveSupport::TestCase
80
+ # # good — genuinely unit-only: sanctioned base + unit directory +
81
+ # # justification header (enforced by RequireUnitTestJustification)
82
+ # # Per best practice, we focus on controller tests. This test is a
83
+ # # deliberate exception: <why a controller test can't cover this>.
84
+ # class SomeServiceTest < Glib::LastResortUnitTest
73
85
  # end
74
- # # rubocop:enable DevDoc/Test/AvoidUnitTest
75
86
  class AvoidUnitTest < Base
76
87
  MSG = 'Prefer a controller test — unit tests are a rare exception. If a controller test ' \
77
- 'genuinely cannot cover this path, disable this cop on the class with a reason.'.freeze
88
+ 'genuinely cannot cover this path, use `Glib::LastResortUnitTest` in a unit-test ' \
89
+ 'directory with a justification header (see ' \
90
+ 'DevDoc/Test/RequireUnitTestJustification); do not disable this cop.'.freeze
78
91
 
79
92
  def on_class(node)
80
93
  superclass = node.parent_class
@@ -0,0 +1,57 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Test
5
+ # In HTTP-driven test directories, integration tests must subclass the
6
+ # project base (`RequiredBase`, default `Glib::IntegrationTest`) rather
7
+ # than a raw framework base (`LegacyBases`, default
8
+ # `ActionDispatch::IntegrationTest`).
9
+ #
10
+ # ## Rationale
11
+ # The project base is where behavioural enforcement and plumbing are
12
+ # attached: DevDoc::Test::Lints::HttpDrivenControllerTests (which fails
13
+ # any test under test/controllers/ that completes without making an
14
+ # HTTP request), plus the glib helpers (submit_form, glib_travel,
15
+ # parallel coverage). A test that inherits the framework base directly
16
+ # sidesteps all of it — and that is an observed evasion shape: when the
17
+ # runtime lint fails a disguised unit test, switching the superclass to
18
+ # the raw framework base would silence the lint without fixing the
19
+ # test. This cop closes that hop statically, completing the loop with
20
+ # DevDoc/Test/RequireUnitTestJustification (which forbids unit bases in
21
+ # these directories and integration bases in unit directories).
22
+ #
23
+ # Framework-specific bases for other test types
24
+ # (`ActionMailer::TestCase`, `ActionCable::Connection::TestCase`, ...)
25
+ # are not flagged — only the configured legacy integration bases.
26
+ #
27
+ # @example
28
+ # # bad
29
+ # class NotesControllerTest < ActionDispatch::IntegrationTest
30
+ # end
31
+ #
32
+ # # good
33
+ # class NotesControllerTest < Glib::IntegrationTest
34
+ # end
35
+ class RequireGlibIntegrationBase < Base
36
+ MSG = '`%<base>s` is the raw framework base — subclass `%<required>s` instead. The project ' \
37
+ 'base carries the runtime HTTP-driven enforcement and the glib test plumbing; ' \
38
+ 'inheriting the framework base directly sidesteps both.'.freeze
39
+
40
+ def on_class(node)
41
+ superclass = node.parent_class
42
+ return unless superclass&.const_type?
43
+ return unless legacy_bases.include?(superclass.const_name)
44
+
45
+ add_offense(superclass, message: format(MSG, base: superclass.const_name, required: required_base))
46
+ end
47
+
48
+ private
49
+
50
+ def legacy_bases = cop_config.fetch('LegacyBases', %w[ActionDispatch::IntegrationTest])
51
+
52
+ def required_base = cop_config.fetch('RequiredBase', 'Glib::IntegrationTest')
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end