rigortype 0.1.9 → 0.1.11
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/README.md +1 -1
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- 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 +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -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 +273 -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 +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -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 +34 -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 +463 -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 +277 -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 +167 -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 +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -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/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +180 -1
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
# Reserved option keys — these are recognised by I18n
|
|
40
|
+
# itself and not treated as interpolation variables.
|
|
41
|
+
RESERVED_OPTION_KEYS = %i[
|
|
42
|
+
default scope locale count raise throw fallback
|
|
43
|
+
fallback_in_progress separator deep_interpolation
|
|
44
|
+
].to_set.freeze
|
|
45
|
+
|
|
46
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# @param path [String]
|
|
51
|
+
# @param root [Prism::Node]
|
|
52
|
+
# @param locale_index [LocaleIndex]
|
|
53
|
+
# @param configured_locales [Array<String>]
|
|
54
|
+
# @return [Array<Diagnostic>]
|
|
55
|
+
def diagnose(path:, root:, locale_index:, configured_locales:)
|
|
56
|
+
diagnostics = []
|
|
57
|
+
walk(root) do |call_node|
|
|
58
|
+
literal_key = literal_key_for(call_node)
|
|
59
|
+
next if literal_key.nil?
|
|
60
|
+
|
|
61
|
+
options = options_hash(call_node)
|
|
62
|
+
entry = locale_index.find(literal_key)
|
|
63
|
+
if entry.nil?
|
|
64
|
+
# CLDR pluralization namespace: the parent key isn't
|
|
65
|
+
# a leaf, but at least one `.one` / `.other` / etc.
|
|
66
|
+
# child exists. `t('accounts.posts', count: n)`
|
|
67
|
+
# resolves through that branch — not a missing key.
|
|
68
|
+
# Accept silently; downstream interpolation checks
|
|
69
|
+
# don't apply (no single entry to read placeholders
|
|
70
|
+
# from).
|
|
71
|
+
next if locale_index.pluralization_namespace?(literal_key)
|
|
72
|
+
|
|
73
|
+
diagnostics << unknown_key_diagnostic(path, call_node, literal_key, locale_index)
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
diagnostics << translation_call_info(path, call_node, literal_key, entry)
|
|
78
|
+
missing_in_locales = locale_index.missing_locales_for(literal_key, configured_locales: configured_locales)
|
|
79
|
+
diagnostics << missing_locale_diagnostic(path, call_node, literal_key, missing_in_locales) \
|
|
80
|
+
if !options[:has_default] && !missing_in_locales.empty?
|
|
81
|
+
|
|
82
|
+
interpolation_diags = interpolation_diagnostics(path, call_node, literal_key, entry, options)
|
|
83
|
+
diagnostics.concat(interpolation_diags)
|
|
84
|
+
end
|
|
85
|
+
diagnostics
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def walk(node, &)
|
|
89
|
+
return unless node.is_a?(Prism::Node)
|
|
90
|
+
|
|
91
|
+
yield node if node.is_a?(Prism::CallNode) && translate_call_candidate?(node)
|
|
92
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def translate_call_candidate?(node)
|
|
96
|
+
return false unless TRANSLATE_METHODS.include?(node.name)
|
|
97
|
+
return true if node.receiver.nil?
|
|
98
|
+
|
|
99
|
+
receiver_name = constant_receiver_name(node.receiver)
|
|
100
|
+
I18N_RECEIVER_NAMES.include?(receiver_name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Extracts the literal-string first argument when
|
|
104
|
+
# present. Returns nil for variable / expression keys —
|
|
105
|
+
# those are out of scope for v0.1.0.
|
|
106
|
+
def literal_key_for(call_node)
|
|
107
|
+
args = call_node.arguments&.arguments || []
|
|
108
|
+
return nil if args.empty?
|
|
109
|
+
|
|
110
|
+
first = args.first
|
|
111
|
+
return nil unless first.is_a?(Prism::StringNode)
|
|
112
|
+
|
|
113
|
+
first.unescaped
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Pulls the interpolation hash from the call's
|
|
117
|
+
# arguments. The trailing `Hash` argument (or
|
|
118
|
+
# `Prism::KeywordHashNode`) carries both reserved I18n
|
|
119
|
+
# options (`default:`, `scope:`, …) and interpolation
|
|
120
|
+
# variables. Returns:
|
|
121
|
+
# {
|
|
122
|
+
# :has_default => bool,
|
|
123
|
+
# :all_keys => Set<Symbol> (every assoc key in the hash),
|
|
124
|
+
# :non_reserved => Set<Symbol> (keys NOT in RESERVED_OPTION_KEYS),
|
|
125
|
+
# :hash_node => Prism::Node (or nil)
|
|
126
|
+
# }
|
|
127
|
+
#
|
|
128
|
+
# Note: a reserved option key (e.g. `count:`) can
|
|
129
|
+
# also serve as an interpolation value when the
|
|
130
|
+
# locale's leaf string has `%{count}`. The analyzer
|
|
131
|
+
# therefore checks the missing-placeholder set
|
|
132
|
+
# against `all_keys` (so `count:` satisfies a
|
|
133
|
+
# `%{count}` placeholder) and the
|
|
134
|
+
# extra-placeholder set against `non_reserved` (so
|
|
135
|
+
# `default:` / `scope:` are never reported as
|
|
136
|
+
# extra interpolation arguments).
|
|
137
|
+
def options_hash(call_node)
|
|
138
|
+
args = call_node.arguments&.arguments || []
|
|
139
|
+
last = args.last
|
|
140
|
+
empty = { has_default: false, all_keys: Set.new, non_reserved: Set.new, hash_node: nil }
|
|
141
|
+
return empty unless hash_like?(last)
|
|
142
|
+
|
|
143
|
+
assoc_keys = collect_assoc_keys(last)
|
|
144
|
+
all_keys = assoc_keys.to_set
|
|
145
|
+
non_reserved = assoc_keys.reject { |k| RESERVED_OPTION_KEYS.include?(k) }.to_set
|
|
146
|
+
{
|
|
147
|
+
has_default: assoc_keys.include?(:default),
|
|
148
|
+
all_keys: all_keys,
|
|
149
|
+
non_reserved: non_reserved,
|
|
150
|
+
hash_node: last
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def hash_like?(node)
|
|
155
|
+
node.is_a?(Prism::HashNode) || node.is_a?(Prism::KeywordHashNode)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def collect_assoc_keys(hash_node)
|
|
159
|
+
# Both `Prism::HashNode` and `Prism::KeywordHashNode`
|
|
160
|
+
# expose `#elements`; the conditional was an
|
|
161
|
+
# accidental no-op carried over from an earlier
|
|
162
|
+
# draft.
|
|
163
|
+
hash_node.elements.filter_map do |element|
|
|
164
|
+
next nil unless element.is_a?(Prism::AssocNode)
|
|
165
|
+
|
|
166
|
+
key_node = element.key
|
|
167
|
+
case key_node
|
|
168
|
+
when Prism::SymbolNode then key_node.unescaped.to_sym
|
|
169
|
+
when Prism::StringNode then key_node.unescaped.to_sym
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def translation_call_info(path, call_node, literal_key, entry)
|
|
175
|
+
location = call_node.location
|
|
176
|
+
locales_text = entry.locales.sort.join(", ")
|
|
177
|
+
Diagnostic.new(
|
|
178
|
+
path: path,
|
|
179
|
+
line: location.start_line,
|
|
180
|
+
column: location.start_column + 1,
|
|
181
|
+
severity: :info,
|
|
182
|
+
rule: "translation-call",
|
|
183
|
+
message: "`t('#{literal_key}')` resolves in #{locales_text}"
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def unknown_key_diagnostic(path, call_node, literal_key, locale_index)
|
|
188
|
+
location = call_node.location
|
|
189
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: locale_index.keys).correct(literal_key)
|
|
190
|
+
suggestion_part = suggestions.empty? ? "" : " (did you mean `#{suggestions.first}`?)"
|
|
191
|
+
Diagnostic.new(
|
|
192
|
+
path: path,
|
|
193
|
+
line: location.start_line,
|
|
194
|
+
column: location.start_column + 1,
|
|
195
|
+
severity: :error,
|
|
196
|
+
rule: "unknown-key",
|
|
197
|
+
message: "missing translation key `#{literal_key}` in any locale#{suggestion_part}"
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def missing_locale_diagnostic(path, call_node, literal_key, missing_locales)
|
|
202
|
+
location = call_node.location
|
|
203
|
+
locales_text = missing_locales.to_a.sort.join(", ")
|
|
204
|
+
Diagnostic.new(
|
|
205
|
+
path: path,
|
|
206
|
+
line: location.start_line,
|
|
207
|
+
column: location.start_column + 1,
|
|
208
|
+
severity: :warning,
|
|
209
|
+
rule: "missing-locale",
|
|
210
|
+
message: "`t('#{literal_key}')` is missing from locale(s) #{locales_text}"
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def interpolation_diagnostics(path, call_node, literal_key, entry, options)
|
|
215
|
+
required = entry.all_placeholders
|
|
216
|
+
all_provided = options[:all_keys].to_set(&:to_s)
|
|
217
|
+
non_reserved_provided = options[:non_reserved].to_set(&:to_s)
|
|
218
|
+
missing = required - all_provided
|
|
219
|
+
extra = non_reserved_provided - required
|
|
220
|
+
location = call_node.location
|
|
221
|
+
|
|
222
|
+
[].tap do |diags|
|
|
223
|
+
unless missing.empty?
|
|
224
|
+
diags << Diagnostic.new(
|
|
225
|
+
path: path,
|
|
226
|
+
line: location.start_line,
|
|
227
|
+
column: location.start_column + 1,
|
|
228
|
+
severity: :error,
|
|
229
|
+
rule: "wrong-interpolation",
|
|
230
|
+
message: "`t('#{literal_key}')` expects interpolation #{format_keys(missing)}, " \
|
|
231
|
+
"got #{format_keys(non_reserved_provided)}"
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
unless extra.empty?
|
|
236
|
+
diags << Diagnostic.new(
|
|
237
|
+
path: path,
|
|
238
|
+
line: location.start_line,
|
|
239
|
+
column: location.start_column + 1,
|
|
240
|
+
severity: :warning,
|
|
241
|
+
rule: "extra-interpolation",
|
|
242
|
+
message: "`t('#{literal_key}')` does not use interpolation #{format_keys(extra)} " \
|
|
243
|
+
"(known placeholders: #{format_keys(required)})"
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def format_keys(set)
|
|
250
|
+
return "(none)" if set.empty?
|
|
251
|
+
|
|
252
|
+
set.to_a.sort.map { |k| "`#{k}`" }.join(", ")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def constant_receiver_name(node)
|
|
256
|
+
case node
|
|
257
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
258
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def constant_path_name(node)
|
|
263
|
+
parts = []
|
|
264
|
+
current = node
|
|
265
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
266
|
+
parts.unshift(current.name.to_s)
|
|
267
|
+
current = current.parent
|
|
268
|
+
end
|
|
269
|
+
case current
|
|
270
|
+
when nil then "::#{parts.join('::')}"
|
|
271
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
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
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
require_relative "rails_i18n/locale_index"
|
|
6
|
+
require_relative "rails_i18n/locale_loader"
|
|
7
|
+
require_relative "rails_i18n/analyzer"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Plugin
|
|
11
|
+
# rigor-rails-i18n — validates `t('key.path')` /
|
|
12
|
+
# `I18n.t(...)` calls against `config/locales/*.yml`.
|
|
13
|
+
#
|
|
14
|
+
# Tier 1B of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
|
|
15
|
+
# Statically reads every YAML file under
|
|
16
|
+
# `locale_search_paths` (default `config/locales/`),
|
|
17
|
+
# builds a flat `dotted_key => Entry` index keyed by the
|
|
18
|
+
# leaf key path, and validates every `t(literal_key, ...)`
|
|
19
|
+
# call site against the catalogue. No Rails runtime
|
|
20
|
+
# dependency.
|
|
21
|
+
#
|
|
22
|
+
# ## Configuration
|
|
23
|
+
#
|
|
24
|
+
# plugins:
|
|
25
|
+
# - gem: rigor-rails-i18n
|
|
26
|
+
# config:
|
|
27
|
+
# locale_search_paths: ["config/locales"] # default; optional
|
|
28
|
+
# configured_locales: ["en"] # default; optional — locales the project ships
|
|
29
|
+
#
|
|
30
|
+
# ## What it checks
|
|
31
|
+
#
|
|
32
|
+
# 1. **Key existence** — `t('users.welcome')` is flagged
|
|
33
|
+
# when `users.welcome` does not appear in any locale.
|
|
34
|
+
# 2. **Per-locale coverage** — when the key resolves in
|
|
35
|
+
# some locales but not all configured locales, the
|
|
36
|
+
# plugin emits a `missing-locale` warning. Suppressed
|
|
37
|
+
# when the call site passes `default:`.
|
|
38
|
+
# 3. **Interpolation variables** — the leaf string's
|
|
39
|
+
# `%{var}` placeholders must match the call's keyword
|
|
40
|
+
# arguments. Missing placeholders are errors; extra
|
|
41
|
+
# arguments are warnings.
|
|
42
|
+
#
|
|
43
|
+
# ## Limitations (v0.1.0)
|
|
44
|
+
#
|
|
45
|
+
# - Only literal-string keys are validated. `t(key)` with
|
|
46
|
+
# a variable receiver is silently passed through.
|
|
47
|
+
# - Lazy lookup (`t('.title')` resolved against the
|
|
48
|
+
# rendered controller / view path) is out of scope.
|
|
49
|
+
# - Pluralization (`t('errors.messages.too_short',
|
|
50
|
+
# count: n)`) is recognised at the call site but the
|
|
51
|
+
# `count` key is not used to validate the locale's
|
|
52
|
+
# pluralization branches.
|
|
53
|
+
# - YAML aliases / merges are accepted (Psych's standard
|
|
54
|
+
# `aliases: true`) but custom Ruby classes inside the
|
|
55
|
+
# YAML are NOT permitted (`safe_load`).
|
|
56
|
+
class RailsI18n < Rigor::Plugin::Base
|
|
57
|
+
manifest(
|
|
58
|
+
id: "rails-i18n",
|
|
59
|
+
version: "0.1.0",
|
|
60
|
+
description: "Validates I18n `t(key)` calls against `config/locales/*.yml`.",
|
|
61
|
+
config_schema: {
|
|
62
|
+
"locale_search_paths" => :array,
|
|
63
|
+
"configured_locales" => :array
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
DEFAULT_LOCALE_SEARCH_PATHS = ["config/locales"].freeze
|
|
68
|
+
DEFAULT_CONFIGURED_LOCALES = ["en"].freeze
|
|
69
|
+
|
|
70
|
+
producer :locale_index do |_params|
|
|
71
|
+
loader = LocaleLoader.new(
|
|
72
|
+
io_boundary: io_boundary,
|
|
73
|
+
search_paths: @locale_search_paths
|
|
74
|
+
)
|
|
75
|
+
index = loader.load
|
|
76
|
+
@load_errors = loader.load_errors
|
|
77
|
+
index
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def init(_services)
|
|
81
|
+
@locale_search_paths = Array(config.fetch("locale_search_paths", DEFAULT_LOCALE_SEARCH_PATHS)).map(&:to_s)
|
|
82
|
+
@configured_locales = Array(config.fetch("configured_locales", DEFAULT_CONFIGURED_LOCALES)).map(&:to_s)
|
|
83
|
+
@locale_index = nil
|
|
84
|
+
@load_errors = []
|
|
85
|
+
@load_errors_emitted = false
|
|
86
|
+
@runtime_error = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
90
|
+
index = locale_index_or_nil
|
|
91
|
+
diagnostics = []
|
|
92
|
+
diagnostics.concat(consume_load_error_diagnostics(path)) unless @load_errors.empty?
|
|
93
|
+
return diagnostics + [runtime_error_diagnostic(path)] if index.nil? && @runtime_error
|
|
94
|
+
return diagnostics if index.nil? || index.empty?
|
|
95
|
+
|
|
96
|
+
diagnostics.concat(
|
|
97
|
+
Analyzer.diagnose(
|
|
98
|
+
path: path,
|
|
99
|
+
root: root,
|
|
100
|
+
locale_index: index,
|
|
101
|
+
configured_locales: @configured_locales
|
|
102
|
+
).map { |diag| build_diagnostic(diag) }
|
|
103
|
+
)
|
|
104
|
+
diagnostics
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def locale_index_or_nil
|
|
110
|
+
return @locale_index if @locale_index
|
|
111
|
+
|
|
112
|
+
# Pass an explicit descriptor covering every `.yml` / `.yaml`
|
|
113
|
+
# file under the configured locale search paths so the cache
|
|
114
|
+
# invalidates when locale files are added, removed, or edited.
|
|
115
|
+
# Without it the auto-built descriptor depends on the
|
|
116
|
+
# `IoBoundary`'s in-process read history — empty on the
|
|
117
|
+
# first call of a fresh process — so warm cache hits would
|
|
118
|
+
# serve stale `LocaleIndex` data and hide per-call load
|
|
119
|
+
# errors (a malformed YAML in one run would not surface
|
|
120
|
+
# when a healthy cache entry from an earlier run exists).
|
|
121
|
+
descriptor = glob_descriptor(@locale_search_paths, "**/*.yml", "**/*.yaml")
|
|
122
|
+
@locale_index = cache_for(:locale_index, params: {}, descriptor: descriptor).call
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
@runtime_error = "rigor-rails-i18n: failed to load locales: #{e.class}: #{e.message}"
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The runner only invokes `diagnostics_for_file` for
|
|
129
|
+
# Ruby files (`paths:` is filtered to `.rb`). YAML
|
|
130
|
+
# parse errors therefore can't be anchored on the
|
|
131
|
+
# offending locale file directly; instead, we emit
|
|
132
|
+
# them once per run on the first analyzed Ruby file,
|
|
133
|
+
# naming the offending YAML path in the message.
|
|
134
|
+
def consume_load_error_diagnostics(path)
|
|
135
|
+
return [] if @load_errors_emitted
|
|
136
|
+
|
|
137
|
+
@load_errors_emitted = true
|
|
138
|
+
@load_errors.map do |err|
|
|
139
|
+
Rigor::Analysis::Diagnostic.new(
|
|
140
|
+
path: path, line: 1, column: 1,
|
|
141
|
+
message: "rigor-rails-i18n: failed to parse `#{err.path}`: #{err.message}",
|
|
142
|
+
severity: :warning,
|
|
143
|
+
rule: "load-error"
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def runtime_error_diagnostic(path)
|
|
149
|
+
Rigor::Analysis::Diagnostic.new(
|
|
150
|
+
path: path, line: 1, column: 1,
|
|
151
|
+
message: @runtime_error,
|
|
152
|
+
severity: :warning,
|
|
153
|
+
rule: "load-error"
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_diagnostic(diag)
|
|
158
|
+
Rigor::Analysis::Diagnostic.new(
|
|
159
|
+
path: diag.path, line: diag.line, column: diag.column,
|
|
160
|
+
message: diag.message, severity: diag.severity, rule: diag.rule
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
Rigor::Plugin.register(RailsI18n)
|
|
166
|
+
end
|
|
167
|
+
end
|