rigortype 0.1.15 → 0.1.16

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +25 -1
  5. data/lib/rigor/analysis/diagnostic.rb +40 -0
  6. data/lib/rigor/analysis/runner.rb +61 -2
  7. data/lib/rigor/analysis/worker_session.rb +3 -2
  8. data/lib/rigor/cache/descriptor.rb +6 -2
  9. data/lib/rigor/cli/plugins_command.rb +51 -4
  10. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  11. data/lib/rigor/cli.rb +135 -5
  12. data/lib/rigor/environment/rbs_loader.rb +259 -1
  13. data/lib/rigor/environment.rb +8 -2
  14. data/lib/rigor/inference/budget_trace.rb +137 -0
  15. data/lib/rigor/inference/expression_typer.rb +9 -2
  16. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  17. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  18. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  19. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  20. data/lib/rigor/inference/precision_scanner.rb +60 -1
  21. data/lib/rigor/inference/scope_indexer.rb +127 -8
  22. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  23. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  24. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  25. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  26. data/lib/rigor/plugin/base.rb +321 -2
  27. data/lib/rigor/plugin/box.rb +64 -0
  28. data/lib/rigor/plugin/inflector.rb +121 -0
  29. data/lib/rigor/plugin/isolation.rb +191 -0
  30. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  31. data/lib/rigor/plugin/macro.rb +1 -0
  32. data/lib/rigor/plugin/manifest.rb +120 -23
  33. data/lib/rigor/plugin/node_context.rb +62 -0
  34. data/lib/rigor/plugin/registry.rb +10 -0
  35. data/lib/rigor/plugin.rb +3 -0
  36. data/lib/rigor/sig_gen/generator.rb +2 -3
  37. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  38. data/lib/rigor/source/literals.rb +118 -0
  39. data/lib/rigor/source/node_walker.rb +26 -0
  40. data/lib/rigor/source.rb +1 -0
  41. data/lib/rigor/type/combinator.rb +6 -1
  42. data/lib/rigor/type/union.rb +65 -1
  43. data/lib/rigor/version.rb +1 -1
  44. data/lib/rigor.rb +1 -0
  45. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  46. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  47. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  48. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  49. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  50. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  51. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  54. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  58. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  59. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  60. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  62. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  63. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  64. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  65. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  66. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  67. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  68. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  69. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  71. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  74. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  75. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  76. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  77. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  78. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  79. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  80. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  81. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  82. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  83. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  84. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  85. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  86. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  87. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  88. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  89. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  90. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  91. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  92. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  93. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  94. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  95. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  96. data/sig/rigor/plugin/base.rbs +58 -3
  97. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  98. data/sig/rigor/plugin/manifest.rbs +31 -1
  99. data/sig/rigor/source.rbs +12 -0
  100. data/sig/rigor.rbs +5 -0
  101. data/skills/rigor-plugin-author/SKILL.md +13 -9
  102. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  103. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  104. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  105. metadata +52 -2
  106. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -31,45 +31,40 @@ module Rigor
31
31
  module Analyzer
32
32
  ENTRY_METHODS = %i[authorize policy policy_scope].freeze
33
33
 
34
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
34
+ # One policy violation: the kebab-case `rule`, `severity`, and
35
+ # human-readable `message`. Carries no path/location — the caller
36
+ # (the `node_rule` block) positions it via `Plugin::Base#diagnostic`.
37
+ Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
35
38
 
36
39
  module_function
37
40
 
38
- # @param path [String]
39
- # @param root [Prism::Node]
41
+ # The policy violations for a single call node (0..2 of them), or
42
+ # `[]` when the node is not a Pundit entry call or its record type
43
+ # is not statically determinable. ADR-37: the engine owns the
44
+ # walk, so this is per-node logic.
45
+ #
46
+ # @param call_node [Prism::Node]
40
47
  # @param policy_index [PolicyIndex]
41
- # @param scope [Rigor::Inference::Scope, nil]
42
- # @return [Array<Diagnostic>]
43
- def diagnose(path:, root:, policy_index:, scope:)
44
- diagnostics = []
45
- walk(root) do |call_node|
46
- record_node = call_node.arguments&.arguments&.first
47
- next if record_node.nil?
48
+ # @param scope [Rigor::Scope, nil]
49
+ # @return [Array<Violation>]
50
+ def violations_for(call_node:, policy_index:, scope:)
51
+ return [] unless call_node.is_a?(Prism::CallNode) && entry_call?(call_node)
48
52
 
