rubocop-dev_doc 0.14.0 → 0.16.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: 05e1aabe21621ec2ef5867764976fcfb58d7e0bb42b6224f56065ac17bb5cd8b
4
+ data.tar.gz: 2adbd5114fa685aaf7240c04df9fadb955d2cba1a0ccfd83d1e8fb44751f9743
5
5
  SHA512:
6
- metadata.gz: cdf5e192d7d3b526311ca7e9b6d6d3d81a50b3a14d8bd9b4b4a5d4459c0ed4d34c4695e1087e109614cf6f5dc040a12b0e7686f4e4cf509e16f4c9b770a0ccfa
7
- data.tar.gz: 3664cc36ac24eb3f1a0bd6061a3671c32695113d50dd87753a35ec13536d02330fa7f10c7188439e90726a9b3a80dac227205cbaf88f539cb668dd7d18ea628d
6
+ metadata.gz: ffe0a1d90cdf8d0a4dcee8ba865631f9d9c8e3a1e2b3de6af25aa6eb9441a966d715d099e3824e40ec5ec2640c001d9043f375e81fc590732309554b81b80e18
7
+ data.tar.gz: 1d164d6cc051b8f216384ef334d0b5015a08d52a71cdb9b93e0cf065b6440e9a7285c3d4904d83be92936631b638428ff1dd816f39ace4a76b6053ee98b184b4
data/config/default.yml CHANGED
@@ -188,6 +188,17 @@ DevDoc/Rails/NoTransactionInController:
188
188
  Include:
189
189
  - "app/controllers/**/*.rb"
190
190
 
191
+ # Same placement rule in an assignment costume: `collection_ids=` on a
192
+ # persisted record writes the join table immediately (no save, no
193
+ # validations), so a controller-side ids write either bypasses the guards the
194
+ # operation's form object/model enforces on its other write paths, or mutates
195
+ # the association even when the surrounding operation fails afterwards.
196
+ DevDoc/Rails/NoCollectionIdsWriterInController:
197
+ Description: "Association `*_ids=` writers in controllers persist join rows immediately on a persisted record, bypassing validations; move the write into the model method or form object that owns the operation's validations."
198
+ Enabled: true
199
+ Include:
200
+ - "app/controllers/**/*.rb"
201
+
191
202
  DevDoc/Rails/EnumMustBeSymbolized:
192
203
  Description: "Declare enums with `enum_symbolize :foo, { … }` instead of a bare `enum`, so the attribute type is set before the enum and the reader returns a symbol."
193
204
  Enabled: true
@@ -986,6 +997,18 @@ DevDoc/I18n/TranslationKeyPrefix:
986
997
  - "app/mailers/**/*.rb"
987
998
  - "app/helpers/**/*.rb"
988
999
 
1000
+ DevDoc/Rails/SoftFailureInBangMethod:
1001
+ Description: "A bang-named model method must not fail softly (errors on the record + falsy return) — that is `save` semantics; drop the `!` or raise."
1002
+ Enabled: true
1003
+ Include:
1004
+ - "app/models/**/*.rb"
1005
+
1006
+ DevDoc/Rails/NoManualRecordInvalid:
1007
+ Description: "Do not raise ActiveRecord::RecordInvalid by hand; only `save!`/`create!` should raise it. Signal domain failure softly via errors + falsy return."
1008
+ Enabled: true
1009
+ Include:
1010
+ - "app/models/**/*.rb"
1011
+
989
1012
  DevDoc/I18n/ReportText:
990
1013
  Description: "Report every user-facing glib text prop — hardcoded and already-localized — to collect all possible texts."
991
1014
  # A tooling aid, not a lint: unlike RequireTranslation it fires on *every*
