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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32fcdd022e9dde952826050d447d83e9fa7f9bf1cfaf7caaaddda3309e392a3b
4
- data.tar.gz: e6775d4b37a1d763966c25eb3b2bf7926026b2ba2fdeae6d11fc05ae9d2ba46e
3
+ metadata.gz: 17a792c93018068695bb2c73dc023577864a51cbe29fc8962ba0d82f44784bd5
4
+ data.tar.gz: 260d22ceb74db21595f99e912a2bcbc4556718a30de5327fabf562b1174c84aa
5
5
  SHA512:
6
- metadata.gz: fb0572fc498d052e2f49d45490c37ddfcfa349d5a4e17b199159537b713cb5206ee828ffa96d5466c5e386e8da7713a22b5d3e51eca9daf228041be278fbe014
7
- data.tar.gz: a96d7bcdb8c34f3b0ab557a8bee46ef6ce91028174728cf06efa7df2943dafe7b67a47b9dff813260f2c824c6d7b1094bb6bc69c15bcb63fbf1c2f0bfab62333
6
+ metadata.gz: 6df2aae2a244e306e2a659dfd2f258a5b057fbfa0dda9d6fbc25ef4169fd6c67acc3906e03df78057eb22b4337727c9992ef0bc2b3cd6e760a63edb30dfcfb3b
7
+ data.tar.gz: 05b1392296eb25d9625e3f755f2310a262b955ecef1e38d0448d25e6c99352351c5284a9e61a5d3361415e2c2a91e5ae9589fbea532b077babd856a55eb3fa8c
data/config/default.yml CHANGED
@@ -1,5 +1,23 @@
1
1
  # Default configuration for rubocop-dev_doc cops.
2
- # Cops are enabled by default; disable them in your project's .rubocop.yml if needed.
2
+ # Cops are disabled by default; enable them in your project's .rubocop.yml.
3
+
4
+ # Department-level settings: DocumentationBaseURL lets RuboCop construct per-cop
5
+ # URLs automatically. DocumentationExtension matches the generated file format.
6
+ DevDoc/Migration:
7
+ DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
8
+ DocumentationExtension: '.md'
9
+
10
+ DevDoc/Rails:
11
+ DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
12
+ DocumentationExtension: '.md'
13
+
14
+ DevDoc/Route:
15
+ DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
16
+ DocumentationExtension: '.md'
17
+
18
+ DevDoc/Style:
19
+ DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
20
+ DocumentationExtension: '.md'
3
21
 
4
22
  DevDoc/Migration/AvoidJsonColumn:
5
23
  Description: 'Use `jsonb` instead of `json` for column types.'
@@ -10,7 +28,7 @@ DevDoc/Migration/AvoidJsonColumn:
10
28
 
11
29
  DevDoc/Migration/RequireTimestamps:
12
30
  Description: 'Always include `t.timestamps` in every `create_table` migration.'
13
- Enabled: true
31
+ Enabled: false
14
32
  Include:
15
33
  - 'db/migrate/*.rb'
16
34
  - 'db/migrate/**/*.rb'
@@ -29,12 +47,14 @@ DevDoc/Migration/AvoidColumnDefault:
29
47
  - 'db/migrate/*.rb'
30
48
  - 'db/migrate/**/*.rb'
31
49
 
32
- DevDoc/Migration/AvoidUpdateColumn:
33
- Description: 'Avoid `update_column`/`update_all`/`update_columns`; they bypass validations and callbacks.'
34
- Enabled: true
35
- Include:
36
- - 'db/migrate/*.rb'
37
- - 'db/migrate/**/*.rb'
50
+ # Intentionally global (no Include) — these patterns are risky in any file, not only migrations.
51
+ # Add project-specific Exclude entries (e.g. db/seeds.rb, lib/tasks/**/*.rb) in your .rubocop.yml
52
+ # for places where bulk operations are intentional and performance-critical.
53
+ DevDoc/Migration/AvoidBypassingValidation:
54
+ Description: 'Avoid methods that bypass validations and callbacks (`update_column`, `update_all`, `insert_all`, etc.).'
55
+ Enabled: false
56
+ Exclude:
57
+ - 'spec/**/*'
38
58
 
