rubocop-dev_doc 0.3.0 → 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: cd15873d6ee53104760162aa0e5cced9dd69ef1297a70a014dd5643b9404703c
4
- data.tar.gz: 25d0fa51436912d008625752269ff09ef93e78c14d6d1a941295c6afa47795b4
3
+ metadata.gz: e13da0de0c2a17314d72cbe4bbeb9882a0ad1f93e4976d2e0548210af1684073
4
+ data.tar.gz: 57bf1fb471573aaa8417f3a86f9c4eba84ebc1d71d38d397c7cac2d05607d058
5
5
  SHA512:
6
- metadata.gz: 516d5742aa468568e3884d779649164761f982391be0c5f7e5df474d64a933cf71148fe1fddb433c5cf1cd577debd7aadcb16bf22893963d721a3661a1f0fff0
7
- data.tar.gz: 69a643cf2335d6c7bb365a4a2f8513e9a66d09a6cddb2bec5e44df861ebcb906de51d17fbbd1b591388c651c2f1cb662de5e5a887df7f11db3c9885e9d6ae597
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
@@ -348,12 +363,17 @@ Rails/HasManyOrHasOneDependent:
348
363
  Enabled: true
349
364
 
350
365
  DevDoc/Auth/LoadResourceCurrentUserGuard:
351
- Description: "Require a `return unless current_user` guard before using `current_user` inside `glib_load_resource`, and forbid `current_user&.`."
366
+ Description: "Require a `return unless current_user` (or `assert_current_user_present`) guard before using `current_user` inside `glib_load_resource`, and forbid `current_user&.`."
352
367
  Enabled: true
353
368
  # LoadResourceMethodNames: list of method names where the guard rule applies.
354
369
  # Extend if your project uses a different lifecycle hook name.
355
370
  LoadResourceMethodNames:
356
371
  - glib_load_resource
372
+ # CurrentUserAssertionMethodNames: calls that raise when current_user is nil
373
+ # (e.g. glib's assert_current_user_present). A call to one before the first
374
+ # current_user use satisfies the guard. Add custom assertion helpers here.
375
+ CurrentUserAssertionMethodNames:
376
+ - assert_current_user_present
357
377
 
358
378
  DevDoc/Auth/CurrentUserBranching:
359
379
  Description: "Avoid branching on `current_user` / `user_signed_in?` in page-specific code. Use shared layouts or an inline disable with a reason for genuinely dual-state pages."
@@ -364,3 +384,135 @@ DevDoc/Auth/CurrentUserBranching:
364
384
  - "app/controllers/concerns/**/*.rb"
365
385
  - "app/views/layouts/**/*"
366
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"
@@ -36,6 +36,24 @@ module RuboCop
36
36
  # end
37
37
  # end
38
38
  #
39
+ # glib's `assert_current_user_present` raises when `current_user` is nil,
40
+ # so it guarantees non-nil just as well as the early return — and any
41
+ # `raise` guard works like the `return` form:
42
+ #
43
+ # ✔️
44
+ # def glib_load_resource
45
+ # assert_current_user_present
46
+ #
47
+ # @post = current_user.posts.find(params[:id])
48
+ # end
49
+ #
50
+ # ✔️
51
+ # def glib_load_resource
52
+ # raise UnauthorizedError unless current_user
53
+ #
54
+ # @post = current_user.posts.find(params[:id])
55
+ # end
56
+ #
39
57
  # ❌ Safe navigation — hides the pre-auth nil risk
40
58
  # def glib_load_resource
41
59
  # @post = current_user&.posts&.find(params[:id])
@@ -51,6 +69,13 @@ module RuboCop
51
69
  # method names where the guard rule applies. Projects standardising on a
52
70
  # different lifecycle method can add it here.
53
71
  #
72
+ # `CurrentUserAssertionMethodNames` (default:
73
+ # `[assert_current_user_present]`) — calls that raise when `current_user`
74
+ # is nil. A call to one of these before the first `current_user` use
75
+ # satisfies the guard. Add project-specific assertion helpers here. NOTE:
76
+ # the cop trusts the named method to actually raise on nil — a configured
77
+ # name that doesn't will turn into a false negative.
78
+ #
54
79
  # NOTE: The cop performs structural analysis of the method body and does
55
80
  # not track aliasing. If you assign `current_user` to a local variable
56
81
  # and then call methods on that variable, the cop will not flag it —
@@ -73,16 +98,32 @@ module RuboCop
73
98
  #
74
99
  # @post = current_user.posts.find(params[:id])
75
100
  # end
101
+ #
102
+ # # good — guarded with the glib assertion (raises when nil)
103
+ # def glib_load_resource
104
+ # assert_current_user_present
105
+ #
106
+ # @post = current_user.posts.find(params[:id])
107
+ # end
76
108
  class LoadResourceCurrentUserGuard < Base
77
109
  MSG_SAFE_NAV = 'Avoid `current_user&.` inside `%<method>s` — use ' \
78
110
  '`return unless current_user` then `current_user.` instead.'.freeze
79
111
  MSG_MISSING_GUARD = '`current_user` is used without a prior ' \
80
- '`return unless current_user` guard in `%<method>s`. ' \
112
+ '`return unless current_user` (or `assert_current_user_present`) ' \
113
+ 'guard in `%<method>s`. ' \
81
114
  'This method runs before the policy, so `current_user` may be nil.'.freeze
82
115
 
83
- # Methods that test current_user for nil — not calls that risk NoMethodError.
116
+ # Predicates safe to call on a nil `current_user` a `current_user.nil?`
117
+ # etc. is not itself an unguarded use that risks NoMethodError.
84
118
  NIL_CHECK_METHODS = %i[nil? blank? present? empty?].freeze
85
119
 
120
+ # Predicates split by polarity, so a guard's exit branch can be matched
121
+ # to the path on which `current_user` is nil. `present?` is a PRESENCE
122
+ # check (reversed from `nil?`/`blank?`), so `unless current_user.present?`
123
+ # is a valid guard while `if current_user.present?` is not.
124
+ ABSENCE_CHECK_METHODS = %i[nil? blank? empty?].freeze
125
+ PRESENCE_CHECK_METHODS = %i[present?].freeze
126
+
86
127
  def on_def(node)
87
128
  check_load_resource_method(node)
88
129
  end
@@ -94,6 +135,10 @@ module RuboCop
94
135
  Array(cop_config.fetch('LoadResourceMethodNames', ['glib_load_resource'])).map(&:to_sym)
95
136
  end
96
137
 
138
+ def current_user_assertion_method_names
139
+ Array(cop_config.fetch('CurrentUserAssertionMethodNames', ['assert_current_user_present'])).map(&:to_sym)
140
+ end
141
+
97
142
  def load_resource_method?(method_name)
98
143
  load_resource_method_names.include?(method_name.to_sym)
99
144
  end
@@ -122,42 +167,30 @@ module RuboCop
122
167
 
123
168
  # `current_user&.something` — always an offense regardless of guards.
124
169
  def safe_nav_on_current_user?(node)
125
- node.csend_type? && current_user_receiver?(node)
170
+ node.csend_type? && bare_current_user?(node.receiver)
126
171
  end
127
172
 
128
- # `current_user.something` where `something` is not a nil check and
129
- # no dominating guard protects the call.
173
+ # `current_user.something` where `something` is not a nil check and no
174
+ # dominating guard protects the call — i.e. not inside an `if current_user`
175
+ # then-branch and not preceded by a guard statement in its enclosing `begin`.
130
176
  def unguarded_current_user_call?(node)
131
177
  return false unless node.send_type?
132
- return false unless current_user_receiver?(node)
133
- return false if nil_check_method?(node.method_name)
134
-
135
- !dominated_by_guard?(node)
136
- end
137
-
138
- # Does the direct receiver of `node` resolve to bare `current_user`?
139
- def current_user_receiver?(node)
140
- recv = node.receiver
141
- recv&.send_type? && recv.method_name == :current_user && recv.receiver.nil?
142
- end
178
+ return false unless bare_current_user?(node.receiver)
179
+ return false if NIL_CHECK_METHODS.include?(node.method_name)
143
180
 
144
- def nil_check_method?(method_name)
145
- NIL_CHECK_METHODS.include?(method_name)
181
+ !(inside_current_user_branch?(node) || preceded_by_guard?(node))
146
182
  end
147
183
 
148
- # Returns true if `node` is protected by a `current_user` nil-guard via:
149
- # 1. Being inside an `if current_user` then-branch, OR
150
- # 2. Having a preceding guard-return statement in its nearest enclosing
151
- # `begin` sequence (handles flat sequences AND nested `when` branches).
152
- def dominated_by_guard?(node)
153
- inside_current_user_branch?(node) || preceded_by_guard?(node)
184
+ # True if `node` is a bare `current_user` send (no receiver).
185
+ def bare_current_user?(node)
186
+ node&.send_type? && node.method_name == :current_user && node.receiver.nil?
154
187
  end
155
188
 
156
189
  # Walk ancestor `if` nodes and return true when `node` is inside the
157
190
  # then-branch of an `if current_user` (not the else-branch).
158
191
  def inside_current_user_branch?(node)
159
192
  node.each_ancestor(:if) do |if_node|
160
- next unless current_user_truthy_condition?(if_node.condition)
193
+ next unless bare_current_user?(if_node.condition)
161
194
 
162
195
  if_br = if_node.if_branch
163
196
  return true if if_br && (if_br.equal?(node) || descendant_by_identity?(if_br, node))
@@ -186,42 +219,66 @@ module RuboCop
186
219
  root.each_descendant.any? { |d| d.equal?(descendant) }
187
220
  end
188
221
 
189
- # True if `stmt` is a guard-return on `current_user`:
190
- # return unless current_user — condition: current_user, one branch: (return)
191
- # return if current_user.nil? — condition: current_user.nil?, one branch: (return)
192
- # return if current_user.blank? — condition: current_user.blank?, one branch: (return)
222
+ # True if `stmt` guarantees `current_user` is non-nil for everything
223
+ # that follows it either:
193
224
  #
194
- # The parser gem swaps `if_branch`/`else_branch` for `unless`-modifier
195
- # forms, so we check BOTH branches for a bare `return` rather than
196
- # assuming which side it falls on.
225
+ # 1. a bare call to a configured assertion helper (default
226
+ # `assert_current_user_present`), which raises when nil; or
227
+ # 2. an early-exit guard whose branch returns OR raises:
228
+ # return unless current_user raise ... unless current_user
229
+ # return if current_user.nil? raise ... if current_user.blank?
197
230
  def guard_statement?(stmt)
198
- return false unless stmt.if_type?
199
-
200
- condition = stmt.condition
201
- return false unless current_user_truthy_condition?(condition) ||
202
- current_user_nil_condition?(condition)
231
+ assertion_guard_call?(stmt) || exit_guard_statement?(stmt)
232
+ end
203
233
 
204
- return_node?(stmt.if_branch) || return_node?(stmt.else_branch)
234
+ # A bare call (nil receiver) to a "raise when current_user is nil"
235
+ # helper, e.g. glib's `assert_current_user_present`.
236
+ def assertion_guard_call?(stmt)
237
+ stmt.send_type? && stmt.receiver.nil? &&
238
+ current_user_assertion_method_names.include?(stmt.method_name)
205
239
  end
206
240
 
207
- # Condition is bare `current_user` (truthy check).
208
- def current_user_truthy_condition?(condition)
209
- condition&.send_type? &&
210
- condition.method_name == :current_user &&
211
- condition.receiver.nil?
241
+ # A `return`/`raise` guard whose exit happens on the NIL path — the only
242
+ # polarity that actually protects later `current_user` use:
243
+ # `... unless current_user` exits via the else branch
244
+ # `... if current_user.nil?` exits via the if branch
245
+ # (Ruby models an `if`/`unless` modifier as an `if` node with an empty
246
+ # opposite branch, so we tie the required exit to the condition polarity.
247
+ # This rejects inverted guards like `return if current_user`.)
248
+ # A `return`/`raise` guard is valid only when the branch that runs while
249
+ # `current_user` is nil exits. `if_branch` is the written body regardless
250
+ # of `if`/`unless`, so combine the condition's polarity with the keyword to
251
+ # pick that branch. This rejects inverted guards (`return if current_user`).
252
+ def exit_guard_statement?(stmt)
253
+ return false unless stmt.if_type?
254
+
255
+ polarity = condition_polarity(stmt.condition)
256
+ return false unless polarity
257
+
258
+ body_runs_when_nil = polarity == (stmt.unless? ? :presence : :absence)
259
+ branch_exits?(body_runs_when_nil ? stmt.if_branch : stmt.else_branch)
212
260
  end
213
261
 
214
- # Condition is `current_user.nil?` or `current_user.blank?`.
215
- def current_user_nil_condition?(condition)
216
- return false unless condition&.send_type?
217
- return false unless NIL_CHECK_METHODS.include?(condition.method_name)
262
+ # :presence condition truthy when current_user is present (`current_user`,
263
+ # `current_user.present?`); :absence — truthy when absent (`.nil?`/`.blank?`/
264
+ # `.empty?`); nil not a current_user condition.
265
+ def condition_polarity(condition)
266
+ return :presence if bare_current_user?(condition)
267
+ return unless condition&.send_type? && bare_current_user?(condition.receiver)
268
+ return :presence if PRESENCE_CHECK_METHODS.include?(condition.method_name)
218
269
 
219
- recv = condition.receiver
220
- recv&.send_type? && recv.method_name == :current_user && recv.receiver.nil?
270
+ :absence if ABSENCE_CHECK_METHODS.include?(condition.method_name)
221
271
  end
222
272
 
223
- def return_node?(node)
224
- node&.return_type?
273
+ # True if `branch` terminates on entry via an early `return` or a bare
274
+ # `raise`/`fail`. A multi-statement branch (a `begin`) counts when its
275
+ # LAST statement does.
276
+ def branch_exits?(branch)
277
+ return false unless branch
278
+
279
+ branch = branch.children.last if branch.begin_type?
280
+ branch&.return_type? ||
281
+ (branch&.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
225
282
  end
226
283
  end
227
284
  end
@@ -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.0".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.0
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: []