@@ -2,13 +2,18 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Auth
5
- # The adjacent-justification contract shared by the auth "flag and make
6
- # the developer state the mechanism" cops: an offense is silenced by a
7
- # comment containing the cop's marker phrase either on the flagged
8
- # node's own line (trailing) or in the contiguous comment block ending
9
- # on the line directly above it. Adjacency is the point a marker
10
- # elsewhere in the file justifies nothing. Host cops must provide
11
- # `justification_marker`.
5
+ # The adjacent-justification contract shared by the "flag and make the
6
+ # developer state the mechanism" cops (the auth family, and
7
+ # `DevDoc/Migration/AvoidNonNull`'s column-kind markers): an offense is
8
+ # silenced by a comment containing the cop's marker either on the
9
+ # flagged node's own line (trailing) or in the contiguous comment block
10
+ # ending on the line directly above it. Adjacency is the point — a
11
+ # marker elsewhere in the file justifies nothing. Host cops provide
12
+ # `justification_marker`, or pass an explicit `marker:` per call. A
13
+ # String marker matches by substring (suits multi-word phrases); a
14
+ # Regexp marker matches by `match?` against the downcased comment text
15
+ # (use `\b` anchors for single-word markers, where substring matching
16
+ # would let "enumeration" satisfy an `enum` marker).
12
17
  #
13
18
  # With `standalone_only: true` the upward walk only crosses comment-ONLY
14
19
  # lines. Use this when the checked node is a class/module declaration:
@@ -17,8 +22,8 @@ module RuboCop
17
22
  module AdjacentJustification
18
23
  private
19
24
 
20
- def adjacent_justification?(node, standalone_only: false)
21
- marker = justification_marker.downcase
25
+ def adjacent_justification?(node, standalone_only: false, marker: justification_marker)
26
+ marker = marker.downcase if marker.is_a?(String)
22
27
  comments_by_line = processed_source.comments.group_by { |comment| comment.location.line }
23
28
 
24
29
  return true if contains_marker?(comments_by_line[node.first_line], marker)
@@ -46,7 +51,10 @@ module RuboCop
46
51
  end
47
52
 
48
53
  def contains_marker?(comments, marker)
49
- Array(comments).any? { |comment| comment.text.downcase.include?(marker) }
54
+ Array(comments).any? do |comment|
55
+ text = comment.text.downcase
56
+ marker.is_a?(Regexp) ? marker.match?(text) : text.include?(marker)
57
+ end
50
58
  end
51
59
  end
52
60
  end
@@ -44,9 +44,7 @@ module RuboCop
44
44
  # add_column :checklists, :approval_required, :boolean
45
45
  #
46
46
  # ✔️ enum; "unset" is explicit, null: false is justified
47
- # # rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
48
- # add_column :checklists, :approval_requirement, :integer, null: false
49
- # # rubocop:enable DevDoc/Migration/AvoidNonNull
47
+ # add_column :checklists, :approval_requirement, :integer, null: false # enum
50
48
  #
51
49
  # class Checklist < ApplicationRecord
52
50
  # enum :approval_requirement, { no_approval_needed: 0, approval_by_admins: 1 }
@@ -76,7 +74,7 @@ module RuboCop
76
74
  #
77
75
  # ✔️ true binary preference, justified
78
76
  # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference; user opt-in
79
- # add_column :service_subscriptions, :auto_renew, :boolean, null: false
77
+ # add_column :service_subscriptions, :auto_renew, :boolean, null: false # boolean
80
78
  # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
81
79
  #
82
80
  # NOTE: This cop catches `t.boolean`, `add_column ..., :boolean`,
@@ -104,7 +102,7 @@ module RuboCop
104
102
  #
105
103
  # # good (justified boolean)
106
104
  # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
107
- # add_column :service_subscriptions, :auto_renew, :boolean, null: false
105
+ # add_column :service_subscriptions, :auto_renew, :boolean, null: false # boolean
108
106
  # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
109
107
  class AvoidBooleanColumn < Base
110
108
  MSG = 'Avoid `boolean` columns; consider a timestamp (approved_at), enum, or model method. ' \
@@ -1,3 +1,5 @@
1
+ require_relative '../auth/adjacent_justification'
2
+
1
3
  module RuboCop
2
4
  module Cop
3
5
  module DevDoc
@@ -50,34 +52,54 @@ module RuboCop
50
52
  # violation), so `null: false` is required, and enforced from the model
51
53
  # side by `DevDoc/Rails/EnumColumnNotNull`. But an enum is a plain
52
54
  # `integer` column, statically indistinguishable from any other
53
- # integer, so THIS cop cannot detect it and WILL flag it. Disable it on
54
- # the line with a brief reason `-- enum` so the migration is
55
- # self-documenting: a reader sees at a glance that the column is an enum.
55
+ # integer, so THIS cop cannot detect it and WILL flag it. Mark the line
56
+ # with an adjacent `# enum` comment (see Markers below) so the
57
+ # migration is self-documenting: a reader sees at a glance that the
58
+ # column is an enum.
56
59
  # - **Boolean columns justified via `DevDoc/Migration/AvoidBooleanColumn`**