49
- policy_class_name = derive_policy_class_name(record_node, scope)
50
- next if policy_class_name.nil?
53
+ record_node = call_node.arguments&.arguments&.first
54
+ return [] if record_node.nil?
51
55
 
52
- policy_entry = policy_index.find(policy_class_name)
53
- if policy_entry.nil?
54
- diagnostics << unknown_policy_class_diagnostic(path, call_node, policy_class_name, policy_index)
55
- next
56
- end
56
+ policy_class_name = derive_policy_class_name(record_node, scope)
57
+ return [] if policy_class_name.nil?
57
58
 
58
- diagnostics << policy_call_info(path, call_node, policy_class_name)
59
+ policy_entry = policy_index.find(policy_class_name)
60
+ return [unknown_policy_class_violation(call_node, policy_class_name, policy_index)] if policy_entry.nil?
59
61
 
60
- next unless call_node.name == :authorize
61
-
62
- action_diag = action_check(path, call_node, policy_entry)
63
- diagnostics << action_diag if action_diag
62
+ violations = [policy_call_violation(call_node, policy_class_name)]
63
+ if call_node.name == :authorize
64
+ action = action_violation(call_node, policy_entry)
65
+ violations << action if action
64
66
  end
65
- diagnostics
66
- end
67
-
68
- def walk(node, &)
69
- return unless node.is_a?(Prism::Node)
70
-
71
- yield node if node.is_a?(Prism::CallNode) && entry_call?(node)
72
- node.compact_child_nodes.each { |child| walk(child, &) }
67
+ violations
73
68
  end
74
69
 
75
70
  def entry_call?(node)
@@ -105,26 +100,18 @@ module Rigor
105
100
  nil
106
101
  end
107
102
 
