rubocop-dev_doc 0.5.0.beta1 → 0.6.0.beta1

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.
@@ -43,6 +43,13 @@ module RuboCop
43
43
  # end
44
44
  # end
45
45
  #
46
+ # ## Forms covered
47
+ # The cop catches `default:` set at column-creation time AND `default:`
48
+ # set later via `change_column_default(..., to: <non-nil>)`. Both are
49
+ # the same anti-pattern — a permanent default living in the schema.
50
+ # `change_column_default(..., to: nil)` (removing a default) is not
51
+ # flagged; that is the cleanup form.
52
+ #
46
53
  # ## Exception (auto-detected)
47
54
  # For performance reasons (large tables with millions of records) or when
48
55
  # using `null: false`, you may temporarily set a default and then
@@ -51,6 +58,10 @@ module RuboCop
51
58
  # for the same table and column anywhere in the same method body (`def
52
59
  # change` / `def up`), including inside `reversible do |dir| dir.up`.
53
60
  #
61
+ # The exception applies symmetrically:
62
+ # - `add_column ... default: X` paired with `change_column_default ... to: nil` → no offense
63
+ # - `change_column_default ... to: X` paired with `change_column_default ... to: nil` → no offense
64
+ #
54
65
  # ✔ (no offense — two-step pattern auto-detected)
55
66
  # add_column :users, :profile_completion_rate, :float, default: 0.0
56
67
  # change_column_default :users, :profile_completion_rate, from: 0.0, to: nil
@@ -62,14 +73,22 @@ module RuboCop
62
73
  # # bad
63
74
  # t.string :status, default: 'active'
64
75
  #
76
+ # # bad — permanent default set via change_column_default
77
+ # change_column_default :users, :score, from: nil, to: 0
78
+ #
65
79
  # # good
66
80
  # add_column :users, :score, :integer
67
81
  #
68
82
  # # good (temporary default immediately removed — two-step pattern)
69
83
  # add_column :users, :score, :integer, default: 0
70
84
  # change_column_default :users, :score, from: 0, to: nil
85
+ #
86
+ # # good (removing an existing default)
87
+ # change_column_default :users, :score, from: 0, to: nil
71
88
  class AvoidColumnDefault < Base
72
89
  MSG = 'Avoid setting `default:` in migrations. Keep business logic defaults in the application layer.'.freeze
90
+ MSG_CHANGE = 'Avoid setting a non-nil default via `change_column_default`. ' \
91
+ 'Keep business logic defaults in the application layer.'.freeze
73
92
 
74
93
  COLUMN_METHODS = %i[
75
94
  string integer float boolean datetime date text binary decimal
@@ -84,9 +103,22 @@ module RuboCop
84
103
  (send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) (nil)) ...>))
85
104
  PATTERN
86
105
 
106
+ # change_column_default(:table, :col, ..., to: <non-nil>, ...) — captures table + col.
107
+ def_node_matcher :change_column_default_to_non_nil?, <<~PATTERN
108
+ (send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) !nil) ...>))
109
+ PATTERN
110
+
87
111
  def on_send(node)
88
- return unless column_method?(node)
112
+ if node.method?(:change_column_default)
113
+ check_change_column_default(node)
114
+ elsif column_method?(node)
115
+ check_column_method(node)
116
+ end
117
+ end
118
+
119
+ private
89
120
 
121
+ def check_column_method(node)
90
122
  options = node.arguments.find(&:hash_type?)
91
123
  return unless options && has_default_option?(options)
92
124
  return if two_step_pattern?(node)
@@ -94,7 +126,16 @@ module RuboCop
94
126
  add_offense(find_default_pair(options))
95
127
  end
96
128
 
97
- private
129
+ def check_change_column_default(node)
130
+ captures = change_column_default_to_non_nil?(node)
131
+ return unless captures
132
+
133
+ table_name, col_name = captures
134
+ return if cancelled_in_same_method?(node, table_name, col_name)
135
+
136
+ options = node.arguments.find(&:hash_type?)
137
+ add_offense(find_to_pair(options), message: MSG_CHANGE)
138
+ end
98
139
 
99
140
  def column_method?(node)
100
141
  node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
@@ -104,10 +145,20 @@ module RuboCop
104
145
  options.pairs.find { |p| p.key.sym_type? && p.key.value == :default }
105
146
  end
