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.
@@ -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
- # ## Watch out for indirect calls
30
+ # ## Configurable blocklist for library wrappers
31
31
  # Some libraries call `perform_later` / `deliver_later` behind the
32
- # scenes e.g. `@user.send_verification_email!` from the Devise gem.
33
- # This cop cannot detect those wrappers; reviewers should still flag
34
- # them when they appear inside a `transaction` block.
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
- RESTRICT_ON_SEND = %i[deliver_later perform_later].freeze
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:` (or `except:`) for `resources` / `resource` in routes.rb.
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
- # In this example, only three actions are exposed for
19
- # `job_applications`: index, new, and create. This is safer because
20
- # only the needed actions are declared and accessible.
24
+ # @example EnforcedStyle: RequireOnly (default)
25
+ # # bad
26
+ # resources :users
27
+ # resources :users, except: [:destroy]
21
28
  #
22
- # `except:` is also acceptable, but `only:` is preferred because it
23
- # is more explicit about what is being exposed.
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
- return if only_or_except?(node)
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
- add_offense(node.loc.selector, message: format(MSG, method: node.method_name, name: name))
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 only_or_except?(node)
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? do |pair|
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()` responses in controllers.
5
+ # Avoid `head()` with error status codes in controllers.
6
6
  #
7
7
  # ## Rationale
8
- # `head()` returns an empty body with no useful information for the
9
- # client. Its presence is usually a sign that error handling should be
10
- # delegated to Rails exceptions (e.g. `ActiveRecord::RecordNotFound`)
11
- # or model validations instead.
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
- # If you find yourself reaching for `head()`, consider whether a
14
- # well-known Rails exception or a model validation can handle the
15
- # case more cleanly:
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
- # NOTE: The cop flags every bare `head(...)` call. Some legitimate uses
29
- # (e.g. `head :no_content` for a successful DELETE, or simple webhook
30
- # acknowledgements) still get flagged — disable per-line in those cases.
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
- # def glib_load_resource
35
- # @user = User.find_by(id: params[:id])
36
- # head(:not_found) unless @user
37
- # end
37
+ # head(:not_found)
38
38
  #
39
- # # good
40
- # def glib_load_resource
41
- # @user = User.find(params[:id])
42
- # end
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()`. Delegate error handling to Rails exceptions ' \
45
- '(e.g. use `find` instead of `find_by` + `head(:not_found)`) or model validations.'.freeze
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
- add_offense(node.loc.selector)
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`. When the method name is dynamic, this is a real risk a
10
- # crafted parameter can invoke methods the developer never intended to
11
- # expose. If `send()` is unavoidable, add safeguards to restrict which
12
- # methods can be called.
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 = 'Avoid `%<method>s` with an explicit receiver. ' \
49
- 'Use bracket notation for model attributes, or restrict callable methods with a prefix.'.freeze
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