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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. 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