106
147
 
148
+ def find_to_pair(options)
149
+ options.pairs.find { |p| p.key.sym_type? && p.key.value == :to }
150
+ end
151
+
107
152
  def two_step_pattern?(node)
108
153
  table_name, col_name = extract_table_and_column(node)
109
154
  return false unless table_name && col_name
110
155
 
156
+ cancelled_in_same_method?(node, table_name, col_name)
157
+ end
158
+
159
+ # Is there a `change_column_default(..., to: nil)` for the same
160
+ # (table, col) anywhere in the enclosing method? Used by both forms.
161
+ def cancelled_in_same_method?(node, table_name, col_name)
111
162
  enclosing_def = node.each_ancestor(:def).first
112
163
  return false unless enclosing_def
113
164
 
@@ -2,20 +2,24 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Migration
5
- # Avoid vague column names like `status` or `group`.
5
+ # Avoid vague column names like `status`, `group`, or `kind`.
6
6
  #
7
7
  # ## Rationale
8
8
  # Use more specific names that describe the domain context. A column
9
9
  # named `status` reveals nothing about *what* status it tracks; a column
10
10
  # named `processing_status` makes the intent obvious at the call site.
11
+ # `kind` is the same shape — a content-free "what variety" label that
12
+ # reads no better than `type` (which Rails reserves for STI).
11
13
  #
12
14
  # ❌
13
15
  # t.string :status
14
16
  # add_column :orders, :group, :integer
17
+ # t.integer :kind
15
18
  #
16
19
  # ✔️
17
20
  # t.string :processing_status
18
21
  # add_column :orders, :user_group, :integer
22
+ # t.integer :membership_kind
19
23
  #
20
24
  # ## Note about `type`
21
25
  # `type` is reserved by Rails for Single Table Inheritance (STI). Even
@@ -29,10 +33,12 @@ module RuboCop
29
33
  # # bad
30
34
  # t.string :status
31
35
  # add_column :orders, :group, :integer
36
+ # t.integer :kind
32
37
  #
33
38
  # # good
34
39
  # t.string :processing_status
35
40
  # add_column :orders, :user_group, :integer
41
+ # t.integer :membership_kind
36
42
  class AvoidVagueColumnNames < Base
37
43
  MSG = 'Avoid vague column name `%<name>s`. Use a more specific name that includes context.'.freeze
38
44
 
@@ -0,0 +1,167 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Avoid ordering by `id` as the primary (sole or leftmost) sort.
6
+ #
7
+ # ## Rationale
8
+ # `order(id: ...)` / `order(:id)` sorts by the primary key, which
9
+ # reflects insertion order (and, for UUIDs, random order) rather
10
+ # than a column the reader can reason about. It makes a list read
11
+ # as "most recent" by accident, hides the real ordering intent,
12
+ # and silently breaks the moment rows are imported out of sequence
13
+ # or the primary key type changes.
14
+ #
15
+ # Order by a meaningful business column instead, and reach for
16
+ # `id` only as a **secondary** key to break ties deterministically:
17
+ #
18
+ # ❌ Primary key is not a sort the reader can reason about
19
+ # Snapshot.order(id: :desc)
20
+ #
21
+ # ✔️ Business column carries the intent
22
+ # Snapshot.order(created_at: :desc)
23
+ #
24
+ # ✔️ `id` as a deterministic tiebreaker — never the primary sort
25
+ # Snapshot.order(created_at: :desc, id: :desc)
26
+ #
27
+ # See `best_practices/backend/en/05_controller.md` — always `.order()`
28
+ # when displaying more than one record, and reserve `order(id: ...)`
29
+ # for the rare case where id-ordering is genuinely intended.
30
+ #
31
+ # ## Scope
32
+ # Flags `order` and `reorder` where `:id` is the **sole** ordering
33
+ # column or the **leftmost** (primary) one, in hash form
34
+ # (`order(id: :desc)`), symbol form (`order(:id)`,
35
+ # `order(:id, :name)`), or array form (`order([:id])`).
36
+ #
37
+ # Only the FIRST argument is inspected — it is the primary sort;
38
+ # every later argument is a tiebreaker. Raw-SQL string ordering
39
+ # (`order("id")`, `order("id DESC")`) is the domain of
40
+ # `DevDoc/Rails/AvoidRawSql`, so this cop defers the moment its
41
+ # leading argument is a string, an `Arel.sql(...)`, or anything
42
+ # other than a hash/symbol/array — the primary sort can't be read
43
+ # statically, and flagging would risk a false positive on a
44
+ # tiebreaker.
45
+ #
46
+ # NOTE: Each `order`/`reorder` call is inspected in isolation, so a
47
+ # chained form like `rel.order(:created_at).order(id: :desc)` is
48
+ # flagged even though Rails *appends* chained `order` calls — making
49
+ # `id` a secondary tiebreaker there. The deterministic-tiebreaker
50
+ # idiom this cop endorses is the single-call hash/symbol form
51
+ # (`order(created_at: :desc, id: :desc)`); prefer that, or disable
52
+ # inline with a reason when the chained form is genuinely intended.
53
+ #
54
+ # ## `id` as a secondary key is allowed
55
+ # `order(created_at: :desc, id: :desc)` is the standard pattern for
56
+ # deterministic ordering — `id` only breaks ties after the business
57
+ # column. The cop allows `:id` in any non-primary position:
58
+ #
59
+ # ✔️ `id` breaks ties only — primary sort is `created_at`
60
+ # Snapshot.order(created_at: :desc, id: :desc)
61
+ # Snapshot.order(:created_at, :id)
62
+ #
63
+ # ## Exception
64
+ # When `id` is genuinely the intended primary sort (rare — usually a
65
+ # sign something is special about the code), suppress the offense
66
+ # with an inline disable comment and a brief reason — e.g. an
67
+ # append-only audit log where `id` is creation order, so
68
+ # id-ordering is the real intent rather than an accident.
69
+ #
70
+ # NOTE: A named scope or model method that wraps the offense hides
71
+ # it at the call site — `scope :newest, -> { order(id: :desc) }` is
72
+ # flagged in the model file, but callers of `.newest` are not. The
73
+ # cop flags the definition, which is where the decision belongs.
74
+ #
75
+ # NOTE: Prefer `order(created_at: :desc)` for deterministic
76
+ # results — see `best_practices/backend/en/05_controller.md` #4.
77
+ # The rare legitimate `order(id:)` cases (see Exception above) —
78
+ # e.g. a test grabbing a record a controller just created, where
79
+ # `created_at` ties across one request and `id` is the only
80
+ # reliable insertion-order proxy — take an inline disable with a
81
+ # reason, not a blanket `Exclude` of `spec/**`/`test/**` (that
82
+ # silences accidental id-ordering in tests).
83
+ #
84
+ # @example
85
+ # # bad — `id` is the sole or primary sort
86
+ # Snapshot.order(id: :desc)
87
+ # Snapshot.order(:id)
88
+ # Snapshot.order(id: :desc, created_at: :desc)
89
+ # Snapshot.order(:id, :created_at)
90
+ # submission.snapshots.reorder(id: :asc)
91
+ #
92
+ # # good — business column primary, `id` as a tiebreaker
93
+ # Snapshot.order(created_at: :desc)
94
+ # Snapshot.order(created_at: :desc, id: :desc)
95
+ # Snapshot.order(:created_at, :id)
96
+ class AvoidOrderingById < Base
97
+ MSG = 'Ordering by `id` as the primary sort exposes the primary ' \
98
+ "key's insertion order rather than a meaningful column. " \
99
+ 'Order by a business column (e.g. `created_at:`), or move ' \
100
+ '`id` to a secondary position as a deterministic tiebreaker. ' \
101
+ 'Disable with a reason when `id` is genuinely the intended ' \
102
+ 'primary sort.'.freeze
103
+
104
+ RESTRICT_ON_SEND = %i[order reorder].freeze
105
+
106
+ def on_send(node)
107
+ first = first_ordering_column(node)
108
+ return unless first
109
+ return unless id_column?(first)
110
+
111
+ add_offense(node.loc.selector)
112
+ end
113
+
114
+ # Safe navigation (`rel&.order(id: :desc)`) parses as a `:csend`
115
+ # node, which `on_send` does not visit — alias it so the same
116
+ # check covers both dispatch forms.
117
+ alias on_csend on_send
118
+
119
+ private
120
+
121
+ # The leftmost ordering column, or nil when the primary sort
122
+ # isn't statically knowable. Only the FIRST argument is inspected
123
+ # — it alone is the primary sort; every later argument is a
124
+ # tiebreaker.
125
+ #
126
+ # order(:created_at, id: :desc) → :created_at (id secondary)
127
+ # order(id: :desc) → :id pair key
128
+ # order([:id]) → :id (array recurses)
129
+ #
130
+ # Returns nil (defers) for a raw-SQL string (`DevDoc/Rails/
131
+ # AvoidRawSql` owns it) AND for any other non-structured leading
132
+ # arg — an `Arel.sql` node, a helper call, a splat, a literal —
133
+ # because the primary sort can't be read statically and flagging
134
+ # would risk a false positive on what may be a tiebreaker.
135
+ def first_ordering_column(node)
136
+ ordering_column_of(node.arguments.first)
137
+ end
138
+
139
+ # Resolve the primary sort from a single argument node, recursing
140
+ # into an array (`order([:id])`) so the array form matches the
141
+ # bare-symbol form.
142
+ def ordering_column_of(arg)
143
+ return nil unless arg
144
+ return nil if arg.str_type? || arg.dstr_type?
145
+ return first_hash_key(arg) if arg.hash_type?
146
+ return ordering_column_of(arg.children.first) if arg.array_type?
147
+ return arg if arg.sym_type?
148
+
149
+ nil
150
+ end
151
+
152
+ def first_hash_key(hash_node)
153
+ pair = hash_node.pairs.first
154
+ return nil unless pair
155
+
156
+ key = pair.key
157
+ key if key&.sym_type?
158
+ end
159
+
160
+ def id_column?(node)
161
+ node.sym_type? && node.value == :id
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -22,12 +22,19 @@ module RuboCop
22
22
  # class Order < ApplicationRecord
