rubocop-dev_doc 0.10.2 → 0.10.3

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: 6516cd758b54f702561cfd1ce43130ccf46bcfd04d8955bbdf065218019f374a
4
- data.tar.gz: f246347aeb2054e19c26844f38057f795a1ee721a30c3c40b8f955d06b6f843a
3
+ metadata.gz: 0ba7422e97cc011bf4700f5f6bfd1c87fd7e358c9c707baf73b5360abbf40129
4
+ data.tar.gz: 10ced56ec89a8d05f3897c76ff85df357cb52a9dc97d155f7caf11b1917faaa6
5
5
  SHA512:
6
- metadata.gz: b30977f9056fd77d2a58227e98b2fc6860ad1333a52fb4bb9bf7ea636ab20ebca1ad9187a84a4ca3e14c8f442861ec3c779ddb88d9d940c459483bcf204726e0
7
- data.tar.gz: 93ae91781dd10664b18b8e304e9b336485a572bd9a32822af81df3e399fb742420ed1d41612e21e4249eb79cbc53569082468091c8994470f552bf43dd7c7b8d
6
+ metadata.gz: 403c116d677b765d86ef3a8852b1087ffa5821a12a1db76f59343d067cfd6c942c4fe1ae69c65a67ae1ad24a07922e7312f2a4f2a84b5164e379f8928c90a57c
7
+ data.tar.gz: 4bd0b0fb718cf6ac10540735e49077da57e0d34b54111dcc5fe9da0a531383546eb83a9300503a10612f7bb0f90b6f6434acf0b307ea6125316202563bd89a52
data/config/default.yml CHANGED
@@ -170,7 +170,7 @@ DevDoc/Rails/NoPerformLaterInModel:
170
170
  - "app/models/**/*.rb"
171
171
 
172
172
  DevDoc/Rails/EnumMustBeSymbolized:
173
- Description: "Declare enums with `enum_symbolize :foo, { … }` instead of a bare `enum`, so the attribute type is set before the enum and the reader returns a symbol."
173
+ Description: "Pair every `enum :foo` with `enum_symbolize :foo` so the reader returns a symbol."
174
174
  Enabled: true
175
175
  Include:
176
176
  - "app/models/**/*.rb"
@@ -436,18 +436,6 @@ Rails/SaveBang:
436
436
  Exclude:
437
437
  - "app/controllers/**/*"
438
438
 
439
- # MySQL-oriented performance cop: it wants multiple ALTER TABLE operations on
440
- # one table combined into `change_table :t, bulk: true` to avoid repeated
441
- # full-table rewrites. That payoff is real on MySQL/MariaDB but marginal on
442
- # PostgreSQL, where ALTER sub-commands are cheap metadata ops — and our projects
443
- # run PostgreSQL. It also contradicts our own doctrine: the `bulk: true` form it
444
- # demands trips DevDoc/Migration/AvoidColumnDefault (on the backfill `default:`)
445
- # and Rails/ReversibleMigration (on `t.change_default`), neither disable-able
446
- # inline, for the standard add-column-then-drop-default backfill — making the
447
- # three rules mutually unsatisfiable. Off for all projects.
448
- Rails/BulkChangeTable:
449
- Enabled: false
450
-
451
439
  # Superseded by DevDoc/Style/CaseElseDecision below: same detection, but the
452
440
  # offense message carries the raise/report/fall-through decision framework
453
441
  # instead of upstream's "Missing else statement" (which teaches the wrong
