rubocop-dev_doc 0.13.1 → 0.14.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: c36d2d77bcdffd7f74968817e83dbdb75134e3e3d63b953127e4475f7c1841f9
4
- data.tar.gz: db6ef7a22d41f10f30e6e6ffedfdb1c7605bc849907290930f9d1eae4f0c49b4
3
+ metadata.gz: 1f6961dde0d54631bae2f2dae6325183cc678f81701aa06575abab88cc1b0f7b
4
+ data.tar.gz: cc20cca8c229dca80fbd57d9950794b71560ccb2c13e0ace82c9b6cab0a0c93c
5
5
  SHA512:
6
- metadata.gz: a874f22bc4773c0abaeb6de19587b5c1934061d40fb608831b2c725d060acdc39c8817689f38cab35041d1a67d99cfc12daf995a4bc3780fcc91a4f99901dfcb
7
- data.tar.gz: 9966867628667e9fee6dd869345d1943e936db024a426b6dbd7433f14ae93396c5d8edeffba3a97b1259aac8f831ada7f288d93fdfceb14c34b4893d03fe613d
6
+ metadata.gz: cdf5e192d7d3b526311ca7e9b6d6d3d81a50b3a14d8bd9b4b4a5d4459c0ed4d34c4695e1087e109614cf6f5dc040a12b0e7686f4e4cf509e16f4c9b770a0ccfa
7
+ data.tar.gz: 3664cc36ac24eb3f1a0bd6061a3671c32695113d50dd87753a35ec13536d02330fa7f10c7188439e90726a9b3a80dac227205cbaf88f539cb668dd7d18ea628d
data/config/default.yml CHANGED
@@ -250,10 +250,14 @@ DevDoc/Style/TapBlockIgnoresValue:
250
250
  Enabled: true
251
251
 
252
252
  DevDoc/Style/AvoidHeadResponse:
253
- Description: "Avoid `head()` with error statuses; delegate error handling to Rails exceptions or model validations."
253
+ Description: "Avoid `head()` with error or no-content statuses; render a body (an exception page, validation errors, or an action spec) instead."
254
254
  Enabled: true
255
255
  Include:
256
256
  - "app/controllers/**/*.rb"
257
+ # Error statuses: delegate to Rails exceptions / model validations.
258
+ # No-content "successes" (no_content/reset_content): a bodyless response is
259
+ # ambiguous to a backend-driven JSON client -- render an action response,
260
+ # even an empty one.
257
261
  FlaggedStatuses:
258
262
  - not_found
259
263
  - unprocessable_entity
@@ -263,6 +267,8 @@ DevDoc/Style/AvoidHeadResponse:
263
267
  - conflict
264
268
  - gone
265
269
  - method_not_allowed
270
+ - no_content
271
+ - reset_content
266
272
  - "404"
267
273
  - "422"
268
274
  - "403"
@@ -271,6 +277,8 @@ DevDoc/Style/AvoidHeadResponse:
271
277
  - "409"
272
278
  - "410"
273
279
  - "405"
280
+ - "204"
281
+ - "205"
274
282
 
275
283
  DevDoc/Migration/AvoidConditionalSchemaChanges:
276
284
  Description: "Avoid conditional schema helpers (`add_column_if_not_exists`, `column_exists?`, etc.) in migrations."
@@ -293,6 +301,54 @@ DevDoc/Migration/NoCreateJoinTable:
293
301
  - "db/migrate/*.rb"
294
302
  - "db/migrate/**/*.rb"
295
303
 
304
+ # The exact inverse of Rails/BulkChangeTable (disabled below) — the two must
305
+ # never both be enabled. Whether this gem's `Enabled: false` for the stock cop
306
+ # reaches a project is plugin-load-order dependent (the last-loaded extension's
307
+ # default wins), so projects should ALSO disable Rails/BulkChangeTable
308
+ # explicitly in their own .rubocop.yml.
309
+ # SafeAutoCorrect false: removing `bulk:` turns one combined ALTER TABLE into
310
+ # N separate statements on adapters that support bulk — identical end schema,
311
+ # but a behavior change for unapplied migrations, so it only fires under -A.
312
+ DevDoc/Migration/NoBulkChangeTable:
313
+ Description: "Avoid `bulk:` on `change_table` — a MySQL optimization that is a no-op on PostgreSQL."
314
+ Enabled: true
315
+ SafeAutoCorrect: false
316
+ Include:
317
+ - "db/migrate/*.rb"
318
+ - "db/migrate/**/*.rb"
319
+
320
+ # The reference-options trio. Each of a reference's three options gets the
321
+ # enforcement shape its house position calls for:
322
+ # - foreign_key: has one right value (constrain) -> enforce the VALUE.
323
+ # - null: both values are legitimate -> enforce EXPLICITNESS (either passes).
324
+ # - index: the default is already correct -> enforce SILENCE (flag redundancy).
325
+ # Net effect: everything written on a reference line is a decision, everything
326
+ # omitted is doctrine.
327
+ DevDoc/Migration/RequireReferenceForeignKey:
328
+ Description: "Non-polymorphic references must carry `foreign_key: true` (or a `to_table:` hash)."
329
+ Enabled: true
330
+ Include:
331
+ - "db/migrate/*.rb"
332
+ - "db/migrate/**/*.rb"
333
+
334
+ DevDoc/Migration/ExplicitNullOnReference:
335
+ Description: "References must state `null:` explicitly — optional and mandatory are both legitimate."
336
+ Enabled: true
337
+ Include:
338
+ - "db/migrate/*.rb"
339
+ - "db/migrate/**/*.rb"
340
+
341
+ # Autocorrect is safe here (unlike NoBulkChangeTable): on Migration[5.0]+ the
342
+ # index is created either way, so removing `index: true` cannot change the
343
+ # resulting schema — and Migration[4.2] classes, where the compatibility layer
344
+ # flips the default and the option IS load-bearing, are skipped entirely.
345
+ DevDoc/Migration/RedundantReferenceIndex:
346
+ Description: "`index: true` is redundant on references — they are indexed by default since Rails 5."
347
+ Enabled: true
348
+ Include:
349
+ - "db/migrate/*.rb"
350
+ - "db/migrate/**/*.rb"
351
+
296
352
  # This cop is heuristic: it matches column names whose last segment is a known
297
353
  # monetary word. Enable it in your project's .rubocop.yml and extend MonetaryNames
298
354
  # if your domain uses different names. Disabled by default to avoid false positives.
@@ -467,10 +523,29 @@ Rails/SaveBang:
467
523
  # demands trips DevDoc/Migration/AvoidColumnDefault (on the backfill `default:`)
468
524
  # and Rails/ReversibleMigration (on `t.change_default`), neither disable-able
469
525
  # inline, for the standard add-column-then-drop-default backfill — making the
470
- # three rules mutually unsatisfiable. Off for all projects.
526
+ # three rules mutually unsatisfiable. Off for all projects — and the opposite
527
+ # direction is enforced by DevDoc/Migration/NoBulkChangeTable above, so this
528
+ # must stay off wherever that cop is enabled. NOTE: whether this entry reaches
529
+ # a consumer is plugin-load-order dependent — the last-loaded extension's
530
+ # default wins (measured on rubocop 1.86: `plugins: [rubocop-rails,
531
+ # rubocop-dev_doc]` resolves to false, the reverse order to true) — so
532
+ # projects should disable it explicitly in their own .rubocop.yml.
471
533
  Rails/BulkChangeTable:
472
534
  Enabled: false
473
535
 
536
+ # Fires on `add_reference ... null: false` — the exact line complying with
537
+ # DevDoc/Migration/ExplicitNullOnReference produces for a mandatory FK, so the
538
+ # two collide on the same token. Little is lost by turning it off: its
539
+ # add_column half is covered more strictly by DevDoc/Migration/AvoidNonNull,
540
+ # and its add_reference half contradicts house doctrine (mandatory FKs carry
541
+ # `null: false`; a populated-table migration fails loudly at migrate time and
542
+ # uses the add-nullable -> backfill -> tighten pattern instead — note its
543
+ # suggested remedy, `change_column_null`, is itself flagged by AvoidNonNull).
544
+ # Same load-order caveat as Rails/BulkChangeTable above: disable it in the
545
+ # project's .rubocop.yml too.
546
+ Rails/NotNullColumn:
547
+ Enabled: false
548
+
474
549
  # Superseded by DevDoc/Style/CaseElseDecision below: same detection, but the
475
550
  # offense message carries the raise/report/fall-through decision framework
476
551
  # instead of upstream's "Missing else statement" (which teaches the wrong
