rigortype 0.1.10 → 0.1.12
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/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +44 -3
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/scope.rbs +22 -0
- metadata +157 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
class RailsI18n < Rigor::Plugin::Base
|
|
9
|
+
# Walks a parsed file's AST looking for `t(...)` /
|
|
10
|
+
# `I18n.t(...)` / `I18n.translate(...)` calls with a
|
|
11
|
+
# literal-string first argument. Calls with a non-literal
|
|
12
|
+
# key (variable, expression) are silently passed through —
|
|
13
|
+
# the plugin only validates what it can prove statically.
|
|
14
|
+
#
|
|
15
|
+
# ## What gets emitted per recognised call
|
|
16
|
+
#
|
|
17
|
+
# - `plugin.rails-i18n.translation-call` (info) names the
|
|
18
|
+
# key and the locales it resolves in.
|
|
19
|
+
# - `plugin.rails-i18n.unknown-key` (error) when the key
|
|
20
|
+
# is missing from every loaded locale; the message
|
|
21
|
+
# includes a did-you-mean suggestion drawn from the
|
|
22
|
+
# index.
|
|
23
|
+
# - `plugin.rails-i18n.missing-locale` (warning) when the
|
|
24
|
+
# key resolves in some configured locales but is absent
|
|
25
|
+
# from at least one. Suppressed when the call passes
|
|
26
|
+
# `default:` (the user has signalled they're aware of
|
|
27
|
+
# the partial coverage).
|
|
28
|
+
# - `plugin.rails-i18n.wrong-interpolation` (error) when
|
|
29
|
+
# the call's interpolation hash uses keys that don't
|
|
30
|
+
# match the value's `%{var}` placeholders, or omits a
|
|
31
|
+
# required placeholder.
|
|
32
|
+
module Analyzer
|
|
33
|
+
TRANSLATE_METHODS = %i[t translate].freeze
|
|
34
|
+
|
|
35
|
+
# Methods that are always I18n receivers (`I18n.t`,
|
|
36
|
+
# `::I18n.t`).
|
|
37
|
+
I18N_RECEIVER_NAMES = %w[I18n ::I18n].freeze
|
|
38
|
+
|
|
39
|
+
# Matches controller file paths so lazy keys (`.key`)
|
|
40
|
+
# can be expanded to `<controller_scope>.<action>.<key>`.
|
|
41
|
+
# Captures the path segment between `controllers/` and
|
|
42
|
+
# `_controller.rb` (e.g. `users`, `admin/users`).
|
|
43
|
+
CONTROLLER_PATH_RE = %r{(?:^|/)controllers/(.+)_controller\.rb$}
|
|
44
|
+
|
|
45
|
+
# Reserved option keys — these are recognised by I18n
|
|
46
|
+
# itself and not treated as interpolation variables.
|
|
47
|
+
RESERVED_OPTION_KEYS = %i[
|
|
48
|
+
default scope locale count raise throw fallback
|
|
49
|
+
fallback_in_progress separator deep_interpolation
|
|
50
|
+
].to_set.freeze
|
|
51
|
+
|
|
52
|
+
# Key prefixes Rails / `rails-i18n` ship in every
|
|
53
|
+
# locale by default. Projects whose own locale files
|
|
54
|
+
# don't redeclare them still get them at runtime (via
|
|
55
|
+
# the `rails-i18n` gem's bundled locale catalogues).
|
|
56
|
+
# `t('date.order')` is the canonical Mastodon case —
|
|
57
|
+
# used by `Settings::Date::Order` and date-of-birth
|
|
58
|
+
# selects, never authored project-side. Skip
|
|
59
|
+
# `unknown-key` for these; downstream interpolation
|
|
60
|
+
# checks have nothing to validate without a leaf entry
|
|
61
|
+
# so they decline silently too.
|
|
62
|
+
RAILS_SHIPPED_KEY_PREFIXES = %w[
|
|
63
|
+
date.
|
|
64
|
+
time.
|
|
65
|
+
datetime.
|
|
66
|
+
support.array.
|
|
67
|
+
errors.format
|
|
68
|
+
errors.messages.
|
|
69
|
+
number.
|
|
70
|
+
helpers.select.
|
|
71
|
+
helpers.submit.
|
|
72
|
+
helpers.label.
|
|
73
|
+
i18n.transliterate.
|
|
74
|
+
activerecord.errors.messages.
|
|
75
|
+
activerecord.errors.models.
|
|
76
|
+
].freeze
|
|
77
|
+
|
|
78
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
79
|
+
|
|
80
|
+
module_function
|
|
81
|
+
|
|
82
|
+
def rails_shipped_key?(literal_key)
|
|
83
|
+
key_str = literal_key.to_s
|
|
84
|
+
RAILS_SHIPPED_KEY_PREFIXES.any? { |prefix| key_str.start_with?(prefix) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param path [String]
|
|
88
|
+
# @param root [Prism::Node]
|
|
89
|
+
# @param locale_index [LocaleIndex]
|
|
90
|
+
# @param configured_locales [Array<String>]
|
|
91
|
+
# @return [Array<Diagnostic>]
|
|
92
|
+
def diagnose(path:, root:, locale_index:, configured_locales:)
|
|
93
|
+
controller_scope = controller_scope_from_path(path)
|
|
94
|
+
diagnostics = []
|
|
95
|
+
walk(root) do |call_node, action|
|
|
96
|
+
raw_key = literal_key_for(call_node)
|
|
97
|
+
next if raw_key.nil?
|
|
98
|
+
|
|
99
|
+
literal_key = expand_key(raw_key, controller_scope: controller_scope, action: action)
|
|
100
|
+
next if literal_key.nil?
|
|
101
|
+
|
|
102
|
+
options = options_hash(call_node)
|
|
103
|
+
entry = locale_index.find(literal_key)
|
|
104
|
+
if entry.nil?
|
|
105
|
+
# CLDR pluralization namespace: the parent key isn't
|
|
106
|
+
# a leaf, but at least one `.one` / `.other` / etc.
|
|
107
|
+
# child exists. `t('accounts.posts', count: n)`
|
|
108
|
+
# resolves through that branch — not a missing key.
|
|
109
|
+
# Accept silently; downstream interpolation checks
|
|
110
|
+
# don't apply (no single entry to read placeholders
|
|
111
|
+
# from).
|
|
112
|
+
next if locale_index.pluralization_namespace?(literal_key)
|
|
113
|
+
|
|
114
|
+
# Rails / rails-i18n ship `date.order`, `time.am`,
|
|
115
|
+
# `support.array.words_connector`, etc. in every
|
|
116
|
+
# locale at runtime even when the project's own
|
|
117
|
+
# locale files don't repeat them. Accept silently
|
|
118
|
+
# — no leaf entry → no downstream interpolation
|
|
119
|
+
# check to run.
|
|
120
|
+
next if rails_shipped_key?(literal_key)
|
|
121
|
+
|
|
122
|
+
diagnostics << unknown_key_diagnostic(path, call_node, literal_key, locale_index)
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
diagnostics << translation_call_info(path, call_node, literal_key, entry)
|
|
127
|
+
missing_in_locales = locale_index.missing_locales_for(literal_key, configured_locales: configured_locales)
|
|
128
|
+
diagnostics << missing_locale_diagnostic(path, call_node, literal_key, missing_in_locales) \
|
|
129
|
+
if !options[:has_default] && !missing_in_locales.empty?
|
|
130
|
+
|
|
131
|
+
interpolation_diags = interpolation_diagnostics(path, call_node, literal_key, entry, options)
|
|
132
|
+
diagnostics.concat(interpolation_diags)
|
|
133
|
+
end
|
|
134
|
+
diagnostics
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Walks the AST yielding `[call_node, action]` pairs where
|
|
138
|
+
# `action` is the name of the innermost enclosing `def`
|
|
139
|
+
# method (or `nil` when the call is at the top level).
|
|
140
|
+
def walk(node, action: nil, &)
|
|
141
|
+
return unless node.is_a?(Prism::Node)
|
|
142
|
+
|
|
143
|
+
current_action = node.is_a?(Prism::DefNode) ? node.name.to_s : action
|
|
144
|
+
yield node, current_action if node.is_a?(Prism::CallNode) && translate_call_candidate?(node)
|
|
145
|
+
node.compact_child_nodes.each { |child| walk(child, action: current_action, &) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Derives the Rails controller scope from the file path,
|
|
149
|
+
# e.g. `app/controllers/admin/users_controller.rb` → `admin.users`.
|
|
150
|
+
# Returns nil for non-controller paths.
|
|
151
|
+
def controller_scope_from_path(path)
|
|
152
|
+
m = CONTROLLER_PATH_RE.match(path.to_s)
|
|
153
|
+
return nil unless m
|
|
154
|
+
|
|
155
|
+
m[1].tr("/", ".")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Expands a lazy key (starting with `.`) to its full
|
|
159
|
+
# dotted path using the controller scope and action name.
|
|
160
|
+
# Returns the raw key unchanged for absolute keys.
|
|
161
|
+
# Returns nil for lazy keys outside a controller context
|
|
162
|
+
# or without an enclosing action — these are silently
|
|
163
|
+
# skipped to avoid false positives.
|
|
164
|
+
def expand_key(raw_key, controller_scope:, action:)
|
|
165
|
+
return raw_key unless raw_key.start_with?(".")
|
|
166
|
+
return nil unless controller_scope && action
|
|
167
|
+
|
|
168
|
+
"#{controller_scope}.#{action}#{raw_key}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def translate_call_candidate?(node)
|
|
172
|
+
return false unless TRANSLATE_METHODS.include?(node.name)
|
|
173
|
+
return true if node.receiver.nil?
|
|
174
|
+
|
|
175
|
+
receiver_name = constant_receiver_name(node.receiver)
|
|
176
|
+
I18N_RECEIVER_NAMES.include?(receiver_name)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Extracts the literal-string first argument when
|
|
180
|
+
# present. Returns nil for variable / expression keys —
|
|
181
|
+
# those are out of scope for v0.1.0.
|
|
182
|
+
def literal_key_for(call_node)
|
|
183
|
+
args = call_node.arguments&.arguments || []
|
|
184
|
+
return nil if args.empty?
|
|
185
|
+
|
|
186
|
+
first = args.first
|
|
187
|
+
return nil unless first.is_a?(Prism::StringNode)
|
|
188
|
+
|
|
189
|
+
first.unescaped
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Pulls the interpolation hash from the call's
|
|
193
|
+
# arguments. The trailing `Hash` argument (or
|
|
194
|
+
# `Prism::KeywordHashNode`) carries both reserved I18n
|
|
195
|
+
# options (`default:`, `scope:`, …) and interpolation
|
|
196
|
+
# variables. Returns:
|
|
197
|
+
# {
|
|
198
|
+
# :has_default => bool,
|
|
199
|
+
# :all_keys => Set<Symbol> (every assoc key in the hash),
|
|
200
|
+
# :non_reserved => Set<Symbol> (keys NOT in RESERVED_OPTION_KEYS),
|
|
201
|
+
# :hash_node => Prism::Node (or nil)
|
|
202
|
+
# }
|
|
203
|
+
#
|
|
204
|
+
# Note: a reserved option key (e.g. `count:`) can
|
|
205
|
+
# also serve as an interpolation value when the
|
|
206
|
+
# locale's leaf string has `%{count}`. The analyzer
|
|
207
|
+
# therefore checks the missing-placeholder set
|
|
208
|
+
# against `all_keys` (so `count:` satisfies a
|
|
209
|
+
# `%{count}` placeholder) and the
|
|
210
|
+
# extra-placeholder set against `non_reserved` (so
|
|
211
|
+
# `default:` / `scope:` are never reported as
|
|
212
|
+
# extra interpolation arguments).
|
|
213
|
+
def options_hash(call_node)
|
|
214
|
+
args = call_node.arguments&.arguments || []
|
|
215
|
+
last = args.last
|
|
216
|
+
empty = { has_default: false, all_keys: Set.new, non_reserved: Set.new, hash_node: nil }
|
|
217
|
+
return empty unless hash_like?(last)
|
|
218
|
+
|
|
219
|
+
assoc_keys = collect_assoc_keys(last)
|
|
220
|
+
all_keys = assoc_keys.to_set
|
|
221
|
+
non_reserved = assoc_keys.reject { |k| RESERVED_OPTION_KEYS.include?(k) }.to_set
|
|
222
|
+
{
|
|
223
|
+
has_default: assoc_keys.include?(:default),
|
|
224
|
+
all_keys: all_keys,
|
|
225
|
+
non_reserved: non_reserved,
|
|
226
|
+
hash_node: last
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def hash_like?(node)
|
|
231
|
+
node.is_a?(Prism::HashNode) || node.is_a?(Prism::KeywordHashNode)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def collect_assoc_keys(hash_node)
|
|
235
|
+
# Both `Prism::HashNode` and `Prism::KeywordHashNode`
|
|
236
|
+
# expose `#elements`; the conditional was an
|
|
237
|
+
# accidental no-op carried over from an earlier
|
|
238
|
+
# draft.
|
|
239
|
+
hash_node.elements.filter_map do |element|
|
|
240
|
+
next nil unless element.is_a?(Prism::AssocNode)
|
|
241
|
+
|
|
242
|
+
key_node = element.key
|
|
243
|
+
case key_node
|
|
244
|
+
when Prism::SymbolNode then key_node.unescaped.to_sym
|
|
245
|
+
when Prism::StringNode then key_node.unescaped.to_sym
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def translation_call_info(path, call_node, literal_key, entry)
|
|
251
|
+
location = call_node.location
|
|
252
|
+
locales_text = entry.locales.sort.join(", ")
|
|
253
|
+
Diagnostic.new(
|
|
254
|
+
path: path,
|
|
255
|
+
line: location.start_line,
|
|
256
|
+
column: location.start_column + 1,
|
|
257
|
+
severity: :info,
|
|
258
|
+
rule: "translation-call",
|
|
259
|
+
message: "`t('#{literal_key}')` resolves in #{locales_text}"
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def unknown_key_diagnostic(path, call_node, literal_key, locale_index)
|
|
264
|
+
location = call_node.location
|
|
265
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: locale_index.keys).correct(literal_key)
|
|
266
|
+
suggestion_part = suggestions.empty? ? "" : " (did you mean `#{suggestions.first}`?)"
|
|
267
|
+
Diagnostic.new(
|
|
268
|
+
path: path,
|
|
269
|
+
line: location.start_line,
|
|
270
|
+
column: location.start_column + 1,
|
|
271
|
+
severity: :error,
|
|
272
|
+
rule: "unknown-key",
|
|
273
|
+
message: "missing translation key `#{literal_key}` in any locale#{suggestion_part}"
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def missing_locale_diagnostic(path, call_node, literal_key, missing_locales)
|
|
278
|
+
location = call_node.location
|
|
279
|
+
locales_text = missing_locales.to_a.sort.join(", ")
|
|
280
|
+
Diagnostic.new(
|
|
281
|
+
path: path,
|
|
282
|
+
line: location.start_line,
|
|
283
|
+
column: location.start_column + 1,
|
|
284
|
+
severity: :warning,
|
|
285
|
+
rule: "missing-locale",
|
|
286
|
+
message: "`t('#{literal_key}')` is missing from locale(s) #{locales_text}"
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def interpolation_diagnostics(path, call_node, literal_key, entry, options)
|
|
291
|
+
required = entry.all_placeholders
|
|
292
|
+
all_provided = options[:all_keys].to_set(&:to_s)
|
|
293
|
+
non_reserved_provided = options[:non_reserved].to_set(&:to_s)
|
|
294
|
+
missing = required - all_provided
|
|
295
|
+
extra = non_reserved_provided - required
|
|
296
|
+
location = call_node.location
|
|
297
|
+
|
|
298
|
+
[].tap do |diags|
|
|
299
|
+
unless missing.empty?
|
|
300
|
+
diags << Diagnostic.new(
|
|
301
|
+
path: path,
|
|
302
|
+
line: location.start_line,
|
|
303
|
+
column: location.start_column + 1,
|
|
304
|
+
severity: :error,
|
|
305
|
+
rule: "wrong-interpolation",
|
|
306
|
+
message: "`t('#{literal_key}')` expects interpolation #{format_keys(missing)}, " \
|
|
307
|
+
"got #{format_keys(non_reserved_provided)}"
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
unless extra.empty?
|
|
312
|
+
diags << Diagnostic.new(
|
|
313
|
+
path: path,
|
|
314
|
+
line: location.start_line,
|
|
315
|
+
column: location.start_column + 1,
|
|
316
|
+
severity: :warning,
|
|
317
|
+
rule: "extra-interpolation",
|
|
318
|
+
message: "`t('#{literal_key}')` does not use interpolation #{format_keys(extra)} " \
|
|
319
|
+
"(known placeholders: #{format_keys(required)})"
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def format_keys(set)
|
|
326
|
+
return "(none)" if set.empty?
|
|
327
|
+
|
|
328
|
+
set.to_a.sort.map { |k| "`#{k}`" }.join(", ")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def constant_receiver_name(node)
|
|
332
|
+
case node
|
|
333
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
334
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def constant_path_name(node)
|
|
339
|
+
parts = []
|
|
340
|
+
current = node
|
|
341
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
342
|
+
parts.unshift(current.name.to_s)
|
|
343
|
+
current = current.parent
|
|
344
|
+
end
|
|
345
|
+
case current
|
|
346
|
+
when nil then "::#{parts.join('::')}"
|
|
347
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
class RailsI18n < Rigor::Plugin::Base
|
|
6
|
+
# Frozen catalogue of every dotted key discovered across
|
|
7
|
+
# all loaded locale files. Each entry tracks the locales
|
|
8
|
+
# the key appears in and, for each locale, the set of
|
|
9
|
+
# `%{var}` interpolation placeholders observed in the
|
|
10
|
+
# leaf string.
|
|
11
|
+
#
|
|
12
|
+
# The catalogue is intentionally lossy: it only records
|
|
13
|
+
# the *presence* of each key per locale and the
|
|
14
|
+
# placeholder names. The actual translated values are
|
|
15
|
+
# not retained — the analyzer doesn't need them and
|
|
16
|
+
# keeping them would bloat the cache slice.
|
|
17
|
+
class LocaleIndex
|
|
18
|
+
# `placeholders` is a Hash: `locale_name => Set<String>`.
|
|
19
|
+
# `array_value` is true when at least one locale's leaf
|
|
20
|
+
# is an Array (used by `l(time, format:)` and similar).
|
|
21
|
+
# `value_kinds` is a Hash: `locale_name => Symbol`
|
|
22
|
+
# (`:string` / `:array` / `:hash`).
|
|
23
|
+
Entry = Data.define(:dotted_key, :placeholders, :value_kinds) do
|
|
24
|
+
def locales
|
|
25
|
+
placeholders.keys
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def in_locale?(locale)
|
|
29
|
+
placeholders.key?(locale.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def required_placeholders_for(locale)
|
|
33
|
+
placeholders.fetch(locale.to_s) { Set.new }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Union of placeholder names across all known
|
|
37
|
+
# locales — used by the analyzer when no specific
|
|
38
|
+
# locale is in scope.
|
|
39
|
+
def all_placeholders
|
|
40
|
+
placeholders.values.reduce(Set.new) { |acc, set| acc | set }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :entries, :locales
|
|
45
|
+
|
|
46
|
+
# @param entries [Array<Entry>]
|
|
47
|
+
# @param locales [Array<String>] all locale names
|
|
48
|
+
# that contributed at least one key.
|
|
49
|
+
def initialize(entries, locales:)
|
|
50
|
+
@entries = entries.freeze
|
|
51
|
+
@locales = locales.dup.freeze
|
|
52
|
+
@by_key = entries.to_h { |e| [e.dotted_key, e] }.freeze
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Entry, nil]
|
|
57
|
+
def find(dotted_key)
|
|
58
|
+
@by_key[dotted_key.to_s]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def known?(dotted_key)
|
|
62
|
+
@by_key.key?(dotted_key.to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# CLDR plural form keys recognised by Ruby I18n. When a
|
|
66
|
+
# locale defines `accounts.posts.one`, `accounts.posts.other`,
|
|
67
|
+
# the call `t('accounts.posts', count: n)` resolves into
|
|
68
|
+
# the matching plural sub-key — the parent `accounts.posts`
|
|
69
|
+
# is a **pluralization namespace**, not a missing key.
|
|
70
|
+
# Mastodon hits this on `accounts.posts` /
|
|
71
|
+
# `accounts.following` / `accounts.followers` for the post,
|
|
72
|
+
# follow, and follower counts shown on the profile page.
|
|
73
|
+
PLURAL_SUBKEYS = %w[zero one two few many other].freeze
|
|
74
|
+
|
|
75
|
+
# Returns true when the dotted key itself isn't a leaf in
|
|
76
|
+
# any locale, but at least one of its CLDR plural form
|
|
77
|
+
# children exists.
|
|
78
|
+
def pluralization_namespace?(dotted_key)
|
|
79
|
+
base = dotted_key.to_s
|
|
80
|
+
PLURAL_SUBKEYS.any? { |sub| @by_key.key?("#{base}.#{sub}") }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def empty?
|
|
84
|
+
@entries.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def size
|
|
88
|
+
@entries.size
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# All known dotted keys, sorted for stable did-you-mean
|
|
92
|
+
# output.
|
|
93
|
+
def keys
|
|
94
|
+
@by_key.keys.sort
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns the locales (set of strings) in which a key
|
|
98
|
+
# is *missing*, given the configured locale list.
|
|
99
|
+
def missing_locales_for(dotted_key, configured_locales:)
|
|
100
|
+
entry = find(dotted_key)
|
|
101
|
+
return configured_locales.to_set if entry.nil?
|
|
102
|
+
|
|
103
|
+
configured_locales.to_set - entry.locales.to_set
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
require_relative "locale_index"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class RailsI18n < Rigor::Plugin::Base
|
|
10
|
+
# Walks `locale_search_paths` for `.yml` / `.yaml`
|
|
11
|
+
# files, reads each through the trusted IoBoundary,
|
|
12
|
+
# parses with `YAML.safe_load`, and folds the resulting
|
|
13
|
+
# nested hash into a flat `dotted_key => Entry` table.
|
|
14
|
+
#
|
|
15
|
+
# The top-level YAML key is the locale (`en:`, `ja:`,
|
|
16
|
+
# …). Anything underneath is recursively flattened into
|
|
17
|
+
# dotted keys (`users.welcome`, `errors.messages.blank`,
|
|
18
|
+
# …). For each leaf string, `%{var}` placeholders are
|
|
19
|
+
# extracted via a simple regex.
|
|
20
|
+
#
|
|
21
|
+
# Files that fail to parse are skipped with a load-error
|
|
22
|
+
# diagnostic surfaced through the plugin's error
|
|
23
|
+
# channel. Non-Hash YAML roots (e.g. a top-level
|
|
24
|
+
# sequence) are also skipped — the format is locale-keyed
|
|
25
|
+
# by convention.
|
|
26
|
+
class LocaleLoader
|
|
27
|
+
PLACEHOLDER_RE = /%\{(?<name>[^}]+)\}/
|
|
28
|
+
|
|
29
|
+
# Errno classes that indicate "this file is not
|
|
30
|
+
# readable as a YAML locale" — swallowed so a single
|
|
31
|
+
# bad path doesn't take down the rest of the index.
|
|
32
|
+
IO_ERRORS = [Errno::ENOENT, Errno::EACCES, Errno::EISDIR].freeze
|
|
33
|
+
|
|
34
|
+
LoadError = Struct.new(:path, :message, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
attr_reader :load_errors
|
|
37
|
+
|
|
38
|
+
def initialize(io_boundary:, search_paths:)
|
|
39
|
+
@io_boundary = io_boundary
|
|
40
|
+
@search_paths = search_paths
|
|
41
|
+
@load_errors = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [LocaleIndex]
|
|
45
|
+
def load
|
|
46
|
+
per_key = {} # dotted_key => { locale => Set<String> }
|
|
47
|
+
per_key_kinds = {} # dotted_key => { locale => :string|:array|:hash }
|
|
48
|
+
locales = Set.new
|
|
49
|
+
|
|
50
|
+
locale_files.each do |path|
|
|
51
|
+
contents = read_safely(path)
|
|
52
|
+
next if contents.nil?
|
|
53
|
+
|
|
54
|
+
parsed = parse_yaml_safely(path, contents)
|
|
55
|
+
next unless parsed.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
parsed.each do |locale, tree|
|
|
58
|
+
locale = locale.to_s
|
|
59
|
+
locales << locale
|
|
60
|
+
flatten_tree(tree, []).each do |dotted_key, value|
|
|
61
|
+
placeholders = (per_key[dotted_key] ||= {})
|
|
62
|
+
placeholders[locale] = extract_placeholders(value)
|
|
63
|
+
kinds = (per_key_kinds[dotted_key] ||= {})
|
|
64
|
+
kinds[locale] = classify_kind(value)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
entries = per_key.map do |dotted_key, placeholder_map|
|
|
70
|
+
LocaleIndex::Entry.new(
|
|
71
|
+
dotted_key: dotted_key,
|
|
72
|
+
placeholders: placeholder_map.freeze,
|
|
73
|
+
value_kinds: per_key_kinds[dotted_key].freeze
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
LocaleIndex.new(entries, locales: locales.to_a.sort)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def read_safely(path)
|
|
82
|
+
@io_boundary.read_file(path)
|
|
83
|
+
rescue Plugin::AccessDeniedError, *IO_ERRORS
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_yaml_safely(path, contents)
|
|
88
|
+
YAML.safe_load(contents, aliases: true, permitted_classes: [Symbol])
|
|
89
|
+
rescue Psych::SyntaxError => e
|
|
90
|
+
@load_errors << LoadError.new(path: path, message: "YAML syntax error: #{e.message}")
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def locale_files
|
|
95
|
+
@search_paths.flat_map do |root|
|
|
96
|
+
absolute = File.expand_path(root)
|
|
97
|
+
next [] unless File.directory?(absolute)
|
|
98
|
+
|
|
99
|
+
Dir.glob(File.join(absolute, "**", "*.{yml,yaml}"))
|
|
100
|
+
end.sort
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Recursively walks the per-locale subtree, yielding
|
|
104
|
+
# `[dotted_key, leaf_value]` pairs. Hash leaves are
|
|
105
|
+
# *not* recorded as entries themselves — only their
|
|
106
|
+
# descendants — but every leaf scalar / array IS
|
|
107
|
+
# recorded.
|
|
108
|
+
def flatten_tree(node, breadcrumbs)
|
|
109
|
+
case node
|
|
110
|
+
when Hash
|
|
111
|
+
node.flat_map do |k, v|
|
|
112
|
+
flatten_tree(v, breadcrumbs + [k.to_s])
|
|
113
|
+
end
|
|
114
|
+
else
|
|
115
|
+
[[breadcrumbs.join("."), node]]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def extract_placeholders(value)
|
|
120
|
+
case value
|
|
121
|
+
when String then value.scan(PLACEHOLDER_RE).flatten.to_set
|
|
122
|
+
when Array then value.map { |v| extract_placeholders(v) }.reduce(Set.new) { |a, s| a | s }
|
|
123
|
+
else Set.new
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def classify_kind(value)
|
|
128
|
+
case value
|
|
129
|
+
when String then :string
|
|
130
|
+
when Array then :array
|
|
131
|
+
when Hash then :hash
|
|
132
|
+
else :scalar
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|