@@ -0,0 +1,168 @@
1
+ # Pseudo-localization backend — a hardcoded-text detector.
2
+ #
3
+ # When installed (see PseudoLocale.install!), an extra locale `en-PSEUDO`
4
+ # becomes available. Every string resolved through Rails `t()` is returned as
5
+ # its real English value, accented and wrapped in `⟦ ⟧` markers, e.g.
6
+ #
7
+ # t('energies.show.title') # "Monthly Statement"
8
+ # --> rendered under en-PSEUDO --> "⟦Móñţĥļý Šţáţéméñţ⟧"
9
+ #
10
+ # Render any page under the pseudo locale and scan the output: any user-facing
11
+ # text WITHOUT the `⟦ ⟧` markers never went through `t()` and is therefore a
12
+ # hardcoded-string candidate — regardless of whether it came from a view,
13
+ # helper, concern, module, or service object.
14
+ #
15
+ # ---------------------------------------------------------------------------
16
+ # Per-project integration
17
+ # ---------------------------------------------------------------------------
18
+ # This gem is a `:development, :test`-only dependency, so it is NOT loaded in
19
+ # production and this file is only required when detection mode is on. The
20
+ # project initializer requires it and installs the backend under PSEUDO_I18N=1:
21
+ #
22
+ # # config/initializers/pseudo_locale.rb
23
+ # if ENV['PSEUDO_I18N'] == '1'
24
+ # require 'dev_doc/i18n/pseudo_locale'
25
+ # DevDoc::I18n::PseudoLocale.install!
26
+ # end
27
+ #
28
+ # `mark` (below) is called from production code paths to flag externally-managed
29
+ # strings for the scanner. Because this gem is absent in production, call it
30
+ # through a null-guard so it degrades to the identity when the gem is not
31
+ # loaded — the value is untouched anyway when detection mode is off:
32
+ #
33
+ # def pseudo_mark(value)
34
+ # return value unless defined?(DevDoc::I18n::PseudoLocale)
35
+ # DevDoc::I18n::PseudoLocale.mark(value)
36
+ # end
37
+ #
38
+ # Guarded behind PSEUDO_I18N, so normal dev/prod is untouched and the fake
39
+ # locale can never leak to real users.
40
+ module DevDoc
41
+ module I18n
42
+ module PseudoLocale
43
+ PSEUDO_LOCALE = 'en-PSEUDO'
44
+ MARKER = '⟦'
45
+
46
+ def self.active?
47
+ ENV['PSEUDO_I18N'] == '1'
48
+ end
49
+
50
+ # Marks an externally-managed string — CMS content, assessment-YAML
51
+ # values, or a chosen ActiveRecord display value — so the hardcoded-text
52
+ # scanner treats it as already-handled and skips it. Returns the string
53
+ # untouched when detection mode is off, so it is safe on production paths
54
+ # (call it through a null-guard; see the header comment).
55
+ def self.mark(value)
56
+ return value unless active?
57
+ return value unless value.is_a?(String) && !value.empty?
58
+
59
+ value.include?(MARKER) ? value : "#{MARKER}#{value}⟧"
60
+ end
61
+
62
+ # Letter map — readable but unmistakably "transformed".
63
+ MAP = {
64
+ 'a' => 'á', 'b' => 'ƀ', 'c' => 'ç', 'd' => 'ð', 'e' => 'é', 'f' => 'ƒ',
65
+ 'g' => 'ĝ', 'h' => 'ĥ', 'i' => 'í', 'j' => 'ĵ', 'k' => 'ķ', 'l' => 'ļ',
66
+ 'm' => 'ɱ', 'n' => 'ñ', 'o' => 'ö', 'p' => 'þ', 'q' => 'ɋ', 'r' => 'ŕ',
67
+ 's' => 'š', 't' => 'ţ', 'u' => 'ü', 'v' => 'ṽ', 'w' => 'ŵ', 'x' => 'ӿ',
68
+ 'y' => 'ý', 'z' => 'ž',
69
+ 'A' => 'Á', 'B' => 'Ɓ', 'C' => 'Ç', 'D' => 'Ð', 'E' => 'É', 'F' => 'Ƒ',
70
+ 'G' => 'Ĝ', 'H' => 'Ĥ', 'I' => 'Í', 'J' => 'Ĵ', 'K' => 'Ķ', 'L' => 'Ļ',
71
+ 'M' => 'Ṁ', 'N' => 'Ñ', 'O' => 'Ö', 'P' => 'Þ', 'Q' => 'Ɋ', 'R' => 'Ŕ',
72
+ 'S' => 'Š', 'T' => 'Ţ', 'U' => 'Ü', 'V' => 'Ṽ', 'W' => 'Ŵ', 'X' => 'Ӿ',
73
+ 'Y' => 'Ý', 'Z' => 'Ž'
74
+ }.freeze
75
+
76
+ # Wires the pseudo backend into I18n and forces every request to render in
77
+ # it. Idempotent — safe to call once from the project initializer.
78
+ def self.install!
79
+ ::I18n::Backend::Simple.prepend(Backend)
80
+ ::I18n.available_locales |= [PSEUDO_LOCALE.to_sym]
81
+ force_locale!
82
+ ::Rails.logger.info('[pseudo_locale] en-PSEUDO locale enabled (forced) for hardcoded-text detection') if defined?(::Rails.logger)
83
+ end
84
+
85
+ # Force every request to render in the pseudo locale.
86
+ #
87
+ # Without this, an app's `default_url_options` typically derives the URL
88
+ # locale via `I18n.locale.to_s.split('-')[0]` ("en-PSEUDO" -> "en"), so
89
+ # every link and redirect the app generates carries `locale=en`;
90
+ # `set_locale` then honours that param and renders plain English — the
91
+ # pseudo transform never fires on any navigated page. Overriding
92
+ # `set_locale` here pins the locale regardless of the incoming param,
93
+ # which is exactly what a "render everything pseudo" detection mode wants.
94
+ def self.force_locale!
95
+ ::Rails.application.config.to_prepare do
96
+ ApplicationController.class_eval do
97
+ private
98
+
99
+ def set_locale
100
+ ::I18n.locale = DevDoc::I18n::PseudoLocale::PSEUDO_LOCALE.to_sym
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # I18n backend override: resolves the real English entry, then accents it.
107
+ module Backend
108
+ # Namespaces whose values are format directives / structural data, not
109
+ # copy. Accenting these would corrupt strftime (`%A`), number formats.
110
+ SKIP_NAMESPACES = %w[i18n date time datetime number support].freeze
111
+
112
+ def lookup(locale, key, scope = [], options = ::I18n::EMPTY_HASH)
113
+ return super unless locale.to_s == PSEUDO_LOCALE
114
+
115
+ # Resolve the real English entry (template, before interpolation).
116
+ real = super(:en, key, scope, options)
117
+ return real if real.nil? || skip?(key, scope)
118
+
119
+ pseudoize(real)
120
+ end
121
+
122
+ private
123
+
124
+ def skip?(key, scope)
125
+ head = Array(scope).first || key.to_s[/\A[^.]+/]
126
+ SKIP_NAMESPACES.include?(head.to_s)
127
+ end
128
+
129
+ # Recurses so pluralization hashes ({ one:, other: }) and arrays work.
130
+ def pseudoize(value)
131
+ case value
132
+ when String then wrap(value)
133
+ when Hash then value.transform_values { |v| pseudoize(v) }
134
+ when Array then value.map { |v| pseudoize(v) }
135
+ else value
136
+ end
137
+ end
138
+
139
+ # Some t() values are not display copy — asset filenames, paths, URLs.
140
+ # The asset helpers feed these to Vite, which raises on a missing
141
+ # manifest entry, so accenting them 500s the page. Leave them untouched.
142
+ #
143
+ # Note: do NOT treat a bare "/" as a path — display copy legitimately
144
+ # contains slashes ("Lamp/Bulbs", "Food/Food Scraps"). A real path is an
145
+ # all-lowercase identifier chain (`shared/layouts/main`, `images/icon`);
146
+ # copy has capitals or spaces, so it won't match the lowercase-path rule.
147
+ NON_COPY = %r{
148
+ \A\s*\z # blank
149
+ | \Ahttps?:// # url
150
+ | \A[a-z0-9_]+(?:[/.][a-z0-9_]+)+\z # lowercase path / dotted id (shared/layouts/main)
151
+ }x
152
+
153
+ ASSET_EXT = /\.(?:png|jpe?g|svg|gif|webp|ico|bmp|css|js|mjs|html?|pdf|woff2?|ttf|otf|mp[34]|json|xml|csv)\z/i
154
+
155
+ def wrap(str)
156
+ return str if str.match?(NON_COPY) || str.match?(ASSET_EXT)
157
+
158
+ # Split on %{name} placeholders (captured), accent only the literal
159
+ # segments so interpolation variables and their data stay intact.
160
+ body = str.split(/(%\{[^}]+\})/).each_with_index.map do |part, i|
161
+ i.odd? ? part : part.chars.map { |c| MAP[c] || c }.join
162
+ end.join
163
+ "⟦#{body}⟧"
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,254 @@
1
+ require 'set'
2
+
3
+ module DevDoc
4
+ module Test
5
+ # Pseudo-localization hardcoded-text crawler.
6
+ #
7
+ # Reuses the glib-web JSON-UI crawler (per-role login + fixtures + full
8
+ # reachability) but renders every page under the `en-PSEUDO` locale and
9
+ # reports any localizable text prop that comes back WITHOUT the `⟦ ⟧`
10
+ # marker — i.e. user-facing text that bypassed Rails `t()` and is therefore
11
+ # a hardcoded-string candidate, no matter which view/helper/concern/module/
12
+ # service emitted it. See dev_doc/i18n/pseudo_locale.rb.
13
+ #
14
+ # Unlike the golden-file JSON-UI crawler this does NOT assert the page log
15
+ # (the accented pseudo output would never match committed baselines); it
16
+ # drives the router directly and only collects unwrapped text.
17
+ #
18
+ # ------------------------------------------------------------------------
19
+ # Per-project integration
20
+ # ------------------------------------------------------------------------
21
+ # Include this module in a single integration test and supply the project's
22
+ # own login credentials as `test` cases. The host class must also include
23
+ # `Glib::TestHelpers` (for HOST / login / logout) and load fixtures. The
24
+ # pseudo backend must be installed — see dev_doc/i18n/pseudo_locale.rb.
25
+ #
26
+ # # test/integration/pseudo_i18n_crawler_test.rb
27
+ # require 'test_helper'
28
+ # require 'dev_doc/test/pseudo_i18n_crawler'
29
+ #
30
+ # class PseudoI18nCrawlerTest < ActionDispatch::IntegrationTest
31
+ # include Glib::TestHelpers
32
+ # include DevDoc::Test::PseudoI18nCrawler
33
+ #
34
+ # self.fixture_paths = ['test/fixtures']
35
+ # fixtures :all
36
+ #
37
+ # test 'crawl superadmin' do
38
+ # crawl_pseudo(email: 'super@admin.com', password: 'password', device: 'web', version: nil)
39
+ # end
40
+ # end
41
+ #
42
+ # Run with the pseudo backend enabled:
43
+ #
44
+ # PSEUDO_I18N=1 bin/rails test test/integration/pseudo_i18n_crawler_test.rb
45
+ #
46
+ # Findings are printed and written to
47
+ # test/integration/pseudo_i18n_crawler_test_results/<email>.txt
48
+ module PseudoI18nCrawler
49
+ MARKER = '⟦'.freeze
50
+
51
+ # Mirrors the cop's DEFAULT_LOCALIZABLE_KEYS — keep in sync with
52
+ # lib/rubocop/cop/dev_doc/i18n/localizable_props.rb. The crawler can only
53
+ # flag hardcoded text on keys it walks, so any key the cop treats as
54
+ # localizable (message/description on dialogs & banners, uploadText on
55
+ # fields_upload, on/left/rightText on switch & toggles) must be listed.
56
+ TEXT_KEYS = %w[
57
+ title
58
+ subtitle
59
+ subsubtitle
60
+ label
61
+ placeholder
62
+ text
63
+ message
64
+ description
65
+ uploadText
66
+ leftText
67
+ rightText
68
+ onLabel
69
+ ].to_set
70
+
71
+ # The test class that included the module — the prepended Scanner (a
72
+ # single module shared across the Http class) dispatches scan results
73
+ # back to it.
74
+ class << self
75
+ attr_accessor :host_class
76
+ end
77
+
78
+ # Scan every crawled JSON-UI response as it is fetched.
79
+ module Scanner
80
+ # Signature must mirror Glib::JsonCrawler::Http#fetch for the
81
+ # prepend/super override to work, so the boolean positional arg can't
82
+ # become a kwarg.
83
+ def fetch(method, url, action, params = {}, inspect_result = true) # rubocop:disable Style/OptionalBooleanParameter
84
+ result = super
85
+ if method == :get
86
+ rec = instance_variable_get(:@content_history)&.last
87
+ PseudoI18nCrawler.host_class.scan_body(url, rec[:response]) if rec && rec[:url] == url
88
+ end
89
+ result
90
+ end
91
+ end
92
+
93
+ def self.included(base)
94
+ base.extend(ClassMethods)
95
+ PseudoI18nCrawler.host_class = base
96
+
97
+ # Prepend the Scanner onto the crawler HTTP client exactly once, no
98
+ # matter how many test classes include the module.
99
+ unless Glib::JsonCrawler::Http.ancestors.include?(Scanner)
100
+ Glib::JsonCrawler::Http.prepend(Scanner)
101
+ end
102
+
103
+ base.setup do
104
+ skip 'Set PSEUDO_I18N=1 to run the pseudo-localization scan.' unless ENV['PSEUDO_I18N'] == '1'
105
+ skip 'en-PSEUDO locale not loaded — is config/initializers/pseudo_locale.rb active?' unless ::I18n.available_locales.include?(:'en-PSEUDO')
106
+
107
+ self.class.hits = {}
108
+ @previous_default_locale = ::I18n.default_locale
109
+ # The app's set_locale falls back to I18n.default_locale when no
110
+ # `_lang` param is present (crawler URLs carry none), so this renders
111
+ # every page in pseudo without touching the crawler's URLs.
112
+ ::I18n.default_locale = :'en-PSEUDO'
113
+ end
114
+
115
+ base.teardown do
116
+ ::I18n.default_locale = @previous_default_locale if @previous_default_locale
117
+ end
118
+ end
119
+
120
+ module ClassMethods
121
+ attr_accessor :hits # { url => [ "[key] value", ... ] }
122
+
123
+ # Directory of the fixtures scanned to filter out AR display values.
124
+ def fixture_dir
125
+ @fixture_dir ||= ::Rails.root.join('test/fixtures').to_s
126
+ end
127
+
128
+ # Where per-role reports are written.
129
+ def report_dir
130
+ @report_dir ||= ::Rails.root.join('test/integration/pseudo_i18n_crawler_test_results').to_s
131
+ end
132
+
133
+ def scan_body(url, body)
134
+ return unless body.is_a?(String) && body.lstrip.start_with?('{', '[')
135
+
136
+ json = begin
137
+ JSON.parse(body)
138
+ rescue JSON::ParserError
139
+ return
140
+ end
141
+
142
+ walk(json) do |key, value|
143
+ if value.include?(MARKER)
144
+ @wrapped = (@wrapped || 0) + 1
145
+ elsif trivial?(value)
146
+ next
147
+ elsif fixture_strings.include?(value.strip)
148
+ # ActiveRecord display value (record title/name/user copy) that
149
+ # came straight from the DB — data, not a hardcoded UI string.
150
+ @data_sourced = (@data_sourced || 0) + 1
151
+ else
152
+ @unwrapped = (@unwrapped || 0) + 1
153
+ (hits[url] ||= []) << "[#{key}] #{value.inspect}"
154
+ end
155
+ end
156
+ end
157
+
158
+ # Verbatim string values from the fixtures. A candidate equal to one of
159
+ # these is an ActiveRecord display value (loaded from the DB, so it
160
+ # never went through t() by design) rather than hardcoded copy, and is
161
+ # excluded to cut the bulk of the AR noise. Exact match only — a
162
+ # hardcoded string that happens to equal a fixture value would be
163
+ # skipped too, an acceptable trade for ~85% less noise. Built once;
164
+ # fixtures contain no ERB so static YAML parsing is exact.
165
+ def fixture_strings
166
+ @fixture_strings ||= Dir[File.join(fixture_dir, '**', '*.yml')].each_with_object(Set.new) do |file, set|
167
+ data = YAML.safe_load(File.read(file), permitted_classes: [Date, Time], aliases: true)
168
+ collect_strings(data, set)
169
+ rescue Psych::Exception
170
+ next
171
+ end
172
+ end
173
+
174
+ def collect_strings(node, set)
175
+ case node
176
+ when Hash then node.each_value { |v| collect_strings(v, set) }
177
+ when Array then node.each { |v| collect_strings(v, set) }
178
+ when String
179
+ s = node.strip
180
+ set << s unless s.empty?
181
+ end
182
+ end
183
+
184
+ def diag
185
+ "wrapped(localized via t)=#{@wrapped || 0} data-sourced(fixtures, skipped)=#{@data_sourced || 0} " \
186
+ "unwrapped(candidates)=#{@unwrapped || 0}"
187
+ end
188
+
189
+ def walk(node, &blk)
190
+ case node
191
+ when Hash
192
+ node.each do |k, v|
193
+ blk.call(k, v) if TEXT_KEYS.include?(k) && v.is_a?(String)
194
+ walk(v, &blk)
195
+ end
196
+ when Array
197
+ node.each { |v| walk(v, &blk) }
198
+ end
199
+ end
200
+
201
+ # Skip values that are not copy: blank, no letters (numbers/units/
202
+ # symbols), or ALL_CAPS code identifiers. Tune as real noise surfaces.
203
+ def trivial?(str)
204
+ s = str.strip
205
+ s.empty? || !s.match?(/[A-Za-z]/) || s.match?(/\A[A-Z0-9_]+\z/)
206
+ end
207
+ end
208
+
209
+ private
210
+
211
+ def crawl_pseudo(user)
212
+ user[:token] = login(user)
213
+
214
+ router = Glib::JsonCrawler::Router.new
215
+ http = Glib::JsonCrawler::Http.new(self, user, router, inspect_result: true)
216
+ router.host = HOST
217
+ router.skip_similar_page = true
218
+
219
+ ENV['CRAWLER_MODE'] = 'true'
220
+ router.step(
221
+ http,
222
+ 'onClick' => {
223
+ 'action' => 'initiate_navigation',
224
+ 'url' => "http://#{HOST}/users/me?format=json&redirect=default"
225
+ }
226
+ )
227
+ ensure
228
+ ENV['CRAWLER_MODE'] = nil
229
+ logout
230
+ report!(user)
231
+ end
232
+
233
+ def report!(user)
234
+ hits = self.class.hits
235
+ report_dir = self.class.report_dir
236
+ FileUtils.mkdir_p(report_dir)
237
+ path = File.join(report_dir, "#{user[:email]}.txt")
238
+
239
+ lines = []
240
+ hits.sort.each do |url, values|
241
+ lines << url
242
+ values.uniq.each { |v| lines << " #{v}" }
243
+ lines << ''
244
+ end
245
+ total = hits.values.sum { |v| v.uniq.size }
246
+ summary = "#{total} hardcoded candidate(s) across #{hits.size} page(s) for #{user[:email]}"
247
+
248
+ File.write(path, ([summary, ''] + lines).join("\n"))
249
+ puts "\n[pseudo_i18n] #{summary}\n report: #{path}"
250
+ puts "[pseudo_i18n] DIAG: #{self.class.diag}"
251
+ end
252
+ end
253
+ end
254
+ end
@@ -2,51 +2,44 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Rails
5
- # Rails `enum` must never be declared directly — use glib-web's
6
- # `enum_symbolize :name, { }` declaring form instead.
5
+ # Every Rails `enum` declaration must be paired with `enum_symbolize`
6
+ # so the reader returns a symbol instead of Rails' default string form.
7
7
  #