39
59
  DevDoc/Migration/DateColumnNaming:
40
60
  Description: 'Date columns should end with `_on`; datetime columns should end with `_at`.'
@@ -56,6 +76,9 @@ DevDoc/Migration/AvoidVagueColumnNames:
56
76
  DevDoc/Route/ResourcesRequireOnly:
57
77
  Description: 'Always use `only:` or `except:` when defining `resources` or `resource` routes.'
58
78
  Enabled: true
79
+ # RequireOnly: true (default) — only `only:` is accepted; `except:` is flagged.
80
+ # RequireOnly: false — both `only:` and `except:` are accepted; only the bare form is flagged.
81
+ RequireOnly: true
59
82
  Include:
60
83
  - 'config/routes.rb'
61
84
  - 'config/routes/**/*.rb'
@@ -63,6 +86,11 @@ DevDoc/Route/ResourcesRequireOnly:
63
86
  DevDoc/Rails/NoDeliverLaterInTransaction:
64
87
  Description: 'Avoid `deliver_later`/`perform_later` inside a `transaction` block; the job may use stale data.'
65
88
  Enabled: true
89
+ KnownAsyncWrappers:
90
+ - send_verification_email!
91
+ - send_reset_password_instructions
92
+ - send_confirmation_instructions
93
+ - send_unlock_instructions
66
94
 
67
95
  DevDoc/Rails/NoPerformLaterInModel:
68
96
  Description: 'Avoid `perform_later` inside model files; use explicit methods called from the controller.'
@@ -70,12 +98,100 @@ DevDoc/Rails/NoPerformLaterInModel:
70
98
  Include:
71
99
  - 'app/models/**/*.rb'
72
100
 
101
+ DevDoc/Rails/EnumMustBeSymbolized:
102
+ Description: 'Pair every `enum :foo` with `enum_symbolize :foo` so the reader returns a symbol.'
103
+ Enabled: true
104
+ Include:
105
+ - 'app/models/**/*.rb'
106
+
73
107
  DevDoc/Style/AvoidSend:
74
108
  Description: 'Avoid `send`/`public_send` with an explicit receiver; prefer direct calls or safer alternatives.'
75
109
  Enabled: true
76
110
 
77
111
  DevDoc/Style/AvoidHeadResponse:
78
- Description: 'Avoid `head()` responses; delegate error handling to Rails exceptions or model validations.'
112
+ Description: 'Avoid `head()` with error statuses; delegate error handling to Rails exceptions or model validations.'
79
113
  Enabled: true
80
114
  Include:
81
115
  - 'app/controllers/**/*.rb'
