rubocop-dev_doc 0.10.2 → 0.10.4

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: b11d7c80b04d4ef7ecbff606f3920260773ae1767ffcacab78e99663920ca9d7
4
+ data.tar.gz: eaae1ebc845ca76bc72a878916d1f096c5495ce16b9238734c823875b0ba2323
5
5
  SHA512:
6
- metadata.gz: b30977f9056fd77d2a58227e98b2fc6860ad1333a52fb4bb9bf7ea636ab20ebca1ad9187a84a4ca3e14c8f442861ec3c779ddb88d9d940c459483bcf204726e0
7
- data.tar.gz: 93ae91781dd10664b18b8e304e9b336485a572bd9a32822af81df3e399fb742420ed1d41612e21e4249eb79cbc53569082468091c8994470f552bf43dd7c7b8d
6
+ metadata.gz: 0c227078e2a29302744d3b14cd3058f5912b50831221b82d7ab8cb13885ffaf4bca0eb7e8784745bd470533190e6c522d3f69b28f7e64cc18f183d2a39d6dd19
7
+ data.tar.gz: b7f474e88689ce7bd672e735458b5b8e45c5c47448b44461b673a46b8ac4b74c997ccebf548b02b33460095214c2d6920211ffb621a82eca3e231d8f65898f4d
@@ -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
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.10.2".freeze
3
+ VERSION = "0.10.4".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
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.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
@@ -88,11 +88,13 @@ extra_rdoc_files: []
88
88
  files:
89
89
  - config/default.yml
90
90
  - config/obsoletion.yml
91
+ - lib/dev_doc/i18n/pseudo_locale.rb
91
92
  - lib/dev_doc/test/best_practice_lints.rb
92
93
  - lib/dev_doc/test/lints/cron_schedule.rb
93
94
  - lib/dev_doc/test/lints/duplicate_snapshot.rb
94
95
  - lib/dev_doc/test/lints/http_driven_controller_tests.rb
95
96
  - lib/dev_doc/test/lints/no_file_excludes.rb
97
+ - lib/dev_doc/test/pseudo_i18n_crawler.rb
96
98
  - lib/rubocop-dev_doc.rb
97
99
  - lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
98
100
  - lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb