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: 1f6961dde0d54631bae2f2dae6325183cc678f81701aa06575abab88cc1b0f7b
4
- data.tar.gz: cc20cca8c229dca80fbd57d9950794b71560ccb2c13e0ace82c9b6cab0a0c93c
3
+ metadata.gz: cad88b36f561dcd41b75c42c242bf3af1877081c6df2c43ffeeaf3edbdb9e712
4
+ data.tar.gz: ae68d462103ffb1bc792bf4cb7bc65e20ecd8eb773a7b729f0194b070833b487
5
5
  SHA512:
6
- metadata.gz: cdf5e192d7d3b526311ca7e9b6d6d3d81a50b3a14d8bd9b4b4a5d4459c0ed4d34c4695e1087e109614cf6f5dc040a12b0e7686f4e4cf509e16f4c9b770a0ccfa
7
- data.tar.gz: 3664cc36ac24eb3f1a0bd6061a3671c32695113d50dd87753a35ec13536d02330fa7f10c7188439e90726a9b3a80dac227205cbaf88f539cb668dd7d18ea628d
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
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.14.0".freeze
3
+ VERSION = "0.15.0".freeze
4
4
  end
5
5
  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.14.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-13 00:00:00.000000000 Z
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