116
+ FlaggedStatuses:
117
+ - not_found
118
+ - unprocessable_entity
119
+ - forbidden
120
+ - unauthorized
121
+ - bad_request
122
+ - conflict
123
+ - gone
124
+ - method_not_allowed
125
+ - '404'
126
+ - '422'
127
+ - '403'
128
+ - '401'
129
+ - '400'
130
+ - '409'
131
+ - '410'
132
+ - '405'
133
+
134
+ DevDoc/Migration/RequirePrimaryKey:
135
+ Description: 'Every `create_table` must have a primary key. Avoid `id: false`.'
136
+ Enabled: true
137
+ Include:
138
+ - 'db/migrate/*.rb'
139
+ - 'db/migrate/**/*.rb'
140
+
141
+ DevDoc/Migration/NoCreateJoinTable:
142
+ Description: 'Avoid `create_join_table` — define an explicit join model with `has_many :through` instead.'
143
+ Enabled: true
144
+ Include:
145
+ - 'db/migrate/*.rb'
146
+ - 'db/migrate/**/*.rb'
147
+
148
+ # This cop is heuristic: it matches column names whose last segment is a known
149
+ # monetary word. Enable it in your project's .rubocop.yml and extend MonetaryNames
150
+ # if your domain uses different names. Disabled by default to avoid false positives.
151
+ DevDoc/Migration/AmountColumnInCents:
152
+ Description: 'Monetary columns must be stored as integer cents with an `_in_cents` suffix.'
153
+ Enabled: false
154
+ MonetaryNames:
155
+ - amount
156
+ - price
157
+ - balance
158
+ - cost
159
+ - fee
160
+ - total
161
+ - subtotal
162
+ - discount
163
+ - tax
164
+ Include:
165
+ - 'db/migrate/*.rb'
166
+ - 'db/migrate/**/*.rb'
167
+
168
+ DevDoc/Rails/AvoidRailsCallbacks:
169
+ Description: 'Avoid Rails callback DSL (`after_create`, `before_save`, etc.) — use explicit methods instead.'
170
+ Enabled: true
171
+ Include:
172
+ - 'app/models/**/*.rb'
173
+
174
+ DevDoc/Rails/ApplicationRecordTransaction:
175
+ Description: 'Use `ApplicationRecord.transaction` instead of `SomeModel.transaction` outside model files.'
176
+ Enabled: true
177
+ Exclude:
178
+ - 'app/models/**/*.rb'
179
+
180
+ DevDoc/Style/AvoidOptionsHash:
181
+ Description: 'Use keyword arguments instead of `**options` — typos raise `ArgumentError`; options hashes swallow them silently.'
182
+ Enabled: true
183
+
184
+ DevDoc/Style/StringSymbolComparison:
185
+ Description: 'Comparing a known-string source (params, request.headers, ENV) to a symbol literal is always false.'
186
+ Enabled: true
187
+
188
+ Rails/CreateTableWithTimestamps:
189
+ Enabled: true
190
+ Include:
191
+ - 'db/migrate/*.rb'
192
+ - 'db/migrate/**/*.rb'
193
+
194
+ Rails/SkipsModelValidations:
195
+ Enabled: true
196
+ Exclude:
197
+ - 'spec/**/*'
@@ -0,0 +1,92 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Monetary columns must be stored as integer cents with an `_in_cents` suffix.
6
+ #
7
+ # ## Rationale
8
+ # Storing monetary values as floats or decimals introduces floating-point
9
+ # precision issues. The safe approach is to store values as integer cents
10
+ # and convert to dollars only in user-facing forms and displays.
11
+ #
12
+ # This cop is heuristic: it matches column names whose last segment is a
13
+ # known monetary word (configurable via `MonetaryNames`). Extend the list
14
+ # in your `.rubocop.yml` if your domain uses different names.
15
+ #
16
+ # ❌
17
+ # t.float :amount
18
+ # t.decimal :price
19
+ # add_column :orders, :total, :decimal
20
+ #
21
+ # ✔️
22
+ # t.integer :amount_in_cents
23
+ # t.integer :price_in_cents
24
+ # add_column :orders, :total_in_cents, :integer
25
+ #
26
+ # To extend the monetary names list:
27
+ #
28
+ # DevDoc/Migration/AmountColumnInCents:
29
+ # Enabled: true
30
+ # MonetaryNames:
31
+ # - amount
32
+ # - price
33
+ # - balance
34
+ # - cost
35
+ # - fee
36
+ # - total
37
+ # - subtotal
38
+ # - discount
39
+ # - tax
40
+ # - revenue # custom addition
41
+ #
42
+ # @example
43
+ # # bad
44
+ # t.float :amount
45
+ # t.decimal :price
46
+ # add_column :orders, :total, :decimal
47
+ #
48
+ # # good
49
+ # t.integer :amount_in_cents
50
+ # t.integer :price_in_cents
51
+ # add_column :orders, :total_in_cents, :integer
52
+ class AmountColumnInCents < Base
53
+ DEFAULT_MONETARY_NAMES = %w[amount price balance cost fee total subtotal discount tax].freeze
54
+
55
+ MSG = 'Store monetary values as integer cents — ' \
56
+ 'rename `%<name>s` to `%<name>s_in_cents` and use `t.integer`.'.freeze
57
+
58
+ COLUMN_METHODS = %i[float decimal integer].freeze
59
+
60
+ def on_send(node)
61
+ col_name_node = column_name_node(node)
62
+ return unless col_name_node&.sym_type?
63
+
64
+ col_name = col_name_node.value.to_s
65
+ return if col_name.end_with?('_in_cents')
66
+ return unless monetary_suffix?(col_name)
67
+
68
+ add_offense(col_name_node, message: format(MSG, name: col_name))
69
+ end
70
+
71
+ private
72
+
73
+ def column_name_node(node)
74
+ if node.method?(:add_column)
75
+ node.arguments[1]
76
+ elsif COLUMN_METHODS.include?(node.method_name)
77
+ node.first_argument
78
+ end
79
+ end
80
+
81
+ def monetary_suffix?(name)
82
+ monetary_names.include?(name.split('_').last)
83
+ end
84
+
85
+ def monetary_names
86
+ cop_config.fetch('MonetaryNames', DEFAULT_MONETARY_NAMES)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,86 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Avoid methods that bypass validations and callbacks.
6
+ #
7
+ # ## Rationale
8
+ # Avoid bypassing validation unless absolutely necessary. Methods like
9
+ # `update_column`, `update_all`, `insert_all`, `upsert_all`, `delete_all`,
10
+ # and `save(validate: false)` skip validations and callbacks, which hides
11
+ # data integrity issues rather than surfacing them.
12
+ #
13
+ # Even in migrations, check the code to see if there is any blatant
14
+ # reason why existing records may be invalid. If there is, fix those
15
+ # records first rather than bypassing validation.
16
+ #
17
+ # ❌ Bypasses validation — hides data integrity issues
18
+ # Faq.where(purpose: nil).update_all(purpose: :intro)
19
+ #
20
+ # ✔️ Runs validation — surfaces problems early
21
+ # Faq.where(purpose: nil).find_each do |faq|
22
+ # faq.purpose = :intro
23
+ # faq.save!
24
+ # end
25
+ #
26
+ # @example
27
+ # # bad
28
+ # Faq.where(purpose: nil).update_all(purpose: :intro)
29
+ #
30
+ # # bad
31
+ # user.update_column(:status, 'active')
32
+ #
33
+ # # bad
34
+ # user.save(validate: false)
35
+ #
36
+ # # bad
37
+ # User.insert_all(rows)
38
+ #
39
+ # # good
40
+ # Faq.where(purpose: nil).find_each do |faq|
41
+ # faq.purpose = :intro
42
+ # faq.save!
43
+ # end
44
+ class AvoidBypassingValidation < Base
45
+ MESSAGES = {
46
+ update_column: 'Avoid `update_column`; it bypasses validations. Use `save!` instead.',
47
+ update_columns: 'Avoid `update_columns`; it bypasses validations. Use `save!` instead.',
48
+ update_all: 'Avoid `update_all`; it bypasses validations. Use `save!` in a loop instead.',
49
+ insert_all: 'Avoid `insert_all`; it bypasses validations. Use `create!` in a loop, ' \
50
+ 'or `# rubocop:disable` with a reason if bulk-insert is intentional.',
51
+ upsert_all: 'Avoid `upsert_all`; it bypasses validations. Use `create!`/`update!` in a loop, ' \
52
+ 'or `# rubocop:disable` with a reason if bulk-upsert is intentional.',
53
+ delete_all: 'Avoid `delete_all`; it bypasses callbacks. Use `destroy_all` to run callbacks, ' \
54
+ 'or `# rubocop:disable` with a reason if bulk-delete is intentional.'
55
+ }.freeze
56
+
57
+ SAVE_MSG = 'Avoid `save(validate: false)`; it bypasses validations. Use `save!` instead.'.freeze
58
+
59
+ RESTRICT_ON_SEND = %i[update_column update_columns update_all insert_all upsert_all delete_all save].freeze
60
+
61
+ def on_send(node)
62
+ if node.method?(:save)
63
+ return unless save_with_validate_false?(node)
64
+
65
+ add_offense(node.loc.selector, message: SAVE_MSG)
66
+ else
67
+ add_offense(node.loc.selector, message: MESSAGES[node.method_name])
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def save_with_validate_false?(node)
74
+ node.arguments.any? do |arg|
75
+ next unless arg.hash_type?
76
+
77
+ arg.pairs.any? do |pair|
78
+ pair.key.sym_type? && pair.key.value == :validate && pair.value.false_type?
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -43,18 +43,18 @@ module RuboCop
43
43
  # end