23
23
  # def save_with_confirmation_email
24
24
  # transaction do
25
- # save!
26
- # OrderMailer.confirmation(self).deliver_later
25
+ # saved = save
26
+ # invoice.save! if saved
27
27
  # end
28
+ # OrderMailer.confirmation(self).deliver_later if saved
28
29
  # end
29
30
  # end
30
31
  #
32
+ # The `deliver_later` is *outside* the transaction deliberately —
33
+ # `DevDoc/Rails/NoDeliverLaterInTransaction` flags mailers/jobs
34
+ # queued inside a transaction (the job may run before commit and
35
+ # read stale data). The `saved` local leaks out of the block
36
+ # (Ruby's normal scoping), gating the mailer on actual success.
37
+ #
31
38
  # ## When you have multiple call sites needing the same guard
32
39
  # The single-method-per-place pattern above works when there is one
33
40
  # natural call site. When several controllers/jobs/bulk operations all
@@ -101,8 +108,11 @@ module RuboCop
101
108
  #
102
109
  # # good
103
110
  # def save_with_confirmation
104
- # transaction { save! }
105
- # send_confirmation
111
+ # transaction do
112
+ # saved = save
113
+ # invoice.save! if saved
114
+ # end
115
+ # send_confirmation if saved
106
116
  # end
107
117
  class AvoidRailsCallbacks < Base
108
118
  CALLBACKS = %i[
@@ -0,0 +1,227 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Avoid raw SQL in query methods and `connection.execute`.
6
+ #
7
+ # ## Rationale
8
+ # Raw SQL bypasses Rails' adapter abstraction, hides typos in
9
+ # column and table names (a misspelled column silently returns
10
+ # nothing instead of raising `NameError`), is non-reversible in
11
+ # migrations, and ships no audit trail for why raw SQL was chosen
12
+ # over the Rails idiom. Every raw-SQL site should be a deliberate,
13
+ # reviewable choice — not a default.
14
+ #
15
+ # Prefer, in order:
16
+ #
17
+ # - The hash/array form: `where(status: "active")`,
18
+ # `where(status: ["active", "pending"])`.
19
+ # - Parameterized fragments: `where("col = ?", val)`,
20
+ # `where("col = :sym", sym: val)`. The `?` / `:name` placeholder
21
+ # binds the value safely and the column name is the only
22
+ # user-controlled surface.
23
+ # - Arel nodes for database-specific constructs that the hash
24
+ # form can't express.
25
+ #
26
+ # ❌ Raw SQL — typo in `statu` returns nothing, silently
27
+ # User.where("statu = 'active'")
28
+ #
29
+ # ✔️ Hash form — typo raises `ArgumentError` at the query layer
30
+ # User.where(status: "active")
31
+ #
32
+ # ✔️ Parameterized fragment — value bound, column name spelled
33
+ # User.where("status = ?", status)
34
+ #
35
+ # ## Scope
36
+ # The cop covers any string argument to:
37
+ #
38
+ # - **Execution methods** — `execute`, `exec_query`, `exec_update`,
39
+ # `exec_delete`, `exec_insert`, `find_by_sql`, `select_rows`,
40
+ # `select_values`, `select_one`, `select_all`, `update_sql`,
41
+ # `insert_sql`, `delete_sql`, `update_all`, `delete_all`.
42
+ # - **Query fragment methods** — `where`, `having`, `order`,
43
+ # `reorder`, `joins`, `select`, `group`, `from`, `lock`, `pluck`.
44
+ # - **`Arel.sql`** — the explicit opt-in to raw SQL (see below).
45
+ #
46
+ # Applies everywhere — application code AND migrations. A
47
+ # migration's `execute "UPDATE ..."` is the same smell as an
48
+ # app code `where("...")`; both bypass the adapter and offer no
49
+ # audit trail.
50
+ #
51
+ # ## `Arel.sql`
52
+ # Always flagged. `Arel.sql` exists specifically to bypass Rails'
53
+ # raw-SQL protection — reaching for it should require
54
+ # justification, not be a reflex. If a fragment is genuinely
55
+ # needed, disable inline with a reason; otherwise express it as
56
+ # an Arel AST node or a parameterized query.
57
+ #
58
+ # ## Counter updates (`update_all("col = col + 1")`)
59
+ # For **single records**, use `Model.increment_counter(:col, id)`
60
+ # or `Model.update_counters(id, col: 1)` — both generate
61
+ # parameterized SQL and surface typos in the column name. Neither
62
+ # is flagged by `DevDoc/Migration/AvoidBypassingValidation` (they
63
+ # are the Rails-blessed atomic-counter primitives).
64
+ #
65
+ # For **bulk counter updates**, the only clean option is
66
+ # `find_each { |m| Model.increment_counter(:col, m.id) }` — N
67
+ # queries, slow on large tables, but free of the
68
+ # validation-bypass smell. If the N-query cost is genuinely
69
+ # unacceptable, `update_all("col = col + 1")` requires disabling
70
+ # BOTH this cop AND `DevDoc/Migration/AvoidBypassingValidation`
71
+ # with reasons — the friction is the audit trail (locking
72
+ # implications, idempotency on re-runs, and the like are worth
73
+ # a second look).
74
+ #
75
+ # ❌ Single-record counter update via raw SQL
76
+ # User.where(id: id).update_all("login_count = login_count + 1")
77
+ #
78
+ # ✔️ Parameterized — generates safe SQL
79
+ # User.increment_counter(:login_count, id)
80
+ #
81
+ # ## Exception
82
+ # Genuine raw-SQL needs — database-specific DDL
83
+ # (`CREATE INDEX CONCURRENTLY`, `ALTER TYPE ... ADD VALUE`),
84
+ # complex joins that don't fit Arel, analytic queries with CTEs
85
+ # — go through an inline disable comment with a brief reason.
86
+ # The friction is the feature: every raw-SQL site ends up in
87
+ # code review with an articulated justification.
88
+ #
89
+ # ## Migration backfills are NOT an exception
90
+ # A common AI-agent reflex is to justify
91
+ # `execute "UPDATE users SET role = 0 ..."` with "models can
92
+ # drift, so raw SQL is safer." That justification is rejected
93
+ # here. The data-integrity cost of bypassing validations and
94
+ # callbacks outweighs the (rare, usually catchable) risk of a
95
+ # migration failing because a model changed.
96
+ #
97
+ # The correct backfill pattern goes through the model with
98
+ # `save!` — `DevDoc/Migration/AvoidBypassingValidation`
99
+ # enforces this, and `best_practices/backend/en/02_migration.md`
100
+ # shows the shape:
101
+ #
102
+ # ✔️ Model through the migration — validations run, callbacks fire
103
+ # User.where(role: nil).find_each do |user|
104
+ # user.role = 0
105
+ # user.save!
106
+ # end
107
+ #
108
+ # If existing rows would fail validation, fix the data first
109
+ # rather than bypassing validation — that's the signal, not an
110
+ # obstacle.
111
+ #
112
+ # NOTE: Parameterized fragments are detected by the presence of
113
+ # a `?` or `:name` placeholder plus at least one additional
114
+ # argument. A `?` appearing in literal SQL that isn't a
115
+ # placeholder (`where("name LIKE '%?%'")`) is a residual false
116
+ # negative; inline-disable if it surfaces.
117
+ #
118
+ # NOTE: Interpolated strings (`where("col = '#{val}'")`) are
119
+ # ALWAYS flagged — they cannot be parameterized by construction
120
+ # and are a SQL-injection risk. Use the `?` placeholder form.
121
+ #
122
+ # NOTE: Method calls returning strings (`where(some_helper)`)
123
+ # are not flagged — the cop only inspects literal strings.
124
+ # Helper methods that wrap raw SQL should themselves carry the
125
+ # disable.
126
+ #
127
+ # @example
128
+ # # bad — raw SQL
129
+ # execute "UPDATE users SET role = 0 WHERE role IS NULL"
130
+ # User.where("status = 'active'")
131
+ # User.where("status = '#{status}'")
132
+ # User.order("created_at")
133
+ # User.joins("INNER JOIN items ON items.user_id = users.id")
134
+ # User.update_all("login_count = login_count + 1")
135
+ # User.select("DISTINCT status")
136
+ # Arel.sql("NULLS LAST")
137
+ #
138
+ # # good — hash / array / symbol form
139
+ # User.where(status: "active")
140
+ # User.where(status: ["active", "pending"])
141
+ # User.order(:created_at)
142
+ # User.joins(:items)
143
+ # User.update_all(status: "active")
144
+ # User.select(:status)
145
+ #
146
+ # # good — parameterized fragment
147
+ # User.where("status = ?", status)
148
+ # User.where("status = :sym", sym: status)
149
+ # User.find_by_sql("SELECT * FROM users WHERE id = ?", id)
150
+ #
151
+ # # good — counter update on a single record
152
+ # User.increment_counter(:login_count, id)
153
+ class AvoidRawSql < Base
154
+ MSG = 'Avoid raw SQL — prefer the hash/array form, a ' \
155
+ 'parameterized fragment, or an Arel node. Disable with ' \
156
+ 'a reason when raw SQL is genuinely required.'.freeze
157
+
158
+ EXECUTION_METHODS = %i[
159
+ execute exec_query exec_update exec_delete exec_insert
160
+ find_by_sql select_rows select_values select_one select_all
161
+ update_sql insert_sql delete_sql update_all delete_all
162
+ ].freeze
163
+
164
+ FRAGMENT_METHODS = %i[
165
+ where having order reorder joins select group from lock pluck
166
+ ].freeze
167
+
168
+ SQL_METHODS = (EXECUTION_METHODS + FRAGMENT_METHODS).freeze
169
+
170
+ RESTRICT_ON_SEND = (SQL_METHODS + [:sql]).freeze
171
+
172
+ def on_send(node)
173
+ if arel_sql?(node)
174
+ check_arel_sql(node)
175
+ elsif SQL_METHODS.include?(node.method_name)
176
+ check_sql_method(node)
177
+ end
178
+ end
179
+
180
+ private
181
+
182
+ # `Arel.sql("...")` — the explicit opt-in to raw SQL. The
183
+ # receiver must be the `Arel` constant so unrelated
184
+ # `obj.sql(...)` calls don't false-positive.
185
+ def arel_sql?(node)
186
+ node.method_name == :sql &&
187
+ node.receiver&.const_type? &&
188
+ node.receiver.const_name == 'Arel'
189
+ end
190
+
191
+ # Arel.sql is always flagged (no parameterized exemption) —
192
+ # reaching for it is itself the smell being audited.
193
+ def check_arel_sql(node)
194
+ arg = node.first_argument
195
+ return unless arg && string?(arg)
196
+
197
+ add_offense(arg)
198
+ end
199
+
200
+ def check_sql_method(node)
201
+ arg = node.first_argument
202
+ return unless arg && string?(arg)
203
+ return if parameterized?(node, arg)
204
+
205
+ add_offense(arg)
206
+ end
207
+
208
+ def string?(arg)
209
+ arg.str_type? || arg.dstr_type?
210
+ end
211
+
212
+ # A literal string (`str`) is parameterized when it contains a
213
+ # `?` or `:name` placeholder AND there's at least one more
214
+ # argument to bind. An interpolated string (`dstr`) is NEVER
215
+ # parameterized — the value is concatenated in, not bound.
216
+ def parameterized?(send_node, string_arg)
217
+ return false if string_arg.dstr_type?
218
+ return false unless send_node.arguments.count > 1
219
+
220
+ value = string_arg.value
221
+ value.include?('?') || value.match?(/:[a-z_][a-z0-9_]*/i)
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,86 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Prefer `public_send` over `send` when dispatching dynamically.
6
+ #
7
+ # ## Rationale
8
+ # `public_send` respects Ruby's method visibility. When the dispatched
9
+ # name should resolve to a public API method (the common case for
10
+ # generic validators, form helpers, attribute readers, etc.), using
11
+ # `public_send`:
12
+ #
13
+ # - **Surfaces typos / misconfig immediately.** A name that drifts to
14
+ # a private internal raises `NoMethodError` instead of silently
15
+ # invoking it.
16
+ # - **Future-proofs the call site.** If the target method later moves
17
+ # to private (refactor, contract narrowing), `public_send` raises
18
+ # on the next run; `send` keeps silently working — potentially
19
+ # masking a real bug.
20
+ # - **Documents intent.** A reader of `public_send` knows the author
21
+ # meant a public-API call. `send` leaves it ambiguous whether
22
+ # private access was wanted.
23
+ #
24
+ # ## Relationship to AvoidSend
25
+ # `DevDoc/Style/AvoidSend` flags *dynamic* dispatch (both `send` and
26
+ # `public_send`) as risky. This cop is orthogonal: it flags `send`
27
+ # specifically, including the cases AvoidSend exempts (literal-symbol
28
+ # args, prefix-string args). The friction asymmetry is intentional —
29
+ # `send` is the deeper exception, so it costs an extra disable:
30
+ #
31
+ # obj.public_send(method_name) # AvoidSend: 1 disable
32
+ # obj.public_send(:foo) # clean
33
+ # obj.send(method_name) # AvoidSend + this cop: 2 disables
34
+ # obj.send(:foo) # this cop only: 1 disable
35
+ #
36
+ # ## When `send` IS the right choice
37
+ # Reach for `send` only when you have an articulable reason to bypass
38
+ # visibility — and leave a comment so the next reader knows it's
39
+ # deliberate:
40
+ #
41
+ # - **Tests** calling a private helper:
42
+ #
43
+ # # rubocop:disable DevDoc/Style/PreferPublicSend -- unit-testing private method
44
+ # assert_equal 5, calculator.send(:internal_carry)
45
+ # # rubocop:enable DevDoc/Style/PreferPublicSend
46
+ #
47
+ # - **Framework / introspection** code that intentionally calls
48
+ # private callbacks (e.g. `record.send(:_run_save_callbacks)`).
49
+ #
50
+ # ## Receiver-less `send` is exempt
51
+ # `send(:foo)` (no explicit receiver) calls a method on `self` and is
52
+ # commonly used inside a class to dispatch to its own private methods.
53
+ # Rewriting to `public_send` would either change semantics (the
54
+ # method must become public) or fail. Same exemption as `AvoidSend`.
55
+ #
56
+ # @example
57
+ # # bad — prefer public_send
58
+ # record.send(attribute)
59
+ # instance.send(:public_method)
60
+ # obj.send("export_#{x}")
61
+ #
62
+ # # good — visibility-respecting dispatch
63
+ # record.public_send(attribute)
64
+ # instance.public_send(:public_method)
65
+ # obj.public_send("export_#{x}")
66
+ #
67
+ # # good — receiver-less send (calling self's own method)
68
+ # send(:internal_helper)
69
+ class PreferPublicSend < Base
70
+ MSG = 'Prefer `public_send` over `send` — it respects method ' \
71
+ 'visibility, surfacing typos/misconfig that would otherwise ' \
72
+ 'silently invoke a private method. Disable with a reason ' \
73
+ 'when bypassing visibility is intentional.'.freeze
74
+
75
+ RESTRICT_ON_SEND = %i[send].freeze
76
+
77
+ def on_send(node)
78
+ return if node.receiver.nil?
79
+
80
+ add_offense(node.loc.selector)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end