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.
- checksums.yaml +4 -4
- data/config/default.yml +138 -1
- 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/style/avoid_head_response.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
- 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 +5 -3
- 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 +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 `
|
|
51
|
-
'
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|