44
44
  # end
45
45
  #
46
- # ## Exception
46
+ # ## Exception (auto-detected)
47
47
  # For performance reasons (large tables with millions of records) or when
48
48
  # using `null: false`, you may temporarily set a default and then
49
- # immediately remove it in the same migration:
49
+ # immediately remove it in the same migration. The cop suppresses the
50
+ # offense when it detects a matching `change_column_default ..., to: nil`
51
+ # for the same table and column anywhere in the same method body (`def
52
+ # change` / `def up`), including inside `reversible do |dir| dir.up`.
50
53
  #
51
- # ✔
54
+ # ✔ (no offense — two-step pattern auto-detected)
52
55
  # add_column :users, :profile_completion_rate, :float, default: 0.0
53
56
  # change_column_default :users, :profile_completion_rate, from: 0.0, to: nil
54
57
  #
55
- # NOTE: This cop currently flags the first line of the exception pattern.
56
- # The follow-up `change_column_default ..., to: nil` is not auto-detected.
57
- #
58
58
  # @example
59
59
  # # bad
60
60
  # add_column :users, :score, :integer, default: 0
@@ -65,7 +65,7 @@ module RuboCop
65
65
  # # good
66
66
  # add_column :users, :score, :integer
67
67
  #
68
- # # good (temporary default immediately removed)
68
+ # # good (temporary default immediately removed — two-step pattern)
69
69
  # add_column :users, :score, :integer, default: 0