@@ -0,0 +1,52 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ # Shared autocorrect helper for deleting one pair from a method call's
5
+ # options hash. The removal range is anchored on neighbouring AST nodes
6
+ # rather than walked-over whitespace, so newlines between arguments are
7
+ # handled without corrupting the code.
8
+ #
9
+ # Callers must skip the correction when `pair_removal_range` returns nil
10
+ # or when the range crosses a comment (`crosses_comment?`) — deleting a
11
+ # range that overlaps a comment would corrupt it.
12
+ module HashPairRemoval
13
+ private
14
+
15
+ # For a single-pair hash the preceding argument is the anchor (and a
16
+ # braced hash is removed whole); otherwise the neighbouring pair is.
17
+ # A hash containing a `**kwsplat` is bailed on entirely (nil): the
18
+ # anchors navigate `pairs`, which excludes kwsplats, so a splat between
19
+ # anchor and victim would silently fall inside the deleted range.
20
+ def pair_removal_range(node, options, pair)
21
+ return nil unless options.children.size == options.pairs.size
22
+
23
+ if options.pairs.one?
24
+ lone_pair_range(node, options, pair)
25
+ else
26
+ sibling_pair_range(options, pair)
27
+ end
28
+ end
29
+
30
+ def lone_pair_range(node, options, pair)
31
+ index = node.arguments.index(options)
32
+ return nil if index.zero?
33
+
34
+ victim = options.braces? ? options : pair
35
+ node.arguments[index - 1].source_range.end.join(victim.source_range.end)
36
+ end
37
+
38
+ def sibling_pair_range(options, pair)
39
+ pairs = options.pairs
40
+ index = pairs.index(pair)
41
+ return pair.source_range.begin.join(pairs[1].source_range.begin) if index.zero?
42
+
43
+ pairs[index - 1].source_range.end.join(pair.source_range.end)
44
+ end
45
+
46
+ def crosses_comment?(range)
47
+ processed_source.comments.any? { |comment| comment.source_range.overlaps?(range) }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -90,8 +90,10 @@ module RuboCop
90
90
  #
91
91
  # NOTE: This cop only flags `null: false` (and the equivalent `false` arg
92
92
  # of `change_column_null`). It does not flag `null: true` (redundant but
93
- # harmless), and it does not require foreign keys to carry `null: false`
94
- # — adding it to an FK is encouraged but not enforced here.
93
+ # harmless on regular columns), and it never requires anything of foreign
94
+ # keys whether a reference is mandatory is a genuine per-reference
95
+ # decision, so the sibling cop `DevDoc/Migration/ExplicitNullOnReference`
96
+ # requires that decision to be STATED (either value passes).
95
97
  #
96
98
  # @example
97
99
  # # bad