8
8
  # ## Rationale
9
- # Two problems with a bare `enum`:
10
- #
11
- # 1. **Type.** `enum` resolves its attribute type by introspecting the
12
- # DB column. When the column doesn't exist yet a not-yet-migrated
13
- # column loaded on deploy, eager-loaded CI, or an un-migrated local
14
- # DB — Rails raises "Undeclared attribute type for enum". Declaring
15
- # the attribute up front (which `enum_symbolize` does, from the enum's
16
- # own values) removes that dependency on the column.
17
- # 2. **Reader.** `enum` defines a reader that returns the string form, so
18
- # `record.status == :active` silently fails (`:foo == 'foo'` is
19
- # false). `enum_symbolize` overrides the reader to return a symbol.
20
- #
21
- # The `enum_symbolize :name, { … }` form does both in one call — it
22
- # declares the attribute, defines the enum, adds a presence validation,
23
- # and symbolizes the reader — so neither footgun can be reintroduced by
24
- # forgetting a pairing line.
9
+ # Strings and symbols are not equal in Ruby (`:foo == 'foo'` is `false`).
10
+ # Rails' `enum` macro defines a reader that returns the string form, so
11
+ # downstream comparisons like `record.status == :active` silently fail.
12
+ # Pairing `enum :status, …` with `enum_symbolize :status` overrides the
13
+ # reader to hand back a symbol, eliminating the footgun.
25
14
  #
