rubocop-dev_doc 0.3.1 → 0.5.0.beta1

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: 163ac4a232d22f5d731e680d4ae882172570cdcf3085a07be7d6c3195223e365
4
- data.tar.gz: a3eae8c77dcb707ddc4875be90255bea2fa33bcaeba370c7aff06436cd045661
3
+ metadata.gz: e13da0de0c2a17314d72cbe4bbeb9882a0ad1f93e4976d2e0548210af1684073
4
+ data.tar.gz: 57bf1fb471573aaa8417f3a86f9c4eba84ebc1d71d38d397c7cac2d05607d058
5
5
  SHA512:
6
- metadata.gz: a605681c5f22058138abe3d8bdcd1e12eb94073f74e02fde30bc1f1df0baa538022b69fe33c4f83279cfaed995e60e5c9a4936de43cad7a89f07105c4b6e7a24
7
- data.tar.gz: 0e845bcf2236245a8c4b0289a02d7731e12c9ac0023732f6135127f02f436c78f349012b98bd18127e89cdb4e70736597c2fc5313229ed3faaf572294e847168
6
+ metadata.gz: c302ad93dcb52860f23365fa361df3c1f593d618c6948b7b113eac124e640687345c2e953eca523777377eb27d7f75b4c177c2029dc5ceb71e7c30e0d52d5fab
7
+ data.tar.gz: bf88a2042d7dce6169ec7b274c1249b4c291518076f1d26c5d5b9f8c5a95699bcd1e343d27970e8809f7ad91fafe67d1206a553b1e5e162f14c59458cf3b0416
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"
@@ -247,6 +251,17 @@ DevDoc/Rails/AvoidRailsCallbacks:
247
251
  Include:
248
252
  - "app/models/**/*.rb"
249
253
 
254
+ DevDoc/Rails/AvoidLifecycleMethodOverride:
255
+ Description: "Avoid overriding Rails validation/persistence lifecycle methods (run_validations!, valid?, etc.) to dodge AvoidRailsCallbacks."
256
+ Enabled: true
257
+ Include:
258
+ - "app/models/**/*.rb"
259
+ Methods:
260
+ - run_validations!
261
+ - valid?
262
+ - invalid?
263
+ - perform_validations
264
+
250
265
  DevDoc/Rails/ApplicationRecordTransaction:
251
266
  Description: "Use `ApplicationRecord.transaction` instead of `SomeModel.transaction` outside model files."
252
267
  Enabled: true
@@ -369,3 +384,135 @@ DevDoc/Auth/CurrentUserBranching:
369
384
  - "app/controllers/concerns/**/*.rb"
370
385
  - "app/views/layouts/**/*"
371
386
  - "app/controllers/application_controller.rb"
387
+
388
+ DevDoc/I18n/RequireTranslation:
389
+ Description: "Localize user-facing strings in glib JSON-UI props; pass `t(...)` instead of a hardcoded string."
390
+ Enabled: false
391
+ # WatchedMethods: glib component builder methods whose hash props carry
392
+ # user-facing text. LocalizableKeys: the prop names checked on those calls.
393
+ # Extend both if your project adds custom components or text props.
394
+ WatchedMethods:
395
+ - h1
396
+ - h2
397
+ - h3
398
+ - h4
399
+ - h5
400
+ - p
401
+ - label
402
+ - markdown
403
+ - fields_text
404
+ - fields_number
405
+ - fields_select
406
+ - fields_password
407
+ - fields_textarea
408
+ - fields_check
409
+ - fields_checkGroup
410
+ - fields_chipGroup
411
+ - fields_timeZone
412
+ - fields_radioGroup
413
+ - fields_date
414
+ - fields_datetime
415
+ LocalizableKeys:
416
+ - title
417
+ - subtitle
418
+ - subsubtitle
419
+ - label
420
+ - placeholder
421
+ - text
422
+ Include:
423
+ - "app/views/**/*.jbuilder"
424
+ - "app/views/**/*.rb"
425
+
426
+ DevDoc/I18n/TranslationKeyPrefix:
427
+ Description: "Translation keys must start with an allowed namespace prefix (e.g. `hotel.`, `general.`)."
428
+ Enabled: false
429
+ # AllowedPrefixes is project-specific, so it defaults to empty and the cop is
430
+ # a no-op until configured. Matches `t`, `translate`, and `I18n.t`. Only
431
+ # statically-literal keys are checked; dynamic/interpolated keys are skipped.
432
+ AllowedPrefixes: []
433
+ Include:
434
+ - "app/views/**/*.jbuilder"
435
+ - "app/views/**/*.rb"
436
+ - "app/controllers/**/*.rb"
437
+ - "app/mailers/**/*.rb"
438
+ - "app/helpers/**/*.rb"
439
+
440
+ DevDoc/I18n/UnverifiedTranslation:
441
+ Description: "Warn when a glib text prop is set from a non-literal value that isn't a `t(...)` call — it may be an unlocalized string."
442
+ # A review aid, not a clean lint: it can't tell a stashed literal from
443
+ # dynamic data (`user.name`) or a translation reached via a variable. Off by
444
+ # default and `info` severity — run it during a localization pass. Mirrors
445
+ # RequireTranslation's WatchedMethods/LocalizableKeys.
446
+ Enabled: false
447
+ Severity: info
448
+ WatchedMethods:
449
+ - h1
450
+ - h2
451
+ - h3
452
+ - h4
453
+ - h5
454
+ - p
455
+ - label
456
+ - markdown
457
+ - fields_text
458
+ - fields_number
459
+ - fields_select
460
+ - fields_password
461
+ - fields_textarea
462
+ - fields_check
463
+ - fields_checkGroup
464
+ - fields_chipGroup
465
+ - fields_timeZone
466
+ - fields_radioGroup
467
+ - fields_date
468
+ - fields_datetime
469
+ LocalizableKeys:
470
+ - title
471
+ - subtitle
472
+ - subsubtitle
473
+ - label
474
+ - placeholder
475
+ - text
476
+ Include:
477
+ - "app/views/**/*.jbuilder"
478
+ - "app/views/**/*.rb"
479
+
480
+ DevDoc/I18n/ReportText:
481
+ Description: "Report every user-facing glib text prop — hardcoded and already-localized — to collect all possible texts."
482
+ # A tooling aid, not a lint: unlike RequireTranslation it fires on *every*
483
+ # text value (including `t(...)` calls) so you can sweep the codebase and
484
+ # collect the full list of user-facing strings. Off by default and `info`
485
+ # severity. Mirrors RequireTranslation's WatchedMethods/LocalizableKeys.
486
+ Enabled: false
487
+ Severity: info
488
+ WatchedMethods:
489
+ - h1
490
+ - h2
491
+ - h3
492
+ - h4
493
+ - h5
494
+ - p
495
+ - label
496
+ - markdown
497
+ - fields_text
498
+ - fields_number
499
+ - fields_select
500
+ - fields_password
501
+ - fields_textarea
502
+ - fields_check
503
+ - fields_checkGroup
504
+ - fields_chipGroup
505
+ - fields_timeZone
506
+ - fields_radioGroup
507
+ - fields_date
508
+ - fields_datetime
509
+ LocalizableKeys:
510
+ - title
511
+ - subtitle
512
+ - subsubtitle
513
+ - label
514
+ - placeholder
515
+ - text
516
+ Include:
517
+ - "app/views/**/*.jbuilder"
518
+ - "app/views/**/*.rb"
@@ -0,0 +1,112 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module I18n
5
+ # Report every user-facing text in a glib JSON-UI text prop — both
6
+ # hardcoded strings and already-localized `t(...)` calls.
7
+ #
8
+ # ## Rationale
9
+ # `DevDoc/I18n/RequireTranslation` flags only *hardcoded* strings; it
10
+ # stays silent once a value is localized. This cop is the opposite: it
11
+ # fires on **every** text value, localized or not, so you can sweep a
12
+ # codebase and collect the full list of user-facing strings (e.g. to
13
+ # seed a translation catalog or audit coverage).
14
+ #
15
+ # It is a tooling aid, not a lint — **disabled by default** and runs at
16
+ # `info` severity. Run it during a localization pass; it is not meant
17
+ # for every commit.
18
+ #
19
+ # Both the hardcoded form (`view.p text: 'Welcome'`) and the localized
20
+ # form (`view.p text: t('home.welcome')`) are reported. Blank/whitespace
21
+ # strings and pure dynamic values (`user.name`) carry no static text and
22
+ # are skipped — see `DevDoc/I18n/UnverifiedTranslation` for those.
23
+ #
24
+ # The watched method names and localizable keys are configurable via
25
+ # `WatchedMethods:` and `LocalizableKeys:`.
26
+ #
27
+ # 📋 Reported — hardcoded text
28
+ # view.p text: 'Welcome'
29
+ #
30
+ # 📋 Reported — localized text
31
+ # view.p text: t('home.welcome')
32
+ #
33
+ # @example
34
+ # # info (hardcoded text)
35
+ # view.p text: 'Welcome'
36
+ #
37
+ # # info (localized text — still reported)
38
+ # view.p text: t('home.welcome')
39
+ #
40
+ # # info (interpolated string)
41
+ # view.p text: "Hi #{name}"
42
+ #
43
+ # # ignored (blank — no text)
44
+ # view.fields_text label: ''
45
+ #
46
+ # # ignored (pure dynamic — no static text)
47
+ # view.p text: user.name
48
+ class ReportText < Base
49
+ MSG = 'Text for `%<key>s:`: review/collect this for localization.'.freeze
50
+
51
+ DEFAULT_WATCHED_METHODS = %w[
52
+ h1 h2 h3 h4 h5 p label markdown
53
+ fields_text fields_number fields_select fields_password
54
+ fields_textarea fields_check fields_checkGroup fields_chipGroup
55
+ fields_timeZone fields_radioGroup fields_date fields_datetime
56
+ ].freeze
57
+
58
+ DEFAULT_LOCALIZABLE_KEYS = %w[
59
+ title subtitle subsubtitle label placeholder text
60
+ ].freeze
61
+
62
+ TRANSLATION_METHODS = %i[t translate].freeze
63
+
64
+ def on_send(node)
65
+ return unless watched_methods.include?(node.method_name.to_s)
66
+
67
+ node.arguments.each do |arg|
68
+ next unless arg.hash_type?
69
+
70
+ arg.pairs.each { |pair| check_pair(pair) }
71
+ end
72
+ end
73
+ alias on_csend on_send
74
+
75
+ private
76
+
77
+ def check_pair(pair)
78
+ key = pair.key
79
+ return unless key.sym_type?
80
+ return unless localizable_keys.include?(key.value.to_s)
81
+ return unless text?(pair.value)
82
+
83
+ add_offense(pair.value, message: format(MSG, key: key.value))
84
+ end
85
+
86
+ # Any value that carries static user-facing text: a non-blank string
87
+ # literal, an interpolated string, or a translation call. Blank strings
88
+ # and pure dynamic values (`user.name`) carry no text and are skipped.
89
+ def text?(node)
90
+ return true if node.dstr_type?
91
+ return !node.value.strip.empty? if node.str_type?
92
+
93
+ translation_call?(node)
94
+ end
95
+
96
+ def translation_call?(node)
97
+ (node.send_type? || node.csend_type?) &&
98
+ TRANSLATION_METHODS.include?(node.method_name)
99
+ end
100
+
101
+ def watched_methods
102
+ cop_config.fetch('WatchedMethods', DEFAULT_WATCHED_METHODS)
103
+ end
104
+
105
+ def localizable_keys
106
+ cop_config.fetch('LocalizableKeys', DEFAULT_LOCALIZABLE_KEYS)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,116 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module I18n
5
+ # Flag hardcoded user-facing strings in glib JSON-UI component props.
6
+ #
7
+ # ## Rationale
8
+ # Glib components render text from props like `text:`, `label:`, and
9
+ # `title:`. Passing a string literal ships untranslatable copy — it can
10
+ # never be localized and bypasses the I18n catalog. Pass `t('...')` (or
11
+ # any non-literal) so the text resolves through the locale files.
12
+ #
13
+ # Only the hash form is checked (`view.p text: '...'`). Empty/whitespace
14
+ # strings are ignored — they carry no user-facing text. An interpolated
15
+ # string (`"Hi #{name}"`) is flagged because its literal portions are
16
+ # still hardcoded copy — unless it interpolates a `t(...)` (or
17
+ # `translate`/`I18n.t`) call, which is treated as positive localization
18
+ # and left alone.
19
+ #
20
+ # The watched method names and localizable keys are configurable via
21
+ # `WatchedMethods:` and `LocalizableKeys:`.
22
+ #
23
+ # ❌ Hardcoded — can't be translated
24
+ # view.p text: 'Welcome'
25
+ # view.fields_text label: 'Email'
26
+ #
27
+ # ✔️ Resolved through I18n
28
+ # view.p text: t('home.welcome')
29
+ # view.fields_text label: t('user.email')
30
+ # view.p text: "#{t('home.welcome')}, #{user.name}"
31
+ #
32
+ # @example
33
+ # # bad
34
+ # view.p text: 'Welcome'
35
+ #
36
+ # # bad
37
+ # view.fields_text label: 'Email'
38
+ #
39
+ # # bad (interpolation, but no translation call)
40
+ # view.p text: "Hi #{name}"
41
+ #
42
+ # # good
43
+ # view.p text: t('home.welcome')
44
+ #
45
+ # # good (interpolates a translation call)
46
+ # view.p text: "#{t('home.greeting')} #{user.name}"
47
+ #
48
+ # # good (non-literal — not flagged)
49
+ # view.p text: user.name
50
+ class RequireTranslation < Base
51
+ MSG = 'Localize this string: pass `t(...)` instead of a hardcoded ' \
52
+ 'string for `%<key>s:`.'.freeze
53
+
54
+ DEFAULT_WATCHED_METHODS = %w[
55
+ h1 h2 h3 h4 h5 p label markdown
56
+ fields_text fields_number fields_select fields_password
57
+ fields_textarea fields_check fields_checkGroup fields_chipGroup
58
+ fields_timeZone fields_radioGroup fields_date fields_datetime
59
+ ].freeze
60
+
61
+ DEFAULT_LOCALIZABLE_KEYS = %w[
62
+ title subtitle subsubtitle label placeholder text
63
+ ].freeze
64
+
65
+ TRANSLATION_METHODS = %i[t translate].freeze
66
+
67
+ def on_send(node)
68
+ return unless watched_methods.include?(node.method_name.to_s)
69
+
70
+ node.arguments.each do |arg|
71
+ next unless arg.hash_type?
72
+
73
+ arg.pairs.each { |pair| check_pair(pair) }
74
+ end
75
+ end
76
+ alias on_csend on_send
77
+
78
+ private
79
+
80
+ def check_pair(pair)
81
+ key = pair.key
82
+ return unless key.sym_type?
83
+ return unless localizable_keys.include?(key.value.to_s)
84
+ return unless hardcoded_string?(pair.value)
85
+
86
+ add_offense(pair.value, message: format(MSG, key: key.value))
87
+ end
88
+
89
+ # A plain string literal is hardcoded copy unless it's blank. A `dstr`
90
+ # (interpolated string) is hardcoded copy too — unless it interpolates
91
+ # a translation call, which counts as positive localization.
92
+ def hardcoded_string?(node)
93
+ return !localized_interpolation?(node) if node.dstr_type?
94
+ return false unless node.str_type?
95
+
96
+ !node.value.strip.empty?
97
+ end
98
+
99
+ def localized_interpolation?(node)
100
+ node.each_descendant(:send, :csend).any? do |call|
101
+ TRANSLATION_METHODS.include?(call.method_name)
102
+ end
103
+ end
104
+
105
+ def watched_methods
106
+ cop_config.fetch('WatchedMethods', DEFAULT_WATCHED_METHODS)
107
+ end
108
+
109
+ def localizable_keys
110
+ cop_config.fetch('LocalizableKeys', DEFAULT_LOCALIZABLE_KEYS)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ 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,106 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module I18n
5
+ # Warn when a glib text prop is set from a non-literal value that isn't
6
+ # a `t(...)` call — it may be an unlocalized string.
7
+ #
8
+ # ## Rationale
9
+ # `DevDoc/I18n/RequireTranslation` only catches string literals written
10
+ # at the call site. A value passed through a variable or method
11
+ # (`label: label_text`) escapes it — the literal could be one hop away.
12
+ # This cop flags those values so a human can confirm they resolve
13
+ # through I18n.
14
+ #
15
+ # It can't tell a stashed literal from genuinely dynamic data
16
+ # (`user.name`) or a translation reached via a variable, so it's a
17
+ # review aid, not a clean lint: it's **disabled by default** and runs at
18
+ # `info` severity. Run it during a localization pass, not on every
19
+ # commit. Values that are obviously translated (`t(...)`, `translate`,
20
+ # `I18n.t`) are excluded; literals are left to `RequireTranslation`.
21
+ #
22
+ # ⚠️ Can't be verified — confirm it's localized
23
+ # view.fields_text label: label_text
24
+ # view.p text: user.name
25
+ #
26
+ # ✔️ Obviously localized — not flagged
27
+ # view.p text: t('home.welcome')
28
+ #
29
+ # @example
30
+ # # warning (non-literal — may be unlocalized)
31
+ # view.fields_text label: label_text
32
+ #
33
+ # # warning (attribute — may be unlocalized)
34
+ # view.p text: user.name
35
+ #
36
+ # # good (obviously localized — not flagged)
37
+ # view.p text: t('home.welcome')
38
+ #
39
+ # # ignored (literal — handled by RequireTranslation)
40
+ # view.p text: 'Welcome'
41
+ class UnverifiedTranslation < Base
42
+ MSG = 'Possibly unlocalized: `%<key>s:` is set from a non-literal. ' \
43
+ 'Use `t(...)` if this is user-facing text.'.freeze
44
+
45
+ DYNAMIC_TYPES = %i[lvar ivar send csend].freeze
46
+ TRANSLATION_METHODS = %i[t translate].freeze
47
+
48
+ DEFAULT_WATCHED_METHODS = %w[
49
+ h1 h2 h3 h4 h5 p label markdown
50
+ fields_text fields_number fields_select fields_password
51
+ fields_textarea fields_check fields_checkGroup fields_chipGroup
52
+ fields_timeZone fields_radioGroup fields_date fields_datetime
53
+ ].freeze
54
+
55
+ DEFAULT_LOCALIZABLE_KEYS = %w[
56
+ title subtitle subsubtitle label placeholder text
57
+ ].freeze
58
+
59
+ def on_send(node)
60
+ return unless watched_methods.include?(node.method_name.to_s)
61
+
62
+ node.arguments.each do |arg|
63
+ next unless arg.hash_type?
64
+
65
+ arg.pairs.each { |pair| check_pair(pair) }
66
+ end
67
+ end
68
+ alias on_csend on_send
69
+
70
+ private
71
+
72
+ def check_pair(pair)
73
+ key = pair.key
74
+ return unless key.sym_type?
75
+ return unless localizable_keys.include?(key.value.to_s)
76
+ return unless unverified?(pair.value)
77
+
78
+ add_offense(pair.value, message: format(MSG, key: key.value))
79
+ end
80
+
81
+ # A value we can neither confirm nor deny is localized: a variable or
82
+ # method result (but not an obvious `t(...)`/`translate` call). String
83
+ # and other literals are excluded — those are RequireTranslation's job.
84
+ def unverified?(node)
85
+ return false unless DYNAMIC_TYPES.include?(node.type)
86
+
87
+ !translation_call?(node)
88
+ end
89
+
90
+ def translation_call?(node)
91
+ (node.send_type? || node.csend_type?) &&
92
+ TRANSLATION_METHODS.include?(node.method_name)
93
+ end
94
+
95
+ def watched_methods
96
+ cop_config.fetch('WatchedMethods', DEFAULT_WATCHED_METHODS)
97
+ end
98
+
99
+ def localizable_keys
100
+ cop_config.fetch('LocalizableKeys', DEFAULT_LOCALIZABLE_KEYS)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,107 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Avoid overriding Rails validation/persistence *lifecycle* methods in
6
+ # model files (`run_validations!`, `valid?`, etc.).
7
+ #
8
+ # ## Rationale
9
+ # `DevDoc/Rails/AvoidRailsCallbacks` bans the callback DSL so lifecycle
10
+ # behaviour stays visible at explicit call sites. Overriding a lifecycle
11
+ # method is a loophole — it reproduces the same hidden control flow
12
+ # without tripping that cop:
13
+ #
14
+ # # Flagged by AvoidRailsCallbacks:
15
+ # before_validation :do_something
16
+ #
17
+ # # NOT flagged by it — but functionally identical, and worse:
18
+ # def run_validations!
19
+ # do_something
20
+ # super
21
+ # end
22
+ #
23
+ # `def run_validations!; do_something; super; end` is a
24
+ # `before_validation :do_something` in disguise, and it's strictly worse:
25
+ #
26
+ # - **Silent on typo.** Misspell the method name (drop the `!`, or
27
+ # `run_validatons!`) and it's just a never-called method — no load-time
28
+ # error, no override in effect, behaviour silently reverts to stock
29
+ # Rails. The callback DSL fails loudly (`NoMethodError`) on a mistyped
30
+ # macro or symbol, caught by the first test that validates.
31
+ # - **More obscure.** Overriding an internal Rails method hides the
32
+ # lifecycle hook even more than the DSL it's avoiding.
33
+ #
34
+ # This cop pairs with (it does not replace) `AvoidRailsCallbacks`: the two
35
+ # cover the two ways to inject lifecycle behaviour — the DSL, and the
36
+ # override.
37
+ #
38
+ # ## What to do instead
39
+ # 1. Prefer an explicit method at the call site (e.g. `save_with_*`) that
40
+ # makes the behaviour visible, or a plain `validate :some_check` where
41
+ # a validation-time check is all you need.
42
+ # 2. If an override is genuinely required (e.g. a model whose validators
43
+ # read access-gated attributes and must wrap the *entire* validation
44
+ # run — something `validate` can't express), disable this cop inline
45
+ # with a written justification:
46
+ #
47
+ # def run_validations! # rubocop:disable DevDoc/Rails/AvoidLifecycleMethodOverride
48
+ # # Reason: <explanation>
49
+ # with_access { super }
50
+ # end
51
+ #
52
+ # ## Not airtight
53
+ # A determined dodge can still `prepend` a module, `alias_method`, or use
54
+ # `method_missing`. This cop raises the bar and makes the documented path
55
+ # (inline disable + justification) the easy one; it does not seal every
56
+ # hole.
57
+ #
58
+ # ## Configuration
59
+ # The flagged methods are configurable via `Methods`. The default is the
60
+ # validation-lifecycle set — the cleanest 1:1 substitute for the
61
+ # validation callbacks `AvoidRailsCallbacks` bans, with the least
62
+ # false-positive noise. Projects that also want to guard persistence
63
+ # verbs can add `save`/`save!`/`create`/`update`/`destroy` etc.
64
+ #
65
+ # @example
66
+ # # bad
67
+ # def run_validations!
68
+ # normalize
69
+ # super
70
+ # end
71
+ #
72
+ # # bad
73
+ # def valid?(context = nil)
74
+ # toggle_access { super }
75
+ # end
76
+ #
77
+ # # good — an explicit method / a plain validation declaration
78
+ # validate :assert_consistent
79
+ #
80
+ # # good — a non-lifecycle override is fine
81
+ # def to_param
82
+ # slug
83
+ # end
84
+ class AvoidLifecycleMethodOverride < Base
85
+ MSG = 'Avoid overriding the Rails lifecycle method `%<method>s` — it hides ' \
86
+ 'callback-like control flow and silently no-ops if mistyped. Prefer an ' \
87
+ 'explicit method or a `validate` declaration; if an override is genuinely ' \
88
+ 'required, disable this cop inline with a written justification.'.freeze
89
+
90
+ DEFAULT_METHODS = %w[run_validations! valid? invalid? perform_validations].freeze
91
+
92
+ def on_def(node)
93
+ return unless flagged_methods.include?(node.method_name)
94
+
95
+ add_offense(node.loc.name, message: format(MSG, method: node.method_name))
96
+ end
97
+
98
+ private
99
+
100
+ def flagged_methods
101
+ @flagged_methods ||= Array(cop_config['Methods'] || DEFAULT_METHODS).map(&:to_sym)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -120,15 +120,21 @@ module RuboCop
120
120
  end
