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 +4 -4
- data/config/default.yml +165 -0
- data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +59 -0
- data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +109 -0
- data/lib/rubocop/cop/dev_doc/i18n/report_text.rb +76 -0
- data/lib/rubocop/cop/dev_doc/i18n/require_translation.rb +95 -0
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +89 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_boolean_column.rb +135 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +72 -11
- data/lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb +122 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +11 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d7cebd4d2c1b85850d5a704dc26319f49526040c26d2573824f68461d42ae8c
|
|
4
|
+
data.tar.gz: 5fede674c17c29dd330e9499a1367c3f1174902b0a76c44d94c7f6683a35d130
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
66
|
-
# (
|
|
67
|
-
# `null: false
|
|
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
|
-
|
|
107
|
-
return unless pair
|
|
108
|
-
|
|
109
|
-
add_offense(pair)
|
|
165
|
+
null_false_pair(options)
|
|
110
166
|
end
|
|
111
167
|
|
|
112
|
-
|
|
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) ||
|
|
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
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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
|