26
15
  # See `best_practices/backend/en/01a_defensive_programming.md` item 7.
27
16
  #
28
17
  # ❌
29
18
  # class Order < ApplicationRecord
30
19
  # enum :payment_status, { draft: 0, pending: 1, finalized: 2 }
31
- # enum_symbolize :payment_status
32
20
  # end
33
21
  #
34
22
  # ✔
35
23
  # class Order < ApplicationRecord
36
- # enum_symbolize :payment_status, { draft: 0, pending: 1, finalized: 2 }
24
+ # enum :payment_status, { draft: 0, pending: 1, finalized: 2 }
25
+ #
26
+ # enum_symbolize :payment_status
37
27
  # end
38
28
  #
39
- # Enum options pass straight through:
29
+ # `enum_symbolize` accepts multiple names so several enums can be
30
+ # paired in one call:
40
31
  #
41
32
  # ✔
42
- # enum_symbolize :finalize_intent, { unknown: 0, charge: 1 }, prefix: true
33
+ # enum_symbolize :payment_status, :finalize_intent
43
34
  #
44
35
  # Enums declared inside a concern's `included do` block are checked the
45
- # same way:
36
+ # same way — the pairing must live in the same `included do` block,
37
+ # which is also where it belongs so every includer gets both:
46
38
  #