121
121
 
122
122
  # Build the replacement for `params.require(:key).permit(*args)`.
123
- # Symbol args stay as-is; hash args are wrapped in `{ }` since they
124
- # need explicit braces when placed inside an array literal.
123
+ # Symbol args stay as-is; hash args need explicit braces when placed
124
+ # inside the `expect` array literal.
125
125
  def build_require_permit_replacement(params_node, key, permit_args)
126
126
  inner = permit_args.map { |arg| permit_arg_source(arg) }.join(', ')
127
127
  "#{params_node.source}.expect(#{key}: [#{inner}])"
128
128
  end
129
129
 
130
+ # A hash arg written WITHOUT braces (the trailing-kwargs form,
131
+ # `permit(:a, b: [1])`) has a brace-less source, so add them. A hash
132
+ # arg written WITH explicit braces (`permit(:a, { b: [1] })`) already
133
+ # includes them in its source — wrapping again would emit `{ {...} }`.
130
134
  def permit_arg_source(arg)
131
- arg.hash_type? ? "{ #{arg.source} }" : arg.source
135
+ return arg.source unless arg.hash_type?
136
+
137
+ arg.braces? ? arg.source : "{ #{arg.source} }"
132
138
  end
133
139
  end
134
140
  end
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.3.1".freeze
3
+ VERSION = "0.5.0.beta1".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -80,8 +79,6 @@ dependencies:
80
79
  - - ">="
