rubocop-dev_doc 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/config/default.yml +182 -4
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
- data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +62 -12
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/avoid_insecure_send.rb +159 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +87 -0
- data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
- data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +10 -8
- data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
- data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
- data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +13 -2
|
@@ -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
|
-
|
|
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)
|
|
@@ -21,16 +21,16 @@ module RuboCop
|
|
|
21
21
|
# meant a public-API call. `send` leaves it ambiguous whether
|
|
22
22
|
# private access was wanted.
|
|
23
23
|
#
|
|
24
|
-
# ## Relationship to
|
|
25
|
-
# `DevDoc/Style/
|
|
24
|
+
# ## Relationship to AvoidInsecureSend
|
|
25
|
+
# `DevDoc/Style/AvoidInsecureSend` flags *dynamic* dispatch (both `send` and
|
|
26
26
|
# `public_send`) as risky. This cop is orthogonal: it flags `send`
|
|
27
|
-
# specifically, including the cases
|
|
27
|
+
# specifically, including the cases AvoidInsecureSend exempts (literal-symbol
|
|
28
28
|
# args, prefix-string args). The friction asymmetry is intentional —
|
|
29
29
|
# `send` is the deeper exception, so it costs an extra disable:
|
|
30
30
|
#
|
|
31
|
-
# obj.public_send(method_name) #
|
|
31
|
+
# obj.public_send(method_name) # AvoidInsecureSend: 1 disable
|
|
32
32
|
# obj.public_send(:foo) # clean
|
|
33
|
-
# obj.send(method_name) #
|
|
33
|
+
# obj.send(method_name) # AvoidInsecureSend + this cop: 2 disables
|
|
34
34
|
# obj.send(:foo) # this cop only: 1 disable
|
|
35
35
|
#
|
|
36
36
|
# ## When `send` IS the right choice
|
|
@@ -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 `
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
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,
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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
|
-
# #
|
|
52
|
-
#
|
|
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
|
-
#
|
|
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 <
|
|
77
|
+
# class NotesControllerTest < Glib::IntegrationTest
|
|
68
78
|
# end
|
|
69
79
|
#
|
|
70
|
-
# # good — genuinely unit-only
|
|
71
|
-
# #
|
|
72
|
-
#
|
|
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,
|
|
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
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# Controller tests must run inside a `glib_travel` block.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Per the testing best practice ("Time Travel"), tests should always run
|
|
9
|
+
# inside a `glib_travel` block — even when they do not appear
|
|
10
|
+
# time-sensitive. Controller tests integrate HTTP, the database, and the
|
|
11
|
+
# clock (timestamps, expiry windows, token validity, rate limits), so they
|
|
12
|
+
# are the highest-leverage place to enforce it. A controller test that
|
|
13
|
+
# does not freeze time passes today and flakes or regresses the moment an
|
|
14
|
+
# ordering, expiry, or TTL boundary shifts under it.
|
|
15
|
+
#
|
|
16
|
+
# The block form auto-returns to real time at the end of the test, so no
|
|
17
|
+
# teardown is needed:
|
|
18
|
+
#
|
|
19
|
+
# test 'shows the row' do
|
|
20
|
+
# glib_travel(FROZEN_TIME) do
|
|
21
|
+
# get items_url(format: :json)
|
|
22
|
+
# assert_response :success
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# This cop is a *presence* check — it only asks "did you freeze time?".
|
|
27
|
+
# Whether you used the block form (vs. the leak-prone bare anchor) is
|
|
28
|
+
# enforced separately by `DevDoc/Test/RequireGlibTravelBlock`, and
|
|
29
|
+
# `glib_travel_freeze` vs. `glib_travel` by `AvoidGlibTravelFreeze`.
|
|
30
|
+
#
|
|
31
|
+
# ## What is NOT flagged
|
|
32
|
+
# - Tests that already call `glib_travel` or `glib_travel_freeze`.
|
|
33
|
+
# - Non-controller test files — the cop is scoped to
|
|
34
|
+
# `test/controllers/**` via `Include`, where the time-sensitivity risk
|
|
35
|
+
# is highest. (The best practice recommends the block form for every
|
|
36
|
+
# test; this cop enforces the subset with the strongest evidence.)
|
|
37
|
+
#
|
|
38
|
+
# ## Inline disable
|
|
39
|
+
# For the rare controller test that is genuinely time-independent and the
|
|
40
|
+
# block adds noise, add a `rubocop:disable DevDoc/Test/RequireGlibTravel`
|
|
41
|
+
# comment to the test's opening line (`test '...' do` or `def test_*`)
|
|
42
|
+
# with a written reason.
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# # bad
|
|
46
|
+
# test 'shows the row' do
|
|
47
|
+
# get items_url(format: :json)
|
|
48
|
+
# assert_response :success
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# # good
|
|
52
|
+
# test 'shows the row' do
|
|
53
|
+
# glib_travel(FROZEN_TIME) do
|
|
54
|
+
# get items_url(format: :json)
|
|
55
|
+
# assert_response :success
|
|
56
|
+
# end
|
|
57
|
+
# end
|
|
58
|
+
#
|
|
59
|
+
# # good (method-style test — also covered)
|
|
60
|
+
# def test_shows_the_row
|
|
61
|
+
# glib_travel(FROZEN_TIME) do
|
|
62
|
+
# get items_url(format: :json)
|
|
63
|
+
# end
|
|
64
|
+
# end
|
|
65
|
+
class RequireGlibTravel < Base
|
|
66
|
+
MSG = 'Wrap this test in a `glib_travel` block — controller tests integrate ' \
|
|
67
|
+
'HTTP, DB, and time, so the frozen time must be deterministic. See the ' \
|
|
68
|
+
'testing best practice ("Time Travel"). Disable with a written reason ' \
|
|
69
|
+
'only if the test is genuinely time-independent.'.freeze
|
|
70
|
+
|
|
71
|
+
# @!method test_block?(node)
|
|
72
|
+
def_node_matcher :test_block?, <<~PATTERN
|
|
73
|
+
(block (send nil? :test ...) _args _body)
|
|
74
|
+
PATTERN
|
|
75
|
+
|
|
76
|
+
# Any glib time helper satisfies the presence check. `glib_travel_freeze`
|
|
77
|
+
# counts as "froze time" here — its form is policed by
|
|
78
|
+
# AvoidGlibTravelFreeze, not by this cop.
|
|
79
|
+
# @!method calls_glib_time_helper?(node)
|
|
80
|
+
def_node_search :calls_glib_time_helper?, <<~PATTERN
|
|
81
|
+
(send nil? {:glib_travel :glib_travel_freeze} ...)
|
|
82
|
+
PATTERN
|
|
83
|
+
|
|
84
|
+
def on_block(node)
|
|
85
|
+
return unless test_block?(node)
|
|
86
|
+
|
|
87
|
+
check_test(node, node.send_node.loc.selector)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Minitest also allows method-style tests (`def test_shows_the_row`);
|
|
91
|
+
# those are the same kind of controller test and must freeze time too.
|
|
92
|
+
def on_def(node)
|
|
93
|
+
return unless test_method?(node)
|
|
94
|
+
|
|
95
|
+
check_test(node, node.loc.name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Shared by the block form (`test '...' do`) and the method form
|
|
101
|
+
# (`def test_*`): a test with a body that never freezes time.
|
|
102
|
+
def check_test(node, location)
|
|
103
|
+
return unless node.body
|
|
104
|
+
return if calls_glib_time_helper?(node)
|
|
105
|
+
|
|
106
|
+
add_offense(location)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Minitest treats any `def test_*` method as a test. Node patterns
|
|
110
|
+
# match exact method names rather than a prefix, so this is a plain
|
|
111
|
+
# Ruby string check. (`def test` — no underscore — is not a test.)
|
|
112
|
+
def test_method?(node)
|
|
113
|
+
node.method_name.to_s.start_with?('test_')
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|