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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/exe/rigor +19 -0
- data/lib/rigor/analysis/check_rules.rb +25 -1
- data/lib/rigor/analysis/diagnostic.rb +40 -0
- data/lib/rigor/analysis/runner.rb +61 -2
- data/lib/rigor/analysis/worker_session.rb +3 -2
- data/lib/rigor/cache/descriptor.rb +6 -2
- data/lib/rigor/cli/plugins_command.rb +51 -4
- data/lib/rigor/cli/plugins_renderer.rb +86 -1
- data/lib/rigor/cli.rb +135 -5
- data/lib/rigor/environment/rbs_loader.rb +259 -1
- data/lib/rigor/environment.rb +8 -2
- data/lib/rigor/inference/budget_trace.rb +137 -0
- data/lib/rigor/inference/expression_typer.rb +9 -2
- data/lib/rigor/inference/hkt_reducer.rb +2 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
- data/lib/rigor/inference/method_dispatcher.rb +57 -10
- data/lib/rigor/inference/precision_scanner.rb +60 -1
- data/lib/rigor/inference/scope_indexer.rb +127 -8
- data/lib/rigor/inference/statement_evaluator.rb +13 -8
- data/lib/rigor/inference/synthetic_method_index.rb +23 -4
- data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
- data/lib/rigor/plugin/additional_initializer.rb +108 -0
- data/lib/rigor/plugin/base.rb +321 -2
- data/lib/rigor/plugin/box.rb +64 -0
- data/lib/rigor/plugin/inflector.rb +121 -0
- data/lib/rigor/plugin/isolation.rb +191 -0
- data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
- data/lib/rigor/plugin/macro.rb +1 -0
- data/lib/rigor/plugin/manifest.rb +120 -23
- data/lib/rigor/plugin/node_context.rb +62 -0
- data/lib/rigor/plugin/registry.rb +10 -0
- data/lib/rigor/plugin.rb +3 -0
- data/lib/rigor/sig_gen/generator.rb +2 -3
- data/lib/rigor/sig_gen/observation_collector.rb +2 -2
- data/lib/rigor/source/literals.rb +118 -0
- data/lib/rigor/source/node_walker.rb +26 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/combinator.rb +6 -1
- data/lib/rigor/type/union.rb +65 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
- data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
- data/sig/rigor/plugin/access_denied_error.rbs +3 -1
- data/sig/rigor/plugin/base.rbs +58 -3
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/plugin/manifest.rbs +31 -1
- data/sig/rigor/source.rbs +12 -0
- data/sig/rigor.rbs +5 -0
- data/skills/rigor-plugin-author/SKILL.md +13 -9
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
- metadata +52 -2
- 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
|
-
|
|
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
|
-
#
|
|
39
|
-
#
|
|
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::
|
|
42
|
-
# @return [Array<
|
|
43
|
-
def
|
|
44
|
-
|
|
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
|
-
|
|
50
|
-
|
|
53
|
+
record_node = call_node.arguments&.arguments&.first
|
|
54
|
+
return [] if record_node.nil?
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
82
|
-
@policy_base_classes = Array(config.fetch("policy_base_classes"
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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(
|
|
251
|
-
location = call_node.location
|
|
241
|
+
def translation_call_info(literal_key, entry)
|
|
252
242
|
locales_text = entry.locales.sort.join(", ")
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 |
|
|
276
|
+
[].tap do |violations|
|
|
299
277
|
unless missing.empty?
|
|
300
|
-
|
|
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
|
-
|
|
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"
|
|
90
|
-
@configured_locales = Array(config.fetch("configured_locales"
|
|
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
|
-
|
|
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)
|