70
70
  # change_column_default :users, :score, from: 0, to: nil
71
71
  class AvoidColumnDefault < Base
@@ -80,19 +80,74 @@ module RuboCop
80
80
  (hash <(pair (sym :default) _) ...>)
81
81
  PATTERN
82
82
 
83
+ def_node_matcher :change_column_default_to_nil?, <<~PATTERN
84
+ (send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) (nil)) ...>))
85
+ PATTERN
86
+
83
87
  def on_send(node)
84
- return unless node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
88
+ return unless column_method?(node)
89
+
90
+ options = node.arguments.find(&:hash_type?)
91
+ return unless options && has_default_option?(options)
92
+ return if two_step_pattern?(node)
85
93
 
86
- check_options(node.arguments.find(&:hash_type?))
94
+ add_offense(find_default_pair(options))
87
95
  end
88
96
 
89
97
  private
90
98
 
91
- def check_options(options)
92
- return unless options && has_default_option?(options)
99
+ def column_method?(node)
100
+ node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
101
+ end
102
+
103
+ def find_default_pair(options)
104
+ options.pairs.find { |p| p.key.sym_type? && p.key.value == :default }
105
+ end
106
+
107
+ def two_step_pattern?(node)
108
+ table_name, col_name = extract_table_and_column(node)
109
+ return false unless table_name && col_name
110
+
111
+ enclosing_def = node.each_ancestor(:def).first
112
+ return false unless enclosing_def
113
+
114
+ enclosing_def.each_descendant(:send).any? do |sibling|
115
+ cancels_default?(sibling, table_name, col_name)
116
+ end
117
+ end
118
+
119
+ def extract_table_and_column(node)
120
+ node.method?(:add_column) ? add_column_pair(node) : column_def_pair(node)
121
+ end
122
+
123
+ def add_column_pair(node)
124
+ t = node.arguments[0]
125
+ c = node.arguments[1]
126
+ [t.value, c.value] if t&.sym_type? && c&.sym_type?
127
+ end
128
+
129
+ def column_def_pair(node)
130
+ c = node.arguments[0]
131
+ return unless c&.sym_type?
132
+
133
+ t = enclosing_table_sym(node)
134
+ [t.value, c.value] if t
135
+ end
136
+
137
+ def enclosing_table_sym(node)
138
+ table_block = node.each_ancestor(:block).find do |b|
139
+ %i[create_table change_table].include?(b.method_name)
140
+ end
141
+ return unless table_block
142
+
143
+ arg = table_block.send_node.arguments[0]
144
+ arg if arg&.sym_type?
145
+ end
93
146
 
