rubocop-dev_doc 0.1.0 → 0.2.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 +125 -9
- data/lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb +92 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +86 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +68 -13
- data/lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb +18 -3
- data/lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb +53 -0
- data/lib/rubocop/cop/dev_doc/migration/require_primary_key.rb +55 -0
- data/lib/rubocop/cop/dev_doc/migration/require_timestamps.rb +4 -13
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +56 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +135 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +83 -0
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +22 -5
- data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +29 -15
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +56 -22
- data/lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb +102 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +14 -9
- data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +91 -0
- data/lib/rubocop-dev_doc.rb +1 -0
- metadata +19 -11
- data/lib/rubocop/cop/dev_doc/migration/avoid_update_column.rb +0 -53
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Use `ApplicationRecord.transaction` instead of `SomeModel.transaction` outside model files.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# When using `transaction` outside of a model, `SomeModel.transaction`
|
|
9
|
+
# reads as if there is a meaningful link to that model when there isn't.
|
|
10
|
+
# `ApplicationRecord.transaction` is functionally identical and makes
|
|
11
|
+
# it clear that the transaction has no special relationship to any
|
|
12
|
+
# particular model.
|
|
13
|
+
#
|
|
14
|
+
# ❌ (in a controller or service)
|
|
15
|
+
# Checklist.transaction do
|
|
16
|
+
# ...
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# ✔️
|
|
20
|
+
# ApplicationRecord.transaction do
|
|
21
|
+
# ...
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# # bad (in a controller or service)
|
|
26
|
+
# Order.transaction do
|
|
27
|
+
# order.save!
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# # good
|
|
31
|
+
# ApplicationRecord.transaction do
|
|
32
|
+
# order.save!
|
|
33
|
+
# end
|
|
34
|
+
class ApplicationRecordTransaction < Base
|
|
35
|
+
extend AutoCorrector
|
|
36
|
+
|
|
37
|
+
ALLOWED_RECEIVERS = %w[ApplicationRecord ActiveRecord::Base].freeze
|
|
38
|
+
|
|
39
|
+
MSG = 'Use `ApplicationRecord.transaction` instead of `%<receiver>s.transaction` outside model files.'.freeze
|
|
40
|
+
RESTRICT_ON_SEND = %i[transaction].freeze
|
|
41
|
+
|
|
42
|
+
def on_send(node)
|
|
43
|
+
receiver = node.receiver
|
|
44
|
+
return if receiver.nil?
|
|
45
|
+
return unless receiver.const_type?
|
|
46
|
+
return if ALLOWED_RECEIVERS.include?(receiver.source)
|
|
47
|
+
|
|
48
|
+
add_offense(receiver, message: format(MSG, receiver: receiver.source)) do |corrector|
|
|
49
|
+
corrector.replace(receiver, 'ApplicationRecord')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Avoid Rails ActiveRecord callback DSL in model files.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Rails callbacks (`after_create`, `before_save`, etc.) cause problems
|
|
9
|
+
# with transaction ordering, hidden side effects, and unclear control
|
|
10
|
+
# flow. When a callback fires is not always obvious to the reader, and
|
|
11
|
+
# callbacks can trigger unexpectedly during testing or data migrations.
|
|
12
|
+
#
|
|
13
|
+
# Instead, implement an explicit method that makes the side effect
|
|
14
|
+
# visible at the call site:
|
|
15
|
+
#
|
|
16
|
+
# ❌
|
|
17
|
+
# class Order < ApplicationRecord
|
|
18
|
+
# after_create :send_confirmation_email
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# ✔️
|
|
22
|
+
# class Order < ApplicationRecord
|
|
23
|
+
# def save_with_confirmation_email
|
|
24
|
+
# transaction do
|
|
25
|
+
# save!
|
|
26
|
+
# OrderMailer.confirmation(self).deliver_later
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# ## When you have multiple call sites needing the same guard
|
|
32
|
+
# The single-method-per-place pattern above works when there is one
|
|
33
|
+
# natural call site. When several controllers/jobs/bulk operations all
|
|
34
|
+
# need to enforce the same invariant before destroying or saving, the
|
|
35
|
+
# temptation is to reach for `before_destroy` / `before_save` so
|
|
36
|
+
# nothing slips through. There is a cleaner pattern that gives the
|
|
37
|
+
# same guarantee without a callback — the **fence-and-helper**:
|
|
38
|
+
#
|
|
39
|
+
# 1. Alias the dangerous primitive as private, e.g.
|
|
40
|
+
# alias _unguarded_hard_destroy hard_destroy
|
|
41
|
+
# private :_unguarded_hard_destroy
|
|
42
|
+
# 2. Override the public name to raise with a pointer to the safe
|
|
43
|
+
# method:
|
|
44
|
+
# def hard_destroy
|
|
45
|
+
# raise 'Use SomeModel#safely_hard_destroy — it keeps X in sync'
|
|
46
|
+
# end
|
|
47
|
+
# 3. Expose a `safely_*` helper that does the guard, calls the
|
|
48
|
+
# private alias, and runs any side effects, all wrapped in a
|
|
49
|
+
# transaction so atomicity is a property of the helper rather
|
|
50
|
+
# than of the caller:
|
|
51
|
+
# def safely_hard_destroy
|
|
52
|
+
# if invariant_would_be_violated?
|
|
53
|
+
# errors.add(:base, '...')
|
|
54
|
+
# return false
|
|
55
|
+
# end
|
|
56
|
+
# ApplicationRecord.transaction do
|
|
57
|
+
# _unguarded_hard_destroy
|
|
58
|
+
# cleanup_side_effects!
|
|
59
|
+
# end
|
|
60
|
+
# destroyed?
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# Direct calls to the dangerous primitive now raise with a helpful
|
|
64
|
+
# message; the only legal path is the safe helper. Every existing
|
|
65
|
+
# caller updates to `safely_*` and inherits the guard automatically.
|
|
66
|
+
#
|
|
67
|
+
# Bonus property: ad-hoc bypass for ops/debugging is clean. Calling
|
|
68
|
+
# `model.send(:_unguarded_hard_destroy)` in the Rails console is
|
|
69
|
+
# scoped to the single call (no global state), self-documenting (you
|
|
70
|
+
# have to type "unguarded"), and greppable. This is intended for
|
|
71
|
+
# console / data-cleanup / debugging use only — if app code reaches
|
|
72
|
+
# for `send(:_unguarded_*)`, that's a design smell and should be
|
|
73
|
+
# solved by extending the helper instead.
|
|
74
|
+
#
|
|
75
|
+
# ## Inline disable
|
|
76
|
+
# We have not yet found a case in our codebases where a callback was
|
|
77
|
+
# the right answer — explicit methods or the fence-and-helper pattern
|
|
78
|
+
# have always covered the legitimate intent. If you think you have a
|
|
79
|
+
# genuine exception, disable this cop inline with a written
|
|
80
|
+
# justification — expect pushback in review:
|
|
81
|
+
#
|
|
82
|
+
# after_create :some_callback # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
|
|
83
|
+
# # Reason: <explanation>
|
|
84
|
+
#
|
|
85
|
+
# Both the symbol form and the block form are flagged:
|
|
86
|
+
#
|
|
87
|
+
# ❌ Symbol form
|
|
88
|
+
# after_create :send_confirmation_email
|
|
89
|
+
#
|
|
90
|
+
# ❌ Block form
|
|
91
|
+
# after_create { send_confirmation_email }
|
|
92
|
+
#
|
|
93
|
+
# @example
|
|
94
|
+
# # bad
|
|
95
|
+
# after_create :send_confirmation
|
|
96
|
+
# before_save :normalize_name
|
|
97
|
+
# around_update :wrap_in_audit_log
|
|
98
|
+
#
|
|
99
|
+
# # bad (block form)
|
|
100
|
+
# after_create { send_confirmation }
|
|
101
|
+
#
|
|
102
|
+
# # good
|
|
103
|
+
# def save_with_confirmation
|
|
104
|
+
# transaction { save! }
|
|
105
|
+
# send_confirmation
|
|
106
|
+
# end
|
|
107
|
+
class AvoidRailsCallbacks < Base
|
|
108
|
+
CALLBACKS = %i[
|
|
109
|
+
after_create after_create_commit after_save after_update
|
|
110
|
+
after_destroy after_commit after_rollback after_initialize
|
|
111
|
+
after_find after_touch before_create before_save before_update
|
|
112
|
+
before_destroy before_validation after_validation
|
|
113
|
+
around_create around_save around_update around_destroy
|
|
114
|
+
].freeze
|
|
115
|
+
|
|
116
|
+
MSG = 'Avoid `%<method>s` — extract an explicit method (e.g. `save_with_*`) ' \
|
|
117
|
+
'so the side effect is visible at the call site.'.freeze
|
|
118
|
+
RESTRICT_ON_SEND = CALLBACKS
|
|
119
|
+
|
|
120
|
+
def on_send(node)
|
|
121
|
+
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
|
|
125
|
+
send_node = node.send_node
|
|
126
|
+
return unless CALLBACKS.include?(send_node.method_name)
|
|
127
|
+
|
|
128
|
+
add_offense(send_node.loc.selector, message: format(MSG, method: send_node.method_name))
|
|
129
|
+
end
|
|
130
|
+
alias on_numblock on_block
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Every Rails `enum` declaration must be paired with `enum_symbolize`
|
|
6
|
+
# so the reader returns a symbol instead of Rails' default string form.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# Strings and symbols are not equal in Ruby (`:foo == 'foo'` is `false`).
|
|
10
|
+
# Rails' `enum` macro defines a reader that returns the string form, so
|
|
11
|
+
# downstream comparisons like `record.status == :active` silently fail.
|
|
12
|
+
# Pairing `enum :status, …` with `enum_symbolize :status` overrides the
|
|
13
|
+
# reader to hand back a symbol, eliminating the footgun.
|
|
14
|
+
#
|
|
15
|
+
# See `best_practices/backend/en/01a_defensive_programming.md` item 7.
|
|
16
|
+
#
|
|
17
|
+
# ❌
|
|
18
|
+
# class Reimbursement < ApplicationRecord
|
|
19
|
+
# enum :payment_status, { draft: 0, pending: 1, finalized: 2 }
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# ✔
|
|
23
|
+
# class Reimbursement < ApplicationRecord
|
|
24
|
+
# enum :payment_status, { draft: 0, pending: 1, finalized: 2 }
|
|
25
|
+
#
|
|
26
|
+
# enum_symbolize :payment_status
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# `enum_symbolize` accepts multiple names so several enums can be
|
|
30
|
+
# paired in one call:
|
|
31
|
+
#
|
|
32
|
+
# ✔
|
|
33
|
+
# enum_symbolize :payment_status, :finalize_intent
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# # bad
|
|
37
|
+
# enum :status, { active: 0, archived: 1 }
|
|
38
|
+
#
|
|
39
|
+
# # good
|
|
40
|
+
# enum :status, { active: 0, archived: 1 }
|
|
41
|
+
# enum_symbolize :status
|
|
42
|
+
class EnumMustBeSymbolized < Base
|
|
43
|
+
MSG = 'Pair `enum :%<name>s` with `enum_symbolize :%<name>s` so the reader returns a symbol ' \
|
|
44
|
+
'(see backend/01a_defensive_programming.md item 7).'.freeze
|
|
45
|
+
|
|
46
|
+
def_node_matcher :enum_call, <<~PATTERN
|
|
47
|
+
(send nil? :enum (sym $_) ...)
|
|
48
|
+
PATTERN
|
|
49
|
+
|
|
50
|
+
def_node_matcher :enum_symbolize_call, <<~PATTERN
|
|
51
|
+
(send nil? :enum_symbolize $...)
|
|
52
|
+
PATTERN
|
|
53
|
+
|
|
54
|
+
def on_class(node)
|
|
55
|
+
body = node.body
|
|
56
|
+
return unless body
|
|
57
|
+
|
|
58
|
+
statements = body.begin_type? ? body.children : [body]
|
|
59
|
+
|
|
60
|
+
enum_decls = []
|
|
61
|
+
symbolized = []
|
|
62
|
+
|
|
63
|
+
statements.each do |stmt|
|
|
64
|
+
next unless stmt.respond_to?(:send_type?) && stmt.send_type?
|
|
65
|
+
|
|
66
|
+
if (name = enum_call(stmt))
|
|
67
|
+
enum_decls << [name, stmt]
|
|
68
|
+
elsif (args = enum_symbolize_call(stmt))
|
|
69
|
+
symbolized.concat(args.select(&:sym_type?).map(&:value))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
enum_decls.each do |name, decl|
|
|
74
|
+
next if symbolized.include?(name)
|
|
75
|
+
|
|
76
|
+
add_offense(decl, message: format(MSG, name: name))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -27,11 +27,12 @@ module RuboCop
|
|
|
27
27
|
# OrganizationMailer.with(organization: organization).deliver_later
|
|
28
28
|
# end
|
|
29
29
|
#
|
|
30
|
-
# ##
|
|
30
|
+
# ## Configurable blocklist for library wrappers
|
|
31
31
|
# Some libraries call `perform_later` / `deliver_later` behind the
|
|
32
|
-
# scenes
|
|
33
|
-
#
|
|
34
|
-
#
|
|
32
|
+
# scenes. Configure `KnownAsyncWrappers` to flag those methods too.
|
|
33
|
+
# Common Devise methods are included by default. This is a partial
|
|
34
|
+
# mitigation — reviewers must still catch unknown wrappers not in the
|
|
35
|
+
# list.
|
|
35
36
|
#
|
|
36
37
|
# @example
|
|
37
38
|
# # bad
|
|
@@ -40,6 +41,12 @@ module RuboCop
|
|
|
40
41
|
# OrganizationMailer.with(organization: organization).deliver_later
|
|
41
42
|
# end
|
|
42
43
|
#
|
|
44
|
+
# # bad (Devise wrapper, caught via KnownAsyncWrappers)
|
|
45
|
+
# User.transaction do
|
|
46
|
+
# @user.save!
|
|
47
|
+
# @user.send_verification_email!
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
43
50
|
# # good
|
|
44
51
|
# organization.transaction do
|
|
45
52
|
# organization.save!
|
|
@@ -47,16 +54,26 @@ module RuboCop
|
|
|
47
54
|
# OrganizationMailer.with(organization: organization).deliver_later
|
|
48
55
|
class NoDeliverLaterInTransaction < Base
|
|
49
56
|
MSG = '`%<method>s` inside a `transaction` block may use stale data. Move it outside the transaction.'.freeze
|
|
50
|
-
|
|
57
|
+
|
|
58
|
+
CORE_METHODS = %i[deliver_later perform_later].freeze
|
|
51
59
|
|
|
52
60
|
def on_send(node)
|
|
53
61
|
return unless inside_transaction?(node)
|
|
62
|
+
return unless tracked_method?(node.method_name)
|
|
54
63
|
|
|
55
64
|
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
56
65
|
end
|
|
57
66
|
|
|
58
67
|
private
|
|
59
68
|
|
|
69
|
+
def tracked_method?(name)
|
|
70
|
+
CORE_METHODS.include?(name) || known_async_wrappers.include?(name.to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def known_async_wrappers
|
|
74
|
+
cop_config.fetch('KnownAsyncWrappers', [])
|
|
75
|
+
end
|
|
76
|
+
|
|
60
77
|
def inside_transaction?(node)
|
|
61
78
|
node.each_ancestor(:block).any? do |ancestor|
|
|
62
79
|
ancestor.method_name == :transaction
|
|
@@ -2,7 +2,7 @@ module RuboCop
|
|
|
2
2
|
module Cop
|
|
3
3
|
module DevDoc
|
|
4
4
|
module Route
|
|
5
|
-
# Always use `only:`
|
|
5
|
+
# Always use `only:` for `resources` / `resource` in routes.rb.
|
|
6
6
|
#
|
|
7
7
|
# ## Rationale
|
|
8
8
|
# When defining routes in routes.rb, it is important to explicitly
|
|
@@ -12,45 +12,59 @@ module RuboCop
|
|
|
12
12
|
# exposes routes the application has no controller action for, or
|
|
13
13
|
# routes that probably should be locked down.
|
|
14
14
|
#
|
|
15
|
+
# `only:` is preferred over `except:` because it is explicit about
|
|
16
|
+
# what is exposed. `except:` exposes everything *not* in the list,
|
|
17
|
+
# which is easier to misread when the action set changes.
|
|
18
|
+
#
|
|
19
|
+
# Set `RequireOnly: false` to accept both `only:` and `except:`.
|
|
20
|
+
#
|
|
15
21
|
# ✔️
|
|
16
22
|
# resources :job_applications, only: [:index, :new, :create]
|
|
17
23
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
24
|
+
# @example EnforcedStyle: RequireOnly (default)
|
|
25
|
+
# # bad
|
|
26
|
+
# resources :users
|
|
27
|
+
# resources :users, except: [:destroy]
|
|
21
28
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
29
|
+
# # good
|
|
30
|
+
# resources :users, only: %i[index show]
|
|
24
31
|
#
|
|
25
|
-
# @example
|
|
32
|
+
# @example EnforcedStyle: RequireOnly: false
|
|
26
33
|
# # bad
|
|
27
34
|
# resources :users
|
|
28
|
-
# resource :profile
|
|
29
35
|
#
|
|
30
36
|
# # good
|
|
31
37
|
# resources :users, only: %i[index show]
|
|
32
|
-
# resource :profile, only: %i[show edit update]
|
|
33
38
|
# resources :users, except: [:destroy]
|
|
34
39
|
class ResourcesRequireOnly < Base
|
|
35
40
|
MSG = 'Specify `only:` or `except:` for `%<method>s :%<name>s` to avoid exposing unintended actions.'.freeze
|
|
41
|
+
MSG_REQUIRE_ONLY = 'Specify `only:` for `%<method>s :%<name>s` ' \
|
|
42
|
+
'(`except:` is allowed only with `RequireOnly: false`).'.freeze
|
|
36
43
|
RESTRICT_ON_SEND = %i[resources resource].freeze
|
|
37
44
|
|
|
38
45
|
def on_send(node)
|
|
39
|
-
|
|
46
|
+
has_only = key_present?(node, :only)
|
|
47
|
+
has_except = key_present?(node, :except)
|
|
48
|
+
|
|
49
|
+
return if has_only
|
|
50
|
+
return if has_except && !require_only?
|
|
40
51
|
|
|
41
52
|
name = node.first_argument&.value || '?'
|
|
42
|
-
|
|
53
|
+
msg = has_except && require_only? ? MSG_REQUIRE_ONLY : MSG
|
|
54
|
+
add_offense(node.loc.selector, message: format(msg, method: node.method_name, name: name))
|
|
43
55
|
end
|
|
44
56
|
|
|
45
57
|
private
|
|
46
58
|
|
|
47
|
-
def
|
|
59
|
+
def require_only?
|
|
60
|
+
cop_config.fetch('RequireOnly', true)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def key_present?(node, key)
|
|
48
64
|
options = node.arguments.find(&:hash_type?)
|
|
49
65
|
return false unless options
|
|
50
66
|
|
|
51
|
-
options.pairs.any?
|
|
52
|
-
pair.key.sym_type? && %i[only except].include?(pair.key.value)
|
|
53
|
-
end
|
|
67
|
+
options.pairs.any? { |pair| pair.key.sym_type? && pair.key.value == key }
|
|
54
68
|
end
|
|
55
69
|
end
|
|
56
70
|
end
|
|
@@ -2,17 +2,18 @@ module RuboCop
|
|
|
2
2
|
module Cop
|
|
3
3
|
module DevDoc
|
|
4
4
|
module Style
|
|
5
|
-
# Avoid `head()`
|
|
5
|
+
# Avoid `head()` with error status codes in controllers.
|
|
6
6
|
#
|
|
7
7
|
# ## Rationale
|
|
8
|
-
# `head()` returns an empty body with no
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# Using `head()` for error responses returns an empty body with no
|
|
9
|
+
# useful information for the client. Error handling should be delegated
|
|
10
|
+
# to Rails exceptions (e.g. `ActiveRecord::RecordNotFound`) or model
|
|
11
|
+
# validations instead, which give the client more context.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# Success statuses like `:ok`, `:no_content`, and `:accepted` are
|
|
14
|
+
# legitimate uses of `head()` and are not flagged.
|
|
15
|
+
#
|
|
16
|
+
# The set of flagged statuses is configurable via `FlaggedStatuses:`.
|
|
16
17
|
#
|
|
17
18
|
# ❌ Manually returns 404 with no body
|
|
18
19
|
# def show
|
|
@@ -25,30 +26,63 @@ module RuboCop
|
|
|
25
26
|
# @user = User.find(params[:id])
|
|
26
27
|
# end
|
|
27
28
|
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
29
|
+
# ✔️ Success response — empty body is correct here
|
|
30
|
+
# def destroy
|
|
31
|
+
# @resource.destroy!
|
|
32
|
+
# head :no_content
|
|
33
|
+
# end
|
|
31
34
|
#
|
|
32
35
|
# @example
|
|
33
36
|
# # bad
|
|
34
|
-
#
|
|
35
|
-
# @user = User.find_by(id: params[:id])
|
|
36
|
-
# head(:not_found) unless @user
|
|
37
|
-
# end
|
|
37
|
+
# head(:not_found)
|
|
38
38
|
#
|
|
39
|
-
# #
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
39
|
+
# # bad
|
|
40
|
+
# head(:unprocessable_entity)
|
|
41
|
+
#
|
|
42
|
+
# # good (success status — not flagged)
|
|
43
|
+
# head(:no_content)
|
|
44
|
+
#
|
|
45
|
+
# # good (success status — not flagged)
|
|
46
|
+
# head(:ok)
|
|
47
|
+
#
|
|
48
|
+
# # good (dynamic status — not flagged to avoid false positives)
|
|
49
|
+
# head(status_code)
|
|
43
50
|
class AvoidHeadResponse < Base
|
|
44
|
-
MSG = 'Avoid `head()
|
|
45
|
-
'
|
|
51
|
+
MSG = 'Avoid `head(%<status>s)` for error handling. ' \
|
|
52
|
+
'Delegate to Rails exceptions or model validations instead.'.freeze
|
|
53
|
+
|
|
46
54
|
RESTRICT_ON_SEND = %i[head].freeze
|
|
47
55
|
|
|
56
|
+
DEFAULT_FLAGGED_STATUSES = %w[
|
|
57
|
+
not_found unprocessable_entity forbidden unauthorized
|
|
58
|
+
bad_request conflict gone method_not_allowed
|
|
59
|
+
404 422 403 401 400 409 410 405
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
48
62
|
def on_send(node)
|
|
49
63
|
return unless node.receiver.nil?
|
|
50
64
|
|
|
51
|
-
|
|
65
|
+
status_node = node.arguments.first
|
|
66
|
+
return unless status_node
|
|
67
|
+
return unless flagged_literal?(status_node)
|
|
68
|
+
|
|
69
|
+
add_offense(node.loc.selector, message: format(MSG, status: status_display(status_node)))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def flagged_literal?(node)
|
|
75
|
+
return false unless node.sym_type? || node.int_type?
|
|
76
|
+
|
|
77
|
+
flagged_statuses.include?(node.value.to_s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def status_display(node)
|
|
81
|
+
node.sym_type? ? ":#{node.value}" : node.value.to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def flagged_statuses
|
|
85
|
+
cop_config.fetch('FlaggedStatuses', DEFAULT_FLAGGED_STATUSES).map(&:to_s)
|
|
52
86
|
end
|
|
53
87
|
end
|
|
54
88
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Avoid `**options`-style kwargs in method signatures; use explicit keyword args.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Keyword args raise `ArgumentError` on typos, are self-labeled at the
|
|
9
|
+
# call site, and get IDE autocomplete. Options hashes (via `**opts`)
|
|
10
|
+
# silently swallow misspelled keys, hide what's accepted, and depend on
|
|
11
|
+
# doc/source-reading to use correctly.
|
|
12
|
+
#
|
|
13
|
+
# ❌ Options hash — typos pass silently
|
|
14
|
+
# def configure(name:, **options)
|
|
15
|
+
# title = options[:title]
|
|
16
|
+
# color = options[:color]
|
|
17
|
+
# end
|
|
18
|
+
# configure(name: 'x', titel: 'wrong') # silently ignored — title is nil
|
|
19
|
+
#
|
|
20
|
+
# ✔️ Keyword args — typo raises immediately
|
|
21
|
+
# def configure(name:, title: nil, color: nil)
|
|
22
|
+
# end
|
|
23
|
+
# configure(name: 'x', titel: 'wrong') # ArgumentError: unknown keyword: :titel
|
|
24
|
+
#
|
|
25
|
+
# Pure-forwarding kwargs are exempt — when the only use of the kwrestarg
|
|
26
|
+
# is to splat it into another call, there is no options-hash behaviour:
|
|
27
|
+
#
|
|
28
|
+
# ✔️ Pure forwarding — exempt
|
|
29
|
+
# def foo(**args)
|
|
30
|
+
# other(**args)
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# Anonymous double-splat (`**`) is also always exempt.
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# # bad
|
|
37
|
+
# def configure(name:, **options)
|
|
38
|
+
# options[:title]
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# # good
|
|
42
|
+
# def configure(name:, title: nil, color: nil)
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# # good (pure forwarding)
|
|
46
|
+
# def foo(**args)
|
|
47
|
+
# other(**args)
|
|
48
|
+
# end
|
|
49
|
+
class AvoidOptionsHash < Base
|
|
50
|
+
MSG = 'Use keyword arguments instead of `**%<name>s` — ' \
|
|
51
|
+
'typos in keyword args raise `ArgumentError`; options hashes swallow them silently.'.freeze
|
|
52
|
+
|
|
53
|
+
def on_def(node)
|
|
54
|
+
check_method(node)
|
|
55
|
+
end
|
|
56
|
+
alias on_defs on_def
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def check_method(node)
|
|
61
|
+
kwrestarg = find_kwrestarg(node)
|
|
62
|
+
return unless kwrestarg
|
|
63
|
+
|
|
64
|
+
kwrest_name = kwrestarg.node_parts[0]
|
|
65
|
+
return if kwrest_name.nil?
|
|
66
|
+
|
|
67
|
+
body = node.body
|
|
68
|
+
return if pure_forwarding?(body, kwrest_name)
|
|
69
|
+
|
|
70
|
+
add_offense(kwrestarg, message: format(MSG, name: kwrest_name))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def find_kwrestarg(node)
|
|
74
|
+
args_node = node.arguments
|
|
75
|
+
args_node.each_child_node(:kwrestarg).first
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pure_forwarding?(body, kwrest_name)
|
|
79
|
+
return true if body.nil?
|
|
80
|
+
|
|
81
|
+
lvar_refs = collect_lvar_refs(body, kwrest_name)
|
|
82
|
+
return true if lvar_refs.empty?
|
|
83
|
+
|
|
84
|
+
lvar_refs.all? { |lvar| under_kwsplat?(lvar) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def collect_lvar_refs(node, name)
|
|
88
|
+
refs = []
|
|
89
|
+
node.each_descendant(:lvar) do |lvar|
|
|
90
|
+
refs << lvar if lvar.node_parts[0] == name
|
|
91
|
+
end
|
|
92
|
+
refs
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def under_kwsplat?(node)
|
|
96
|
+
node.parent&.kwsplat_type?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -2,14 +2,16 @@ module RuboCop
|
|
|
2
2
|
module Cop
|
|
3
3
|
module DevDoc
|
|
4
4
|
module Style
|
|
5
|
-
# Avoid `send` and `public_send` with an explicit receiver.
|
|
5
|
+
# Avoid dynamic `send` and `public_send` with an explicit receiver.
|
|
6
6
|
#
|
|
7
7
|
# ## Rationale
|
|
8
8
|
# `send()` can call *any* method, including destructive ones like
|
|
9
|
-
# `destroy`.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
9
|
+
# `destroy`. The risk is specifically with **dynamic** method names —
|
|
10
|
+
# when the argument is a variable or interpolated string, a crafted
|
|
11
|
+
# value could invoke methods the developer never intended to expose.
|
|
12
|
+
#
|
|
13
|
+
# **Literal symbol arguments are exempt** — the method name is fixed at
|
|
14
|
+
# code-write time and visible to reviewers, equivalent to a direct call.
|
|
13
15
|
#
|
|
14
16
|
# ## Safer alternatives
|
|
15
17
|
#
|
|
@@ -34,23 +36,26 @@ module RuboCop
|
|
|
34
36
|
# ✔️ Restricted — only methods with the prefix can be called
|
|
35
37
|
# obj.send("export_#{method_name}")
|
|
36
38
|
#
|
|
37
|
-
# **c) For known methods — call directly instead of via `send`.**
|
|
38
|
-
#
|
|
39
39
|
# @example
|
|
40
40
|
# # bad
|
|
41
41
|
# @user.send(method_name)
|
|
42
42
|
# obj.public_send(action)
|
|
43
|
+
# obj.send("export_#{x}")
|
|
44
|
+
#
|
|
45
|
+
# # good — literal symbol: method name is statically visible
|
|
46
|
+
# instance.send(:private_helper, arg)
|
|
43
47
|
#
|
|
44
48
|
# # good
|
|
45
49
|
# @user[attribute_name]
|
|
46
50
|
# obj.send("export_#{method_name}")
|
|
47
51
|
class AvoidSend < Base
|
|
48
|
-
MSG =
|
|
49
|
-
|
|
52
|
+
MSG = "Avoid dynamic `%<method>s` — use bracket notation for model attributes, " \
|
|
53
|
+
"or a prefix (`obj.send(\"export_\#{x}\")`) to restrict callable methods.".freeze
|
|
50
54
|
RESTRICT_ON_SEND = %i[send public_send].freeze
|
|
51
55
|
|
|
52
56
|
def on_send(node)
|
|
53
57
|
return if node.receiver.nil?
|
|
58
|
+
return if node.first_argument&.sym_type?
|
|
54
59
|
|
|
55
60
|
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
56
61
|
end
|