57
60
  # — once the developer has justified a boolean through that cop's
58
61
  # escape hatch, NULL is outside {true, false} (just as it is outside an
59
62
  # enum's domain), so `null: false` is required and enforced by the
60
- # sibling cop `DevDoc/Migration/BooleanColumnNotNull`. Disable this cop
61
- # on the line with `-- boolean` so the migration is self-documenting.
63
+ # sibling cop `DevDoc/Migration/BooleanColumnNotNull`. Mark the line
64
+ # with an adjacent `# boolean` comment.
65
+ #
66
+ # ## Markers
67
+ # The marker is a comment containing the word `enum` or `boolean`, either
68
+ # trailing the flagged line itself or in the contiguous COMMENT-ONLY
69
+ # block ending on the line directly above it. A marker trailing a
70
+ # *previous code line* deliberately does not count — otherwise one
71
+ # marked column would silence the unmarked column defined on the next
72
+ # line. Matching is case-insensitive and word-bounded (exactly `enum` /
73
+ # `boolean`): a prose comment like `# enum — NULL is outside the
74
+ # enum's domain` satisfies the marker, while `enumeration`, `enums`,
75
+ # or `enum_type` do not. Adjacency is the point — a marker elsewhere
76
+ # in the file justifies nothing.
62
77
  #
63
78
  # ✔️ Required foreign key (never flagged)
64
79
  # t.belongs_to :user, null: false, foreign_key: true
65
80
  #
66
- # ✔️ Enum (flagged here disable with a brief `-- enum` reason)
67
- # # rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
81
+ # ✔️ Enum, marker trailing the line
82
+ # add_column :orders, :status, :integer, null: false # enum
83
+ #
84
+ # ✔️ Enum, marker in the comment above
85
+ # # enum — NULL is outside the enum's domain
68
86
  # add_column :orders, :status, :integer, null: false
69
- # # rubocop:enable DevDoc/Migration/AvoidNonNull
70
87
  #
71
- # ✔️ Boolean (flagged here disable with a brief `-- boolean` reason)
72
- # # rubocop:disable DevDoc/Migration/AvoidNonNull -- boolean
88
+ # ✔️ Boolean (justified through AvoidBooleanColumn's escape hatch)
73
89
  # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
74
- # add_column :things, :flag, :boolean, null: false
90
+ # add_column :things, :flag, :boolean, null: false # boolean
75
91
  # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
76
- # # rubocop:enable DevDoc/Migration/AvoidNonNull
92
+ #
93
+ # A `# rubocop:disable` directive still silences the cop like any other
94
+ # (RuboCop-level behavior), but the marker is the sanctioned form —
95
+ # consumer projects that police inline disables (e.g. via
96
+ # `Style/DisableCopsWithinSourceCodeDirective`'s `AllowedCops`) can drop
97
+ # this cop from their allowed-disables list once existing sites carry
98
+ # markers.
77
99
  #
78
100
  # NOTE: This cop is deliberately NOT enum-aware. It could read the
79
101
  # model's `enum` declarations and skip those columns, but requiring an
80
- # explicit per-line disable is intentional: it forces the developer to
102
+ # explicit per-line marker is intentional: it forces the developer to
81
103
  # signal that the column is an enum, which documents the migration. A
82
104
  # silent skip would hide that intent.
83
105
  #
@@ -85,8 +107,10 @@ module RuboCop
85
107
  # API: `change_column_null(table, column, false)` and
86
108
  # `change_column(table, column, type, null: false)`. Both express the same
87
109
  # constraint as `null: false` on a definition (the add-nullable -> backfill
88
- # -> tighten step), so a legit enum/boolean tightening disables this cop
89
- # inline with `-- enum`/`-- boolean`, exactly as for a new column.
110
+ # -> tighten step), so a legit enum/boolean tightening carries the same
111
+ # `# enum`/`# boolean` marker, exactly as for a new column. When the
112
+ # tightening runs inside a loop, put the marker on the line of the
113
+ # `change_column_null` call itself.
90
114
  #