@@ -0,0 +1,85 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Every reference must state `null:` explicitly.
6
+ #
7
+ # ## Rationale
8
+ # For regular columns the house rule is nullable
9
+ # (`DevDoc/Migration/AvoidNonNull`), so an absent `null:` is
10
+ # unambiguous — silence IS the decision. Foreign-key columns are the
11
+ # exception that `AvoidNonNull` deliberately carves out: optional and
12
+ # mandatory are both completely legitimate, a genuine per-reference
13
+ # business decision. But a legitimate decision that is never written down is
14
+ # indistinguishable from an omission — a bare reference reads as "did
15
+ # the developer forget?".
16
+ #
17
+ # This cop requires the decision to be stated. Either value passes;
18
+ # `null: true` is not redundant here, it is the self-documenting
19
+ # spelling of "optional by design".
20
+ #
21
+ # ❌ Ambiguous — optional by design, or forgotten?
22
+ # t.belongs_to :organization, foreign_key: true
23
+ #
24
+ # ✔️ Mandatory, stated
25
+ # t.belongs_to :organization, foreign_key: true, null: false
26
+ #
27
+ # ✔️ Optional, stated
28
+ # t.belongs_to :organization, foreign_key: true, null: true
29
+ #
30
+ # Polymorphic references are held to the same rule — their
31
+ # optionality is just as much a decision.
32
+ #
33
+ # ## Relationship with Rails/NotNullColumn
34
+ # The stock cop flags `add_reference ... null: false` (NOT NULL added
35
+ # to a possibly-populated table without a default) — the exact line
36
+ # complying with THIS cop produces for a mandatory FK, so the two
37
+ # collide. Consumers disable `Rails/NotNullColumn`: its `add_column`
38
+ # half is covered more strictly by `DevDoc/Migration/AvoidNonNull`,
39
+ # and its `add_reference` half contradicts house doctrine (a
40
+ # populated-table migration fails loudly at migrate time and uses the
41
+ # add-nullable -> backfill -> tighten pattern instead). This gem's
42
+ # default config disables it, but that propagation is load-order
43
+ # dependent (see `NoBulkChangeTable`'s docstring) — disable it in the
44
+ # project's `.rubocop.yml` too.
45
+ #
46
+ # NOTE: This cop enforces that optionality is stated; the sibling cop
47
+ # `DevDoc/Migration/RequireReferenceForeignKey` enforces the
48
+ # constraint's presence, and
49
+ # `DevDoc/Migration/RedundantReferenceIndex` removes the one reference
50
+ # option whose default is already correct. Together: everything
51
+ # written on a reference is a decision, everything omitted is
52
+ # doctrine.
53
+ #
54
+ # @example
55
+ # # bad
56
+ # t.belongs_to :user, foreign_key: true
57
+ #
58
+ # # good
59
+ # t.belongs_to :user, foreign_key: true, null: false
60
+ #
61
+ # # good
62
+ # t.belongs_to :user, foreign_key: true, null: true
63
+ class ExplicitNullOnReference < Base
64
+ MSG = 'State `null:` explicitly — optional and mandatory are both legitimate ' \
65
+ 'for a reference, so omission reads as an unmade decision.'.freeze
66
+
67
+ RESTRICT_ON_SEND = %i[belongs_to references add_reference add_belongs_to].freeze
68
+
69
+ def on_send(node)
70
+ options = node.arguments.find(&:hash_type?)
71
+ return if options && null_stated?(options)
72
+
73
+ add_offense(node.loc.selector)
74
+ end
75
+
76
+ private
77
+
78
+ def null_stated?(options)
79
+ options.pairs.any? { |pair| pair.key.sym_type? && pair.key.value == :null }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,96 @@
1
+ require_relative '../hash_pair_removal'
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DevDoc
6
+ module Migration
7
+ # Avoid the `bulk:` option on `change_table`.
8
+ #
9
+ # ## Rationale
10
+ # `bulk: true` combines multiple ALTER TABLE sub-commands into a single
11
+ # statement. That is a MySQL/MariaDB optimization — it avoids repeated
12
+ # full-table rewrites there — but consuming projects run PostgreSQL,
13
+ # where ALTER sub-commands are cheap metadata operations and `bulk:` is
14
+ # a no-op that only adds noise.
15
+ #
16
+ # Worse, the combined form conflicts with the standard
17
+ # add-column-then-drop-default backfill pattern: inside a `bulk: true`
18
+ # block it trips `DevDoc/Migration/AvoidColumnDefault` (on the backfill
19
+ # `default:`) and `Rails/ReversibleMigration` (on `t.change_default`),
20
+ # neither of which can be disabled inline.
21
+ #
22
+ # ❌
23
+ # change_table :users, bulk: true do |t|
24
+ # t.string :token
25
+ # t.datetime :closed_at
26
+ # end
27
+ #
28
+ # ✔️
29
+ # change_table :users do |t|
30
+ # t.string :token
31
+ # t.datetime :closed_at
32
+ # end
33
+ #
34
+ # ## Relationship with Rails/BulkChangeTable
35
+ # This cop is the exact inverse of `Rails/BulkChangeTable`, which
36
+ # DEMANDS `bulk: true` whenever several ALTERs touch one table. The two
37
+ # cannot both be enabled. This gem's default config disables
38
+ # `Rails/BulkChangeTable`, but whether that default reaches a project
39
+ # is plugin-load-order dependent — the last-loaded extension's default
40
+ # wins, so a project listing `rubocop-dev_doc` before `rubocop-rails`
41
+ # still gets rubocop-rails' `Enabled: true`. Disable it explicitly in
42
+ # the project's `.rubocop.yml` rather than relying on load order.
43
+ #
44
+ # NOTE: When a comment sits between `change_table`'s arguments, the cop
45
+ # flags the option but does not autocorrect (removing the range would
46
+ # corrupt the comment) — delete the option manually.
47
+ #
48
+ # @example
49
+ # # bad
50
+ # change_table :users, bulk: true do |t|
51
+ # t.string :token
52
+ # end
53
+ #
54
+ # # good
55
+ # change_table :users do |t|
56
+ # t.string :token
57
+ # end
58
+ class NoBulkChangeTable < Base
59
+ extend AutoCorrector
60
+ include HashPairRemoval
61
+
62
+ MSG = 'Avoid `bulk:` on `change_table` — a MySQL optimization that is a no-op on PostgreSQL.'.freeze
63
+ RESTRICT_ON_SEND = %i[change_table].freeze
64
+
65
+ def on_send(node)
66
+ options = node.arguments.find(&:hash_type?)
67
+ return unless options
68
+
69
+ options.pairs.each { |pair| check_pair(node, options, pair) }
70
+ end
71
+
72
+ private
73
+
74
+ def check_pair(node, options, pair)
75
+ return unless bulk_key?(pair.key)
76
+
77
+ range = pair_removal_range(node, options, pair)
78
+ if range.nil? || crosses_comment?(range)
79
+ add_offense(pair)
80
+ else
81
+ add_offense(pair) { |corrector| corrector.remove(range) }
82
+ end
83
+ end
84
+
85
+ def bulk_key?(key)
86
+ (key.sym_type? || key.str_type?) && key.value.to_s == 'bulk'
87
+ end
88
+
89
+ def crosses_comment?(range)
90
+ processed_source.comments.any? { |comment| comment.source_range.overlaps?(range) }
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,104 @@
1
+ require_relative '../hash_pair_removal'
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DevDoc
6
+ module Migration
7
+ # Avoid the redundant `index: true` on references.
8
+ #
9
+ # ## Rationale
10
+ # `belongs_to` / `references` / `add_reference` default to
11
+ # `index: true` since Rails 5 — the index is created whether or not
12
+ # the option is written. An explicit `index: true` therefore does
13
+ # nothing except make every future reader wonder whether it does
14
+ # (and imply, wrongly, that references without it are unindexed).
15
+ #
16
+ # The option is only meaningful when it *overrides* the default:
17
+ # `index: false` to opt out, or a hash to customize
18
+ # (`index: { unique: true }`). Those forms pass untouched.
19
+ #
20
+ # ❌ Redundant — this index exists anyway
21
+ # t.belongs_to :user, foreign_key: true, null: false, index: true
22
+ #
23
+ # ✔️
24
+ # t.belongs_to :user, foreign_key: true, null: false
25
+ #
26
+ # ✔️ Meaningful override
27
+ # t.belongs_to :user, foreign_key: true, null: false, index: { unique: true }
28
+ #
29
+ # NOTE: This cop removes the one reference option whose default is
30
+ # already correct; the sibling cops
31
+ # `DevDoc/Migration/RequireReferenceForeignKey` and
32
+ # `DevDoc/Migration/ExplicitNullOnReference` require the two options
33
+ # that ARE decisions to be made and stated. Together: everything
34
+ # written on a reference is a decision, everything omitted is
35
+ # doctrine.
36
+ #
37
+ # NOTE: When a comment sits between the call's arguments, the cop
38
+ # flags the option but does not autocorrect (removing the range would
39
+ # corrupt the comment) — delete the option manually.
40
+ #
41
+ # NOTE: Migrations declared `< ActiveRecord::Migration[4.2]` are
42
+ # skipped entirely: the 4.2 compatibility layer flips the reference
43
+ # default to `index: false`, so there `index: true` is meaningful —
44
+ # removing it would drop the index on a from-zero `db:migrate`.
45
+ #
46
+ # @example
47
+ # # bad
48
+ # t.belongs_to :user, foreign_key: true, index: true
49
+ #
50
+ # # good
51
+ # t.belongs_to :user, foreign_key: true
52
+ #
53
+ # # good (meaningful override)
54
+ # t.belongs_to :user, foreign_key: true, index: { unique: true }
55
+ class RedundantReferenceIndex < Base
56
+ extend AutoCorrector
57
+ include HashPairRemoval
58
+
59
+ MSG = '`index: true` is redundant — references are indexed by default since Rails 5.'.freeze
60
+
61
+ RESTRICT_ON_SEND = %i[belongs_to references add_reference add_belongs_to].freeze
62
+
63
+ def on_send(node)
64
+ options = node.arguments.find(&:hash_type?)
65
+ return unless options
66
+ return if legacy_migration?(node)
67
+
68
+ options.pairs.each { |pair| check_pair(node, options, pair) }
69
+ end
70
+
71
+ private
72
+
73
+ # `ActiveRecord::Migration[4.2]` compatibility flips the reference
74
+ # default to `index: false` (see compatibility.rb's V4_2), so inside
75
+ # such a class `index: true` is load-bearing, not redundant.
76
+ def legacy_migration?(node)
77
+ node.each_ancestor(:class).any? do |klass|
78
+ superclass = klass.children[1]
79
+ next false unless superclass&.send_type? && superclass.method?(:[])
80
+
81
+ version = superclass.first_argument
82
+ version&.numeric_type? && version.value < 5.0
83
+ end
84
+ end
85
+
86
+ def check_pair(node, options, pair)
87
+ return unless index_key?(pair.key) && pair.value.true_type?
88
+
89
+ range = pair_removal_range(node, options, pair)
90
+ if range.nil? || crosses_comment?(range)
91
+ add_offense(pair)
92
+ else
93
+ add_offense(pair) { |corrector| corrector.remove(range) }
94
+ end
95
+ end
96
+
97
+ def index_key?(key)
98
+ (key.sym_type? || key.str_type?) && key.value.to_s == 'index'
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,109 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Every non-polymorphic reference must carry a truthy `foreign_key:`.
6
+ #
7
+ # ## Rationale
8
+ # `foreign_key: true` (or a `to_table:` hash) is pure referential
9
+ # integrity — never a business decision — so the house rule is that
10
+ # every reference carries the DB-level constraint. Rails defaults
11
+ # references to NO constraint, which means both an omitted option and
12
+ # an explicit `foreign_key: false` silently ship an unconstrained
13
+ # column, indistinguishable from an oversight.
14
+ #
15
+ # This cop makes the constraint the enforced default and turns the
16
+ # rare deliberate exception into a documented, greppable site: keep
17
+ # `foreign_key: false` on the line and disable this cop with a brief
18
+ # reason.
19
+ #
20
+ # ❌ Omitted — reads as "never decided"
21
+ # t.belongs_to :author, null: false
22
+ #
23
+ # ❌ Explicit false without justification
24
+ # t.belongs_to :author, null: false, foreign_key: false
25
+ #
26
+ # ✔️
27
+ # t.belongs_to :author, null: false, foreign_key: true
28
+ #
29
+ # ✔️ Cross-table naming
30
+ # add_reference :posts, :reviewer, foreign_key: { to_table: :users }
31
+ #
32
+ # ✔️ Deliberate exception, self-documenting
33
+ # # rubocop:disable DevDoc/Migration/RequireReferenceForeignKey -- append-only audit table
34
+ # t.belongs_to :snapshot, null: false, foreign_key: false
35
+ # # rubocop:enable DevDoc/Migration/RequireReferenceForeignKey
36
+ #
37
+ # ## Exception
38
+ # Polymorphic references (`polymorphic: true`) are skipped — a
39
+ # polymorphic column points at many tables, so no single FK constraint
40
+ # can exist.
41
+ #
42
+ # NOTE: A reference whose constraint is added separately via
43
+ # `add_foreign_key` later in the migration (the pattern engine-vendored
44
+ # migrations use) is still flagged — the cop cannot reliably match the
45
+ # two up. Prefer the inline `foreign_key:` option; for vendored engine
46
+ # migrations, disable with a reason or exclude the file.
47
+ #
48
+ # NOTE: This cop enforces the constraint's presence; the sibling cop
49
+ # `DevDoc/Migration/ExplicitNullOnReference` enforces that the
50
+ # column's optionality is stated, and
51
+ # `DevDoc/Migration/RedundantReferenceIndex` removes the one reference
52
+ # option whose default is already correct. Together: everything
53
+ # written on a reference is a decision, everything omitted is
54
+ # doctrine.
55
+ #
56
+ # @example
57
+ # # bad
58
+ # t.belongs_to :author
59
+ #
60
+ # # bad
61
+ # t.belongs_to :author, foreign_key: false
62
+ #
63
+ # # good
64
+ # t.belongs_to :author, foreign_key: true
65
+ #
66
+ # # good (polymorphic — no constraint possible)
67
+ # t.belongs_to :item, polymorphic: true
68
+ class RequireReferenceForeignKey < Base
69
+ MSG = 'Add `foreign_key: true` (or a `to_table:` hash); a deliberate exception ' \
70
+ 'keeps `foreign_key: false` plus a reasoned disable of this cop.'.freeze
71
+
72
+ RESTRICT_ON_SEND = %i[belongs_to references add_reference add_belongs_to].freeze
73
+
74
+ def on_send(node)
75
+ options = node.arguments.find(&:hash_type?)
76
+ return if polymorphic?(options)
77
+
78
+ pair = foreign_key_pair(options)
79
+ if pair.nil?
80
+ add_offense(node.loc.selector)
81
+ elsif pair.value.false_type? || pair.value.nil_type?
82
+ add_offense(pair)
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ # Any `polymorphic:` value other than literal false/nil counts as
89
+ # polymorphic — a non-literal value can't be resolved statically, and
90
+ # demanding an impossible FK would be a worse failure than skipping.
91
+ def polymorphic?(options)
92
+ return false unless options
93
+
94
+ options.pairs.any? do |pair|
95
+ pair.key.sym_type? && pair.key.value == :polymorphic &&
96
+ !pair.value.false_type? && !pair.value.nil_type?
97
+ end
98
+ end
99
+
100
+ def foreign_key_pair(options)
101
+ return nil unless options
102
+
103
+ options.pairs.find { |pair| pair.key.sym_type? && pair.key.value == :foreign_key }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -38,8 +38,16 @@ module RuboCop
38
38
  # (e.g. projects using `structure.sql`). It also relies on the schema