94
- default_pair = options.pairs.find { |p| p.key.sym_type? && p.key.value == :default }
95
- add_offense(default_pair)
147
+ def cancels_default?(node, table_name, col_name)
148
+ change_column_default_to_nil?(node) do |t, c|
149
+ t == table_name && c == col_name
150
+ end
96
151
  end
97
152
  end
98
153
  end
@@ -12,27 +12,42 @@ module RuboCop
12
12
  #
13
13
  # ❌
14
14
  # t.json :metadata
15
+ # add_column :users, :metadata, :json
15
16
  #
16
17
  # ✔️
17
18
  # t.jsonb :metadata
19
+ # add_column :users, :metadata, :jsonb
18
20
  #
19
21
  # @example
20
22
  # # bad
21
23
  # t.json :metadata
24
+ # add_column :users, :metadata, :json
22
25
  #
23
26
  # # good
24
27
  # t.jsonb :metadata
28
+ # add_column :users, :metadata, :jsonb
25
29
  class AvoidJsonColumn < Base
26
30
  extend AutoCorrector
27
31
 
28
32
  MSG = 'Use `jsonb` instead of `json`. `jsonb` supports indexing and is faster to read.'.freeze
29
- RESTRICT_ON_SEND = %i[json].freeze
33
+ RESTRICT_ON_SEND = %i[json add_column].freeze
30
34
 
31
35
  def on_send(node)
32
- add_offense(node.loc.selector) do |corrector|
33
- corrector.replace(node.loc.selector, 'jsonb')
36
+ if node.method_name == :add_column
37
+ check_add_column(node)
38
+ else
39
+ add_offense(node.loc.selector) { |c| c.replace(node.loc.selector, 'jsonb') }
34
40
  end
35
41
  end
42
+
43
+ private
44
+
45
+ def check_add_column(node)
46
+ type_arg = node.arguments[2]
47
+ return unless type_arg&.sym_type? && type_arg.value == :json
48
+
49
+ add_offense(type_arg) { |c| c.replace(type_arg, ':jsonb') }
50
+ end
36
51
  end
37
52
  end
38
53
  end