91
115
  # NOTE: This cop only flags `null: false` (and the equivalent `false` arg
92
116
  # of `change_column_null`). It does not flag `null: true` (redundant but
@@ -105,17 +129,27 @@ module RuboCop
105
129
  # # bad (same tightening, via change_column)
106
130
  # change_column :users, :name, :string, null: false
107
131
  #
108
- # # bad (enum without a disable — the cop flags it; disable with `-- enum`)
132
+ # # bad (enum without a marker — the cop flags it)
109
133
  # t.integer :processing_status, null: false
110
134
  #
111
135
  # # good
112
136
  # add_column :users, :name, :string
113
137
  #
138
+ # # good (enum, marked)
139
+ # t.integer :processing_status, null: false # enum
140
+ #
114
141
  # # good (required foreign key — never flagged)
115
142
  # t.belongs_to :user, null: false, foreign_key: true
116
143
  class AvoidNonNull < Base
144
+ include Auth::AdjacentJustification
145
+
117
146
  MSG = 'Avoid `null: false` on regular columns; enforce presence in the model layer. ' \
118
- 'If this is an enum column, disable this cop on the line with a brief reason, e.g. `-- enum`.'.freeze
147
+ 'If this is an enum or justified boolean column, mark the line with an adjacent ' \
148
+ '`# enum` / `# boolean` comment.'.freeze
149
+
150
+ # Word-bounded so an incidental "enumeration" in a comment cannot
151
+ # satisfy the marker. Comment text is downcased before matching.
152
+ KIND_MARKER = /\b(?:enum|boolean)\b/
119
153
 
120
154
  # Column-definition helpers that take a `null:` option. Deliberately
121
155
  # EXCLUDES `references` / `belongs_to` (and the separate `add_reference`
@@ -139,14 +173,17 @@ module RuboCop
139
173
  # step. Flagged so the cop cannot be sidestepped by choice of API: a
140
174
  # regular column tightened to NOT NULL is held to the same standard as one
141
175
  # declared NOT NULL up front. A legit enum/boolean tightening resolves at
142
- # the disable site, exactly as for a new column.
176
+ # the marker site, exactly as for a new column.
143
177
  def_node_matcher :change_column_null_false, <<~PATTERN
144
178
  (send _ :change_column_null _ _ $false ...)
145
179
  PATTERN
146
180
 
147
181
  def on_send(node)
148
182
  flag = offense_node(node)
149
- add_offense(flag) if flag
183
+ return unless flag
184
+ return if kind_marker?(node, flag)
185
+
186
+ add_offense(flag)
150
187
  end
151
188
 
152
189
  private
@@ -158,6 +195,17 @@ module RuboCop
158
195
  change_column_null_false(node) || column_null_false_pair(node)
159
196
  end
160
197
 
198
+ # Checked from both the call's first line and the offending
199
+ # `null: false` pair's own line, so a multi-line call accepts the
200
+ # marker trailing either. standalone_only: the upward walk crosses
201
+ # comment-only lines exclusively — a marker trailing a PREVIOUS code
202
+ # line (the column defined one line up, the enclosing create_table,
203
+ # an earlier argument of this very call) must not silence this one.
204
+ def kind_marker?(node, flag)
205
+ adjacent_justification?(node, marker: KIND_MARKER, standalone_only: true) ||
206
+ adjacent_justification?(flag, marker: KIND_MARKER, standalone_only: true)
207
+ end
208
+
161
209
  def column_null_false_pair(node)
162
210
  return unless column_method?(node)
163
211
 
@@ -24,8 +24,8 @@ module RuboCop
24
24
  # the boolean case: that cop flags `null: false` on regular
25
25
  # columns (including booleans, which it cannot distinguish from
26
26
  # other types); this cop REQUIRES `null: false` on booleans. The
27
- # contradiction is resolved at the disable site: when the
28
- # developer disables `AvoidNonNull` on a boolean with `-- boolean`
27
+ # contradiction is resolved at the marker site: when the
28
+ # developer marks the `AvoidNonNull` line with `# boolean`
29
29
  # (alongside the `AvoidBooleanColumn` disable), this cop fires if
30
30
  # `null: false` is missing.
31
31
  #
@@ -47,9 +47,7 @@ module RuboCop
47
47
  # end