108
- def policy_call_info(path, call_node, policy_class_name)
109
- location = call_node.location
110
- Diagnostic.new(
111
- path: path,
112
- line: location.start_line,
113
- column: location.start_column + 1,
103
+ def policy_call_violation(call_node, policy_class_name)
104
+ Violation.new(
114
105
  severity: :info,
115
106
  rule: "policy-call",
116
107
  message: "`#{call_node.name}(...)` resolves to `#{policy_class_name}`"
117
108
  )
118
109
  end
119
110
 
120
- def unknown_policy_class_diagnostic(path, call_node, policy_class_name, policy_index)
121
- location = call_node.location
111
+ def unknown_policy_class_violation(call_node, policy_class_name, policy_index)
122
112
  suggestions = DidYouMean::SpellChecker.new(dictionary: policy_index.names).correct(policy_class_name)
123
113
  suggestion_part = suggestions.empty? ? "" : " (did you mean `#{suggestions.first}`?)"
124
- Diagnostic.new(
125
- path: path,
126
- line: location.start_line,
127
- column: location.start_column + 1,
114
+ Violation.new(
128
115
  severity: :error,
129
116
  rule: "unknown-policy-class",
130
117
  message: "no policy class `#{policy_class_name}` for `#{call_node.name}` call#{suggestion_part}"
@@ -136,7 +123,7 @@ module Rigor
136
123
  # (the runtime infers it from the controller — out
137
124
  # of scope here) or when the second argument isn't
138
125
  # a literal symbol / string.
139
- def action_check(path, call_node, policy_entry)
126
+ def action_violation(call_node, policy_entry)
140
127
  args = call_node.arguments&.arguments || []
141
128
  return nil if args.size < 2
142
129
 
@@ -147,14 +134,10 @@ module Rigor
147
134
  predicate = policy_entry.normalize(action_name)
148
135
  return nil if policy_entry.includes_method?(predicate)
149
136
 
150
- location = call_node.location
151
137
  dictionary = policy_entry.predicate_methods.map(&:to_s)
152
138
  suggestions = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(predicate.to_s)
153
139
  suggestion_part = suggestions.empty? ? "" : " (did you mean `:#{suggestions.first.delete_suffix('?')}`?)"
154
- Diagnostic.new(
155
- path: path,
156
- line: location.start_line,
157
- column: location.start_column + 1,
140
+ Violation.new(
158
141
  severity: :error,
159
142
  rule: "unknown-policy-method",
160
143
  message: "`#{policy_entry.policy_class_name}##{predicate}` is not defined " \
@@ -61,14 +61,11 @@ module Rigor
61
61
  version: "0.1.0",
62
62
  description: "Validates Pundit policy / authorize calls.",
63
63
  config_schema: {
64
- "policy_search_paths" => :array,
65
- "policy_base_classes" => :array
64
+ "policy_search_paths" => { kind: :array, default: ["app/policies"] },
65
+ "policy_base_classes" => { kind: :array, default: %w[ApplicationPolicy] }
66
66
  }
67
67
  )
68
68
 
69
- DEFAULT_POLICY_SEARCH_PATHS = ["app/policies"].freeze
70
- DEFAULT_POLICY_BASE_CLASSES = %w[ApplicationPolicy].freeze
71
-
72
69
  producer :policy_index do |_params|
73
70
  PolicyDiscoverer.new(
74
71
  io_boundary: io_boundary,
@@ -78,23 +75,30 @@ module Rigor
78
75
  end
79
76
 
80
77
  def init(_services)
81
- @policy_search_paths = Array(config.fetch("policy_search_paths", DEFAULT_POLICY_SEARCH_PATHS)).map(&:to_s)
82
- @policy_base_classes = Array(config.fetch("policy_base_classes", DEFAULT_POLICY_BASE_CLASSES)).map(&:to_s)
78
+ @policy_search_paths = Array(config.fetch("policy_search_paths")).map(&:to_s)
79
+ @policy_base_classes = Array(config.fetch("policy_base_classes")).map(&:to_s)
83
80
  @policy_index = nil
84
81
  @load_error = nil
85
82
  end
86
83
 
87
- def diagnostics_for_file(path:, scope:, root:)
84
+ # File-level only: the load-error emission. The per-call policy
85
+ # validation runs over the engine-owned walk via the node_rule
86
+ # below (ADR-37). The index is lazily loaded + memoised by
87
+ # policy_index_or_nil, so both surfaces share one load.
88
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
88
89
  index = policy_index_or_nil
89
90
  return [load_error_diagnostic(path)] if index.nil? && @load_error
90
- return [] if index.nil? || index.empty?
91
91
 
92
- Analyzer.diagnose(
93
- path: path,
94
- root: root,
95
- policy_index: index,
96
- scope: scope
97
- ).map { |diag| build_diagnostic(diag) }
92
+ []
93
+ end
94
+
95
+ node_rule Prism::CallNode do |node, scope, path|
96
+ index = policy_index_or_nil
97
+ next [] if index.nil? || index.empty?
98
+
99
+ Analyzer.violations_for(call_node: node, policy_index: index, scope: scope).map do |violation|
100
+ diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
101
+ end
98
102
  end
99
103
 
100
104
  private
@@ -102,7 +106,8 @@ module Rigor
102
106
  def policy_index_or_nil
103
107
  return @policy_index if @policy_index
104
108
 
105
- @policy_index = cache_for(:policy_index, params: {}).call
109
+ descriptor = glob_descriptor(@policy_search_paths, "**/*.rb")
110
+ @policy_index = cache_for(:policy_index, params: {}, descriptor: descriptor).call
106
111
  rescue StandardError => e
107
112
  @load_error = "rigor-pundit: failed to discover policies: #{e.class}: #{e.message}"
108
113
  nil
@@ -116,13 +121,6 @@ module Rigor
116
121
  rule: "load-error"
117
122
  )
118
123
  end
119
-
120
- def build_diagnostic(diag)
121
- Rigor::Analysis::Diagnostic.new(
122
- path: diag.path, line: diag.line, column: diag.column,
123
- message: diag.message, severity: diag.severity, rule: diag.rule
124
- )
125
- end
126
124
  end
127
125
 
128
126
  Rigor::Plugin.register(Pundit)
@@ -75,7 +75,10 @@ module Rigor
75
75
  activerecord.errors.models.
76
76
  ].freeze
77
77
 
78
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
78
+ # One translation-call observation. Carries no path/location
79
+ # the caller (the `node_rule` block) positions it via
80
+ # `Plugin::Base#diagnostic`.
81
+ Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
79
82
 
80
83
  module_function
81
84
 
@@ -89,60 +92,48 @@ module Rigor
89
92
  # @param locale_index [LocaleIndex]
90
93
  # @param configured_locales [Array<String>]
91
94
  # @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)
95
+ # The translation violations for a single call node, or `[]`
96
+ # when it is not a recognised `t` / `translate` call with a
97
+ # literal key. ADR-37: the engine owns the walk; `action` (the
98
+ # enclosing method, for lazy `t('.key')` expansion) comes from
99
+ # the node-rule `NodeContext`, and `controller_scope` from the
100
+ # file path.
101
+ #
102
+ # @param call_node [Prism::Node]
103
+ # @param locale_index [LocaleIndex]
104
+ # @param configured_locales [Array<String>]
105
+ # @param controller_scope [String, nil]
106
+ # @param action [String, nil]
107
+ # @return [Array<Violation>]
108
+ def violations_for(call_node:, locale_index:, configured_locales:, controller_scope:, action:)
109
+ return [] unless call_node.is_a?(Prism::CallNode) && translate_call_candidate?(call_node)
110
+
111
+ raw_key = literal_key_for(call_node)
112
+ return [] if raw_key.nil?
113
+
114
+ literal_key = expand_key(raw_key, controller_scope: controller_scope, action: action)
115
+ return [] if literal_key.nil?
116
+
117
+ options = options_hash(call_node)
118
+ entry = locale_index.find(literal_key)
119
+ if entry.nil?
120
+ # CLDR pluralization namespace: `t('accounts.posts', count: n)`
121
+ # resolves through a `.one` / `.other` child — not missing.
122
+ return [] if locale_index.pluralization_namespace?(literal_key)
123
+ # Rails / rails-i18n ship `date.order`, `time.am`, etc. in
124
+ # every locale at runtime even when project files omit them.
125
+ return [] if rails_shipped_key?(literal_key)
126
+
127
+ return [unknown_key_violation(literal_key, locale_index)]
133
128
  end
134
- diagnostics
135
- end
136
129
 
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, &) }
130
+ violations = [translation_call_info(literal_key, entry)]
131
+ missing_in_locales = locale_index.missing_locales_for(literal_key, configured_locales: configured_locales)
132
+ if !options[:has_default] && !missing_in_locales.empty?
133
+ violations << missing_locale_violation(literal_key, missing_in_locales)
134
+ end
135
+ violations.concat(interpolation_violations(literal_key, entry, options))
136
+ violations
146
137
  end
147
138
 
148
139
  # Derives the Rails controller scope from the file path,
@@ -247,60 +238,44 @@ module Rigor
247
238
  end
248
239
  end
249
240
 
250
- def translation_call_info(path, call_node, literal_key, entry)
251
- location = call_node.location
241
+ def translation_call_info(literal_key, entry)
252
242
  locales_text = entry.locales.sort.join(", ")
253
- Diagnostic.new(
254
- path: path,
255
- line: location.start_line,
256
- column: location.start_column + 1,
243
+ Violation.new(
257
244
  severity: :info,
258
245
  rule: "translation-call",
259
246
  message: "`t('#{literal_key}')` resolves in #{locales_text}"
260
247
  )
261
248
  end
262
249
 
263
- def unknown_key_diagnostic(path, call_node, literal_key, locale_index)
264
- location = call_node.location
250
+ def unknown_key_violation(literal_key, locale_index)
265
251
  suggestions = DidYouMean::SpellChecker.new(dictionary: locale_index.keys).correct(literal_key)
266
252
  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,
253
+ Violation.new(
271
254
  severity: :error,
272
255
  rule: "unknown-key",
273
256
  message: "missing translation key `#{literal_key}` in any locale#{suggestion_part}"
274
257
  )
275
258
  end
276
259
 
277
- def missing_locale_diagnostic(path, call_node, literal_key, missing_locales)
278
- location = call_node.location
260
+ def missing_locale_violation(literal_key, missing_locales)
279
261
  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,
262
+ Violation.new(
284
263
  severity: :warning,
285
264
  rule: "missing-locale",
286
265
  message: "`t('#{literal_key}')` is missing from locale(s) #{locales_text}"
287
266
  )
288
267
  end
289
268
 
290
- def interpolation_diagnostics(path, call_node, literal_key, entry, options)
269
+ def interpolation_violations(literal_key, entry, options)
291
270
  required = entry.all_placeholders
292
271
  all_provided = options[:all_keys].to_set(&:to_s)
293
272
  non_reserved_provided = options[:non_reserved].to_set(&:to_s)
294
273
  missing = required - all_provided
295
274
  extra = non_reserved_provided - required
296
- location = call_node.location
297
275
 
298
- [].tap do |diags|
276
+ [].tap do |violations|
299
277
  unless missing.empty?
300
- diags << Diagnostic.new(
301
- path: path,
302
- line: location.start_line,
303
- column: location.start_column + 1,
278
+ violations << Violation.new(
304
279
  severity: :error,
305
280
  rule: "wrong-interpolation",
306
281
  message: "`t('#{literal_key}')` expects interpolation #{format_keys(missing)}, " \
@@ -309,10 +284,7 @@ module Rigor
309
284
  end
310
285
 
311
286
  unless extra.empty?
312
- diags << Diagnostic.new(
313
- path: path,
314
- line: location.start_line,
315
- column: location.start_column + 1,
287
+ violations << Violation.new(
316
288
  severity: :warning,
317
289
  rule: "extra-interpolation",
318
290
  message: "`t('#{literal_key}')` does not use interpolation #{format_keys(extra)} " \
@@ -67,14 +67,11 @@ module Rigor
67
67
  version: "0.2.0",
68
68
  description: "Validates I18n `t(key)` calls against `config/locales/*.yml`.",
69
69
  config_schema: {
70
- "locale_search_paths" => :array,
71
- "configured_locales" => :array
70
+ "locale_search_paths" => { kind: :array, default: ["config/locales"] },
71
+ "configured_locales" => { kind: :array, default: ["en"] }
72
72
  }
73
73
  )
74
74
 
75
- DEFAULT_LOCALE_SEARCH_PATHS = ["config/locales"].freeze
76
- DEFAULT_CONFIGURED_LOCALES = ["en"].freeze
77
-
78
75
  producer :locale_index do |_params|
79
76
  loader = LocaleLoader.new(
80
77
  io_boundary: io_boundary,
@@ -86,32 +83,42 @@ module Rigor
86
83
  end
87
84
 
88
85
  def init(_services)
89
- @locale_search_paths = Array(config.fetch("locale_search_paths", DEFAULT_LOCALE_SEARCH_PATHS)).map(&:to_s)
90
- @configured_locales = Array(config.fetch("configured_locales", DEFAULT_CONFIGURED_LOCALES)).map(&:to_s)
86
+ @locale_search_paths = Array(config.fetch("locale_search_paths")).map(&:to_s)
87
+ @configured_locales = Array(config.fetch("configured_locales")).map(&:to_s)
91
88
  @locale_index = nil
92
89
  @load_errors = []
93
90
  @load_errors_emitted = false
94
91
  @runtime_error = nil
95
92
  end
96
93
 
94
+ # File-level only: the once-per-run YAML load errors + the
95
+ # runtime (cache-load) error. Per-call `t('key')` validation runs
96
+ # over the engine-owned walk via the node_rule below (ADR-37). The
97
+ # locale index is lazily loaded + memoised by locale_index_or_nil.
97
98
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
98
99
  index = locale_index_or_nil
99
100
  diagnostics = []
100
101
  diagnostics.concat(consume_load_error_diagnostics(path)) unless @load_errors.empty?
101
- return diagnostics + [runtime_error_diagnostic(path)] if index.nil? && @runtime_error
102
- return diagnostics if index.nil? || index.empty?
103
-
104
- diagnostics.concat(
105
- Analyzer.diagnose(
106
- path: path,
107
- root: root,
108
- locale_index: index,
109
- configured_locales: @configured_locales
110
- ).map { |diag| build_diagnostic(diag) }
111
- )
102
+ diagnostics << runtime_error_diagnostic(path) if index.nil? && @runtime_error
112
103
  diagnostics
113
104
  end
114
105
 
106
+ # The lazy-key (`t('.key')`) expansion needs the enclosing method
107
+ # (the controller action), supplied by the node-rule NodeContext;
108
+ # the controller scope comes from the file path.
109
+ node_rule Prism::CallNode do |node, _scope, path, _fc, context|
110
+ index = locale_index_or_nil
111
+ next [] if index.nil? || index.empty?
112
+
113
+ Analyzer.violations_for(
114
+ call_node: node, locale_index: index, configured_locales: @configured_locales,
115
+ controller_scope: Analyzer.controller_scope_from_path(path),
116
+ action: context.enclosing_def&.name
117
+ ).map do |violation|
118
+ diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
119
+ end
120
+ end
121
+
115
122
  private
116
123
 
117
124
  def locale_index_or_nil
@@ -161,13 +168,6 @@ module Rigor
161
168
  rule: "load-error"
162
169
  )
163
170
  end
164
-
165
- def build_diagnostic(diag)
166
- Rigor::Analysis::Diagnostic.new(
167
- path: diag.path, line: diag.line, column: diag.column,
168
- message: diag.message, severity: diag.severity, rule: diag.rule
169
- )
170
- end
171
171
  end
172
172
 
173
173
  Rigor::Plugin.register(RailsI18n)