rubocop-dev_doc 0.5.0 → 0.7.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: 2076123d07503e775a29287eafcd8dc9d8cf6e344eeb4b462d29120fdef12879
4
- data.tar.gz: 910414d217e37c808b7626add2b876d24f6ef214a725b373e86dc736acb24ff7
3
+ metadata.gz: 2d7cebd4d2c1b85850d5a704dc26319f49526040c26d2573824f68461d42ae8c
4
+ data.tar.gz: 5fede674c17c29dd330e9499a1367c3f1174902b0a76c44d94c7f6683a35d130
5
5
  SHA512:
6
- metadata.gz: 7681307dae1b8d09d0344c199d4be497bc4c986b4314711ae39550d7d8ced8d8b811d07d98997f8e3fc85d294e6fde1b97c8cb6cdc3c33a40ad3074cda985d62
7
- data.tar.gz: a047b648abfcaeb1842158a562c17028601a2f24c67e1f0aa2cdc548377cccd1658dae4b6b68cee1cb4906275a59822f2870160380e71cb5d730c04ce5d44196
6
+ metadata.gz: 7b3666259413a6d37a6d13765c095005fd8395f1033a6d33de9539616b75d0e3d5091b26ab27ade88069ecd9c678231bd3126f116eb60a36db4e1220c50fefde
7
+ data.tar.gz: 2fcbae3b394369ab9e969a0edc7f1b7ba2379b4572f73e3311228fe265ab29285ebe34a7635d43f7bf02c9ab8e56921454b94817323c723a473fc37e20999726
data/config/default.yml CHANGED
@@ -7,6 +7,10 @@ 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
+
10
14
  DevDoc/Migration:
11
15
  DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
12
16
  DocumentationExtension: ".md"
@@ -62,6 +66,20 @@ DevDoc/Migration/AvoidNonNull:
62
66
  - "db/migrate/*.rb"
63
67
  - "db/migrate/**/*.rb"
64
68
 
69
+ DevDoc/Migration/AvoidBooleanColumn:
70
+ Description: "Avoid `boolean` columns; prefer a timestamp, enum, or model method that better models the data."
71
+ Enabled: true
72
+ Include:
73
+ - "db/migrate/*.rb"
74
+ - "db/migrate/**/*.rb"
75
+
76
+ DevDoc/Migration/BooleanColumnNotNull:
77
+ Description: "Boolean columns must carry `null: false` — NULL is outside {true, false}."
78
+ Enabled: true
79
+ Include:
80
+ - "db/migrate/*.rb"
81
+ - "db/migrate/**/*.rb"
82
+
65
83
  # Intentionally global (no Include) — these patterns are risky in any file, not only migrations.
66
84
  # Add project-specific Exclude entries (e.g. db/seeds.rb, lib/tasks/**/*.rb) in your .rubocop.yml
67
85
  # for places where bulk operations are intentional and performance-critical.
@@ -396,3 +414,150 @@ DevDoc/Auth/CurrentUserBranching:
396
414
  - "app/helpers/**/*.rb"
397
415
  - "app/controllers/concerns/**/*.rb"
398
416
  - "app/views/layouts/**/*"
417
+
418
+ DevDoc/I18n/AvoidTitleizeHumanize:
419
+ Description: "Avoid `.titleize`/`.humanize` for display text; it's English-only and bypasses I18n. Use `t(...)`."
420
+ # A review aid, not a clean lint: it can't tell a display string from one
421
+ # used to build a key or a log line. Off by default and `warning` severity;
422
+ # run it during a localization pass. The flagged methods are configurable
423
+ # via `Methods:`.
424
+ Enabled: false
425
+ Severity: warning
426
+ Methods:
427
+ - titleize
428
+ - humanize
429
+ Include:
430
+ - "app/views/**/*.jbuilder"
431
+ - "app/views/**/*.rb"
432
+
433
+ DevDoc/I18n/RequireTranslation:
434
+ Description: "Localize user-facing strings in glib JSON-UI props; pass `t(...)` instead of a hardcoded string."
435
+ Enabled: false
436
+ # WatchedMethods: glib component builder methods whose hash props carry
437
+ # user-facing text. LocalizableKeys: the prop names checked on those calls.
438
+ # Extend both if your project adds custom components or text props.
439
+ WatchedMethods:
440
+ - h1
441
+ - h2
442
+ - h3
443
+ - h4
444
+ - h5
445
+ - h6
446
+ - p
447
+ - label
448
+ - markdown
449
+ - html
450
+ - button
451
+ - switch
452
+ - chip
453
+ - progressCircle
454
+ - shareButton
455
+ - fields_text
456
+ - fields_number
457
+ - fields_select
458
+ - fields_password
459
+ - fields_textarea
460
+ - fields_check
461
+ - fields_checkGroup
462
+ - fields_chipGroup
463
+ - fields_timeZone
464
+ - fields_radioGroup
465
+ - fields_date
466
+ - fields_datetime
467
+ - fields_upload
468
+ - dialogs_alert
469
+ - dialogs_notification
470
+ - snackbars_alert
471
+ - snackbars_select
472
+ - banners_alert
473
+ - banners_select
474
+ LocalizableKeys:
475
+ - title
476
+ - subtitle
477
+ - subsubtitle
478
+ - label
479
+ - placeholder
480
+ - text
481
+ - message
482
+ - description
483
+ - uploadText
484
+ - leftText
485
+ - rightText
486
+ - onLabel
487
+ Include:
488
+ - "app/views/**/*.jbuilder"
489
+ - "app/views/**/*.rb"
490
+
491
+ DevDoc/I18n/TranslationKeyPrefix:
492
+ Description: "Translation keys must start with an allowed namespace prefix (e.g. `hotel.`, `general.`)."
493
+ Enabled: false
494
+ # AllowedPrefixes is project-specific, so it defaults to empty and the cop is
495
+ # a no-op until configured. Matches `t`, `translate`, and `I18n.t`. Only
496
+ # statically-literal keys are checked; dynamic/interpolated keys are skipped.
497
+ AllowedPrefixes: []
498
+ Include:
499
+ - "app/views/**/*.jbuilder"
500
+ - "app/views/**/*.rb"
501
+ - "app/controllers/**/*.rb"
502
+ - "app/mailers/**/*.rb"
503
+ - "app/helpers/**/*.rb"
504
+
505
+ DevDoc/I18n/ReportText:
506
+ Description: "Report every user-facing glib text prop — hardcoded and already-localized — to collect all possible texts."
507
+ # A tooling aid, not a lint: unlike RequireTranslation it fires on *every*
508
+ # text value (including `t(...)` calls) so you can sweep the codebase and
509
+ # collect the full list of user-facing strings. Off by default and `info`
510
+ # severity. Mirrors RequireTranslation's WatchedMethods/LocalizableKeys.
511
+ Enabled: false
512
+ Severity: info
513
+ WatchedMethods:
514
+ - h1
515
+ - h2
516
+ - h3
517
+ - h4
518
+ - h5
519
+ - h6
520
+ - p
521
+ - label
522
+ - markdown
523
+ - html
524
+ - button
525
+ - switch
526
+ - chip
527
+ - progressCircle
528
+ - shareButton
529
+ - fields_text
530
+ - fields_number
531
+ - fields_select
532
+ - fields_password
533
+ - fields_textarea
534
+ - fields_check
535
+ - fields_checkGroup
536
+ - fields_chipGroup
537
+ - fields_timeZone
538
+ - fields_radioGroup
539
+ - fields_date
540
+ - fields_datetime
541
+ - fields_upload
542
+ - dialogs_alert
543
+ - dialogs_notification
544
+ - snackbars_alert
545
+ - snackbars_select
546
+ - banners_alert
547
+ - banners_select
548
+ LocalizableKeys:
549
+ - title
550
+ - subtitle
551
+ - subsubtitle
552
+ - label
553
+ - placeholder
554
+ - text
555
+ - message
556
+ - description
557
+ - uploadText
558
+ - leftText
559
+ - rightText
560
+ - onLabel
561
+ Include:
562
+ - "app/views/**/*.jbuilder"
563
+ - "app/views/**/*.rb"
@@ -0,0 +1,59 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module I18n
5
+ # Warn when `.titleize` or `.humanize` is used to derive display text.
6
+ #
7
+ # ## Rationale
8
+ # `titleize`/`humanize` turn an identifier into an English phrase
9
+ # (`'sign_up'.titleize # => 'Sign Up'`). When that result is shown to a
10
+ # user it bypasses the I18n catalog and can only ever read as English —
11
+ # it can't be translated. Localize the text with a `t(...)` lookup
12
+ # instead of transforming a symbol/column name at render time.
13
+ #
14
+ # This can't tell a display string from one used to build a key or a
15
+ # log line, so it is a **review aid**: disabled by default, `warning`
16
+ # severity, and scoped to view files. Run it during a localization pass.
17
+ #
18
+ # ⚠️ English-only — can't be translated
19
+ # view.h1 text: status.titleize
20
+ # view.p text: user.role.humanize
21
+ #
22
+ # ✔️ Resolved through I18n
23
+ # view.h1 text: t("status.#{status}")
24
+ #
25
+ # @example
26
+ # # warning
27
+ # view.h1 text: status.titleize
28
+ #
29
+ # # warning
30
+ # view.p text: model_name.humanize
31
+ #
32
+ # # good
33
+ # view.h1 text: t("status.#{status}")
34
+ class AvoidTitleizeHumanize < Base
35
+ MSG = 'Avoid `.%<method>s` for display text: the result is ' \
36
+ 'English-only and bypasses I18n. Use `t(...)` instead.'.freeze
37
+
38
+ DEFAULT_METHODS = %w[titleize humanize].freeze
39
+
40
+ def on_send(node)
41
+ return unless forbidden_methods.include?(node.method_name.to_s)
42
+
43
+ add_offense(
44
+ node.loc.selector,
45
+ message: format(MSG, method: node.method_name)
46
+ )
47
+ end
48
+ alias on_csend on_send
49
+
50
+ private
51
+
52
+ def forbidden_methods
53
+ cop_config.fetch('Methods', DEFAULT_METHODS)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,109 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module I18n
5
+ # Shared traversal for the glib JSON-UI localization cops
6
+ # (`RequireTranslation`, `ReportText`).
7
+ #
8
+ # It locates user-facing text values on watched glib component calls and
9
+ # hands each `(key, value_node)` to the including cop's
10
+ # `#inspect_localizable`, which decides whether to report. Three call
11
+ # shapes are covered:
12
+ #
13
+ # * hash prop: `view.p text: '...'`
14
+ # * nested hash prop: `view.icon tooltip: { text: '...' }`
15
+ # * jbuilder positional: `json.title '...'`
16
+ #
17
+ # Nested hashes (and hashes inside arrays, e.g. select options) are
18
+ # walked to any depth, so a localizable key is found wherever it sits.
19
+ #
20
+ # The watched method names and localizable keys are configurable via
21
+ # `WatchedMethods:` and `LocalizableKeys:`.
22
+ module LocalizableProps
23
+ DEFAULT_WATCHED_METHODS = %w[
24
+ h1 h2 h3 h4 h5 h6 p label markdown html
25
+ button switch chip progressCircle shareButton
26
+ fields_text fields_number fields_select fields_password
27
+ fields_textarea fields_check fields_checkGroup fields_chipGroup
28
+ fields_timeZone fields_radioGroup fields_date fields_datetime
29
+ fields_upload
30
+ dialogs_alert dialogs_notification
31
+ snackbars_alert snackbars_select
32
+ banners_alert banners_select
33
+ ].freeze
34
+
35
+ DEFAULT_LOCALIZABLE_KEYS = %w[
36
+ title subtitle subsubtitle label placeholder text
37
+ message description uploadText leftText rightText onLabel
38
+ ].freeze
39
+
40
+ TRANSLATION_METHODS = %i[t translate].freeze
41
+
42
+ def on_send(node)
43
+ if watched_methods.include?(node.method_name.to_s)
44
+ node.arguments.each { |arg| each_localizable(arg) }
45
+ end
46
+
47
+ check_jbuilder_positional(node)
48
+ end
49
+ alias on_csend on_send
50
+
51
+ private
52
+
53
+ # Walk a value, handing every localizable `(key, value)` pair found at
54
+ # any depth to the cop: top-level hash pairs, nested hashes, and
55
+ # hashes nested inside arrays (e.g. select options / menu buttons).
56
+ def each_localizable(node)
57
+ case node.type
58
+ when :hash
59
+ node.pairs.each do |pair|
60
+ key = pair.key
61
+ if key.sym_type? && localizable_keys.include?(key.value.to_s)
62
+ inspect_localizable(key.value, pair.value)
63
+ end
64
+
65
+ each_localizable(pair.value)
66
+ end
67
+ when :array
68
+ node.children.each { |child| each_localizable(child) }
69
+ end
70
+ end
71
+
72
+ # jbuilder positional form: `json.title 'Forms'`. The method name is
73
+ # the key and the first argument is the value. Restricted to the
74
+ # jbuilder root `json` so ordinary `obj.text('...')` calls aren't
75
+ # mistaken for localizable props.
76
+ def check_jbuilder_positional(node)
77
+ return unless localizable_keys.include?(node.method_name.to_s)
78
+ return unless json_receiver?(node.receiver)
79
+
80
+ value = node.first_argument
81
+ return unless value
82
+
83
+ inspect_localizable(node.method_name, value)
84
+ end
85
+
86
+ def json_receiver?(node)
87
+ return false unless node
88
+
89
+ (node.send_type? && node.method_name == :json && node.receiver.nil?) ||
90
+ (node.lvar_type? && node.children.first == :json)
91
+ end
92
+
93
+ def translation_call?(node)
94
+ (node.send_type? || node.csend_type?) &&
95
+ TRANSLATION_METHODS.include?(node.method_name)
96
+ end
97
+
98
+ def watched_methods
99
+ cop_config.fetch('WatchedMethods', DEFAULT_WATCHED_METHODS)
100
+ end
101
+
102
+ def localizable_keys
103
+ cop_config.fetch('LocalizableKeys', DEFAULT_LOCALIZABLE_KEYS)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,76 @@
1
+ require_relative 'localizable_props'
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DevDoc
6
+ module I18n
7
+ # Report every user-facing text in a glib JSON-UI text prop — both
8
+ # hardcoded strings and already-localized `t(...)` calls.
9
+ #
10
+ # ## Rationale
11
+ # `DevDoc/I18n/RequireTranslation` flags only *hardcoded* strings; it
12
+ # stays silent once a value is localized. This cop is the opposite: it
13
+ # fires on **every** text value, localized or not, so you can sweep a
14
+ # codebase and collect the full list of user-facing strings (e.g. to
15
+ # seed a translation catalog or audit coverage).
16
+ #
17
+ # It is a tooling aid, not a lint — **disabled by default** and runs at
18
+ # `info` severity. Run it during a localization pass; it is not meant
19
+ # for every commit.
20
+ #
21
+ # Both the hardcoded form (`view.p text: 'Welcome'`) and the localized
22
+ # form (`view.p text: t('home.welcome')`) are reported. Blank/whitespace
23
+ # strings and pure dynamic values (`user.name`) carry no static text and
24
+ # are skipped.
25
+ #
26
+ # The watched method names and localizable keys are configurable via
27
+ # `WatchedMethods:` and `LocalizableKeys:`.
28
+ #
29
+ # 📋 Reported — hardcoded text
30
+ # view.p text: 'Welcome'
31
+ #
32
+ # 📋 Reported — localized text
33
+ # view.p text: t('home.welcome')
34
+ #
35
+ # @example
36
+ # # info (hardcoded text)
37
+ # view.p text: 'Welcome'
38
+ #
39
+ # # info (localized text — still reported)
40
+ # view.p text: t('home.welcome')
41
+ #
42
+ # # info (interpolated string)
43
+ # view.p text: "Hi #{name}"
44
+ #
45
+ # # ignored (blank — no text)
46
+ # view.fields_text label: ''
47
+ #
48
+ # # ignored (pure dynamic — no static text)
49
+ # view.p text: user.name
50
+ class ReportText < Base
51
+ include LocalizableProps
52
+
53
+ MSG = 'Text for `%<key>s:`: review/collect this for localization.'.freeze
54
+
55
+ private
56
+
57
+ def inspect_localizable(key, value)
58
+ return unless text?(value)
59
+
60
+ add_offense(value, message: format(MSG, key: key))
61
+ end
62
+
63
+ # Any value that carries static user-facing text: a non-blank string
64
+ # literal, an interpolated string, or a translation call. Blank strings
65
+ # and pure dynamic values (`user.name`) carry no text and are skipped.
66
+ def text?(node)
67
+ return true if node.dstr_type?
68
+ return !node.value.strip.empty? if node.str_type?
69
+
70
+ translation_call?(node)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,95 @@
1
+ require_relative 'localizable_props'
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DevDoc
6
+ module I18n
7
+ # Flag hardcoded user-facing strings in glib JSON-UI component props.
8
+ #
9
+ # ## Rationale
10
+ # Glib components render text from props like `text:`, `label:`, and
11
+ # `title:`. Passing a string literal ships untranslatable copy — it can
12
+ # never be localized and bypasses the I18n catalog. Pass `t('...')` (or
13
+ # any non-literal) so the text resolves through the locale files.
14
+ #
15
+ # Hash props (`view.p text: '...'`), nested hash props
16
+ # (`view.icon tooltip: { text: '...' }`) and the jbuilder positional
17
+ # form (`json.title '...'`) are all checked. Empty/whitespace strings
18
+ # are ignored — they carry no user-facing text. An interpolated string
19
+ # (`"Hi #{name}"`) is flagged because its literal portions are still
20
+ # hardcoded copy — unless it interpolates a `t(...)` (or
21
+ # `translate`/`I18n.t`) call, which is treated as positive localization
22
+ # and left alone.
23
+ #
24
+ # The watched method names and localizable keys are configurable via
25
+ # `WatchedMethods:` and `LocalizableKeys:`.
26
+ #
27
+ # ❌ Hardcoded — can't be translated
28
+ # view.p text: 'Welcome'
29
+ # view.fields_text label: 'Email'
30
+ # view.icon tooltip: { text: 'Share' }
31
+ # json.title 'Forms'
32
+ #
33
+ # ✔️ Resolved through I18n
34
+ # view.p text: t('home.welcome')
35
+ # view.fields_text label: t('user.email')
36
+ # view.p text: "#{t('home.welcome')}, #{user.name}"
37
+ #
38
+ # @example
39
+ # # bad
40
+ # view.p text: 'Welcome'
41
+ #
42
+ # # bad
43
+ # view.fields_text label: 'Email'
44
+ #
45
+ # # bad (nested hash prop)
46
+ # view.icon tooltip: { text: 'Share' }
47
+ #
48
+ # # bad (jbuilder positional)
49
+ # json.title 'Forms'
50
+ #
51
+ # # bad (interpolation, but no translation call)
52
+ # view.p text: "Hi #{name}"
53
+ #
54
+ # # good
55
+ # view.p text: t('home.welcome')
56
+ #
57
+ # # good (interpolates a translation call)
58
+ # view.p text: "#{t('home.greeting')} #{user.name}"
59
+ #
60
+ # # good (non-literal — not flagged)
61
+ # view.p text: user.name
62
+ class RequireTranslation < Base
63
+ include LocalizableProps
64
+
65
+ MSG = 'Localize this string: pass `t(...)` instead of a hardcoded ' \
66
+ 'string for `%<key>s:`.'.freeze
67
+
68
+ private
69
+
70
+ def inspect_localizable(key, value)
71
+ return unless hardcoded_string?(value)
72
+
73
+ add_offense(value, message: format(MSG, key: key))
74
+ end
75
+
76
+ # A plain string literal is hardcoded copy unless it's blank. A `dstr`
77
+ # (interpolated string) is hardcoded copy too — unless it interpolates
78
+ # a translation call, which counts as positive localization.
79
+ def hardcoded_string?(node)
80
+ return !localized_interpolation?(node) if node.dstr_type?
81
+ return false unless node.str_type?
82
+
83
+ !node.value.strip.empty?
84
+ end
85
+
86
+ def localized_interpolation?(node)
87
+ node.each_descendant(:send, :csend).any? do |call|
88
+ TRANSLATION_METHODS.include?(call.method_name)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,89 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module I18n
5
+ # Require translation keys to start with an allowed namespace prefix.
6
+ #
7
+ # ## Rationale
8
+ # A flat translation catalog drifts into collisions and dead keys.
9
+ # Requiring every `t(...)` key to start with an agreed namespace (e.g.
10
+ # `hotel.`, `general.`) keeps the locale files organized and makes it
11
+ # obvious which feature owns a key.
12
+ #
13
+ # The allowed prefixes are project-specific, so `AllowedPrefixes:`
14
+ # defaults to empty and the cop does nothing until a project configures
15
+ # it. Matches `t`, `translate`, and `I18n.t` (the receiver is ignored).
16
+ #
17
+ # Only statically-literal keys are checked. A key built from
18
+ # interpolation (`t("#{prefix}.foo")`) or a variable (`t(key)`) can't be
19
+ # verified and is skipped. A key that begins with a literal segment
20
+ # (`t("hotel.#{id}")`) is checked against that leading segment.
21
+ #
22
+ # ❌ No recognized prefix
23
+ # t('welcome.title')
24
+ #
25
+ # ✔️ Namespaced
26
+ # t('general.welcome.title')
27
+ # t('hotel.rooms.heading')
28
+ #
29
+ # @example AllowedPrefixes: ['hotel.', 'general.']
30
+ # # bad
31
+ # t('welcome.title')
32
+ #
33
+ # # bad (lazy key has no namespace)
34
+ # t('.title')
35
+ #
36
+ # # good
37
+ # t('hotel.rooms.heading')
38
+ #
39
+ # # good (variable suffix, literal prefix is checked)
40
+ # t("general.#{key}")
41
+ #
42
+ # # not checked (fully dynamic key)
43
+ # t("#{prefix}.title")
44
+ class TranslationKeyPrefix < Base
45
+ MSG = 'Translation key `%<key>s` must start with an allowed ' \
46
+ 'prefix: %<prefixes>s.'.freeze
47
+
48
+ RESTRICT_ON_SEND = %i[t translate].freeze
49
+
50
+ def on_send(node)
51
+ prefixes = allowed_prefixes
52
+ return if prefixes.empty?
53
+
54
+ key_node = node.first_argument
55
+ return unless key_node
56
+
57
+ leading = leading_literal(key_node)
58
+ return if leading.nil?
59
+ return if prefixes.any? { |prefix| leading.start_with?(prefix) }
60
+
61
+ add_offense(key_node, message: format(MSG, key: key_node.source, prefixes: prefixes_display(prefixes)))
62
+ end
63
+
64
+ private
65
+
66
+ # The statically-known leading portion of the key, or nil when the key
67
+ # is dynamic (a variable, or a string that begins with interpolation).
68
+ def leading_literal(node)
69
+ case node.type
70
+ when :str then node.value
71
+ when :sym then node.value.to_s
72
+ when :dstr
73
+ first = node.children.first
74
+ first&.str_type? ? first.value : nil
75
+ end
76
+ end
77
+
78
+ def allowed_prefixes
79
+ Array(cop_config['AllowedPrefixes'])
80
+ end
81
+
82
+ def prefixes_display(prefixes)
83
+ prefixes.map { |prefix| "`#{prefix}`" }.join(', ')
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,135 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Avoid `boolean` columns; prefer an alternative that better models
6
+ # the data.
7
+ #
8
+ # ## Rationale
9
+ # A boolean column collapses a domain into exactly two states, but
10
+ # most "is X?" questions are richer than that in practice. The
11
+ # constraint pushes developers past the path-of-least-resistance
12
+ # `add_column :boolean` toward a shape that captures what the data
13
+ # actually means.
14
+ #
15
+ # The question to ask before reaching for `t.boolean` is:
16
+ # "what does NULL mean here?" — and if the answer is "unset" or
17
+ # "not yet decided", that semantic should be explicit in the
18
+ # schema, not silently smuggled in as a third boolean state.
19
+ #
20
+ # Consider the alternatives in order:
21
+ #
22
+ # ### 1. Timestamp — when the flag implies a moment in time
23
+ # If the boolean answers "did X happen?", a timestamp gives both
24
+ # the answer (`approved_at.present?`) AND the moment it happened
25
+ # (`approved_at`), for free. A boolean gives only the first.
26
+ #
27
+ # ❌ `approved` loses the moment of approval
28
+ # add_column :submissions, :approved, :boolean
29
+ #
30
+ # ✔️ `approved_at` answers both "is it approved?" and "when?"
31
+ # add_column :submissions, :approved_at, :datetime
32
+ #
33
+ # This pattern dominates the codebase (`published_at`, `deleted_at`,
34
+ # `archived_at`, `completed_at`, etc.) for good reason.
35
+ #
36
+ # ### 2. Enum — when "unset" is a meaningful state, or a third
37
+ # value is plausible
38
+ # An enum with an explicit "no_X_needed" value models "unset"
39
+ # honestly. NULL is outside an enum's domain (a type violation),
40
+ # so the column can carry `null: false` without controversy — see
41
+ # `DevDoc/Rails/EnumColumnNotNull`.
42
+ #
43
+ # ❌ nullable boolean; nil is a silent third state
44
+ # add_column :checklists, :approval_required, :boolean
45
+ #
46
+ # ✔️ enum; "unset" is explicit, null: false is justified
47
+ # # rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
48
+ # add_column :checklists, :approval_requirement, :integer, null: false
49
+ # # rubocop:enable DevDoc/Migration/AvoidNonNull
50
+ #
51
+ # class Checklist < ApplicationRecord
52
+ # enum :approval_requirement, { no_approval_needed: 0, approval_by_admins: 1 }
53
+ # end
54
+ #
55
+ # ### 3. Model method — when the flag is derived from other data
56
+ # If the answer can be computed from existing columns or
57
+ # associations, do not store it. A method keeps the source of
58
+ # truth singular.
59
+ #
60
+ # ❌ denormalises role into a column
61
+ # add_column :users, :is_admin, :boolean
62
+ #
63
+ # ✔️ derived from `role`
64
+ # class User < ApplicationRecord
65
+ # def admin?
66
+ # role == "admin"
67
+ # end
68
+ # end
69
+ #
70
+ # ### 4. Boolean — only when none of the above apply
71
+ # A genuine binary preference with no meaningful third state
72
+ # (e.g. `auto_renew`, `communication_allowed`). Inline-disable
73
+ # this cop with a brief `-- boolean` reason. The column MUST also
74
+ # carry `null: false` — enforced by the sibling cop
75
+ # `DevDoc/Migration/BooleanColumnNotNull`.
76
+ #
77
+ # ✔️ true binary preference, justified
78
+ # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference; user opt-in
79
+ # add_column :service_subscriptions, :auto_renew, :boolean, null: false
80
+ # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
81
+ #
82
+ # NOTE: This cop catches `t.boolean`, `add_column ..., :boolean`,
83
+ # and `change_column ..., :boolean` (a type change to boolean).
84
+ # `change_column_type` migrations to a non-boolean type are not
85
+ # flagged — that is the cleanup direction.
86
+ #
87
+ # NOTE: This cop is deliberately NOT timestamp-aware. It cannot
88
+ # tell whether a `:datetime` column would have been a better fit;
89
+ # that judgment is the developer's at review time. The role of
90
+ # this cop is to force the moment of judgment, not to make it.
91
+ #
92
+ # @example
93
+ # # bad
94
+ # add_column :users, :is_admin, :boolean
95
+ #
96
+ # # bad
97
+ # t.boolean :approved
98
+ #
99
+ # # good (timestamp alternative)
100
+ # add_column :submissions, :approved_at, :datetime
101
+ #
102
+ # # good (enum alternative)
103
+ # add_column :checklists, :approval_requirement, :integer
104
+ #
105
+ # # good (justified boolean)
106
+ # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
107
+ # add_column :service_subscriptions, :auto_renew, :boolean, null: false
108
+ # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
109
+ class AvoidBooleanColumn < Base
110
+ MSG = 'Avoid `boolean` columns; consider a timestamp (approved_at), enum, or model method. ' \
111
+ 'If a boolean is genuinely the right shape, disable this cop with a brief `-- boolean` reason ' \
112
+ 'and ensure the column carries `null: false` (see DevDoc/Migration/BooleanColumnNotNull).'.freeze
113
+
114
+ def_node_matcher :boolean_column?, <<~PATTERN
115
+ (send _ {:boolean} ...)
116
+ PATTERN
117
+
118
+ def_node_matcher :add_column_boolean?, <<~PATTERN
119
+ (send _ :add_column _ _ (sym :boolean) ...)
120
+ PATTERN
121
+
122
+ def_node_matcher :change_column_to_boolean?, <<~PATTERN
123
+ (send _ :change_column _ _ (sym :boolean) ...)
124
+ PATTERN
125
+
126
+ def on_send(node)
127
+ return unless boolean_column?(node) || add_column_boolean?(node) || change_column_to_boolean?(node)
128
+
129
+ add_offense(node)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -26,6 +26,12 @@ module RuboCop
26
26
  # ❌ Regular column