48
48
  # end
49
49
  #
50
- # # rubocop:disable DevDoc/Migration/AvoidNonNull -- boolean
51
- # change_column_null :table, :flag, false
52
- # # rubocop:enable DevDoc/Migration/AvoidNonNull
50
+ # change_column_null :table, :flag, false # boolean
53
51
  # end
54
52
  #
55
53
  # The application layer is the source of truth for the default
@@ -27,12 +27,11 @@ module RuboCop
27
27
  # ## Interaction with AvoidNonNull
28
28
  # An enum is a plain `integer` column, so `AvoidNonNull` cannot tell it
29
29
  # apart from a regular integer and WILL flag the `null: false` you add
30
- # to satisfy this cop. Disable it on that migration with a brief `-- enum`
31
- # reason, so the migration is self-documenting:
30
+ # to satisfy this cop. Mark the migration line with an adjacent `# enum`
31
+ # comment (that cop's sanctioned marker), so the migration is
32
+ # self-documenting:
32
33
  #
33
- # # rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
34
- # add_column :orders, :status, :integer, null: false
35
- # # rubocop:enable DevDoc/Migration/AvoidNonNull
34
+ # add_column :orders, :status, :integer, null: false # enum
36
35
  #
37
36
  # NOTE: This cop reads `db/schema.rb` and does nothing if it is absent
38
37
  # (e.g. projects using `structure.sql`). It also relies on the schema