39
39
  # being current, resolves the table name by Rails convention (STI,
40
40
  # `self.table_name` overrides, or namespaced models may not resolve),
41
- # recognizes only the positional `enum :name, …` form, and skips
42
- # silently if the backing column cannot be found in the schema.
41
+ # recognizes the positional `enum :name, …` form (and glib's
42
+ # `enum_symbolize`; see below), and skips silently if the backing
43
+ # column cannot be found in the schema.
44
+ #
45
+ # Glib's `enum_symbolize :name, { … }` (which declares the enum itself)
46
+ # is recognized too — its presence validation guards the app layer, but
47
+ # this cop is about the DB-level guarantee, which validations cannot
48
+ # provide (`update_column`, `update_all`, `insert_all`, raw SQL). The
49
+ # legacy two-line form (`enum :name, …` + `enum_symbolize :name`) is
50
+ # covered via its bare `enum` line.
43
51
  #
44
52
  # @example
45
53
  # # bad - the `status` column is nullable in db/schema.rb
@@ -51,16 +59,26 @@ module RuboCop
51
59
  # class Order < ApplicationRecord
52
60
  # enum :status, { active: 0, archived: 1 }
53
61
  # end
62
+ #
63
+ # @example Glib's `enum_symbolize`
64
+ # # bad - the same rule applies; the column must still be `null: false`
65
+ # class Order < ApplicationRecord
66
+ # enum_symbolize :status, { active: 0, archived: 1 }
67
+ # end
54
68
  class EnumColumnNotNull < Base