27
27
  # add_column :users, :profile_completion_rate, :float, null: false
28
28
  #
29
+ # ❌ Tightening an existing column to NOT NULL
30
+ # change_column_null :users, :name, false
31
+ #
32
+ # ❌ Same tightening, via change_column
33
+ # change_column :users, :name, :string, null: false
34
+ #
29
35
  # ✔️ Regular column
30
36
  # add_column :users, :profile_completion_rate, :float
31
37
  #
@@ -47,6 +53,12 @@ module RuboCop
47
53
  # integer, so THIS cop cannot detect it and WILL flag it. Disable it on
48
54
  # the line with a brief reason — `-- enum` — so the migration is
49
55
  # self-documenting: a reader sees at a glance that the column is an enum.
56
+ # - **Boolean columns justified via `DevDoc/Migration/AvoidBooleanColumn`**
57
+ # — once the developer has justified a boolean through that cop's
58
+ # escape hatch, NULL is outside {true, false} (just as it is outside an
59
+ # enum's domain), so `null: false` is required and enforced by the
60
+ # sibling cop `DevDoc/Migration/BooleanColumnNotNull`. Disable this cop
61
+ # on the line with `-- boolean` so the migration is self-documenting.
50
62
  #
51
63
  # ✔️ Required foreign key (never flagged)