@@ -0,0 +1,129 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Controllers must not call association `*_ids=` writers; the write
6
+ # belongs behind the model method or form-object PORO that owns the
7
+ # operation's validations.
8
+ #
9
+ # ## Rationale
10
+ # `collection_ids=` looks like plain attribute assignment but is not:
11
+ # on a persisted record it writes the join table IMMEDIATELY — no save
12
+ # call, no validations, no surrounding transaction. In a controller
13
+ # that produces two failure modes:
14
+ #
15
+ # - **Validation asymmetry.** The create path routes through a form
16
+ # object whose validations guard the association (say, "at least one
17
+ # tag"); a later-added update action assigns `record.tag_ids = ...`
18
+ # directly, and every one of those guards silently stops existing on
19
+ # the second write path. Nothing fails loudly — the update simply
20
+ # never consults the rules that create enforces.
21
+ # - **Partial writes.** The association mutates even when the
22
+ # surrounding operation fails afterwards: clear `record.tag_ids = []`
23
+ # and then fail to save a status flag, and the two halves are left
24
+ # inconsistent with no transaction to roll the join rows back.
25
+ #
26
+ # Both are the data-saving-orchestration placement rule (see the
27
+ # orchestration best-practice doc, category 1) wearing an assignment
28
+ # costume: which rows must land together, and under which guards, is
29
+ # domain knowledge. Move the write into a model method or the
30
+ # form-object PORO that owns the operation's validations, so updates
31
+ # travel the same validation path as creates.
32
+ #
33
+ # ❌ Hand-rolled update path — join rows written before save!, guards skipped
34
+ # def update
35
+ # @article.category = update_params[:category]
36
+ # @article.tag_ids = checked_tag_ids
37
+ # @article.save!
38
+ # end
39
+ #
40
+ # ✔️ The model method owns the write and its guards
41
+ # def update
42
+ # @article.update_with_tags!(update_params, tag_ids: checked_tag_ids)
43
+ # end
44
+ #
45
+ # ## Relationship with DevDoc/Rails/AvoidBypassingValidation
46
+ # Same bypass family, different signal: that cop matches a fixed list
47
+ # of ActiveRecord method names repo-wide, while `*_ids=` is a NAME
48
+ # PATTERN that is perfectly legitimate on form objects (common under
49
+ # `app/models/`). The pattern is only dependable where a direct ids
50
+ # write is either a validation bypass or misplaced orchestration —
51
+ # controllers — so it lives in this scoped cop instead.
52
+ #
53
+ # ## Exception
54
+ # The receiver's type is statically invisible, so this cop flags every
55
+ # `*_ids=` spelling, including ones that persist nothing: an `ActiveModel`
56
+ # form object, a not-yet-saved record (its ids apply at save time, under
57
+ # validations), or a non-AR object such as a presenter or OpenStruct.
58
+ # Those are ordinary params→object binding (orchestration category 2) —
59
+ # disable inline, naming why nothing persists:
60
+ #
61
+ # # rubocop:disable DevDoc/Rails/NoCollectionIdsWriterInController
62
+ # # -- ArticleImport is an ActiveModel PORO; nothing persists
63
+ #
64
+ # # rubocop:disable DevDoc/Rails/NoCollectionIdsWriterInController
65
+ # # -- @article is built in this action and unsaved; ids apply at save,
66
+ # # under validations
67
+ #
68
+ # NOTE: Spellings with the same immediate-write behavior that this cop
69
+ # cannot see, for reviewers: the plain collection writer
70
+ # (`record.tags = [...]`) — indistinguishable from an attribute write —
71
+ # a STANDALONE `assign_attributes(tag_ids: [...])` on a persisted record
72
+ # (mass assignment invokes the same writer, with no transaction around
73
+ # it), and dynamic dispatch (`record.send(:tag_ids=, x)`). By contrast,
74
+ # `update`/`update!` with an ids key is NOT in this class: Rails wraps
75
+ # assign_attributes + save in a transaction precisely so those join-row
76
+ # writes roll back when validations fail (see the comment inside
77
+ # `ActiveRecord::Persistence#update!`).
78
+ #
79
+ # @example
80
+ # # bad
81
+ # @article.tag_ids = checked_tag_ids
82
+ #
83
+ # # bad
84
+ # article&.tag_ids = []
85
+ #
86
+ # # bad — op-assign calls the same writer
87
+ # @article.tag_ids += [tag.id]
88
+ #
89
+ # # good — the model method owns the write and its validations
90
+ # @article.update_with_tags!(update_params, tag_ids: checked_tag_ids)
91
+ class NoCollectionIdsWriterInController < Base
92
+ MSG = '`%<method>s` in a controller — on a persisted record a collection `_ids=` writer ' \
93
+ 'updates the join table immediately, skipping the owner\'s save and validations. Move the ' \
94
+ 'write into the model method or form-object PORO that owns the operation\'s validations.'.freeze
95
+
96
+ # Dynamic name pattern, so RESTRICT_ON_SEND cannot apply (it is
97
+ # static); the filter runs inside on_send instead.
98
+ IDS_WRITER = /\A\w+_ids=\z/
99
+
100
+ def on_send(node)
101
+ return unless node.assignment_method?
102
+ return unless IDS_WRITER.match?(node.method_name.to_s)
103
+
104
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
105
+ end
106
+
107
+ # Safe navigation (`record&.tag_ids = x`) parses as a csend node that
108
+ # on_send does not receive — alias it so `&.` is caught too.
109
+ alias on_csend on_send
110
+
111
+ # Op-assigns (`record.tag_ids += [x]`, `||=`, `&&=`) call the same
112
+ # writer but parse as op_asgn/or_asgn/and_asgn nodes whose inner send
113
+ # is the READER (`tag_ids`, no `=`), so on_send never sees a match.
114
+ def on_op_asgn(node)
115
+ lhs = node.children.first
116
+ return unless lhs.type?(:send, :csend)
117
+
118
+ writer = "#{lhs.method_name}="
119
+ return unless IDS_WRITER.match?(writer)
120
+
121
+ add_offense(lhs.loc.selector, message: format(MSG, method: writer))
122
+ end
123
+ alias on_or_asgn on_op_asgn
124
+ alias on_and_asgn on_op_asgn
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -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.16.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.16.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-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -143,10 +143,13 @@ files:
143
143
  - lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb
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
+ - lib/rubocop/cop/dev_doc/rails/no_collection_ids_writer_in_controller.rb
146
147
  - lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb
148
+ - lib/rubocop/cop/dev_doc/rails/no_manual_record_invalid.rb
147
149
  - lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb
148
150
  - lib/rubocop/cop/dev_doc/rails/no_persistence_in_service.rb
149
151
  - lib/rubocop/cop/dev_doc/rails/no_transaction_in_controller.rb
152
+ - lib/rubocop/cop/dev_doc/rails/soft_failure_in_bang_method.rb
150
153
  - lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb
151
154
  - lib/rubocop/cop/dev_doc/route/no_custom_actions.rb
152
155
  - lib/rubocop/cop/dev_doc/route/resource_name_number.rb