rubocop-dev_doc 0.5.0.beta1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e13da0de0c2a17314d72cbe4bbeb9882a0ad1f93e4976d2e0548210af1684073
4
- data.tar.gz: 57bf1fb471573aaa8417f3a86f9c4eba84ebc1d71d38d397c7cac2d05607d058
3
+ metadata.gz: 2076123d07503e775a29287eafcd8dc9d8cf6e344eeb4b462d29120fdef12879
4
+ data.tar.gz: 910414d217e37c808b7626add2b876d24f6ef214a725b373e86dc736acb24ff7
5
5
  SHA512:
6
- metadata.gz: c302ad93dcb52860f23365fa361df3c1f593d618c6948b7b113eac124e640687345c2e953eca523777377eb27d7f75b4c177c2029dc5ceb71e7c30e0d52d5fab
7
- data.tar.gz: bf88a2042d7dce6169ec7b274c1249b4c291518076f1d26c5d5b9f8c5a95699bcd1e343d27970e8809f7ad91fafe67d1206a553b1e5e162f14c59458cf3b0416
6
+ metadata.gz: 7681307dae1b8d09d0344c199d4be497bc4c986b4314711ae39550d7d8ced8d8b811d07d98997f8e3fc85d294e6fde1b97c8cb6cdc3c33a40ad3074cda985d62
7
+ data.tar.gz: a047b648abfcaeb1842158a562c17028601a2f24c67e1f0aa2cdc548377cccd1658dae4b6b68cee1cb4906275a59822f2870160380e71cb5d730c04ce5d44196
data/config/default.yml CHANGED
@@ -7,10 +7,6 @@ DevDoc/Auth:
7
7
  DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
8
8
  DocumentationExtension: ".md"
9
9
 
10
- DevDoc/I18n:
11
- DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
12
- DocumentationExtension: ".md"
13
-
14
10
  DevDoc/Migration:
15
11
  DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
16
12
  DocumentationExtension: ".md"
@@ -83,11 +79,12 @@ DevDoc/Migration/DateColumnNaming:
83
79
  - "db/migrate/**/*.rb"
84
80
 
85
81
  DevDoc/Migration/AvoidVagueColumnNames:
86
- Description: "Avoid vague column names like `status` or `group`. Use more specific names."
82
+ Description: "Avoid vague column names like `status`, `group`, or `kind`. Use more specific names."
87
83
  Enabled: true
88
84
  VagueNames:
89
85
  - status
90
86
  - group
87
+ - kind
91
88
  Include:
92
89
  - "db/migrate/*.rb"
93
90
  - "db/migrate/**/*.rb"
@@ -150,10 +147,10 @@ DevDoc/Rails/EnumColumnNotNull:
150
147
  DevDoc/Rails/NoBlockPredicateOnRelation:
151
148
  Description: "Avoid block-form `count`/`reject`/`select`/`find`/`any?` on AR relations; push the predicate into SQL with `.where(...)` or a scope."
152
149
  Enabled: true
150
+ AdditionalNonRelationMethods: []
153
151
  Exclude:
154
152
  - "spec/**/*"
155
153
  - "test/**/*"
156
- - "db/seeds.rb"
157
154
  - "db/seeds/**/*.rb"
158
155
  - "lib/tasks/**/*.rb"
159
156
 
@@ -169,6 +166,10 @@ DevDoc/Style/AvoidSend:
169
166
  Description: "Avoid `send`/`public_send` with an explicit receiver; prefer direct calls or safer alternatives."
170
167
  Enabled: true
171
168
 
169
+ DevDoc/Style/PreferPublicSend:
170
+ Description: "Prefer `public_send` over `send` — visibility-respecting dispatch surfaces typos/misconfig and documents intent."
171
+ Enabled: true
172
+
172
173
  DevDoc/Style/RepeatedSafeNavigationReceiver:
173
174
  Description: "Avoid using `&.` on the same receiver more than once in a method body — assign once and use `.` after."
174
175
  Enabled: true
@@ -181,6 +182,10 @@ DevDoc/Style/MinimizeVariableScope:
181
182
  Description: "Assign a variable inside the `if` condition that guards it, to keep its scope local to the branch."
182
183
  Enabled: true
183
184
 
185
+ DevDoc/Style/TapBlockIgnoresValue:
186
+ Description: "Flag `tap` whose block ignores the yielded value — `tap` is for operating on the receiver, not eliding a temp var."
187
+ Enabled: true
188
+
184
189
  DevDoc/Style/AvoidHeadResponse:
185
190
  Description: "Avoid `head()` with error statuses; delegate error handling to Rails exceptions or model validations."
186
191
  Enabled: true
@@ -311,6 +316,14 @@ DevDoc/Rails/StrongParametersExpect:
311
316
  Include:
312
317
  - "app/controllers/**/*.rb"
313
318
 
319
+ DevDoc/Rails/AvoidRawSql:
320
+ Description: "Avoid raw SQL — prefer the hash/array form, parameterized fragments, or Arel; disable with a reason when raw SQL is genuinely required."
321
+ Enabled: true
322
+
323
+ DevDoc/Rails/AvoidOrderingById:
324
+ Description: "Avoid `order`/`reorder` by `id` as the primary (sole or leftmost) sort — order by a business column, or use `id` only as a secondary tiebreaker."
325
+ Enabled: true
326
+
314
327
  # `save!` / `update!` raise on persistence failure, which is what
315
328
  # we want in jobs/services/rake tasks — silent `false` returns
316
329
  # hide bugs. Excluded for controllers, which lean on `save`
@@ -383,136 +396,3 @@ DevDoc/Auth/CurrentUserBranching:
383
396
  - "app/helpers/**/*.rb"
384
397
  - "app/controllers/concerns/**/*.rb"
385
398
  - "app/views/layouts/**/*"
386
- - "app/controllers/application_controller.rb"
387
-
388
- DevDoc/I18n/RequireTranslation:
389
- Description: "Localize user-facing strings in glib JSON-UI props; pass `t(...)` instead of a hardcoded string."
390
- Enabled: false
391
- # WatchedMethods: glib component builder methods whose hash props carry
392
- # user-facing text. LocalizableKeys: the prop names checked on those calls.
393
- # Extend both if your project adds custom components or text props.
394
- WatchedMethods:
395
- - h1
396
- - h2
397
- - h3
398
- - h4
399
- - h5
400
- - p
401
- - label
402
- - markdown
403
- - fields_text
404
- - fields_number
405
- - fields_select
406
- - fields_password
407
- - fields_textarea
408
- - fields_check
409
- - fields_checkGroup
410
- - fields_chipGroup
411
- - fields_timeZone
412
- - fields_radioGroup
413
- - fields_date
414
- - fields_datetime
415
- LocalizableKeys:
416
- - title
417
- - subtitle
418
- - subsubtitle
419
- - label
420
- - placeholder
421
- - text
422
- Include:
423
- - "app/views/**/*.jbuilder"
424
- - "app/views/**/*.rb"
425
-
426
- DevDoc/I18n/TranslationKeyPrefix:
427
- Description: "Translation keys must start with an allowed namespace prefix (e.g. `hotel.`, `general.`)."
428
- Enabled: false
429
- # AllowedPrefixes is project-specific, so it defaults to empty and the cop is
430
- # a no-op until configured. Matches `t`, `translate`, and `I18n.t`. Only
431
- # statically-literal keys are checked; dynamic/interpolated keys are skipped.
432
- AllowedPrefixes: []
433
- Include:
434
- - "app/views/**/*.jbuilder"
435
- - "app/views/**/*.rb"
436
- - "app/controllers/**/*.rb"
437
- - "app/mailers/**/*.rb"
438
- - "app/helpers/**/*.rb"
439
-
440
- DevDoc/I18n/UnverifiedTranslation:
441
- Description: "Warn when a glib text prop is set from a non-literal value that isn't a `t(...)` call — it may be an unlocalized string."
442
- # A review aid, not a clean lint: it can't tell a stashed literal from
443
- # dynamic data (`user.name`) or a translation reached via a variable. Off by
444
- # default and `info` severity — run it during a localization pass. Mirrors
445
- # RequireTranslation's WatchedMethods/LocalizableKeys.
446
- Enabled: false
447
- Severity: info
448
- WatchedMethods:
449
- - h1
450
- - h2
451
- - h3
452
- - h4
453
- - h5
454
- - p
455
- - label
456
- - markdown
457
- - fields_text
458
- - fields_number
459
- - fields_select
460
- - fields_password
461
- - fields_textarea
462
- - fields_check
463
- - fields_checkGroup
464
- - fields_chipGroup
465
- - fields_timeZone
466
- - fields_radioGroup
467
- - fields_date
468
- - fields_datetime
469
- LocalizableKeys:
470
- - title
471
- - subtitle
472
- - subsubtitle
473
- - label
474
- - placeholder
475
- - text
476
- Include:
477
- - "app/views/**/*.jbuilder"
478
- - "app/views/**/*.rb"
479
-
480
- DevDoc/I18n/ReportText:
481
- Description: "Report every user-facing glib text prop — hardcoded and already-localized — to collect all possible texts."
482
- # A tooling aid, not a lint: unlike RequireTranslation it fires on *every*
483
- # text value (including `t(...)` calls) so you can sweep the codebase and
484
- # collect the full list of user-facing strings. Off by default and `info`
485
- # severity. Mirrors RequireTranslation's WatchedMethods/LocalizableKeys.
486
- Enabled: false
487
- Severity: info
488
- WatchedMethods:
489
- - h1
490
- - h2
491
- - h3
492
- - h4
493
- - h5
494
- - p
495
- - label
496
- - markdown
497
- - fields_text
498
- - fields_number
499
- - fields_select
500
- - fields_password
501
- - fields_textarea
502
- - fields_check
503
- - fields_checkGroup
504
- - fields_chipGroup
505
- - fields_timeZone
506
- - fields_radioGroup
507
- - fields_date
508
- - fields_datetime
509
- LocalizableKeys:
510
- - title
511
- - subtitle
512
- - subsubtitle
513
- - label
514
- - placeholder
515
- - text
516
- Include:
517
- - "app/views/**/*.jbuilder"
518
- - "app/views/**/*.rb"
@@ -56,13 +56,56 @@ module RuboCop
56
56
  #