55
69
  include ActiveRecordHelper
56
70
 
57
71
  MSG = 'Enum column `%<name>s` should be `null: false` — NULL is outside an enum\'s domain. ' \
58
72
  'Model "unset" as an explicit enum value.'.freeze
59
73
 
60
- RESTRICT_ON_SEND = %i[enum].freeze
74
+ RESTRICT_ON_SEND = %i[enum enum_symbolize].freeze
61
75
 
76
+ # `enum_symbolize` matches only its full (value-declaring) form; the
77
+ # legacy `enum_symbolize :name` (no values) is not an enum declaration —
78
+ # the bare `enum` line above it is, and is matched separately.
62
79
  def_node_matcher :enum_call, <<~PATTERN
63
- (send nil? :enum (sym $_) ...)
80
+ {(send nil? :enum (sym $_) ...)
81
+ (send nil? :enum_symbolize (sym $_) {hash array} ...)}
64
82
  PATTERN
65
83
 
66
84
  def on_send(node)
@@ -2,7 +2,7 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Style
5
- # Avoid `head()` with error status codes in controllers.
5
+ # Avoid `head()` with error or no-content status codes in controllers.
6
6
  #
7
7
  # ## Rationale
8
8
  # Using `head()` for error responses returns an empty body with no
@@ -10,8 +10,15 @@ module RuboCop
10
10
  # to Rails exceptions (e.g. `ActiveRecord::RecordNotFound`) or model
