rubocop-dev_doc 0.2.0 → 0.3.1
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 +235 -61
- data/lib/dev_doc/test/best_practice_lints.rb +31 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +287 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +1 -1
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +2 -2
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +31 -4
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
- data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +58 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Flag `def` / `define_method` whose enclosing scope chain does not
|
|
6
|
+
# include a `class` or `module` body — the method lands on `Object`.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# A `def` not inside an explicit `class` or `module` body defines a
|
|
10
|
+
# method on `Object`, even when it visually looks scoped. The most common
|
|
11
|
+
# failure mode is inside Rake's `namespace` block:
|
|
12
|
+
#
|
|
13
|
+
# ❌ Two rake files both define `build_load_plan` — whichever file
|
|
14
|
+
# loads second silently wins. Tests for one task call the other
|
|
15
|
+
# task's helper logic without warning.
|
|
16
|
+
# namespace :faqs do
|
|
17
|
+
# def build_load_plan(fixtures, by_slug)
|
|
18
|
+
# ...
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# ✔️ Wrapped in a real Ruby scope — no collision risk.
|
|
23
|
+
# module FaqsLoader
|
|
24
|
+
# extend self
|
|
25
|
+
#
|
|
26
|
+
# def build_load_plan(fixtures, by_slug)
|
|
27
|
+
# ...
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# Core's `Style/TopLevelMethodDefinition` catches literal top-level
|
|
32
|
+
# `def` (not inside any block) but misses the rake `namespace` pattern
|
|
33
|
+
# because the `def` is technically inside a `block` node. This cop
|
|
34
|
+
# subsumes that case — disable `Style/TopLevelMethodDefinition` when
|
|
35
|
+
# this cop is enabled to avoid double-flagging.
|
|
36
|
+
#
|
|
37
|
+
# ## Allowlist
|
|
38
|
+
# Some DSLs legitimately define methods inside blocks where the block's
|
|
39
|
+
# receiver is not `Object` (`Struct.new`, `Class.new`, `Module.new`).
|
|
40
|
+
# Configure `SafeDSLReceivers` to extend the allowlist.
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# # bad — literal top-level def
|
|
44
|
+
# def helper_method
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# # bad — inside a rake namespace (lands on Object)
|
|
48
|
+
# namespace :faqs do
|
|
49
|
+
# def build_load_plan(fixtures, by_slug)
|
|
50
|
+
# end
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# # good — inside an explicit module
|
|
54
|
+
# module FaqsLoader
|
|
55
|
+
# extend self
|
|
56
|
+
#
|
|
57
|
+
# def build_load_plan(fixtures, by_slug)
|
|
58
|
+
# end
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# # good — inside an explicit class
|
|
62
|
+
# class FaqsImporter
|
|
63
|
+
# def import
|
|
64
|
+
# end
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# # good — Struct.new block (allowlisted by default)
|
|
68
|
+
# Point = Struct.new(:x, :y) do
|
|
69
|
+
# def distance
|
|
70
|
+
# end
|
|
71
|
+
# end
|
|
72
|
+
class NoUnscopedMethodDefinitions < Base
|
|
73
|
+
MSG = 'Define methods inside an explicit `module` or `class`, not at the top level ' \
|
|
74
|
+
'or inside a DSL block (e.g. Rake `namespace`). ' \
|
|
75
|
+
'Methods defined here land on `Object` and can silently collide across files.'.freeze
|
|
76
|
+
|
|
77
|
+
DEFAULT_SAFE_DSL_RECEIVERS = %w[Struct Class Module].freeze
|
|
78
|
+
|
|
79
|
+
def on_def(node)
|
|
80
|
+
add_offense(node.loc.keyword) unless enclosed_in_class_or_module?(node)
|
|
81
|
+
end
|
|
82
|
+
alias on_defs on_def
|
|
83
|
+
|
|
84
|
+
def on_send(node)
|
|
85
|
+
return unless node.method_name == :define_method
|
|
86
|
+
return if enclosed_in_class_or_module?(node)
|
|
87
|
+
|
|
88
|
+
add_offense(node.loc.selector)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def enclosed_in_class_or_module?(node)
|
|
94
|
+
node.each_ancestor.any? do |ancestor|
|
|
95
|
+
next true if ancestor.class_type? || ancestor.module_type?
|
|
96
|
+
next true if safe_dsl_block?(ancestor)
|
|
97
|
+
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A `block` node is safe when its method call receiver is an
|
|
103
|
+
# allowlisted DSL (Struct.new, Class.new, Module.new, etc.)
|
|
104
|
+
def safe_dsl_block?(node)
|
|
105
|
+
return false unless node.block_type?
|
|
106
|
+
|
|
107
|
+
send_node = node.send_node
|
|
108
|
+
receiver = send_node.receiver
|
|
109
|
+
|
|
110
|
+
return false if receiver.nil?
|
|
111
|
+
|
|
112
|
+
safe_receivers.any? do |safe|
|
|
113
|
+
receiver_matches?(receiver, safe)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def receiver_matches?(receiver, safe_name)
|
|
118
|
+
# Handles `Struct`, `Class`, `Module` (const nodes)
|
|
119
|
+
receiver.const_type? && receiver.short_name.to_s == safe_name
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def safe_receivers
|
|
123
|
+
DEFAULT_SAFE_DSL_RECEIVERS + Array(cop_config.fetch('SafeDSLReceivers', []))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Avoid reading `obj[key]` more than once with the same receiver and
|
|
6
|
+
# same key in a single method body.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# When the same bracket read appears in multiple places, two distinct
|
|
10
|
+
# failure modes open up:
|
|
11
|
+
#
|
|
12
|
+
# 1. **Silent typos.** Bracket access returns nil for missing keys;
|
|
13
|
+
# nothing raises. Two occurrences of `params[:status]` keep the
|
|
14
|
+
# spelling in sync, but if one of them silently becomes
|
|
15
|
+
# `params[:stutus]`, the line returns nil and the bug ships. A
|
|
16
|
+
# single assignment fixes the spelling in exactly one place — a
|
|
17
|
+
# typo there becomes a `NameError`, not a silent nil.
|
|
18
|
+
#
|
|
19
|
+
# 2. **Reader ambiguity and mutation risk.** The reader has to verify
|
|
20
|
+
# every occurrence really is the same key and that nothing in
|
|
21
|
+
# between mutates the hash. Assigning once makes the value a
|
|
22
|
+
# stable named thing — and on receivers like `session` or shared
|
|
23
|
+
# hashes, it also avoids a real TOCTOU shape where the value can
|
|
24
|
+
# change between the guard and the use.
|
|
25
|
+
#
|
|
26
|
+
# ❌
|
|
27
|
+
# def show
|
|
28
|
+
# @item = Item.lookup_by_slug(params[:slug])
|
|
29
|
+
# redirect_to canonical_url(@item) if @item.slug != params[:slug]
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# ✔️
|
|
33
|
+
# def show
|
|
34
|
+
# slug = params[:slug]
|
|
35
|
+
# @item = Item.lookup_by_slug(slug)
|
|
36
|
+
# redirect_to canonical_url(@item) if @item.slug != slug
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# The cop only counts reads. `obj[k] = v` is a write (`:[]=`) and is
|
|
40
|
+
# not compared against bracket reads of the same key.
|
|
41
|
+
#
|
|
42
|
+
# ## Exception
|
|
43
|
+
# Genuine intentional re-reads (e.g. a deliberate second check after
|
|
44
|
+
# a write that may have mutated the receiver) go through inline
|
|
45
|
+
# `# rubocop:disable` with a reason.
|
|
46
|
+
#
|
|
47
|
+
# NOTE: Receiver and key are compared by source text — `hash['foo']`
|
|
48
|
+
# and `hash[:foo]` look different to the cop even though they're the
|
|
49
|
+
# same value on a `HashWithIndifferentAccess` like `params`. That is
|
|
50
|
+
# a known false negative; the cop will miss that shape rather than
|
|
51
|
+
# over-fire.
|
|
52
|
+
#
|
|
53
|
+
# NOTE: Scope is the enclosing `def`/`defs` body, including nested
|
|
54
|
+
# blocks. Block parameters are scoped to their block, so the same name
|
|
55
|
+
# in two sibling blocks (`arr.each { |item| item[:k] }; other.each {
|
|
56
|
+
# |item| item[:k] }`) is correctly treated as two different bindings,
|
|
57
|
+
# not a repeat. Reads of the same block param *within one block* are
|
|
58
|
+
# still flagged. (Numbered/`it` block params are matched textually — a
|
|
59
|
+
# rare residual false positive; inline-disable if it surfaces.)
|
|
60
|
+
#
|
|
61
|
+
# NOTE: Multi-argument bracket calls (`arr[i, len]`, slicing) are
|
|
62
|
+
# ignored. Only single-argument reads participate.
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# # bad — same key read twice
|
|
66
|
+
# def show
|
|
67
|
+
# @item = Item.lookup_by_slug(params[:slug])
|
|
68
|
+
# redirect_to canonical_url(@item) if @item.slug != params[:slug]
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# # bad — guard-then-use re-reads the receiver
|
|
72
|
+
# def session_get(key)
|
|
73
|
+
# return nil unless session[key]
|
|
74
|
+
# session[key].symbolize_keys
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# # good — assign once
|
|
78
|
+
# def session_get(key)
|
|
79
|
+
# payload = session[key]
|
|
80
|
+
# return nil unless payload
|
|
81
|
+
# payload.symbolize_keys
|
|
82
|
+
# end
|
|
83
|
+
#
|
|
84
|
+
# # good — different keys, no offense
|
|
85
|
+
# def filters
|
|
86
|
+
# @search = params[:search]
|
|
87
|
+
# @status = params[:status]
|
|
88
|
+
# end
|
|
89
|
+
class RepeatedBracketRead < Base
|
|
90
|
+
MSG = "Receiver `%<receiver>s[%<key>s]` already read earlier in this method — assign once and reuse.".freeze
|
|
91
|
+
|
|
92
|
+
def on_def(node)
|
|
93
|
+
check_method(node)
|
|
94
|
+
end
|
|
95
|
+
alias on_defs on_def
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def check_method(def_node)
|
|
100
|
+
body = def_node.body
|
|
101
|
+
return unless body
|
|
102
|
+
|
|
103
|
+
seen = {}
|
|
104
|
+
body.each_descendant(:send) do |send_node|
|
|
105
|
+
next unless bracket_read?(send_node)
|
|
106
|
+
|
|
107
|
+
receiver = send_node.receiver
|
|
108
|
+
key = send_node.first_argument
|
|
109
|
+
composite = composite_key(receiver, key)
|
|
110
|
+
|
|
111
|
+
if seen[composite]
|
|
112
|
+
add_offense(send_node.loc.selector, message: format(MSG, receiver: receiver.source, key: key.source))
|
|
113
|
+
else
|
|
114
|
+
seen[composite] = true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Block parameters are scoped to their block: the same name read in two
|
|
120
|
+
# sibling blocks is a *different* binding, not a repeat. Key those reads
|
|
121
|
+
# by their binding block so they don't collide. Method-call / ivar /
|
|
122
|
+
# method-local receivers keep a purely textual key.
|
|
123
|
+
def composite_key(receiver, key_node)
|
|
124
|
+
key = key_node.source
|
|
125
|
+
block = binding_block(receiver)
|
|
126
|
+
block ? "#{block.object_id}|#{receiver.source}|#{key}" : "#{receiver.source}|#{key}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The nearest enclosing block that binds the receiver as a block
|
|
130
|
+
# argument, or nil when the receiver is not a block-local variable.
|
|
131
|
+
def binding_block(receiver)
|
|
132
|
+
return nil unless receiver.lvar_type?
|
|
133
|
+
|
|
134
|
+
name = receiver.children.first
|
|
135
|
+
receiver.each_ancestor(:block) do |block|
|
|
136
|
+
return block if block.arguments.any? { |arg| arg.respond_to?(:name) && arg.name == name }
|
|
137
|
+
end
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def bracket_read?(node)
|
|
142
|
+
node.method_name == :[] &&
|
|
143
|
+
node.receiver &&
|
|
144
|
+
node.arguments.size == 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Avoid using `&.` on the same receiver more than once in a method body.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# When a receiver appears with `&.` repeatedly across a method, the
|
|
9
|
+
# reader has to mentally re-evaluate the nil case at every call, and
|
|
10
|
+
# the *intent* of each `&.` becomes ambiguous — is this call nullable
|
|
11
|
+
# for a fresh reason, or is the dev just mirroring the earlier `&.`
|
|
12
|
+
# out of habit? Repetition also propagates nil silently into
|
|
13
|
+
# downstream comparisons (`current_user&.id == something` evaluates
|
|
14
|
+
# to `nil == something`, which is false but not for the reason the
|
|
15
|
+
# reader expects).
|
|
16
|
+
#
|
|
17
|
+
# Resolve the nil case once at the top of the method, then use the
|
|
18
|
+
# local with plain `.`:
|
|
19
|
+
#
|
|
20
|
+
# ❌
|
|
21
|
+
# def show?
|
|
22
|
+
# current_user&.super_admin? ||
|
|
23
|
+
# current_user&.developer? ||
|
|
24
|
+
# current_user&.admin_of_organization?(organization)
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# ✔️ Guard once, then plain calls
|
|
28
|
+
# def show?
|
|
29
|
+
# return false unless current_user
|
|
30
|
+
#
|
|
31
|
+
# current_user.super_admin? ||
|
|
32
|
+
# current_user.developer? ||
|
|
33
|
+
# current_user.admin_of_organization?(organization)
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# ✔️ Alternative — assign-and-test in the condition
|
|
37
|
+
# def label
|
|
38
|
+
# if (user = current_user)
|
|
39
|
+
# "#{user.full_name} <#{user.email}>"
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# The cop is policy-safe by design: it does not assume anything
|
|
44
|
+
# about `current_user` non-nullability or branching semantics — it
|
|
45
|
+
# only flags repeated `&.` on the same receiver, which is a smell
|
|
46
|
+
# regardless of whether the receiver is `current_user`, a model
|
|
47
|
+
# attribute, or a local variable.
|
|
48
|
+
#
|
|
49
|
+
# ## Exception
|
|
50
|
+
# Legitimate cases (e.g. when the repeated calls are conceptually
|
|
51
|
+
# distinct sources that happen to share source spelling) go through
|
|
52
|
+
# inline `# rubocop:disable` with a reason.
|
|
53
|
+
#
|
|
54
|
+
# NOTE: Receivers are compared by source text — two `&.` calls
|
|
55
|
+
# share a receiver if their source spelling matches verbatim. The
|
|
56
|
+
# cop does not perform flow analysis, so a receiver reassigned
|
|
57
|
+
# between uses is not detected (false negative).
|
|
58
|
+
#
|
|
59
|
+
# NOTE: Scope is the enclosing `def`/`defs` body. The cop does not
|
|
60
|
+
# partition by inner blocks, so the same parameter name reused
|
|
61
|
+
# across separate block bodies in one method may produce a false
|
|
62
|
+
# positive — inline-disable with a reason if it surfaces.
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# # bad — same receiver, multiple safe-nav reads
|
|
66
|
+
# def card
|
|
67
|
+
# name = current_user&.full_name
|
|
68
|
+
# email = current_user&.email
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# # bad — same receiver, multiple safe-nav predicates
|
|
72
|
+
# def show?
|
|
73
|
+
# user&.super_admin? || user&.developer?
|
|
74
|
+
# end
|
|
75
|
+
#
|
|
76
|
+
# # good — assign once, then plain calls
|
|
77
|
+
# def show?
|
|
78
|
+
# return false unless user
|
|
79
|
+
#
|
|
80
|
+
# user.super_admin? || user.developer?
|
|
81
|
+
# end
|
|
82
|
+
#
|
|
83
|
+
# # good — single use of safe-nav
|
|
84
|
+
# def label
|
|
85
|
+
# current_user&.full_name
|
|
86
|
+
# end
|
|
87
|
+
class RepeatedSafeNavigationReceiver < Base
|
|
88
|
+
MSG = "Receiver `%<receiver>s` already used with `&.` earlier in this method — assign once and use `.` after.".freeze
|
|
89
|
+
|
|
90
|
+
def on_def(node)
|
|
91
|
+
check_method(node)
|
|
92
|
+
end
|
|
93
|
+
alias on_defs on_def
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def check_method(def_node)
|
|
98
|
+
body = def_node.body
|
|
99
|
+
return unless body
|
|
100
|
+
|
|
101
|
+
seen = {}
|
|
102
|
+
body.each_descendant(:csend) do |csend|
|
|
103
|
+
receiver = csend.receiver
|
|
104
|
+
next unless receiver
|
|
105
|
+
|
|
106
|
+
key = receiver.source
|
|
107
|
+
if seen[key]
|
|
108
|
+
add_offense(csend.loc.dot, message: format(MSG, receiver: key))
|
|
109
|
+
else
|
|
110
|
+
seen[key] = true
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# Flag `glib_travel_freeze` calls in test files — use `glib_travel` instead.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `glib_travel_freeze` stops the clock completely, which is not representative
|
|
9
|
+
# of real-world behavior: in production, time ticks as code executes.
|
|
10
|
+
# We have had bugs that went undetected in tests because `glib_travel_freeze`
|
|
11
|
+
# masked timing issues, only to surface in production.
|
|
12
|
+
#
|
|
13
|
+
# `glib_travel` advances time normally inside the block — use it instead.
|
|
14
|
+
# Reserve `glib_travel_freeze` only as a last resort when `glib_travel`
|
|
15
|
+
# truly cannot work for a specific test, and document why.
|
|
16
|
+
#
|
|
17
|
+
# ## Escape hatch
|
|
18
|
+
# Disable per-line with a comment explaining why `glib_travel` doesn't work:
|
|
19
|
+
#
|
|
20
|
+
# glib_travel_freeze(time) do # rubocop:disable DevDoc/Test/AvoidGlibTravelFreeze
|
|
21
|
+
# # Reason: <explanation>
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# # bad
|
|
26
|
+
# glib_travel_freeze(Time.current) do
|
|
27
|
+
# expect(order.expired?).to be true
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# # bad (non-block form)
|
|
31
|
+
# glib_travel_freeze(Time.current)
|
|
32
|
+
# expect(order.expired?).to be true
|
|
33
|
+
#
|
|
34
|
+
# # good
|
|
35
|
+
# glib_travel(Time.current) do
|
|
36
|
+
# expect(order.expired?).to be true
|
|
37
|
+
# end
|
|
38
|
+
class AvoidGlibTravelFreeze < Base
|
|
39
|
+
MSG = 'Avoid `glib_travel_freeze` — use `glib_travel` instead. ' \
|
|
40
|
+
'Frozen time masks timing bugs that surface in production. ' \
|
|
41
|
+
'Use `# rubocop:disable DevDoc/Test/AvoidGlibTravelFreeze` ' \
|
|
42
|
+
'with a comment explaining why `glib_travel` cannot work for this test.'
|
|
43
|
+
|
|
44
|
+
RESTRICT_ON_SEND = %i[glib_travel_freeze].freeze
|
|
45
|
+
|
|
46
|
+
def on_send(node)
|
|
47
|
+
add_offense(node.loc.selector)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# Prefer controller tests; flag unit/service tests (`< ActiveSupport::TestCase`).
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# What the user sees and experiences is what matters; internal
|
|
9
|
+
# implementation does not. A controller test exercises behaviour
|
|
10
|
+
# end-to-end through the same path a user takes, so it catches the
|
|
11
|
+
# regressions that actually reach production. A unit/service test is only
|
|
12
|
+
# needed when a code path genuinely cannot be reached through a
|
|
13
|
+
# controller test (very rare) — e.g. a search-ranking detail the
|
|
14
|
+
# controller never exposes.
|
|
15
|
+
#
|
|
16
|
+
# This cop flags only the literal `< ActiveSupport::TestCase`
|
|
17
|
+
# superclass. The blessed blackbox bases —
|
|
18
|
+
# `ActionDispatch::IntegrationTest`, `Glib::IntegrationTest`,
|
|
19
|
+
# `ActionMailer::TestCase`, `ActiveJob::TestCase` — are NOT flagged, even
|
|
20
|
+
# though they inherit from `ActiveSupport::TestCase` transitively.
|
|
21
|
+
#
|
|
22
|
+
# ## Escape hatch
|
|
23
|
+
# When a unit test is genuinely necessary, suppress with a reason that
|
|
24
|
+
# explains why a controller test can't cover the path. That reason IS the
|
|
25
|
+
# required justification — keep it specific and reviewable:
|
|
26
|
+
#
|
|
27
|
+
# # rubocop:disable DevDoc/Test/AvoidUnitTest -- search ranking isn't visible through the controller
|
|
28
|
+
# class Ai::Retrieval::PgSearchStrategyTest < ActiveSupport::TestCase
|
|
29
|
+
# # ...
|
|
30
|
+
# end
|
|
31
|
+
# # rubocop:enable DevDoc/Test/AvoidUnitTest
|
|
32
|
+
#
|
|
33
|
+
# NOTE: The cop matches the *direct* superclass only. A project base
|
|
34
|
+
# (`class ApplicationServiceTest < ActiveSupport::TestCase`) is flagged
|
|
35
|
+
# once (justify it there); subclasses of that base are not re-flagged.
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# # bad
|
|
39
|
+
# class NoteRenderingTest < ActiveSupport::TestCase
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# # good — exercised through the controller
|
|
43
|
+
# class NotesControllerTest < ActionDispatch::IntegrationTest
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# # good — genuinely unit-only, justified with a reason
|
|
47
|
+
# # rubocop:disable DevDoc/Test/AvoidUnitTest -- <why a controller test can't cover this>
|
|
48
|
+
# class SomeServiceTest < ActiveSupport::TestCase
|
|
49
|
+
# end
|
|
50
|
+
# # rubocop:enable DevDoc/Test/AvoidUnitTest
|
|
51
|
+
class AvoidUnitTest < Base
|
|
52
|
+
MSG = 'Prefer a controller test — unit tests are a rare exception. If a controller test ' \
|
|
53
|
+
'genuinely cannot cover this path, disable this cop on the class with a reason.'.freeze
|
|
54
|
+
|
|
55
|
+
def on_class(node)
|
|
56
|
+
superclass = node.parent_class
|
|
57
|
+
return unless superclass&.const_type?
|
|
58
|
+
return unless superclass.const_name == 'ActiveSupport::TestCase'
|
|
59
|
+
|
|
60
|
+
add_offense(superclass)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|