57
57
  # ## Allowed paths (Exclude:)
58
58
  # By default the cop is silent in:
59
- # app/policies/**/*.rb
59
+ # app/policies/**/*.rb ← auth-dependent checks belong here
60
60
  # app/helpers/**/*.rb
61
61
  # app/controllers/concerns/**/*.rb
62
- # app/views/layouts/**/*
63
- # app/controllers/application_controller.rb
62
+ # app/views/layouts/**/* ← display branching (nav bars, etc.)
64
63
  #
65
- # Override via `Exclude:` in your `.rubocop.yml`.
64
+ # Override via `Exclude:` in your `.rubocop.yml`. Note: literal file
65
+ # paths (e.g. `app/controllers/application_controller.rb`) in
66
+ # `.rubocop.yml` `Exclude:` lists are flagged by the
67
+ # `DevDoc::Test::Lints::NoFileExcludes` lint — they hide the
68
+ # suppression from readers of that file. If `ApplicationController`
69
+ # needs an exception, use an inline `# rubocop:disable` at the
70
+ # specific line with a reason.
71
+ #
72
+ # ## Before disabling inline, consider the Policy
73
+ # The Policy exclusion is not accidental — it's the canonical
74
+ # Rails home for auth-dependent branching. If the flagged line
75
+ # is an authorization check (`return if @record.accessible_by?(current_user)`,
76
+ # `if current_user.admin?`, etc.), the right move is almost
77
+ # always to push that check into a Pundit policy method, not
78
+ # disable inline. The controller becomes
79
+ # `return unless policy(@record).access?` — same behaviour,
80
+ # no `current_user` reference in the controller, and the auth
81
+ # logic is reusable + testable in isolation.
82
+ #
83
+ # Even better: declare `glib_authorize_resource` at the
84
+ # controller level. glib-web runs the appropriate policy
85
+ # method before each action automatically, so the per-action
86
+ # `verify_access` / `authorize @record` line is usually not
87
+ # needed at all — the controller body no longer references
88
+ # `current_user` because the auth check has moved out of the
89
+ # action entirely. See `best_practices/backend/en/05_controller.md`
90
+ # item #7 for the canonical pattern.
91
+ #
92
+ # ❌ Auth check in the controller — flagged
93
+ # def verify_access
94
+ # return if @support_question.accessible_by?(current_user)
95
+ # # ... token fallback ...
96
+ # end
97
+ #
98
+ # ✔️ Same check in the Policy — silently allowed
99
+ # # app/policies/support_question_policy.rb
100
+ # def access?
101
+ # user.present? && record.accessible_by?(user)
102
+ # end
103
+ #
104
+ # # in the controller:
105
+ # def verify_access
106
+ # return if policy(@support_question).access?
107
+ # # ... token fallback ...
108
+ # end
66
109
  #
67
110
  # NOTE: The cop does not autocorrect — there is no mechanical fix. The
68
111
  # right response depends on developer intent: drop the branch (if auth
@@ -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[