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.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/rails_i18n"