@@ -0,0 +1,53 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Avoid `create_join_table` — define an explicit join model instead.
6
+ #
7
+ # ## Rationale
8
+ # `create_join_table` produces a PK-less table tied to
9
+ # `has_and_belongs_to_many`, which has well-known limitations: no
10
+ # timestamps, no per-row callbacks, and no room for extra columns
11
+ # without migration gymnastics.
12
+ #
13
+ # The preferred Rails idiom is `has_many :through` with an explicit
14
+ # join model, which is just a normal table with a PK and timestamps.
15
+ #
16
+ # ❌
17
+ # create_join_table :products, :categories
18
+ #
19
+ # ✔️ Define an explicit join model
20
+ # create_table :product_categories do |t|
21
+ # t.belongs_to :product, null: false, foreign_key: true
22
+ # t.belongs_to :category, null: false, foreign_key: true
23
+ # t.timestamps
24
+ # end
25
+ #
26
+ # # In models:
27
+ # class Product < ApplicationRecord
28
+ # has_many :product_categories
29
+ # has_many :categories, through: :product_categories
30
+ # end
31
+ #
32
+ # @example
33
+ # # bad
34
+ # create_join_table :products, :categories
35
+ #
36
+ # # good
37
+ # create_table :product_categories do |t|
38
+ # t.belongs_to :product, null: false, foreign_key: true
39
+ # t.belongs_to :category, null: false, foreign_key: true
40
+ # t.timestamps
41
+ # end
42
+ class NoCreateJoinTable < Base
43
+ MSG = 'Avoid `create_join_table` — define an explicit join model with `has_many :through` instead.'.freeze
44
+ RESTRICT_ON_SEND = %i[create_join_table].freeze
45
+
46
+ def on_send(node)
47
+ add_offense(node.loc.selector)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,55 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Every `create_table` migration must have a primary key.
6
+ #
7
+ # ## Rationale
8
+ # A table without a primary key is hard to reference, hard to debug,
9
+ # and hard to migrate later. `id: false` is rarely the right answer;
10
+ # `has_many :through` with an explicit join model is preferred for
11
+ # many-to-many relationships, and that join model has its own `id`.
12
+ #
13
+ # ❌
14
+ # create_table :products_categories, id: false do |t|
15
+ # t.belongs_to :product
16
+ # t.belongs_to :category
17
+ # end
18
+ #
19
+ # ✔️ Use an explicit join model with has_many :through instead
20
+ # create_table :product_categories do |t|
21
+ # t.belongs_to :product, null: false, foreign_key: true
22
+ # t.belongs_to :category, null: false, foreign_key: true
23
+ # t.timestamps
24
+ # end
25
+ #
26
+ # @example
27
+ # # bad
28
+ # create_table :products_categories, id: false do |t|
29
+ # t.belongs_to :product
30
+ # end
31
+ #
32
+ # # good
33
+ # create_table :product_categories do |t|
34
+ # t.belongs_to :product, null: false, foreign_key: true
35
+ # end
36
+ class RequirePrimaryKey < Base
37
+ MSG = 'Every table must have a primary key. Avoid `id: false` — ' \
38
+ 'use an explicit join model with `has_many :through` instead.'.freeze
39
+ RESTRICT_ON_SEND = %i[create_table].freeze
40
+
41
+ def_node_matcher :id_false_pair?, <<~PATTERN
42
+ (pair (sym :id) (false))
43
+ PATTERN
44
+
45
+ def on_send(node)
46
+ id_false = node.each_descendant(:pair).find { |pair| id_false_pair?(pair) }
47
+ return unless id_false
48
+
49
+ add_offense(id_false)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -10,6 +10,10 @@ module RuboCop
10
10
  # auditing, and ordering records, and adding them later is much more
11
11
  # painful than including them up front.
12
12
  #
13
+ # This rule applies to **every** `create_table` call, including those
14
+ # with `id: false`. If a genuinely timestamp-less table is needed, add
15
+ # an explicit `# rubocop:disable` with a rationale comment.
16
+ #
13
17
  # ❌
14
18
  # create_table :ai_api_failures do |t|
15
19
  # t.belongs_to :ai_chat_message, null: false, foreign_key: true
@@ -24,9 +28,6 @@ module RuboCop
24
28
  # t.timestamps
25
29
  # end
26
30
  #
27
- # NOTE: This cop skips tables declared with `id: false` (typically join
28
- # tables), where omitting timestamps is a deliberate choice.
29
- #
30
31
  # @example
31
32
  # # bad
32
33
  # create_table :users do |t|
@@ -54,13 +55,7 @@ module RuboCop
54
55
  ...)
55
56
  PATTERN
56
57
 
57
- def_node_matcher :id_false_option?, <<~PATTERN
58
- (pair (sym :id) (false))
59
- PATTERN
60
-
61
58
  def on_send(node)
62
- return if skip_table?(node)
63
-
64
59
  block = node.parent
65
60
  return unless block&.block_type?
66
61
  return if timestamps_present?(block.body)
@@ -72,10 +67,6 @@ module RuboCop
72
67
 
73
68
  private
74
69
 
75
- def skip_table?(node)
76
- node.each_descendant(:pair).any? { |pair| id_false_option?(pair) }
77
- end
78
-
79
70
  def timestamps_present?(body)
80
71
  return false if body.nil?
81
72