52
64
  # t.belongs_to :user, null: false, foreign_key: true
@@ -56,20 +68,41 @@ module RuboCop
56
68
  # add_column :orders, :status, :integer, null: false
57
69
  # # rubocop:enable DevDoc/Migration/AvoidNonNull
58
70
  #
71
+ # ✔️ Boolean (flagged here — disable with a brief `-- boolean` reason)
72
+ # # rubocop:disable DevDoc/Migration/AvoidNonNull -- boolean
73
+ # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
74
+ # add_column :things, :flag, :boolean, null: false
75
+ # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
76
+ # # rubocop:enable DevDoc/Migration/AvoidNonNull
77
+ #
59
78
  # NOTE: This cop is deliberately NOT enum-aware. It could read the
60
79
  # model's `enum` declarations and skip those columns, but requiring an
61
80
  # explicit per-line disable is intentional: it forces the developer to
62
81
  # signal that the column is an enum, which documents the migration. A
63
82
  # silent skip would hide that intent.
64
83
  #
65
- # NOTE: This cop only flags `null: false`. It does not flag `null: true`
66
- # (redundant but harmless), and it does not require foreign keys to carry
67
- # `null: false` adding it to an FK is encouraged but not enforced here.
84
+ # NOTE: This cop also flags NOT NULL set on an existing column, by either
85
+ # API: `change_column_null(table, column, false)` and
86
+ # `change_column(table, column, type, null: false)`. Both express the same
87
+ # constraint as `null: false` on a definition (the add-nullable -> backfill
88
+ # -> tighten step), so a legit enum/boolean tightening disables this cop
89
+ # inline with `-- enum`/`-- boolean`, exactly as for a new column.
90
+ #
91
+ # NOTE: This cop only flags `null: false` (and the equivalent `false` arg
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.
68
95
  #