81
80
  - !ruby/object:Gem::Version
82
81
  version: '2.0'
83
- description:
84
- email:
85
82
  executables: []
86
83
  extensions: []
87
84
  extra_rdoc_files: []
@@ -94,6 +91,10 @@ files:
94
91
  - lib/rubocop-dev_doc.rb
95
92
  - lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
96
93
  - lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
94
+ - lib/rubocop/cop/dev_doc/i18n/report_text.rb
95
+ - lib/rubocop/cop/dev_doc/i18n/require_translation.rb
96
+ - lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb
97
+ - lib/rubocop/cop/dev_doc/i18n/unverified_translation.rb
97
98
  - lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb
98
99
  - lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb
99
100
  - lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb
@@ -107,6 +108,7 @@ files:
107
108
  - lib/rubocop/cop/dev_doc/migration/require_primary_key.rb
108
109
  - lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
109
110
  - lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb
111
+ - lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb
110
112
  - lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb
111
113
  - lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb
112
114
  - lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb
@@ -132,12 +134,10 @@ files:
132
134
  - lib/rubocop/dev_doc.rb
133
135
  - lib/rubocop/dev_doc/plugin.rb
134
136
  - lib/rubocop/dev_doc/version.rb
135
- homepage:
136
137
  licenses: []
137
138
  metadata:
138
139
  default_lint_roller_plugin: RuboCop::DevDoc::Plugin
139
140
  rubygems_mfa_required: 'true'
140
- post_install_message:
141
141
  rdoc_options: []
142
142
  require_paths:
143
143
  - lib
@@ -152,8 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
152
152
  - !ruby/object:Gem::Version
153
153
  version: '0'
154
154
  requirements: []
155
- rubygems_version: 3.4.6
156
- signing_key:
155
+ rubygems_version: 4.0.6
157
156
  specification_version: 4
158
157
  summary: RuboCop cops enforcing dev-doc best practices
159
158
  test_files: []