47
39
  # ✔
48
40
  # included do
49
- # enum_symbolize :discount_type, DISCOUNT_TYPES
41
+ # enum :discount_type, DISCOUNT_TYPES
42
+ # enum_symbolize :discount_type
50
43
  # end
51
44
  #
52
45
  # @example
@@ -54,16 +47,20 @@ module RuboCop
54
47
  # enum :status, { active: 0, archived: 1 }
55
48
  #
56
49
  # # good
57
- # enum_symbolize :status, { active: 0, archived: 1 }
50
+ # enum :status, { active: 0, archived: 1 }
51
+ # enum_symbolize :status
58
52
  class EnumMustBeSymbolized < Base
59
- MSG = 'Declare enums with `enum_symbolize :%<name>s, { }` instead of `enum`, so the ' \
60
- 'attribute type is set before the enum (surviving a not-yet-migrated column) and the ' \
61
- 'reader returns a symbol (see backend/01a_defensive_programming.md item 7).'.freeze
53
+ MSG = 'Pair `enum :%<name>s` with `enum_symbolize :%<name>s` so the reader returns a symbol ' \
54
+ '(see backend/01a_defensive_programming.md item 7).'.freeze
62
55
 
63
56
  def_node_matcher :enum_call, <<~PATTERN
64
57
  (send nil? :enum (sym $_) ...)
65
58
  PATTERN
66
59
 
60
+ def_node_matcher :enum_symbolize_call, <<~PATTERN
61
+ (send nil? :enum_symbolize $...)
62
+ PATTERN
63
+
67
64
  def_node_matcher :included_block?, <<~PATTERN
68
65
  (block (send nil? :included) (args) _)
69
66
  PATTERN
@@ -86,15 +83,28 @@ module RuboCop
86
83
  return unless body
87
84
 
88
85
  statements = body.begin_type? ? body.children : [body]
89
- statements.each do |stmt|
90
- next unless stmt.respond_to?(:send_type?) && stmt.send_type?
86
+ enum_decls, symbolized = scan(statements)
91
87
 
92
- name = enum_call(stmt)
93
- next unless name
88
+ enum_decls.each do |name, decl|
89
+ next if symbolized.include?(name)
94
90
 
95
- add_offense(stmt, message: format(MSG, name: name))
91
+ add_offense(decl, message: format(MSG, name: name))
96
92
  end
97
93
  end
94
+
95
+ def scan(statements)
96
+ sends = statements.select { |stmt| stmt.respond_to?(:send_type?) && stmt.send_type? }
97
+ enum_decls = sends.filter_map { |stmt| (name = enum_call(stmt)) && [name, stmt] }
98
+ symbolized = sends.flat_map { |stmt| symbolized_names(stmt) }
99
+ [enum_decls, symbolized]
100
+ end
101
+
102
+ def symbolized_names(stmt)
103
+ args = enum_symbolize_call(stmt)
104
+ return [] unless args
105
+
106
+ args.select(&:sym_type?).map(&:value)
107
+ end
98
108
  end
99
109
  end
100
110
  end
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.10.2".freeze
3
+ VERSION = "0.10.3".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.10.2
4
+ version: 0.10.3
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-07-09 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,19 +79,19 @@ dependencies:
80
79
  - - ">="
81
80
  - !ruby/object:Gem::Version
82
81
  version: '2.29'
83
- description:
84
- email:
85
82
  executables: []
86
83
  extensions: []
87
84
  extra_rdoc_files: []
88
85
  files:
89
86
  - config/default.yml
90
87
  - config/obsoletion.yml
88
+ - lib/dev_doc/i18n/pseudo_locale.rb
91
89
  - lib/dev_doc/test/best_practice_lints.rb
92
90
  - lib/dev_doc/test/lints/cron_schedule.rb
93
91
  - lib/dev_doc/test/lints/duplicate_snapshot.rb
94
92
  - lib/dev_doc/test/lints/http_driven_controller_tests.rb
95
93
  - lib/dev_doc/test/lints/no_file_excludes.rb
94
+ - lib/dev_doc/test/pseudo_i18n_crawler.rb
96
95
  - lib/rubocop-dev_doc.rb
97
96
  - lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
98
97
  - lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
@@ -158,12 +157,10 @@ files:
158
157
  - lib/rubocop/dev_doc.rb
159
158
  - lib/rubocop/dev_doc/plugin.rb
160
159
  - lib/rubocop/dev_doc/version.rb
161
- homepage:
162
160
  licenses: []
163
161
  metadata:
164
162
  default_lint_roller_plugin: RuboCop::DevDoc::Plugin
165
163
  rubygems_mfa_required: 'true'
166
- post_install_message:
167
164
  rdoc_options: []
168
165
  require_paths:
169
166
  - lib
@@ -178,8 +175,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
178
175
  - !ruby/object:Gem::Version
179
176
  version: '0'
180
177
  requirements: []
181
- rubygems_version: 3.4.6
182
- signing_key:
178
+ rubygems_version: 4.0.6
183
179
  specification_version: 4
184
180
  summary: RuboCop cops enforcing dev-doc best practices
185
181
  test_files: []