rubocop-dev_doc 0.6.0.beta1 → 0.8.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 +152 -1
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
- data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
- data/lib/rubocop/cop/dev_doc/migration/avoid_boolean_column.rb +135 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +72 -11
- data/lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb +122 -0
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
- data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +5 -3
- data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
- data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
- data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +22 -5
|
@@ -26,6 +26,12 @@ module RuboCop
|
|
|
26
26
|
# ❌ Regular column
|
|
27
27
|
# add_column :users, :profile_completion_rate, :float, null: false
|
|
28
28
|
#
|
|
29
|
+
# ❌ Tightening an existing column to NOT NULL
|
|
30
|
+
# change_column_null :users, :name, false
|
|
31
|
+
#
|
|
32
|
+
# ❌ Same tightening, via change_column
|
|
33
|
+
# change_column :users, :name, :string, null: false
|
|
34
|
+
#
|
|
29
35
|
# ✔️ Regular column
|
|
30
36
|
# add_column :users, :profile_completion_rate, :float
|
|
31
37
|
#
|
|
@@ -47,6 +53,12 @@ module RuboCop
|
|
|
47
53
|
# integer, so THIS cop cannot detect it and WILL flag it. Disable it on
|
|
48
54
|
# the line with a brief reason — `-- enum` — so the migration is
|
|
49
55
|
# self-documenting: a reader sees at a glance that the column is an enum.
|
|
56
|
+
# - **Boolean columns justified via `DevDoc/Migration/AvoidBooleanColumn`**
|
|
57
|
+
# — once the developer has justified a boolean through that cop's
|
|
58
|
+
# escape hatch, NULL is outside {true, false} (just as it is outside an
|
|
59
|
+
# 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.
|
|
50
62
|
#
|
|
51
63
|
# ✔️ Required foreign key (never flagged)
|
|
52
64
|
# t.belongs_to :user, null: false, foreign_key: true
|
|
@@ -56,20 +68,41 @@ module RuboCop
|
|
|
56
68
|
# add_column :orders, :status, :integer, null: false
|
|
57
69
|
# # rubocop:enable DevDoc/Migration/AvoidNonNull
|
|
58
70
|
#
|
|
71
|
+
# ✔️ Boolean (flagged here — disable with a brief `-- boolean` reason)
|
|
72
|
+
# # rubocop:disable DevDoc/Migration/AvoidNonNull -- boolean
|
|
73
|
+
# # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
|
|
74
|
+
# add_column :things, :flag, :boolean, null: false
|
|
75
|
+
# # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
|
|
76
|
+
# # rubocop:enable DevDoc/Migration/AvoidNonNull
|
|
77
|
+
#
|
|
59
78
|
# NOTE: This cop is deliberately NOT enum-aware. It could read the
|
|
60
79
|
# model's `enum` declarations and skip those columns, but requiring an
|
|
61
80
|
# explicit per-line disable is intentional: it forces the developer to
|
|
62
81
|
# signal that the column is an enum, which documents the migration. A
|
|
63
82
|
# silent skip would hide that intent.
|
|
64
83
|
#
|
|
65
|
-
# NOTE: This cop
|
|
66
|
-
# (
|
|
67
|
-
# `null: false
|
|
84
|
+
# NOTE: This cop also flags NOT NULL set on an existing column, by either
|
|
85
|
+
# API: `change_column_null(table, column, false)` and
|
|
86
|
+
# `change_column(table, column, type, null: false)`. Both express the same
|
|
87
|
+
# 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.
|
|
90
|
+
#
|
|
91
|
+
# NOTE: This cop only flags `null: false` (and the equivalent `false` arg
|
|
92
|
+
# of `change_column_null`). It does not flag `null: true` (redundant but
|
|
93
|
+
# harmless), and it does not require foreign keys to carry `null: false`
|
|
94
|
+
# — adding it to an FK is encouraged but not enforced here.
|
|
68
95
|
#
|
|
69
96
|
# @example
|
|
70
97
|
# # bad
|
|
71
98
|
# add_column :users, :name, :string, null: false
|
|
72
99
|
#
|
|
100
|
+
# # bad (tightening an existing column to NOT NULL)
|
|
101
|
+
# change_column_null :users, :name, false
|
|
102
|
+
#
|
|
103
|
+
# # bad (same tightening, via change_column)
|
|
104
|
+
# change_column :users, :name, :string, null: false
|
|
105
|
+
#
|
|
73
106
|
# # bad (enum without a disable — the cop flags it; disable with `-- enum`)
|
|
74
107
|
# t.integer :processing_status, null: false
|
|
75
108
|
#
|
|
@@ -91,28 +124,56 @@ module RuboCop
|
|
|
91
124
|
json jsonb bigint
|
|
92
125
|
].freeze
|
|
93
126
|
|
|
94
|
-
RESTRICT_ON_SEND = (COLUMN_METHODS + %i[add_column]).freeze
|
|
127
|
+
RESTRICT_ON_SEND = (COLUMN_METHODS + %i[add_column change_column change_column_null]).freeze
|
|
95
128
|
|
|
96
129
|
def_node_matcher :null_false_pair, <<~PATTERN
|
|
97
130
|
(hash <$(pair (sym :null) (false)) ...>)
|
|
98
131
|
PATTERN
|
|
99
132
|
|
|
133
|
+
# Matches `change_column_null :table, :column, false` (and the optional
|
|
134
|
+
# trailing-default 4-arg form). The `false` arg IS the NOT NULL directive
|
|
135
|
+
# — the same constraint as `null: false` on a definition, just applied to
|
|
136
|
+
# an existing column via the standard add-nullable -> backfill -> tighten
|
|
137
|
+
# step. Flagged so the cop cannot be sidestepped by choice of API: a
|
|
138
|
+
# regular column tightened to NOT NULL is held to the same standard as one
|
|
139
|
+
# declared NOT NULL up front. A legit enum/boolean tightening resolves at
|
|
140
|
+
# the disable site, exactly as for a new column.
|
|
141
|
+
def_node_matcher :change_column_null_false, <<~PATTERN
|
|
142
|
+
(send _ :change_column_null _ _ $false ...)
|
|
143
|
+
PATTERN
|
|
144
|
+
|
|
100
145
|
def on_send(node)
|
|
146
|
+
flag = offense_node(node)
|
|
147
|
+
add_offense(flag) if flag
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# The node whose NOT NULL directive offends: the `false` arg of
|
|
153
|
+
# `change_column_null`, or the `null: false` pair on a regular column
|
|
154
|
+
# definition. nil when there is nothing to flag.
|
|
155
|
+
def offense_node(node)
|
|
156
|
+
change_column_null_false(node) || column_null_false_pair(node)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def column_null_false_pair(node)
|
|
101
160
|
return unless column_method?(node)
|
|
102
161
|
|
|
103
162
|
options = node.arguments.find(&:hash_type?)
|
|
104
163
|
return unless options
|
|
105
164
|
|
|
106
|
-
|
|
107
|
-
return unless pair
|
|
108
|
-
|
|
109
|
-
add_offense(pair)
|
|
165
|
+
null_false_pair(options)
|
|
110
166
|
end
|
|
111
167
|
|
|
112
|
-
|
|
113
|
-
|
|
168
|
+
# Methods that define or change a regular column and carry an options
|
|
169
|
+
# hash where `null:` may appear: the `t.<type>` helpers plus
|
|
170
|
+
# `add_column`/`change_column`. (`change_column_null` is handled
|
|
171
|
+
# separately — its NOT NULL directive is a positional `false`, not an
|
|
172
|
+
# options pair.)
|
|
114
173
|
def column_method?(node)
|
|
115
|
-
node.method?(:add_column) ||
|
|
174
|
+
node.method?(:add_column) ||
|
|
175
|
+
node.method?(:change_column) ||
|
|
176
|
+
COLUMN_METHODS.include?(node.method_name)
|
|
116
177
|
end
|
|
117
178
|
end
|
|
118
179
|
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Boolean columns must carry `null: false`.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `null: false` is reserved for cases where NULL has no meaningful
|
|
9
|
+
# interpretation — and a justified boolean is one of those cases.
|
|
10
|
+
# NULL is outside the column's stated domain of {true, false}: it
|
|
11
|
+
# is neither value, and every read site has to silently coerce it
|
|
12
|
+
# (`nil || false == false`) or risk a footgun.
|
|
13
|
+
#
|
|
14
|
+
# The line is drawn here for standardization and non-subjectivity.
|
|
15
|
+
# Whether a *regular* column should be present is a business
|
|
16
|
+
# decision open to debate (see `DevDoc/Migration/AvoidNonNull`),
|
|
17
|
+
# but once the developer has justified a boolean via
|
|
18
|
+
# `DevDoc/Migration/AvoidBooleanColumn`'s escape hatch, the
|
|
19
|
+
# boolean's non-null-ness is objective — it does not depend on
|
|
20
|
+
# any further business decision — so it is enforced mechanically
|
|
21
|
+
# rather than argued column-by-column.
|
|
22
|
+
#
|
|
23
|
+
# This cop is the inverse of `DevDoc/Migration/AvoidNonNull` for
|
|
24
|
+
# the boolean case: that cop flags `null: false` on regular
|
|
25
|
+
# columns (including booleans, which it cannot distinguish from
|
|
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`
|
|
29
|
+
# (alongside the `AvoidBooleanColumn` disable), this cop fires if
|
|
30
|
+
# `null: false` is missing.
|
|
31
|
+
#
|
|
32
|
+
# ## Adding `null: false` to a table with existing rows
|
|
33
|
+
# Postgres refuses to add a NOT NULL column to a non-empty table
|
|
34
|
+
# without a backfill. Use the model-validations backfill pattern
|
|
35
|
+
# (sanctioned by `DevDoc/Migration/AvoidColumnDefault`):
|
|
36
|
+
#
|
|
37
|
+
# def change
|
|
38
|
+
# add_column :table, :flag, :boolean
|
|
39
|
+
#
|
|
40
|
+
# reversible do |dir|
|
|
41
|
+
# dir.up do
|
|
42
|
+
# Table.reset_column_information
|
|
43
|
+
# Table.where(flag: nil).find_each do |t|
|
|
44
|
+
# t.flag = false
|
|
45
|
+
# t.save!
|
|
46
|
+
# end
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# # rubocop:disable DevDoc/Migration/AvoidNonNull -- boolean
|
|
51
|
+
# change_column_null :table, :flag, false
|
|
52
|
+
# # rubocop:enable DevDoc/Migration/AvoidNonNull
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# The application layer is the source of truth for the default
|
|
56
|
+
# value on new records (e.g. via `after_initialize` or a factory).
|
|
57
|
+
# `default:` in the migration is still disallowed by
|
|
58
|
+
# `DevDoc/Migration/AvoidColumnDefault`.
|
|
59
|
+
#
|
|
60
|
+
# ## Interaction with AvoidBooleanColumn
|
|
61
|
+
# These two cops fire together on every boolean addition:
|
|
62
|
+
#
|
|
63
|
+
# - `AvoidBooleanColumn`: "reconsider using a boolean at all"
|
|
64
|
+
# - `BooleanColumnNotNull`: "if you do, it must be NOT NULL"
|
|
65
|
+
#
|
|
66
|
+
# Satisfy both by either changing the column type (timestamp /
|
|
67
|
+
# enum / model method) or, for a justified boolean, disabling
|
|
68
|
+
# `AvoidBooleanColumn` with `-- boolean` AND ensuring `null: false`
|
|
69
|
+
# is present so this cop does not fire.
|
|
70
|
+
#
|
|
71
|
+
# NOTE: This cop catches `t.boolean`, `add_column ..., :boolean`, and
|
|
72
|
+
# `change_column ..., :boolean` (a type change to boolean) without
|
|
73
|
+
# `null: false`. It does NOT scan `db/schema.rb` for legacy debt — it
|
|
74
|
+
# only enforces the invariant on declarations it can see. Existing
|
|
75
|
+
# nullable booleans are surfaced by inspection, not by this cop.
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# # bad - nullable boolean
|
|
79
|
+
# add_column :users, :active, :boolean
|
|
80
|
+
#
|
|
81
|
+
# # bad - nullable boolean in a create_table block
|
|
82
|
+
# create_table :things do |t|
|
|
83
|
+
# t.boolean :flag
|
|
84
|
+
# end
|
|
85
|
+
#
|
|
86
|
+
# # good - boolean carries null: false
|
|
87
|
+
# # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
|
|
88
|
+
# add_column :things, :flag, :boolean, null: false
|
|
89
|
+
# # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
|
|
90
|
+
class BooleanColumnNotNull < Base
|
|
91
|
+
MSG = 'Boolean columns must carry `null: false` — NULL is outside {true, false}. ' \
|
|
92
|
+
'Backfill existing rows via the model, then `change_column_null`.'.freeze
|
|
93
|
+
|
|
94
|
+
def_node_matcher :boolean_column?, <<~PATTERN
|
|
95
|
+
(send _ {:boolean} ...)
|
|
96
|
+
PATTERN
|
|
97
|
+
|
|
98
|
+
def_node_matcher :add_column_boolean?, <<~PATTERN
|
|
99
|
+
(send _ :add_column _ _ (sym :boolean) ...)
|
|
100
|
+
PATTERN
|
|
101
|
+
|
|
102
|
+
def_node_matcher :change_column_to_boolean?, <<~PATTERN
|
|
103
|
+
(send _ :change_column _ _ (sym :boolean) ...)
|
|
104
|
+
PATTERN
|
|
105
|
+
|
|
106
|
+
def_node_matcher :null_false_pair, <<~PATTERN
|
|
107
|
+
(hash <$(pair (sym :null) (false)) ...>)
|
|
108
|
+
PATTERN
|
|
109
|
+
|
|
110
|
+
def on_send(node)
|
|
111
|
+
return unless boolean_column?(node) || add_column_boolean?(node) || change_column_to_boolean?(node)
|
|
112
|
+
|
|
113
|
+
options = node.arguments.find(&:hash_type?)
|
|
114
|
+
return if options && null_false_pair(options)
|
|
115
|
+
|
|
116
|
+
add_offense(node)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -34,7 +34,10 @@ module RuboCop
|
|
|
34
34
|
class ApplicationRecordTransaction < Base
|
|
35
35
|
extend AutoCorrector
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
# Source-string comparison, so the cbase-qualified spellings must be
|
|
38
|
+
# listed too — `::ApplicationRecord.transaction` was a false positive.
|
|
39
|
+
ALLOWED_RECEIVERS = %w[ApplicationRecord ::ApplicationRecord
|
|
40
|
+
ActiveRecord::Base ::ActiveRecord::Base].freeze
|
|
38
41
|
|
|
39
42
|
MSG = 'Use `ApplicationRecord.transaction` instead of `%<receiver>s.transaction` outside model files.'.freeze
|
|
40
43
|
RESTRICT_ON_SEND = %i[transaction].freeze
|
|
@@ -115,12 +115,17 @@ module RuboCop
|
|
|
115
115
|
# send_confirmation if saved
|
|
116
116
|
# end
|
|
117
117
|
class AvoidRailsCallbacks < Base
|
|
118
|
+
# Includes every *_commit shorthand and the underlying set_callback
|
|
119
|
+
# API — omitting any of them (after_update_commit was missed for a
|
|
120
|
+
# while) leaves a zero-effort spelling of the same callback.
|
|
118
121
|
CALLBACKS = %i[
|
|
119
|
-
after_create after_create_commit after_save
|
|
120
|
-
|
|
122
|
+
after_create after_create_commit after_save after_save_commit
|
|
123
|
+
after_update after_update_commit after_destroy after_destroy_commit
|
|
124
|
+
after_commit before_commit after_rollback after_initialize
|
|
121
125
|
after_find after_touch before_create before_save before_update
|
|
122
126
|
before_destroy before_validation after_validation
|
|
123
127
|
around_create around_save around_update around_destroy
|
|
128
|
+
set_callback
|
|
124
129
|
].freeze
|
|
125
130
|
|
|
126
131
|
MSG = 'Avoid `%<method>s` — extract an explicit method (e.g. `save_with_*`) ' \
|
|
@@ -64,7 +64,10 @@ module RuboCop
|
|
|
64
64
|
MSG = 'Use `%<bang>s` inside a `transaction` block, or check its return value. ' \
|
|
65
65
|
'A non-bang call whose return value is discarded does not roll back the transaction on failure.'.freeze
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
# destroy has the same failure mode: it returns false when a
|
|
68
|
+
# before_destroy callback aborts, and a discarded false does not
|
|
69
|
+
# roll the transaction back.
|
|
70
|
+
FLAGGED_METHODS = %i[save update create destroy].freeze
|
|
68
71
|
|
|
69
72
|
# Node types whose parent always means the return value is consumed.
|
|
70
73
|
CONSUMING_PARENT_TYPES = %i[
|
|
@@ -87,9 +90,11 @@ module RuboCop
|
|
|
87
90
|
|
|
88
91
|
private
|
|
89
92
|
|
|
93
|
+
# with_lock opens a transaction too, so a discarded non-bang save
|
|
94
|
+
# inside it fails just as silently.
|
|
90
95
|
def inside_transaction?(node)
|
|
91
96
|
node.each_ancestor(:block).any? do |ancestor|
|
|
92
|
-
ancestor.method_name
|
|
97
|
+
%i[transaction with_lock].include?(ancestor.method_name)
|
|
93
98
|
end
|
|
94
99
|
end
|
|
95
100
|
|
|
@@ -32,6 +32,16 @@ module RuboCop
|
|
|
32
32
|
# ✔
|
|
33
33
|
# enum_symbolize :payment_status, :finalize_intent
|
|
34
34
|
#
|
|
35
|
+
# Enums declared inside a concern's `included do` block are checked the
|
|
36
|
+
# same way — the pairing must live in the same `included do` block,
|
|
37
|
+
# which is also where it belongs so every includer gets both:
|
|
38
|
+
#
|
|
39
|
+
# ✔
|
|
40
|
+
# included do
|
|
41
|
+
# enum :discount_type, DISCOUNT_TYPES
|
|
42
|
+
# enum_symbolize :discount_type
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
35
45
|
# @example
|
|
36
46
|
# # bad
|
|
37
47
|
# enum :status, { active: 0, archived: 1 }
|
|
@@ -51,24 +61,29 @@ module RuboCop
|
|
|
51
61
|
(send nil? :enum_symbolize $...)
|
|
52
62
|
PATTERN
|
|
53
63
|
|
|
64
|
+
def_node_matcher :included_block?, <<~PATTERN
|
|
65
|
+
(block (send nil? :included) (args) _)
|
|
66
|
+
PATTERN
|
|
67
|
+
|
|
54
68
|
def on_class(node)
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
check_body(node.body)
|
|
70
|
+
end
|
|
57
71
|
|
|
58
|
-
|
|
72
|
+
# Concern-declared enums: `included do ... end` runs in the including
|
|
73
|
+
# class's context, so its statements are checked like a class body.
|
|
74
|
+
def on_block(node)
|
|
75
|
+
return unless included_block?(node)
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
check_body(node.body)
|
|
78
|
+
end
|
|
62
79
|
|
|
63
|
-
|
|
64
|
-
next unless stmt.respond_to?(:send_type?) && stmt.send_type?
|
|
80
|
+
private
|
|
65
81
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
end
|
|
82
|
+
def check_body(body)
|
|
83
|
+
return unless body
|
|
84
|
+
|
|
85
|
+
statements = body.begin_type? ? body.children : [body]
|
|
86
|
+
enum_decls, symbolized = scan(statements)
|
|
72
87
|
|
|
73
88
|
enum_decls.each do |name, decl|
|
|
74
89
|
next if symbolized.include?(name)
|
|
@@ -76,6 +91,20 @@ module RuboCop
|
|
|
76
91
|
add_offense(decl, message: format(MSG, name: name))
|
|
77
92
|
end
|
|
78
93
|
end
|
|
94
|
+
|
|
95
|
+
def scan(statements)
|
|
96
|
+
sends = statements.select { |stmt| stmt.respond_to?(:send_type?) && stmt.send_type? }
|
|
97
|
+
enum_decls = sends.filter_map { |stmt| (name = enum_call(stmt)) && [name, stmt] }
|
|
98
|
+
symbolized = sends.flat_map { |stmt| symbolized_names(stmt) }
|
|
99
|
+
[enum_decls, symbolized]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def symbolized_names(stmt)
|
|
103
|
+
args = enum_symbolize_call(stmt)
|
|
104
|
+
return [] unless args
|
|
105
|
+
|
|
106
|
+
args.select(&:sym_type?).map(&:value)
|
|
107
|
+
end
|
|
79
108
|
end
|
|
80
109
|
end
|
|
81
110
|
end
|
|
@@ -104,7 +104,9 @@ module RuboCop
|
|
|
104
104
|
MSG = '`%<method>s` with a block loads every row into Ruby. ' \
|
|
105
105
|
'Push the predicate into SQL with `.where(...)` or a model scope.'.freeze
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
# filter/detect are the Ruby aliases of select/find — leaving them
|
|
108
|
+
# out would make the alias a zero-cost dodge.
|
|
109
|
+
RESTRICT_ON_SEND = %i[count reject select filter find detect any?].freeze
|
|
108
110
|
|
|
109
111
|
# Methods whose return value is known to be a non-Relation collection
|
|
110
112
|
# (Array, Hash, or Enumerator). When a `.select`/`.reject`/etc. with a
|
|
@@ -129,7 +131,7 @@ module RuboCop
|
|
|
129
131
|
].freeze
|
|
130
132
|
|
|
131
133
|
def on_send(node)
|
|
132
|
-
return unless node.block_literal?
|
|
134
|
+
return unless node.block_literal? || symbol_block_pass?(node)
|
|
133
135
|
return if node.receiver.nil?
|
|
134
136
|
return if excluded_receiver?(node.receiver)
|
|
135
137
|
return if excluded_block?(node)
|
|
@@ -139,6 +141,13 @@ module RuboCop
|
|
|
139
141
|
|
|
140
142
|
private
|
|
141
143
|
|
|
144
|
+
# `reject(&:archived?)` loads every row exactly like the literal
|
|
145
|
+
# block form — one character shorter, so it must not be a dodge.
|
|
146
|
+
def symbol_block_pass?(node)
|
|
147
|
+
last = node.last_argument
|
|
148
|
+
last&.block_pass_type? && last.children.first&.sym_type?
|
|
149
|
+
end
|
|
150
|
+
|
|
142
151
|
def excluded_receiver?(receiver)
|
|
143
152
|
return true if receiver.array_type? || receiver.hash_type?
|
|
144
153
|
return true if receiver.const_type? && screaming_case_const?(receiver)
|
|
@@ -55,7 +55,10 @@ module RuboCop
|
|
|
55
55
|
class NoDeliverLaterInTransaction < Base
|
|
56
56
|
MSG = '`%<method>s` inside a `transaction` block may use stale data. Move it outside the transaction.'.freeze
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
# Also the bang form, the bulk API, and the enqueue primitive that
|
|
59
|
+
# perform_later wraps — each is an equally easy spelling of the same
|
|
60
|
+
# premature enqueue.
|
|
61
|
+
CORE_METHODS = %i[deliver_later deliver_later! perform_later perform_all_later enqueue].freeze
|
|
59
62
|
|
|
60
63
|
def on_send(node)
|
|
61
64
|
return unless inside_transaction?(node)
|
|
@@ -74,9 +77,11 @@ module RuboCop
|
|
|
74
77
|
cop_config.fetch('KnownAsyncWrappers', [])
|
|
75
78
|
end
|
|
76
79
|
|
|
80
|
+
# with_lock opens a transaction too (lock! inside a transaction
|
|
81
|
+
# block), so an enqueue inside it has the identical hazard.
|
|
77
82
|
def inside_transaction?(node)
|
|
78
83
|
node.each_ancestor(:block).any? do |ancestor|
|
|
79
|
-
ancestor.method_name
|
|
84
|
+
%i[transaction with_lock].include?(ancestor.method_name)
|
|
80
85
|
end
|
|
81
86
|
end
|
|
82
87
|
end
|
|
@@ -47,12 +47,16 @@ module RuboCop
|
|
|
47
47
|
# OrderJob.perform_later(self)
|
|
48
48
|
# end
|
|
49
49
|
class NoPerformLaterInModel < Base
|
|
50
|
-
MSG = 'Avoid `
|
|
51
|
-
'
|
|
52
|
-
|
|
50
|
+
MSG = 'Avoid `%<method>s` in model files — it enqueues an ActiveJob (mailer deliveries ' \
|
|
51
|
+
'included), with the same transaction/stale-data hazard as `perform_later`. Call it ' \
|
|
52
|
+
'from the controller, or use an explicit method name to signal the side effect.'.freeze
|
|
53
|
+
# deliver_later is ActiveJob under the hood (MailDeliveryJob) and
|
|
54
|
+
# enqueue is the primitive perform_later wraps — omitting either
|
|
55
|
+
# would leave an everyday spelling of the same async enqueue.
|
|
56
|
+
RESTRICT_ON_SEND = %i[perform_later perform_all_later deliver_later deliver_later! enqueue].freeze
|
|
53
57
|
|
|
54
58
|
def on_send(node)
|
|
55
|
-
add_offense(node.loc.selector)
|
|
59
|
+
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
56
60
|
end
|
|
57
61
|
end
|
|
58
62
|
end
|
|
@@ -60,7 +60,9 @@ module RuboCop
|
|
|
60
60
|
].freeze
|
|
61
61
|
|
|
62
62
|
def on_send(node)
|
|
63
|
-
|
|
63
|
+
# `self.head(...)` is the same controller call — a bare receiver
|
|
64
|
+
# check would make explicit-self a one-token dodge.
|
|
65
|
+
return unless node.receiver.nil? || node.receiver.self_type?
|
|
64
66
|
|
|
65
67
|
status_node = node.arguments.first
|
|
66
68
|
return unless status_node
|
|
@@ -62,7 +62,9 @@ module RuboCop
|
|
|
62
62
|
class AvoidSend < Base
|
|
63
63
|
MSG = "Avoid dynamic `%<method>s` — use bracket notation for model attributes, " \
|
|
64
64
|
"or a prefix (`obj.send(\"export_\#{x}\")`) to restrict callable methods.".freeze
|
|
65
|
-
|
|
65
|
+
# `__send__` is the canonical alias — omitting it would leave a
|
|
66
|
+
# zero-cost dodge for the exact dynamic dispatch this cop restricts.
|
|
67
|
+
RESTRICT_ON_SEND = %i[send public_send __send__].freeze
|
|
66
68
|
|
|
67
69
|
def on_send(node)
|
|
68
70
|
return if node.receiver.nil?
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Flag `||` between literals in a boolean condition (`if`, `unless`,
|
|
6
|
+
# `while`, `until`, and ternary). The operator folds to one operand
|
|
7
|
+
# immediately (`:a || :b` is just `:a`), so the condition is constant —
|
|
8
|
+
# the branch (or loop body) always runs the same way, and the other
|
|
9
|
+
# literal is dead.
|
|
10
|
+
#
|
|
11
|
+
# ## Rationale
|
|
12
|
+
# `if :billing || :payments` reads like "either is truthy" but compiles
|
|
13
|
+
# to `if :billing` — always truthy. The author almost certainly meant a
|
|
14
|
+
# real comparison (`==`) or a variable, not two literal alternatives.
|
|
15
|
+
# Unlike `DevDoc/Style/LiteralOrInWhenClause`, a condition has no
|
|
16
|
+
# comma-form alternative, so the right fix depends on intent and is NOT
|
|
17
|
+
# autocorrected — the cop only flags.
|
|
18
|
+
#
|
|
19
|
+
# Scoped to `||` only: core `Lint/LiteralAsCondition` already catches
|
|
20
|
+
# literal `&&` chains and single literals, but misses `||` chains and
|
|
21
|
+
# ternaries — this cop fills exactly that gap with no overlap.
|
|
22
|
+
#
|
|
23
|
+
# ❌ always truthy — :payments is dead
|
|
24
|
+
# if :billing || :payments
|
|
25
|
+
# charge
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# ✔️ a real comparison
|
|
29
|
+
# if status == :billing || status == :payments
|
|
30
|
+
# charge
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# # bad
|
|
35
|
+
# x = (:a || :b) ? one : two
|
|
36
|
+
#
|
|
37
|
+
# # good
|
|
38
|
+
# x = (val == :a || val == :b) ? one : two
|
|
39
|
+
class LiteralOperatorInCondition < Base
|
|
40
|
+
MSG = '`%<source>s` always evaluates to `%<result>s`, so this condition is constant.'.freeze
|
|
41
|
+
|
|
42
|
+
LITERAL_TYPES = %i[sym str int float true false nil].freeze
|
|
43
|
+
FALSY_TYPES = %i[false nil].freeze
|
|
44
|
+
|
|
45
|
+
# `unless` desugars to `if` and the ternary `? :` is an `if` node, so a
|
|
46
|
+
# single on_if covers both (plus modifier forms). `until` mirrors
|
|
47
|
+
# `while` — both expose `condition`.
|
|
48
|
+
def on_if(node)
|
|
49
|
+
check(node.condition)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def on_while(node)
|
|
53
|
+
check(node.condition)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
alias on_until on_while
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def check(condition)
|
|
61
|
+
inner = unwrap_begin(condition)
|
|
62
|
+
return unless inner.or_type?
|
|
63
|
+
|
|
64
|
+
leaves = flatten_chain(inner)
|
|
65
|
+
return unless leaves.all? { |leaf| LITERAL_TYPES.include?(leaf.type) }
|
|
66
|
+
|
|
67
|
+
add_offense(
|
|
68
|
+
inner,
|
|
69
|
+
message: format(MSG, source: inner.source, result: fold(leaves).source)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The helpers below parallel DevDoc/Style/LiteralOrInWhenClause
|
|
74
|
+
# (`||`-only here). Lift them into a shared module if a third consumer
|
|
75
|
+
# appears; duplicated for now to keep that cop untouched.
|
|
76
|
+
|
|
77
|
+
# Parentheses wrap a condition in a `begin` node; unwrap it.
|
|
78
|
+
def unwrap_begin(node)
|
|
79
|
+
node = node.children.first while node.begin_type?
|
|
80
|
+
node
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Flatten a homogeneous `||` chain into its operand nodes. An operand
|
|
84
|
+
# wrapped in parentheses arrives as a `begin` node (`(:a || :b) || :c`),
|
|
85
|
+
# so unwrap before recursing; otherwise the inner chain reads as a
|
|
86
|
+
# single non-literal leaf and slips past the all-literal check.
|
|
87
|
+
def flatten_chain(node)
|
|
88
|
+
node = unwrap_begin(node)
|
|
89
|
+
return [node] unless node.or_type?
|
|
90
|
+
|
|
91
|
+
flatten_chain(node.lhs) + flatten_chain(node.rhs)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The first truthy literal the `||` chain folds to (or its last
|
|
95
|
+
# operand when the whole chain is nil/false).
|
|
96
|
+
def fold(leaves)
|
|
97
|
+
leaves.find { |leaf| !FALSY_TYPES.include?(leaf.type) } || leaves.last
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|