rubocop-dev_doc 0.14.0 → 0.15.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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cad88b36f561dcd41b75c42c242bf3af1877081c6df2c43ffeeaf3edbdb9e712
|
|
4
|
+
data.tar.gz: ae68d462103ffb1bc792bf4cb7bc65e20ecd8eb773a7b729f0194b070833b487
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5a74d067f5e2f0e7ca71f6457a590db627cde1bfe30349bccedaa363b3f7d8a08dc503eb5e301d7056434a03c600fbd85e716f95054d74a98a74b02923e17bba
|
|
7
|
+
data.tar.gz: 8439d5bb064c8d5211213f2912a6815cc3b4fb26db881f7f0bcd844a7b00ff11bcb457ab4a754c4144758e4dd203263afb51e81b13e6c3c862e7b4ae496b3b5d
|
data/config/default.yml
CHANGED
|
@@ -986,6 +986,18 @@ DevDoc/I18n/TranslationKeyPrefix:
|
|
|
986
986
|
- "app/mailers/**/*.rb"
|
|
987
987
|
- "app/helpers/**/*.rb"
|
|
988
988
|
|
|
989
|
+
DevDoc/Rails/SoftFailureInBangMethod:
|
|
990
|
+
Description: "A bang-named model method must not fail softly (errors on the record + falsy return) — that is `save` semantics; drop the `!` or raise."
|
|
991
|
+
Enabled: true
|
|
992
|
+
Include:
|
|
993
|
+
- "app/models/**/*.rb"
|
|
994
|
+
|
|
995
|
+
DevDoc/Rails/NoManualRecordInvalid:
|
|
996
|
+
Description: "Do not raise ActiveRecord::RecordInvalid by hand; only `save!`/`create!` should raise it. Signal domain failure softly via errors + falsy return."
|
|
997
|
+
Enabled: true
|
|
998
|
+
Include:
|
|
999
|
+
- "app/models/**/*.rb"
|
|
1000
|
+
|
|
989
1001
|
DevDoc/I18n/ReportText:
|
|
990
1002
|
Description: "Report every user-facing glib text prop — hardcoded and already-localized — to collect all possible texts."
|
|
991
1003
|
# A tooling aid, not a lint: unlike RequireTranslation it fires on *every*
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Flag hand-raised (or hand-constructed) `ActiveRecord::RecordInvalid`
|
|
6
|
+
# in model code. Only Active Record's own bang persistence
|
|
7
|
+
# (`save!`/`create!`/`update!`) should raise it.
|
|
8
|
+
#
|
|
9
|
+
# ## Rationale
|
|
10
|
+
# `RecordInvalid` is the exception Active Record raises when a bang
|
|
11
|
+
# persistence call fails validation. Raising it by hand to signal a
|
|
12
|
+
# domain precondition ("only drafts can be approved") borrows the
|
|
13
|
+
# framework's exception to smuggle soft-failure data (the errors on
|
|
14
|
+
# the record) through a raise — and the caller then needs a rescue
|
|
15
|
+
# plus a comment explaining that the errors are already on the model.
|
|
16
|
+
# The soft shape says the same thing without the detour: add the
|
|
17
|
+
# error, return false, and let the caller check the result and render
|
|
18
|
+
# `record.errors` exactly as it does for a plain failed `save`.
|
|
19
|
+
#
|
|
20
|
+
# ❌ hand-raised to reuse the rescue/rendering machinery
|
|
21
|
+
# def approve!
|
|
22
|
+
# errors.add(:base, 'Only drafts can be approved') unless draft?
|
|
23
|
+
# raise ActiveRecord::RecordInvalid.new(self) if errors.any?
|
|
24
|
+
#
|
|
25
|
+
# update!(approved_at: Time.current)
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# ✔️ soft failure — the caller checks the return and renders errors
|
|
29
|
+
# def approve
|
|
30
|
+
# unless draft?
|
|
31
|
+
# errors.add(:base, 'Only drafts can be approved')
|
|
32
|
+
# return false
|
|
33
|
+
# end
|
|
34
|
+
# self.approved_at = Time.current
|
|
35
|
+
# save
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# ## Relationship with `DevDoc/Rails/SoftFailureInBangMethod`
|
|
39
|
+
# The two cops close the two spellings of the same confusion: that one
|
|
40
|
+
# catches soft failure hiding under a raising name; this one catches a
|
|
41
|
+
# raise simulating a validation failure. Together they funnel
|
|
42
|
+
# controller-invoked domain actions to the soft non-bang shape.
|
|
43
|
+
#
|
|
44
|
+
# ## Exception
|
|
45
|
+
# Re-raising a rescued `RecordInvalid` (`raise e`, or a bare `raise`
|
|
46
|
+
# inside the rescue) is not flagged — the exception originated in
|
|
47
|
+
# Active Record, not by hand. A genuine need to construct one (for
|
|
48
|
+
# example, aborting a batch import through machinery that renders
|
|
49
|
+
# `RecordInvalid` specifically) takes an inline disable stating the
|
|
50
|
+
# reason.
|
|
51
|
+
#
|
|
52
|
+
# NOTE: Indirection is not detected — the class stashed in a variable
|
|
53
|
+
# before raising, or an app-defined `RecordInvalid` subclass —
|
|
54
|
+
# reviewers must cover those.
|
|
55
|
+
class NoManualRecordInvalid < Base
|
|
56
|
+
MSG = 'Do not raise `ActiveRecord::RecordInvalid` by hand — only Active Record persistence ' \
|
|
57
|
+
'(`save!`, `create!`) should raise it. Signal domain failure softly: add to `errors` ' \
|
|
58
|
+
'and return false.'.freeze
|
|
59
|
+
|
|
60
|
+
RESTRICT_ON_SEND = %i[new raise fail].freeze
|
|
61
|
+
|
|
62
|
+
def_node_matcher :record_invalid_const?, <<~PATTERN
|
|
63
|
+
(const (const {nil? cbase} :ActiveRecord) :RecordInvalid)
|
|
64
|
+
PATTERN
|
|
65
|
+
|
|
66
|
+
def on_send(node)
|
|
67
|
+
if node.method_name == :new
|
|
68
|
+
return unless record_invalid_const?(node.receiver)
|
|
69
|
+
else
|
|
70
|
+
return unless node.receiver.nil?
|
|
71
|
+
|
|
72
|
+
# `raise ActiveRecord::RecordInvalid.new(...)` is already flagged
|
|
73
|
+
# at the `.new` itself; this branch covers the class-only form
|
|
74
|
+
# `raise ActiveRecord::RecordInvalid`.
|
|
75
|
+
argument = node.first_argument
|
|
76
|
+
return unless argument && record_invalid_const?(argument)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
add_offense(node)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Flag a `!`-named model method that signals failure softly — errors on
|
|
6
|
+
# the record and a falsy return instead of a raise. Soft failure is
|
|
7
|
+
# `save` semantics and belongs to a non-bang name.
|
|
8
|
+
#
|
|
9
|
+
# ## Rationale
|
|
10
|
+
# By convention a `!` method signals failure by raising, the way
|
|
11
|
+
# `save!`/`create!` do (`DevDoc/Style/RedundantGuardAfterBang` polices
|
|
12
|
+
# call sites under the same assumption). Rails' non-bang `save` is the
|
|
13
|
+
# opposite contract: validation errors land on the record, the call
|
|
14
|
+
# returns false, and the caller — typically a controller rendering
|
|
15
|
+
# `record.errors` — checks the result. A domain-action method that
|
|
16
|
+
# wears the `!` while failing softly mixes the two contracts: callers
|
|
17
|
+
# cannot tell whether to rescue or to check the return value, call
|
|
18
|
+
# sites accrete rescue blocks and comments explaining what the name
|
|
19
|
+
# should have said, and the naming convention other code relies on
|
|
20
|
+
# stops being trustworthy.
|
|
21
|
+
#
|
|
22
|
+
# Controller-invoked domain actions (finalize, publish, approve, ...)
|
|
23
|
+
# should normally take the soft shape under a non-bang name: assign
|
|
24
|
+
# attributes, add any precondition errors, return `save`'s boolean —
|
|
25
|
+
# the controller then renders the errors exactly as it does for a
|
|
26
|
+
# plain failed `save`, with no rescue. Reserve the `!` for methods
|
|
27
|
+
# that raise on failure (delegating to `save!`/`update!` is the common
|
|
28
|
+
# case), for callers — jobs, migrations, internal invariants — that
|
|
29
|
+
# want the exception.
|
|
30
|
+
#
|
|
31
|
+
# ❌ soft failure under a bang name — the `!` lies
|
|
32
|
+
# def publish!
|
|
33
|
+
# if archived?
|
|
34
|
+
# errors.add(:base, 'Archived posts cannot be published')
|
|
35
|
+
# return
|
|
36
|
+
# end
|
|
37
|
+
# self.published_at = Time.current
|
|
38
|
+
# save
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# ❌ return-gated non-bang save under a bang name — same lie
|
|
42
|
+
# def archive!
|
|
43
|
+
# self.archived_at = Time.current
|
|
44
|
+
# transaction do
|
|
45
|
+
# next unless save
|
|
46
|
+
# items.each { |item| item.update!(archived: true) }
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# ✔️ same behavior, honest name — `save` semantics, non-bang
|
|
51
|
+
# def publish
|
|
52
|
+
# if archived?
|
|
53
|
+
# errors.add(:base, 'Archived posts cannot be published')
|
|
54
|
+
# return false
|
|
55
|
+
# end
|
|
56
|
+
# self.published_at = Time.current
|
|
57
|
+
# save
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# ✔️ raising bang — the `!` is earned
|
|
61
|
+
# def publish!
|
|
62
|
+
# raise ArgumentError, 'already archived' if archived?
|
|
63
|
+
#
|
|
64
|
+
# update!(published_at: Time.current)
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# A bang-named custom validator that adds errors by design should
|
|
68
|
+
# simply drop its `!` — validators are the canonical soft-failure
|
|
69
|
+
# shape.
|
|
70
|
+
#
|
|
71
|
+
# ## Relationship with `DevDoc/Rails/NoManualRecordInvalid`
|
|
72
|
+
# The two cops close the two spellings of the same confusion. This cop
|
|
73
|
+
# catches soft failure hiding under a raising name; that one catches a
|
|
74
|
+
# hand-raised `ActiveRecord::RecordInvalid` simulating a validation
|
|
75
|
+
# failure. Together they funnel controller-invoked domain actions to
|
|
76
|
+
# the soft non-bang shape, while leaving genuinely raising bang
|
|
77
|
+
# methods untouched.
|
|
78
|
+
#
|
|
79
|
+
# NOTE: Only a literal `raise`/`fail` (`Kernel.`-qualified included)
|
|
80
|
+
# in the method's own body counts as raise semantics — a nested `def`
|
|
81
|
+
# is a separate method whose raises and soft signals both stay its
|
|
82
|
+
# own. Delegating to `save!` on the happy path does NOT excuse an
|
|
83
|
+
# `errors.add`-and-return on a precondition path — that mixed
|
|
84
|
+
# contract is exactly what this cop exists to catch. Blind spots
|
|
85
|
+
# reviewers must cover: soft failure hidden entirely in a callee
|
|
86
|
+
# (the method returns a callee's false without touching `errors`),
|
|
87
|
+
# a persistence boolean stashed in a variable before branching,
|
|
88
|
+
# methods defined via `define_method`/DSL, and failure signalled
|
|
89
|
+
# through a custom exception swallowed internally.
|
|
90
|
+
class SoftFailureInBangMethod < Base
|
|
91
|
+
MSG = 'Bang method `%<method>s` %<signal>s — a `!` name promises raise-on-failure. ' \
|
|
92
|
+
'Drop the `!` (soft `save` semantics), or raise on the failing path.'.freeze
|
|
93
|
+
|
|
94
|
+
# `create` is omitted: receiverless `create` in an instance method is
|
|
95
|
+
# not a persistence call on self.
|
|
96
|
+
SOFT_PERSISTENCE = %i[save update destroy].freeze
|
|
97
|
+
|
|
98
|
+
# Writes to self's own errors only; `other_record.errors.add` is a
|
|
99
|
+
# statement about another object, not this method's failure contract.
|
|
100
|
+
def_node_matcher :own_errors_write?, <<~PATTERN
|
|
101
|
+
({send csend} ({send csend} {nil? self} :errors) {:add :import} ...)
|
|
102
|
+
PATTERN
|
|
103
|
+
|
|
104
|
+
def_node_matcher :own_soft_persistence?, <<~PATTERN
|
|
105
|
+
({send csend} {nil? self} {:save :update :destroy} ...)
|
|
106
|
+
PATTERN
|
|
107
|
+
|
|
108
|
+
# `Kernel.raise`/`::Kernel.raise` is the same raise, spelled
|
|
109
|
+
# explicitly.
|
|
110
|
+
def_node_matcher :raising_call?, <<~PATTERN
|
|
111
|
+
(send {nil? (const {nil? cbase} :Kernel)} {:raise :fail} ...)
|
|
112
|
+
PATTERN
|
|
113
|
+
|
|
114
|
+
def on_def(node)
|
|
115
|
+
return unless bang_name?(node.method_name)
|
|
116
|
+
return if node.body.nil?
|
|
117
|
+
return if contains_raise?(node)
|
|
118
|
+
|
|
119
|
+
signal = soft_signal(node)
|
|
120
|
+
return unless signal
|
|
121
|
+
|
|
122
|
+
add_offense(node.loc.name, message: format(MSG, method: node.method_name, signal: signal))
|
|
123
|
+
end
|
|
124
|
+
alias on_defs on_def
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# The unary negation operator defines as `def !`, which is not a
|
|
129
|
+
# bang method.
|
|
130
|
+
def bang_name?(name)
|
|
131
|
+
string = name.to_s
|
|
132
|
+
string.end_with?('!') && string != '!'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Any literal raise/fail (Kernel-qualified included) means the
|
|
136
|
+
# method enforces raise semantics on some path; path-sensitive
|
|
137
|
+
# analysis is not attempted.
|
|
138
|
+
def contains_raise?(node)
|
|
139
|
+
each_own_send(node).any? { |sent| raising_call?(sent) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def soft_signal(node)
|
|
143
|
+
return 'sets `errors` without raising' if each_own_send(node).any? { |sent| own_errors_write?(sent) }
|
|
144
|
+
|
|
145
|
+
gated = each_own_send(node).find do |sent|
|
|
146
|
+
own_soft_persistence?(sent) && condition_position?(sent, node)
|
|
147
|
+
end
|
|
148
|
+
return unless gated
|
|
149
|
+
|
|
150
|
+
format('branches on non-bang `%<name>s` instead of raising', name: gated.method_name)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Sends belonging to this method body only — a nested def (inside a
|
|
154
|
+
# metaprogrammed class body, etc.) is its own method with its own
|
|
155
|
+
# contract, so neither its raises nor its soft signals count here.
|
|
156
|
+
def each_own_send(def_node)
|
|
157
|
+
def_node.each_descendant(:send, :csend).select do |sent|
|
|
158
|
+
sent.each_ancestor(:def, :defs).first.equal?(def_node)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# True when the call's boolean flows into a branch condition
|
|
163
|
+
# (`if save`, `next unless save`, `save && ...`) — the soft-failure
|
|
164
|
+
# idiom. The walk crosses only value-transparent wrappers
|
|
165
|
+
# (parentheses, `!`), so a call whose value is discarded inside a
|
|
166
|
+
# block or consumed as an argument is not a signal; a bare
|
|
167
|
+
# discarded `save` is BangSaveInTransaction's territory, not a
|
|
168
|
+
# naming signal.
|
|
169
|
+
def condition_position?(node, def_node)
|
|
170
|
+
child = node
|
|
171
|
+
node.each_ancestor do |ancestor|
|
|
172
|
+
return false if boundary?(ancestor, def_node)
|
|
173
|
+
|
|
174
|
+
verdict = branch_verdict(ancestor, child)
|
|
175
|
+
return verdict unless verdict.nil?
|
|
176
|
+
|
|
177
|
+
child = ancestor
|
|
178
|
+
end
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def boundary?(ancestor, def_node)
|
|
183
|
+
ancestor.equal?(def_node) || ancestor.def_type? || ancestor.defs_type?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# true/false end the walk; nil crosses a value-transparent wrapper
|
|
187
|
+
# (parentheses, `!`) and continues with the wrapper as the child.
|
|
188
|
+
def branch_verdict(ancestor, child)
|
|
189
|
+
case ancestor.type
|
|
190
|
+
when :and, :or then true
|
|
191
|
+
when :if, :while, :until, :while_post, :until_post, :case
|
|
192
|
+
condition_of?(ancestor, child)
|
|
193
|
+
when :begin, :kwbegin then nil
|
|
194
|
+
when :send, :csend then negation_of?(ancestor, child) ? nil : false
|
|
195
|
+
else false
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def condition_of?(ancestor, child)
|
|
200
|
+
!ancestor.condition.nil? && ancestor.condition.equal?(child)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def negation_of?(ancestor, child)
|
|
204
|
+
ancestor.method_name == :! && !ancestor.receiver.nil? && ancestor.receiver.equal?(child)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-dev_doc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dev-doc contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -144,9 +144,11 @@ files:
|
|
|
144
144
|
- lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb
|
|
145
145
|
- lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb
|
|
146
146
|
- lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb
|
|
147
|
+
- lib/rubocop/cop/dev_doc/rails/no_manual_record_invalid.rb
|
|
147
148
|
- lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb
|
|
148
149
|
- lib/rubocop/cop/dev_doc/rails/no_persistence_in_service.rb
|
|
149
150
|
- lib/rubocop/cop/dev_doc/rails/no_transaction_in_controller.rb
|
|
151
|
+
- lib/rubocop/cop/dev_doc/rails/soft_failure_in_bang_method.rb
|
|
150
152
|
- lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb
|
|
151
153
|
- lib/rubocop/cop/dev_doc/route/no_custom_actions.rb
|
|
152
154
|
- lib/rubocop/cop/dev_doc/route/resource_name_number.rb
|