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 +4 -4
- data/config/default.yml +23 -0
- data/lib/rubocop/cop/dev_doc/auth/adjacent_justification.rb +18 -10
- data/lib/rubocop/cop/dev_doc/migration/avoid_boolean_column.rb +3 -5
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +67 -19
- data/lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb +3 -5
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +4 -5
- data/lib/rubocop/cop/dev_doc/rails/no_collection_ids_writer_in_controller.rb +129 -0
- data/lib/rubocop/cop/dev_doc/rails/no_manual_record_invalid.rb +85 -0
- data/lib/rubocop/cop/dev_doc/rails/soft_failure_in_bang_method.rb +210 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05e1aabe21621ec2ef5867764976fcfb58d7e0bb42b6224f56065ac17bb5cd8b
|
|
4
|
+
data.tar.gz: 2adbd5114fa685aaf7240c04df9fadb955d2cba1a0ccfd83d1e8fb44751f9743
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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 =
|
|
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?
|
|
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
|
-
#
|
|
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.
|
|
54
|
-
#
|
|
55
|
-
# self-documenting: a reader sees at a glance that the
|
|
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`.
|
|
61
|
-
#
|
|
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
|
|
67
|
-
#
|
|
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 (
|
|
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
|
-
#
|
|
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
|
|
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
|
|
89
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
28
|
-
# developer
|
|
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
|
-
#
|
|
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.
|
|
31
|
-
#
|
|
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
|
-
#
|
|
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
|
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.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-
|
|
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
|