69
96
  # @example
70
97
  # # bad
71
98
  # add_column :users, :name, :string, null: false
72
99
  #
100
+ # # bad (tightening an existing column to NOT NULL)
101
+ # change_column_null :users, :name, false
102
+ #
103
+ # # bad (same tightening, via change_column)
104
+ # change_column :users, :name, :string, null: false
105
+ #
73
106
  # # bad (enum without a disable — the cop flags it; disable with `-- enum`)
74
107
  # t.integer :processing_status, null: false
75
108
  #
@@ -91,28 +124,56 @@ module RuboCop
91
124
  json jsonb bigint
92
125
  ].freeze
93
126
 
94
- RESTRICT_ON_SEND = (COLUMN_METHODS + %i[add_column]).freeze
127
+ RESTRICT_ON_SEND = (COLUMN_METHODS + %i[add_column change_column change_column_null]).freeze
95
128
 
96
129
  def_node_matcher :null_false_pair, <<~PATTERN
97
130
  (hash <$(pair (sym :null) (false)) ...>)
98
131
  PATTERN
99
132
 
133
+ # Matches `change_column_null :table, :column, false` (and the optional
134
+ # trailing-default 4-arg form). The `false` arg IS the NOT NULL directive
135
+ # — the same constraint as `null: false` on a definition, just applied to
136
+ # an existing column via the standard add-nullable -> backfill -> tighten
137
+ # step. Flagged so the cop cannot be sidestepped by choice of API: a
138
+ # regular column tightened to NOT NULL is held to the same standard as one
139
+ # declared NOT NULL up front. A legit enum/boolean tightening resolves at
140
+ # the disable site, exactly as for a new column.
141
+ def_node_matcher :change_column_null_false, <<~PATTERN
142
+ (send _ :change_column_null _ _ $false ...)
143
+ PATTERN
144
+
100
145
  def on_send(node)
146
+ flag = offense_node(node)
147
+ add_offense(flag) if flag
148
+ end
149
+
150
+ private
151
+
152
+ # The node whose NOT NULL directive offends: the `false` arg of
153
+ # `change_column_null`, or the `null: false` pair on a regular column
154
+ # definition. nil when there is nothing to flag.
155
+ def offense_node(node)
156
+ change_column_null_false(node) || column_null_false_pair(node)
157
+ end
158
+
159
+ def column_null_false_pair(node)
101
160
  return unless column_method?(node)
102
161
 
103
162
  options = node.arguments.find(&:hash_type?)
104
163
  return unless options
105
164
 
106
- pair = null_false_pair(options)
107
- return unless pair
108
-
109
- add_offense(pair)
165
+ null_false_pair(options)
110
166
  end
111
167
 
112
- private
113
-
168
+ # Methods that define or change a regular column and carry an options
169
+ # hash where `null:` may appear: the `t.<type>` helpers plus
170
+ # `add_column`/`change_column`. (`change_column_null` is handled
171
+ # separately — its NOT NULL directive is a positional `false`, not an
172
+ # options pair.)
114
173
  def column_method?(node)
115
- node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
174
+ node.method?(:add_column) ||
175
+ node.method?(:change_column) ||
176
+ COLUMN_METHODS.include?(node.method_name)
116
177
  end
117
178
  end
118
179
  end
@@ -0,0 +1,122 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Boolean columns must carry `null: false`.
6
+ #
7
+ # ## Rationale
8
+ # `null: false` is reserved for cases where NULL has no meaningful
9
+ # interpretation — and a justified boolean is one of those cases.
10
+ # NULL is outside the column's stated domain of {true, false}: it
11
+ # is neither value, and every read site has to silently coerce it
12
+ # (`nil || false == false`) or risk a footgun.
13
+ #
14
+ # The line is drawn here for standardization and non-subjectivity.
15
+ # Whether a *regular* column should be present is a business
16
+ # decision open to debate (see `DevDoc/Migration/AvoidNonNull`),
17
+ # but once the developer has justified a boolean via
18
+ # `DevDoc/Migration/AvoidBooleanColumn`'s escape hatch, the
19
+ # boolean's non-null-ness is objective — it does not depend on
20
+ # any further business decision — so it is enforced mechanically
21
+ # rather than argued column-by-column.
22
+ #
23
+ # This cop is the inverse of `DevDoc/Migration/AvoidNonNull` for
24
+ # the boolean case: that cop flags `null: false` on regular
25
+ # columns (including booleans, which it cannot distinguish from
26
+ # other types); this cop REQUIRES `null: false` on booleans. The
27
+ # contradiction is resolved at the disable site: when the
28
+ # developer disables `AvoidNonNull` on a boolean with `-- boolean`
29
+ # (alongside the `AvoidBooleanColumn` disable), this cop fires if
30
+ # `null: false` is missing.
31
+ #
32
+ # ## Adding `null: false` to a table with existing rows
33
+ # Postgres refuses to add a NOT NULL column to a non-empty table
34
+ # without a backfill. Use the model-validations backfill pattern
35
+ # (sanctioned by `DevDoc/Migration/AvoidColumnDefault`):
36
+ #
37
+ # def change
38
+ # add_column :table, :flag, :boolean
39
+ #
40
+ # reversible do |dir|
41
+ # dir.up do
42
+ # Table.reset_column_information
43
+ # Table.where(flag: nil).find_each do |t|
44
+ # t.flag = false
45
+ # t.save!
46
+ # end
47
+ # end
48
+ # end
49
+ #
50
+ # # rubocop:disable DevDoc/Migration/AvoidNonNull -- boolean
51
+ # change_column_null :table, :flag, false
52
+ # # rubocop:enable DevDoc/Migration/AvoidNonNull
53
+ # end
54
+ #
55
+ # The application layer is the source of truth for the default
56
+ # value on new records (e.g. via `after_initialize` or a factory).
57
+ # `default:` in the migration is still disallowed by
58
+ # `DevDoc/Migration/AvoidColumnDefault`.
59
+ #
60
+ # ## Interaction with AvoidBooleanColumn
61
+ # These two cops fire together on every boolean addition:
62
+ #
63
+ # - `AvoidBooleanColumn`: "reconsider using a boolean at all"
64
+ # - `BooleanColumnNotNull`: "if you do, it must be NOT NULL"
65
+ #
66
+ # Satisfy both by either changing the column type (timestamp /
67
+ # enum / model method) or, for a justified boolean, disabling
68
+ # `AvoidBooleanColumn` with `-- boolean` AND ensuring `null: false`
69
+ # is present so this cop does not fire.
70
+ #
71
+ # NOTE: This cop catches `t.boolean`, `add_column ..., :boolean`, and
72
+ # `change_column ..., :boolean` (a type change to boolean) without
73
+ # `null: false`. It does NOT scan `db/schema.rb` for legacy debt — it
74
+ # only enforces the invariant on declarations it can see. Existing
75
+ # nullable booleans are surfaced by inspection, not by this cop.
76
+ #
77
+ # @example
78
+ # # bad - nullable boolean
79
+ # add_column :users, :active, :boolean
80
+ #
81
+ # # bad - nullable boolean in a create_table block
82
+ # create_table :things do |t|
83
+ # t.boolean :flag
84
+ # end
85
+ #
86
+ # # good - boolean carries null: false
87
+ # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
88
+ # add_column :things, :flag, :boolean, null: false
89
+ # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
90
+ class BooleanColumnNotNull < Base
91
+ MSG = 'Boolean columns must carry `null: false` — NULL is outside {true, false}. ' \
92
+ 'Backfill existing rows via the model, then `change_column_null`.'.freeze
93
+
94
+ def_node_matcher :boolean_column?, <<~PATTERN
95
+ (send _ {:boolean} ...)
96
+ PATTERN
97
+
98
+ def_node_matcher :add_column_boolean?, <<~PATTERN
99
+ (send _ :add_column _ _ (sym :boolean) ...)
100
+ PATTERN
101
+
102
+ def_node_matcher :change_column_to_boolean?, <<~PATTERN
103
+ (send _ :change_column _ _ (sym :boolean) ...)
104
+ PATTERN
105
+
106
+ def_node_matcher :null_false_pair, <<~PATTERN
107
+ (hash <$(pair (sym :null) (false)) ...>)
108
+ PATTERN
109
+
110
+ def on_send(node)
111
+ return unless boolean_column?(node) || add_column_boolean?(node) || change_column_to_boolean?(node)
112
+
113
+ options = node.arguments.find(&:hash_type?)
114
+ return if options && null_false_pair(options)
115
+
116
+ add_offense(node)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.5.0".freeze
3
+ VERSION = "0.7.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.5.0
4
+ version: 0.7.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-06-27 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -72,14 +72,14 @@ dependencies:
72
72
  requirements:
73
73
  - - ">="
74
74
  - !ruby/object:Gem::Version
75
- version: '2.0'
75
+ version: '2.29'
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
- version: '2.0'
82
+ version: '2.29'
83
83
  description:
84
84
  email:
85
85
  executables: []
@@ -94,13 +94,20 @@ files:
94
94
  - lib/rubocop-dev_doc.rb
95
95
  - lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
96
96
  - lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
97
+ - lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb
98
+ - lib/rubocop/cop/dev_doc/i18n/localizable_props.rb
99
+ - lib/rubocop/cop/dev_doc/i18n/report_text.rb
100
+ - lib/rubocop/cop/dev_doc/i18n/require_translation.rb
101
+ - lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb
97
102
  - lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb
103
+ - lib/rubocop/cop/dev_doc/migration/avoid_boolean_column.rb
98
104
  - lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb
99
105
  - lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb
100
106
  - lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb
101
107
  - lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb
102
108
  - lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb
103
109
  - lib/rubocop/cop/dev_doc/migration/avoid_vague_column_names.rb
110
+ - lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb
104
111
  - lib/rubocop/cop/dev_doc/migration/date_column_naming.rb
105
112
  - lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb
106
113
  - lib/rubocop/cop/dev_doc/migration/prefer_belongs_to.rb