rubocop-dev_doc 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/config/default.yml +312 -60
- data/config/obsoletion.yml +7 -3
- data/lib/dev_doc/i18n/tasks.rb +68 -0
- data/lib/dev_doc/railtie.rb +19 -0
- data/lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb +2 -0
- data/lib/dev_doc/test/lints/domain_class_base.rb +138 -0
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +2 -0
- data/lib/dev_doc/test/pseudo_i18n_crawler.rb +5 -0
- data/lib/rubocop/cop/dev_doc/i18n/hardcoded_text.rb +243 -0
- data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +69 -19
- data/lib/rubocop/cop/dev_doc/i18n/require_translation.rb +13 -17
- data/lib/rubocop/cop/dev_doc/i18n/text_shape.rb +94 -0
- data/lib/rubocop/cop/dev_doc/rails/application_model_base.rb +56 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +81 -26
- data/lib/rubocop/cop/dev_doc/rails/no_errors_add_in_controller.rb +177 -0
- data/lib/rubocop/cop/dev_doc/rails/no_form_param_lookup_in_controller.rb +236 -0
- data/lib/rubocop/cop/dev_doc/style/in_memory_association_find.rb +162 -0
- data/lib/rubocop/cop/dev_doc/style/no_silent_rescue.rb +269 -0
- data/lib/rubocop/cop/dev_doc/style/prefer_allowlist_symbol_param.rb +193 -0
- data/lib/rubocop/cop/dev_doc/style/use_g_log.rb +133 -0
- data/lib/rubocop/cop/dev_doc/test/prefer_find_sole_by.rb +146 -0
- data/lib/rubocop/cop/dev_doc/test/test_name_special_characters.rb +108 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +11 -1
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +16 -3
- data/lib/rubocop/cop/dev_doc/i18n/report_text.rb +0 -76
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
require 'pathname'
|
|
2
|
+
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
module Lints
|
|
6
|
+
# Runtime check: every CLASS defined by a file under `app/models/`
|
|
7
|
+
# (excluding `concerns/`) must descend from one of the project's domain
|
|
8
|
+
# base classes. Runs with the app loaded, so real ancestry is checked —
|
|
9
|
+
# intermediate family bases and indirect inheritance resolve correctly,
|
|
10
|
+
# which is exactly what a per-file static cop cannot do.
|
|
11
|
+
#
|
|
12
|
+
# Wrapped by the Minitest module `DomainClassBase` below — see that
|
|
13
|
+
# module for the rationale.
|
|
14
|
+
class DomainClassBaseChecker
|
|
15
|
+
# The three-way rule (best_practices/backend/en/03_model.md item 9):
|
|
16
|
+
# persisted -> ApplicationRecord; table-less but form-backed ->
|
|
17
|
+
# ApplicationModel; plain domain logic -> PlainModel.
|
|
18
|
+
DEFAULT_BASE_CLASS_NAMES = %w[ApplicationRecord ApplicationModel PlainModel].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(project_root, base_class_names: DEFAULT_BASE_CLASS_NAMES, allowed_paths: [])
|
|
21
|
+
@project_root = Pathname(project_root)
|
|
22
|
+
@base_class_names = base_class_names
|
|
23
|
+
@allowed_paths = allowed_paths
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns an Array<String> of offender descriptions, or `[]` when
|
|
27
|
+
# every model class descends from an allowed base. Modules are skipped:
|
|
28
|
+
# the three-way rule classifies CLASSES — namespaces, function-bag
|
|
29
|
+
# modules, and concerns carry no instance state to classify.
|
|
30
|
+
def offenders
|
|
31
|
+
bases = @base_class_names.map(&:constantize)
|
|
32
|
+
|
|
33
|
+
model_files.filter_map do |path|
|
|
34
|
+
constant = constant_for(path)
|
|
35
|
+
next if constant.nil? # module or namespace-only file
|
|
36
|
+
next if bases.any? { |base| constant <= base }
|
|
37
|
+
|
|
38
|
+
" #{relative(path)}: #{constant.name} < #{constant.superclass.name} — #{hint_for(constant)}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def model_files
|
|
45
|
+
Dir.glob(@project_root.join('app/models/**/*.rb')).reject do |path|
|
|
46
|
+
relative = relative(path)
|
|
47
|
+
relative.start_with?('app/models/concerns/') ||
|
|
48
|
+
@allowed_paths.any? { |allowed| relative.start_with?(allowed) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Zeitwerk guarantees the file defines the constant its path names, so
|
|
53
|
+
# deriving it from the path (rather than parsing the source) is exact,
|
|
54
|
+
# and nested helper classes (e.g. error classes inside a model) are
|
|
55
|
+
# never enumerated.
|
|
56
|
+
def constant_for(path)
|
|
57
|
+
name = relative(path).delete_prefix('app/models/').delete_suffix('.rb').camelize
|
|
58
|
+
constant = name.constantize
|
|
59
|
+
constant.is_a?(Class) ? constant : nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def hint_for(constant)
|
|
63
|
+
if constant.include?(ActiveModel::Model)
|
|
64
|
+
'it includes ActiveModel::Model, so inherit ApplicationModel instead'
|
|
65
|
+
else
|
|
66
|
+
'inherit PlainModel (or ApplicationRecord/ApplicationModel if it persists or backs a form)'
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def relative(path)
|
|
71
|
+
Pathname(path).relative_path_from(@project_root).to_s
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Domain-class base tripwire: every class under `app/models/` must be one
|
|
76
|
+
# of the project's three kinds — persisted (ApplicationRecord),
|
|
77
|
+
# form-backed (ApplicationModel), or plain domain logic (PlainModel).
|
|
78
|
+
#
|
|
79
|
+
# ## Rationale
|
|
80
|
+
# The three-way rule (backend/03_model.md item 9) makes the author
|
|
81
|
+
# classify each domain class at creation time; the base's docstring then
|
|
82
|
+
# states the contract that kind carries. Static analysis cannot enforce
|
|
83
|
+
# totality — RuboCop sees one file at a time, so it cannot resolve
|
|
84
|
+
# whether `class Foo < SomeFamilyBase` ultimately reaches an allowed
|
|
85
|
+
# base. This lint checks real ancestry with the app loaded, so
|
|
86
|
+
# intermediate bases resolve and the rule is enforced literally.
|
|
87
|
+
# `Rails/ApplicationRecord` and `DevDoc/Rails/ApplicationModelBase`
|
|
88
|
+
# remain useful beside it for editor-time feedback on the two common
|
|
89
|
+
# direct mistakes.
|
|
90
|
+
#
|
|
91
|
+
# NOTE: Limitations:
|
|
92
|
+
# - Only classes whose files live under `app/models/` are checked; a
|
|
93
|
+
# domain class parked elsewhere is invisible (placement itself is the
|
|
94
|
+
# orchestration taxonomy's reviewer-owned residual).
|
|
95
|
+
# - Modules are skipped by design (namespaces, function-bag modules,
|
|
96
|
+
# concerns) — the rule classifies classes.
|
|
97
|
+
#
|
|
98
|
+
# ## Usage
|
|
99
|
+
# Include this module in a Minitest test class (Rails test env) in a
|
|
100
|
+
# project whose three bases exist. Override the constants on the test
|
|
101
|
+
# class to rename bases or exempt a sanctioned file:
|
|
102
|
+
#
|
|
103
|
+
# class DomainClassBaseTest < ActiveSupport::TestCase
|
|
104
|
+
# include DevDoc::Test::Lints::DomainClassBase
|
|
105
|
+
# # DOMAIN_BASE_CLASS_NAMES = %w[ApplicationRecord ApplicationModel PlainModel].freeze
|
|
106
|
+
# # ALLOWED_DOMAIN_CLASS_PATHS = %w[app/models/legacy/].freeze
|
|
107
|
+
# end
|
|
108
|
+
module DomainClassBase
|
|
109
|
+
# Defaults. Per-project override: redefine the constants on the test
|
|
110
|
+
# class that includes this module.
|
|
111
|
+
DOMAIN_BASE_CLASS_NAMES = DomainClassBaseChecker::DEFAULT_BASE_CLASS_NAMES
|
|
112
|
+
ALLOWED_DOMAIN_CLASS_PATHS = [].freeze
|
|
113
|
+
|
|
114
|
+
def test_every_model_class_descends_from_a_domain_base
|
|
115
|
+
offenders = DomainClassBaseChecker.new(
|
|
116
|
+
Rails.root,
|
|
117
|
+
base_class_names: self.class::DOMAIN_BASE_CLASS_NAMES,
|
|
118
|
+
allowed_paths: self.class::ALLOWED_DOMAIN_CLASS_PATHS
|
|
119
|
+
).offenders
|
|
120
|
+
|
|
121
|
+
assert offenders.empty?, domain_class_base_message(offenders)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def domain_class_base_message(offenders)
|
|
127
|
+
"Classes under app/models must descend from one of " \
|
|
128
|
+
"#{self.class::DOMAIN_BASE_CLASS_NAMES.join(' / ')} — the three-way rule " \
|
|
129
|
+
"(backend/03_model.md item 9): persisted -> ApplicationRecord, form-backed " \
|
|
130
|
+
"-> ApplicationModel, plain domain logic -> PlainModel. If a class is a " \
|
|
131
|
+
"sanctioned exception, add its path to ALLOWED_DOMAIN_CLASS_PATHS on the " \
|
|
132
|
+
"including test class with a comment.\n\n" \
|
|
133
|
+
"Offenders:\n#{offenders.join("\n")}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -103,6 +103,8 @@ module DevDoc
|
|
|
103
103
|
def __dev_doc_source_file
|
|
104
104
|
method(name).source_location&.first
|
|
105
105
|
rescue NameError
|
|
106
|
+
# silent-rescue: an unresolvable test-method name means the source
|
|
107
|
+
# file is unknown; the location line renders without it.
|
|
106
108
|
nil
|
|
107
109
|
end
|
|
108
110
|
|
|
@@ -123,6 +123,8 @@ module DevDoc
|
|
|
123
123
|
def parse_json(body)
|
|
124
124
|
JSON.parse(body)
|
|
125
125
|
rescue JSON::ParserError
|
|
126
|
+
# silent-rescue: non-JSON bodies are expected here — scan_body only
|
|
127
|
+
# wants the parseable ones and skips the rest.
|
|
126
128
|
nil
|
|
127
129
|
end
|
|
128
130
|
|
|
@@ -170,6 +172,9 @@ module DevDoc
|
|
|
170
172
|
data = YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true)
|
|
171
173
|
collect_strings(data, set)
|
|
172
174
|
rescue Psych::Exception
|
|
175
|
+
# silent-rescue: an unparsable fixture is skipped; its strings
|
|
176
|
+
# then count as noise candidates instead of exclusions, which
|
|
177
|
+
# shows up in the report rather than hiding anything.
|
|
173
178
|
next
|
|
174
179
|
end
|
|
175
180
|
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
require_relative 'text_shape'
|
|
2
|
+
require_relative 'localizable_props'
|
|
3
|
+
|
|
4
|
+
module RuboCop
|
|
5
|
+
module Cop
|
|
6
|
+
module DevDoc
|
|
7
|
+
module I18n
|
|
8
|
+
# Warn on any string literal that reads like user-facing copy, wherever
|
|
9
|
+
# it sits — not only in glib text props.
|
|
10
|
+
#
|
|
11
|
+
# ## Rationale
|
|
12
|
+
# `DevDoc/I18n/RequireTranslation` checks the value of a known text prop
|
|
13
|
+
# on a known component. Copy escapes it whenever it reaches the screen
|
|
14
|
+
# by any other route: a ternary or `||` fallback inside the prop
|
|
15
|
+
# (`text: signed_in ? 'Sign out' : 'Sign in'`), a hash built in a local
|
|
16
|
+
# and passed in later (`required = { message: 'Required' }`), a model
|
|
17
|
+
# method that builds select options, a controller error string, a mailer
|
|
18
|
+
# subject, a constant. Each of those ships English-only text that can
|
|
19
|
+
# never be translated.
|
|
20
|
+
#
|
|
21
|
+
# This cop inspects every `str`/`dstr` node and reports the ones whose
|
|
22
|
+
# *shape* is prose (two or more words, a capitalised word, or non-ASCII
|
|
23
|
+
# letters) and whose *position* is not a known non-text one. It skips:
|
|
24
|
+
#
|
|
25
|
+
# * identifier-shaped strings — keys, CSS class lists, paths, URLs,
|
|
26
|
+
# MIME types, emails, constants, inline CSS, SQL, `strftime` formats;
|
|
27
|
+
# * `t(...)`/`translate`/`l`/`localize` keys (a literal `default:` is
|
|
28
|
+
# still checked — it is the English the user sees when the key is
|
|
29
|
+
# missing);
|
|
30
|
+
# * hash keys, and values under `ExemptKeys` (`styleClass:`,
|
|
31
|
+
# `class_name:`, `url:` ...);
|
|
32
|
+
# * receivers and direct arguments of `ExemptMethods` (SQL builders,
|
|
33
|
+
# comparisons, string matching, hash lookups, `require` ...);
|
|
34
|
+
# * anything inside `DeveloperFacingMethods` (`raise`, `warn`, ...), a
|
|
35
|
+
# `logger.*` call, or a call on a `DeveloperFacingReceivers` constant
|
|
36
|
+
# (error trackers such as `Rollbar.error`) — exception, log and
|
|
37
|
+
# error-report text is for developers;
|
|
38
|
+
# * `when` conditions, and the parts of regexps and backticks;
|
|
39
|
+
# * literals `RequireTranslation` already checks (see below).
|
|
40
|
+
#
|
|
41
|
+
# An interpolated string is judged by its literal parts, with each
|
|
42
|
+
# interpolation counted as a word: `"#{count} items"` is prose,
|
|
43
|
+
# `"#{a}. #{b}"` and `"badge--#{state}"` are not.
|
|
44
|
+
#
|
|
45
|
+
# It cannot tell copy a user sees from prose that never leaves the
|
|
46
|
+
# server, so it is a **review aid**: disabled by default, `warning`
|
|
47
|
+
# severity. Run it during a localization pass and either localize each
|
|
48
|
+
# hit or disable it inline with the reason it is not user-facing.
|
|
49
|
+
#
|
|
50
|
+
# ## Relationship with RequireTranslation
|
|
51
|
+
# The two split the work so each literal is reported by exactly one of
|
|
52
|
+
# them. `RequireTranslation` owns a literal that is the direct value of
|
|
53
|
+
# a text prop on a watched glib component (`view.p text: 'Welcome'`,
|
|
54
|
+
# `json.title 'Orders'`) and flags it whatever its shape; this cop
|
|
55
|
+
# skips those and covers every other position, using that cop's
|
|
56
|
+
# `WatchedMethods` and `LocalizableKeys` to tell them apart. Two cases
|
|
57
|
+
# inside a prop stay here because `RequireTranslation` lets them
|
|
58
|
+
# through: a literal wrapped in a ternary or `||`, and an interpolated
|
|
59
|
+
# string that also calls `t(...)`
|
|
60
|
+
# (`text: "#{t('orders.title')} this month"`). Run both during a
|
|
61
|
+
# localization pass — on its own, this cop does not see literal glib
|
|
62
|
+
# props.
|
|
63
|
+
#
|
|
64
|
+
# NOTE: ERB templates are not parsed by RuboCop, so literal text in
|
|
65
|
+
# `.html.erb`/`.text.erb` (mailers, layouts) is not checked. Text built
|
|
66
|
+
# at runtime (`titleize`, `humanize`, ActiveRecord attribute names in
|
|
67
|
+
# `errors.full_messages`) is not a literal either — see
|
|
68
|
+
# `DevDoc/I18n/AvoidTitleizeHumanize` for the former.
|
|
69
|
+
#
|
|
70
|
+
# ⚠️ English-only — can't be translated
|
|
71
|
+
# view.button text: signed_in ? 'Sign out' : 'Sign in'
|
|
72
|
+
# required = { required: { message: 'Required' } }
|
|
73
|
+
# render_error 'Could not save the record'
|
|
74
|
+
# mail to: user.email, subject: "Welcome, #{user.name}"
|
|
75
|
+
#
|
|
76
|
+
# ✔️ Resolved through I18n
|
|
77
|
+
# view.button text: t(signed_in ? 'auth.sign_out' : 'auth.sign_in')
|
|
78
|
+
# render_error t('records.save_failed')
|
|
79
|
+
#
|
|
80
|
+
# @example
|
|
81
|
+
# # warning (fallback branch of a text prop)
|
|
82
|
+
# view.button text: signed_in ? 'Sign out' : 'Sign in'
|
|
83
|
+
#
|
|
84
|
+
# # warning (hash built outside the component call)
|
|
85
|
+
# required = { required: { message: 'Required' } }
|
|
86
|
+
#
|
|
87
|
+
# # warning (literal words around a translation)
|
|
88
|
+
# view.label text: "#{t('orders.title')} this month"
|
|
89
|
+
#
|
|
90
|
+
# # good
|
|
91
|
+
# view.button text: t(signed_in ? 'auth.sign_out' : 'auth.sign_in')
|
|
92
|
+
#
|
|
93
|
+
# # good (identifier-shaped)
|
|
94
|
+
# view.panels_vertical styleClass: 'card card--active'
|
|
95
|
+
#
|
|
96
|
+
# # good (developer-facing)
|
|
97
|
+
# raise ArgumentError, "Unknown step: #{step}"
|
|
98
|
+
class HardcodedText < Base
|
|
99
|
+
MSG ='Possible hardcoded text: this string reads like user-facing copy. ' \
|
|
100
|
+
'Use `t(...)`, or disable inline with the reason it is never shown to a user.'.freeze
|
|
101
|
+
|
|
102
|
+
TRANSLATION_METHODS = %i[t translate l localize].freeze
|
|
103
|
+
LOGGER_METHODS = %i[debug info warn error fatal unknown add log].freeze
|
|
104
|
+
# Nodes a value passes through on its way to a key or call
|
|
105
|
+
# (`[a, b]`, `x ? a : b`, `x || a`, `(a)`), so `styleClass: x ? 'a' : 'b'`
|
|
106
|
+
# is judged by its key just like `styleClass: 'a'`.
|
|
107
|
+
VALUE_WRAPPERS = %i[array splat if or and begin].freeze
|
|
108
|
+
|
|
109
|
+
def on_str(node)
|
|
110
|
+
return if part_of_enclosing_literal?(node)
|
|
111
|
+
return if node.source == '__FILE__'
|
|
112
|
+
|
|
113
|
+
check(node)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def on_dstr(node)
|
|
117
|
+
return if part_of_enclosing_literal?(node)
|
|
118
|
+
|
|
119
|
+
check(node)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def check(node)
|
|
125
|
+
return unless TextShape.prose?(literal_text(node))
|
|
126
|
+
return if exempt_position?(node)
|
|
127
|
+
return if checked_by_require_translation?(node)
|
|
128
|
+
|
|
129
|
+
add_offense(node)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Adjacent/continued literals and interpolated strings are judged as
|
|
133
|
+
# a whole at their outermost `dstr`; regexp and backtick contents are
|
|
134
|
+
# patterns and commands, not copy.
|
|
135
|
+
def part_of_enclosing_literal?(node)
|
|
136
|
+
return true if %i[dstr dsym].include?(node.parent&.type)
|
|
137
|
+
|
|
138
|
+
node.each_ancestor(:regexp, :xstr).any?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def literal_text(node)
|
|
142
|
+
return node.value.to_s if node.str_type?
|
|
143
|
+
return TextShape::PLACEHOLDER unless node.dstr_type?
|
|
144
|
+
|
|
145
|
+
node.children.map { |child| literal_text(child) }.join
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def exempt_position?(node)
|
|
149
|
+
value = outermost_value(node)
|
|
150
|
+
parent = value.parent
|
|
151
|
+
|
|
152
|
+
return true if exempt_pair?(value, parent)
|
|
153
|
+
return true if exempt_call_position?(value, parent)
|
|
154
|
+
return true if parent&.when_type? && parent.conditions.include?(value)
|
|
155
|
+
return true if node.each_ancestor(:in_pattern).any?
|
|
156
|
+
|
|
157
|
+
developer_facing?(node)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def checked_by_require_translation?(node)
|
|
161
|
+
# RequireTranslation accepts a string that interpolates `t(...)`,
|
|
162
|
+
# so the literal words around it are left for this cop.
|
|
163
|
+
return false if node.dstr_type? && LocalizableProps.translated_interpolation?(node)
|
|
164
|
+
|
|
165
|
+
require_translation = config.for_cop('DevDoc/I18n/RequireTranslation')
|
|
166
|
+
LocalizableProps.inspected_value?(
|
|
167
|
+
node,
|
|
168
|
+
require_translation.fetch('WatchedMethods', LocalizableProps::DEFAULT_WATCHED_METHODS),
|
|
169
|
+
require_translation.fetch('LocalizableKeys', LocalizableProps::DEFAULT_LOCALIZABLE_KEYS)
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def outermost_value(node)
|
|
174
|
+
current = node
|
|
175
|
+
while (parent = current.parent) && VALUE_WRAPPERS.include?(parent.type)
|
|
176
|
+
break if parent.if_type? && parent.condition.equal?(current)
|
|
177
|
+
|
|
178
|
+
current = parent
|
|
179
|
+
end
|
|
180
|
+
current
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def exempt_pair?(value, parent)
|
|
184
|
+
return false unless parent&.pair_type?
|
|
185
|
+
return true if parent.key.equal?(value)
|
|
186
|
+
|
|
187
|
+
key = parent.key
|
|
188
|
+
(key.sym_type? || key.str_type?) && exempt_keys.include?(key.value.to_s)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def exempt_call_position?(value, parent)
|
|
192
|
+
return false unless parent&.call_type?
|
|
193
|
+
return true if translation_key?(value, parent)
|
|
194
|
+
return false unless exempt_methods.include?(parent.method_name.to_s)
|
|
195
|
+
|
|
196
|
+
parent.receiver.equal?(value) || parent.arguments.any? { |arg| arg.equal?(value) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def translation_key?(value, call)
|
|
200
|
+
return false unless TRANSLATION_METHODS.include?(call.method_name)
|
|
201
|
+
|
|
202
|
+
receiver = call.receiver
|
|
203
|
+
(receiver.nil? || (receiver.const_type? && receiver.const_name == 'I18n')) &&
|
|
204
|
+
call.first_argument.equal?(value)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def developer_facing?(node)
|
|
208
|
+
node.each_ancestor(:send, :csend).any? { |call| developer_facing_call?(call) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def developer_facing_call?(call)
|
|
212
|
+
receiver = call.receiver
|
|
213
|
+
return developer_facing_methods.include?(call.method_name.to_s) if receiver.nil?
|
|
214
|
+
return true if receiver.const_type? && developer_facing_receivers.include?(receiver.const_name)
|
|
215
|
+
|
|
216
|
+
LOGGER_METHODS.include?(call.method_name) && logger_receiver?(receiver)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def logger_receiver?(receiver)
|
|
220
|
+
(receiver.send_type? || receiver.lvar_type? || receiver.ivar_type?) &&
|
|
221
|
+
receiver.source.match?(/logger\z/i)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def exempt_keys
|
|
225
|
+
cop_config.fetch('ExemptKeys', [])
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def exempt_methods
|
|
229
|
+
cop_config.fetch('ExemptMethods', [])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def developer_facing_methods
|
|
233
|
+
cop_config.fetch('DeveloperFacingMethods', [])
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def developer_facing_receivers
|
|
237
|
+
cop_config.fetch('DeveloperFacingReceivers', [])
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -3,7 +3,7 @@ module RuboCop
|
|
|
3
3
|
module DevDoc
|
|
4
4
|
module I18n
|
|
5
5
|
# Shared traversal for the glib JSON-UI localization cops
|
|
6
|
-
# (`RequireTranslation
|
|
6
|
+
# (`RequireTranslation`).
|
|
7
7
|
#
|
|
8
8
|
# It locates user-facing text values on watched glib component calls and
|
|
9
9
|
# hands each `(key, value_node)` to the including cop's
|
|
@@ -23,22 +23,84 @@ module RuboCop
|
|
|
23
23
|
DEFAULT_WATCHED_METHODS = %w[
|
|
24
24
|
h1 h2 h3 h4 h5 h6 p label markdown html
|
|
25
25
|
button switch chip progressCircle shareButton
|
|
26
|
-
fields_text fields_number
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
fields_text fields_number fields_email fields_url fields_password
|
|
27
|
+
fields_timer fields_submit fields_textarea fields_richText
|
|
28
|
+
fields_select fields_dynamicSelect fields_dynamicGroup
|
|
29
|
+
fields_check fields_checkGroup fields_chipGroup fields_radio
|
|
30
|
+
fields_radioGroup fields_timeZone fields_date fields_datetime
|
|
31
|
+
fields_file fields_upload fields_sign fields_location fields_phone
|
|
32
|
+
fields_otp fields_captcha fields_rating fields_creditCard
|
|
33
|
+
fields_stripeToken fields_stripeExternalAccount
|
|
30
34
|
dialogs_alert dialogs_notification
|
|
31
35
|
snackbars_alert snackbars_select
|
|
32
36
|
banners_alert banners_select
|
|
33
37
|
].freeze
|
|
34
38
|
|
|
35
39
|
DEFAULT_LOCALIZABLE_KEYS = %w[
|
|
36
|
-
title subtitle subsubtitle label placeholder text
|
|
40
|
+
title subtitle subsubtitle label placeholder hint text
|
|
37
41
|
message description uploadText leftText rightText onLabel
|
|
38
42
|
].freeze
|
|
39
43
|
|
|
40
44
|
TRANSLATION_METHODS = %i[t translate].freeze
|
|
41
45
|
|
|
46
|
+
# Whether `node` is a value this traversal hands to the cop: a
|
|
47
|
+
# localizable key's value on a watched call, at any hash/array depth,
|
|
48
|
+
# or a jbuilder positional argument. HardcodedText skips these so one
|
|
49
|
+
# literal isn't reported by both cops.
|
|
50
|
+
def self.inspected_value?(node, watched_methods, localizable_keys)
|
|
51
|
+
parent = node.parent
|
|
52
|
+
return false unless parent
|
|
53
|
+
|
|
54
|
+
if parent.pair_type?
|
|
55
|
+
localizable_pair_value?(parent, node, localizable_keys) &&
|
|
56
|
+
watched_call_argument?(parent.parent, watched_methods)
|
|
57
|
+
elsif parent.call_type?
|
|
58
|
+
jbuilder_positional_value?(parent, node, localizable_keys)
|
|
59
|
+
else
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.localizable_pair_value?(pair, node, localizable_keys)
|
|
65
|
+
pair.value.equal?(node) && pair.key.sym_type? && localizable_keys.include?(pair.key.value.to_s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.jbuilder_positional_value?(call, node, localizable_keys)
|
|
69
|
+
call.first_argument.equal?(node) && localizable_keys.include?(call.method_name.to_s) &&
|
|
70
|
+
json_receiver?(call.receiver)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Climbs out of the nested hashes and arrays `each_localizable` walks
|
|
74
|
+
# into, then checks the call they are an argument of.
|
|
75
|
+
def self.watched_call_argument?(container, watched_methods)
|
|
76
|
+
current = container
|
|
77
|
+
current = current.parent.pair_type? ? current.parent.parent : current.parent while nested_value?(current)
|
|
78
|
+
call = current.parent
|
|
79
|
+
return false unless call&.call_type? && watched_methods.include?(call.method_name.to_s)
|
|
80
|
+
|
|
81
|
+
call.arguments.any? { |arg| arg.equal?(current) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.nested_value?(node)
|
|
85
|
+
parent = node.parent
|
|
86
|
+
return false unless parent
|
|
87
|
+
|
|
88
|
+
parent.array_type? || (parent.pair_type? && parent.value.equal?(node))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.translated_interpolation?(node)
|
|
92
|
+
node.each_descendant(:send, :csend).any? do |call|
|
|
93
|
+
TRANSLATION_METHODS.include?(call.method_name)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.json_receiver?(node)
|
|
98
|
+
return false unless node
|
|
99
|
+
|
|
100
|
+
(node.send_type? && node.method_name == :json && node.receiver.nil?) ||
|
|
101
|
+
(node.lvar_type? && node.children.first == :json)
|
|
102
|
+
end
|
|
103
|
+
|
|
42
104
|
def on_send(node)
|
|
43
105
|
node.arguments.each { |arg| each_localizable(arg) } if watched_methods.include?(node.method_name.to_s)
|
|
44
106
|
|
|
@@ -76,7 +138,7 @@ module RuboCop
|
|
|
76
138
|
# mistaken for localizable props.
|
|
77
139
|
def check_jbuilder_positional(node)
|
|
78
140
|
return unless localizable_keys.include?(node.method_name.to_s)
|
|
79
|
-
return unless json_receiver?(node.receiver)
|
|
141
|
+
return unless LocalizableProps.json_receiver?(node.receiver)
|
|
80
142
|
|
|
81
143
|
value = node.first_argument
|
|
82
144
|
return unless value
|
|
@@ -84,18 +146,6 @@ module RuboCop
|
|
|
84
146
|
inspect_localizable(node.method_name, value)
|
|
85
147
|
end
|
|
86
148
|
|
|
87
|
-
def json_receiver?(node)
|
|
88
|
-
return false unless node
|
|
89
|
-
|
|
90
|
-
(node.send_type? && node.method_name == :json && node.receiver.nil?) ||
|
|
91
|
-
(node.lvar_type? && node.children.first == :json)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def translation_call?(node)
|
|
95
|
-
(node.send_type? || node.csend_type?) &&
|
|
96
|
-
TRANSLATION_METHODS.include?(node.method_name)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
149
|
def watched_methods
|
|
100
150
|
cop_config.fetch('WatchedMethods', DEFAULT_WATCHED_METHODS)
|
|
101
151
|
end
|
|
@@ -14,12 +14,13 @@ module RuboCop
|
|
|
14
14
|
#
|
|
15
15
|
# Hash props (`view.p text: '...'`), nested hash props
|
|
16
16
|
# (`view.icon tooltip: { text: '...' }`) and the jbuilder positional
|
|
17
|
-
# form (`json.title '...'`) are all checked.
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
17
|
+
# form (`json.title '...'`) are all checked. Plain strings with no
|
|
18
|
+
# letters (blank, `'0'`, `'2025'`, `'%'`, `'-'`) are ignored — there is
|
|
19
|
+
# nothing in them to translate. An interpolated string is flagged even
|
|
20
|
+
# then: its literal portions are hardcoded copy (`"Hi #{name}"`) or a
|
|
21
|
+
# format that can differ by locale (`"#{n}%"`, `"#{a}, #{b}"`) —
|
|
22
|
+
# unless it interpolates a `t(...)` (or `translate`/`I18n.t`) call,
|
|
23
|
+
# which is treated as positive localization and left alone.
|
|
23
24
|
#
|
|
24
25
|
# The watched method names and localizable keys are configurable via
|
|
25
26
|
# `WatchedMethods:` and `LocalizableKeys:`.
|
|
@@ -73,20 +74,15 @@ module RuboCop
|
|
|
73
74
|
add_offense(value, message: format(MSG, key: key))
|
|
74
75
|
end
|
|
75
76
|
|
|
76
|
-
# A plain string literal is hardcoded copy
|
|
77
|
-
# (interpolated string) is hardcoded copy too — unless it
|
|
78
|
-
# a translation call, which counts as positive
|
|
77
|
+
# A plain string literal is hardcoded copy when it has a letter. A
|
|
78
|
+
# `dstr` (interpolated string) is hardcoded copy too — unless it
|
|
79
|
+
# interpolates a translation call, which counts as positive
|
|
80
|
+
# localization.
|
|
79
81
|
def hardcoded_string?(node)
|
|
80
|
-
return !
|
|
82
|
+
return !LocalizableProps.translated_interpolation?(node) if node.dstr_type?
|
|
81
83
|
return false unless node.str_type?
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def localized_interpolation?(node)
|
|
87
|
-
node.each_descendant(:send, :csend).any? do |call|
|
|
88
|
-
TRANSLATION_METHODS.include?(call.method_name)
|
|
89
|
-
end
|
|
85
|
+
node.value.match?(/\p{L}/)
|
|
90
86
|
end
|
|
91
87
|
end
|
|
92
88
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module I18n
|
|
5
|
+
# Decides whether a string literal's text reads like prose (copy a user
|
|
6
|
+
# could see) or like an identifier (a key, CSS class list, path, URL,
|
|
7
|
+
# constant, inline CSS, SQL or format string). Used by
|
|
8
|
+
# `DevDoc/I18n/HardcodedText`.
|
|
9
|
+
#
|
|
10
|
+
# Interpolations (`#{...}`) and format directives (`%<x>s`, `%{x}`,
|
|
11
|
+
# `%Y`) are replaced by PLACEHOLDER before judging: each counts as one
|
|
12
|
+
# word toward "two or more words" but contributes no letters, so
|
|
13
|
+
# `"#{n} items"` is prose while `"#{a}. #{b}"` and `"badge--#{x}"` are
|
|
14
|
+
# not.
|
|
15
|
+
module TextShape
|
|
16
|
+
PLACEHOLDER = "␀".freeze
|
|
17
|
+
|
|
18
|
+
FORMAT_DIRECTIVE = /%(?:<\w+>[a-z]|\{\w+\}|[-_0^#]?[a-zA-Z])/
|
|
19
|
+
HTML_TAG = %r{</?[a-zA-Z][^<>]*>}
|
|
20
|
+
HTML_ENTITY = /&#?\w+;/
|
|
21
|
+
|
|
22
|
+
LITERAL_WORD = /\p{L}{2,}/
|
|
23
|
+
NON_ASCII_LETTER = /(?=\p{L})[^\p{ASCII}]/
|
|
24
|
+
WORDISH = /\p{L}|␀/
|
|
25
|
+
CAPITALISED_WORD = /\A\p{Lu}\p{Ll}[\p{Ll}'’]*[.!?:…]?\z/
|
|
26
|
+
|
|
27
|
+
URL = %r{\A(?:[a-z][a-z0-9+.-]*://|mailto:|tel:|www\.)\S*\z}i
|
|
28
|
+
PATH = %r{\A[^\s/]*/\S*\z}
|
|
29
|
+
EMAIL = /\A[^\s@]+@[^\s@]+\z/
|
|
30
|
+
# Tailwind arbitrary values (`bg-[#0A2A9E]`) may hold uppercase hex,
|
|
31
|
+
# and `!` marks an important modifier at either end (`!px-4`, `px-4!`).
|
|
32
|
+
IDENTIFIER_TOKEN = %r{\A!?(?:[a-z0-9_.:\-\[\]#/?=&*␀]|-\[[^\]\s]*\])+!?\z}
|
|
33
|
+
IDENTIFIER_SEPARATOR = %r{[-_./:#]}
|
|
34
|
+
CONSTANT_PATH = /\A[A-Z]\w*(?:::[A-Z]\w*)+\z/
|
|
35
|
+
SCREAMING_TOKEN = /\A[A-Z0-9_\-␀]+\z/
|
|
36
|
+
CAMEL_CASE_TOKEN = /\A(?:[a-z]+|[A-Z][a-z0-9]+)(?:[A-Z][a-z0-9]*)+\z/
|
|
37
|
+
SINGLE_TOKEN_NON_TEXT = [
|
|
38
|
+
URL, PATH, EMAIL, IDENTIFIER_TOKEN, CONSTANT_PATH, SCREAMING_TOKEN, CAMEL_CASE_TOKEN
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
CSS_DECLARATIONS = /\A(?:\s*[a-z-]+\s*:\s*[^;:]+;)+\s*(?:[a-z-]+\s*:\s*[^;:]+)?\s*\z/
|
|
42
|
+
SQL = /
|
|
43
|
+
\b(?:SELECT|INSERT\s+INTO|DELETE\s+FROM|WHERE|JOIN|COALESCE|ILIKE|ORDER\s+BY|GROUP\s+BY|
|
|
44
|
+
IS\s+(?:NOT\s+)?NULL|CASE\s+WHEN|ASC|DESC)\b
|
|
45
|
+
| (?:=|<>|!=|>=|<=|\bLIKE|\bIN)\s*\(?\s*(?:\?|:\w)
|
|
46
|
+
| ->>?
|
|
47
|
+
| \b(?:COUNT|SUM|AVG|MIN|MAX|CAST|DATE|DATE_DIFF|DATE_TRUNC|FORMAT_DATE|EXTRACT|NULLIF)\(
|
|
48
|
+
| (?:\)|\b[a-z0-9_]\w*)\s+AS\s+(?:[a-z_]\w*|␀)
|
|
49
|
+
| \b(?:AND|OR)\s+\(?\s*[a-z_][\w.]*\s*(?:[=<>!]|\b(?:IS|IN|LIKE|ILIKE|BETWEEN)\b)
|
|
50
|
+
/x
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
def prose?(raw)
|
|
55
|
+
text = normalize(raw)
|
|
56
|
+
return false unless text.match?(LITERAL_WORD) || text.match?(NON_ASCII_LETTER)
|
|
57
|
+
return false if non_text?(text)
|
|
58
|
+
|
|
59
|
+
tokens = text.split
|
|
60
|
+
return tokens.count { |token| token.match?(WORDISH) } >= 2 if tokens.size >= 2
|
|
61
|
+
|
|
62
|
+
text.match?(CAPITALISED_WORD) || text.match?(NON_ASCII_LETTER)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize(raw)
|
|
66
|
+
raw.gsub(FORMAT_DIRECTIVE, PLACEHOLDER)
|
|
67
|
+
.gsub(HTML_TAG, ' ')
|
|
68
|
+
.gsub(HTML_ENTITY, ' ')
|
|
69
|
+
.strip
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def non_text?(text)
|
|
73
|
+
return true if text.match?(SQL)
|
|
74
|
+
return true if text.match?(CSS_DECLARATIONS) && (text.include?(';') || text.match?(/\A[a-z]+-[a-z]/))
|
|
75
|
+
|
|
76
|
+
tokens = text.split
|
|
77
|
+
return SINGLE_TOKEN_NON_TEXT.any? { |pattern| text.match?(pattern) } if tokens.size == 1
|
|
78
|
+
|
|
79
|
+
identifier_list?(tokens)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A CSS class list or similar: every token lowercase and
|
|
83
|
+
# identifier-shaped, at least one with a separator (`card card--active`).
|
|
84
|
+
# A plain lowercase phrase (`not yet submitted`) has no separator and
|
|
85
|
+
# stays prose.
|
|
86
|
+
def identifier_list?(tokens)
|
|
87
|
+
tokens.all? { |token| token.match?(IDENTIFIER_TOKEN) } &&
|
|
88
|
+
tokens.any? { |token| token.match?(IDENTIFIER_SEPARATOR) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|