11
11
  # validations instead, which give the client more context.
12
12
  #
13
- # Success statuses like `:ok`, `:no_content`, and `:accepted` are
14
- # legitimate uses of `head()` and are not flagged.
13
+ # `head :no_content` (and `:reset_content`) is flagged for a different
14
+ # reason: in a backend-driven JSON UI a bodyless "success" is
15
+ # ambiguous — the client needs a response body (even an empty action
16
+ # spec) to know what to do. A production `head :no_content` escape
17
+ # hatch surfaced as an error snackbar in front of a customer before
18
+ # this was flagged.
19
+ #
20
+ # Success statuses that normally carry a body (`:ok`, `:accepted`)
21
+ # are legitimate uses of `head()` and are not flagged.
15
22
  #
16
23
  # The set of flagged statuses is configurable via `FlaggedStatuses:`.
17
24
  #
@@ -26,12 +33,18 @@ module RuboCop
26
33
  # @user = User.find(params[:id])
27
34
  # end
28
35
  #
29
- # ✔️ Success responseempty body is correct here
36
+ # Bodyless successa JSON-driven client learns nothing
30
37
  # def destroy
31
38
  # @resource.destroy!
32
39
  # head :no_content
33
40
  # end
34
41
  #
42
+ # ✔️ Tell the client what to do, even when that is "nothing"
43
+ # def destroy
44
+ # @resource.destroy!
45
+ # render json: {}
46
+ # end
47
+ #
35
48
  # @example
36
49
  # # bad
37
50
  # head(:not_found)
@@ -39,10 +52,10 @@ module RuboCop
39
52
  # # bad
40
53
  # head(:unprocessable_entity)
41
54
  #
42
- # # good (success status not flagged)
55
+ # # bad (bodyless success — ambiguous to a JSON-driven client)
43
56
  # head(:no_content)
44
57
  #
45
- # # good (success status — not flagged)
58
+ # # good (success status with a body expected — not flagged)
46
59
  # head(:ok)
47
60
  #
48
61
  # # good (dynamic status — not flagged to avoid false positives)
@@ -51,13 +64,18 @@ module RuboCop
51
64
  MSG = 'Avoid `head(%<status>s)` for error handling. ' \
52
65
  'Delegate to Rails exceptions or model validations instead.'.freeze
53
66
 
67
+ NO_CONTENT_MSG = 'Avoid `head(%<status>s)`: a bodyless response tells a JSON-driven ' \
68
+ 'client nothing. Render an action response (even an empty one) instead.'.freeze
69
+
54
70
  RESTRICT_ON_SEND = %i[head].freeze
55
71
 
56
- DEFAULT_FLAGGED_STATUSES = %w[
72
+ NO_CONTENT_STATUSES = %w[no_content reset_content 204 205].freeze
73
+
74
+ DEFAULT_FLAGGED_STATUSES = (%w[
57
75
  not_found unprocessable_entity forbidden unauthorized
58
76
  bad_request conflict gone method_not_allowed
59
77
  404 422 403 401 400 409 410 405
60
- ].freeze
78
+ ] + NO_CONTENT_STATUSES).freeze
61
79
 
62
80
  def on_send(node)
63
81
  # `self.head(...)` is the same controller call — a bare receiver
@@ -68,7 +86,8 @@ module RuboCop
68
86
  return unless status_node
69
87
  return unless flagged_literal?(status_node)
70
88
 
71
- add_offense(node.loc.selector, message: format(MSG, status: status_display(status_node)))
89
+ template = NO_CONTENT_STATUSES.include?(status_node.value.to_s) ? NO_CONTENT_MSG : MSG
90
+ add_offense(node.loc.selector, message: format(template, status: status_display(status_node)))
72
91
  end
73
92
 
74
93
  private
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.13.1".freeze
3
+ VERSION = "0.14.0".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-25 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -110,6 +110,7 @@ files:
110
110
  - lib/rubocop/cop/dev_doc/auth/no_record_presence_in_policy.rb
111
111
  - lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb
112
112
  - lib/rubocop/cop/dev_doc/auth/unscoped_find_justification.rb
113
+ - lib/rubocop/cop/dev_doc/hash_pair_removal.rb
113
114
  - lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb
114
115
  - lib/rubocop/cop/dev_doc/i18n/localizable_props.rb
115
116
  - lib/rubocop/cop/dev_doc/i18n/report_text.rb
@@ -124,9 +125,13 @@ files:
124
125
  - lib/rubocop/cop/dev_doc/migration/avoid_vague_column_names.rb
125
126
  - lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb
126
127
  - lib/rubocop/cop/dev_doc/migration/date_column_naming.rb
128
+ - lib/rubocop/cop/dev_doc/migration/explicit_null_on_reference.rb
129
+ - lib/rubocop/cop/dev_doc/migration/no_bulk_change_table.rb
127
130
  - lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb
128
131
  - lib/rubocop/cop/dev_doc/migration/prefer_belongs_to.rb
132
+ - lib/rubocop/cop/dev_doc/migration/redundant_reference_index.rb
129
133
  - lib/rubocop/cop/dev_doc/migration/require_primary_key.rb
134
+ - lib/rubocop/cop/dev_doc/migration/require_reference_foreign_key.rb
130
135
  - lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
131
136
  - lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb
132
137
  - lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb