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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +44 -3
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/scope.rbs +22 -0
- metadata +157 -1
|
@@ -0,0 +1,175 @@
|
|
|
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('.key')`) is supported for controller
|
|
48
|
+
# files (`app/controllers/**/*_controller.rb`): the key
|
|
49
|
+
# is expanded to `<controller_scope>.<action>.<key>`
|
|
50
|
+
# using the file path and the innermost enclosing `def`.
|
|
51
|
+
# Lazy keys in non-controller `.rb` files (models, helpers,
|
|
52
|
+
# mailers, …) are silently skipped — the controller/action
|
|
53
|
+
# scope cannot be statically determined there.
|
|
54
|
+
# - Pluralization (`t('errors.messages.too_short',
|
|
55
|
+
# count: n)`) is recognised at the call site but the
|
|
56
|
+
# `count` key is not used to validate the locale's
|
|
57
|
+
# pluralization branches.
|
|
58
|
+
# - YAML aliases / merges are accepted (Psych's standard
|
|
59
|
+
# `aliases: true`) but custom Ruby classes inside the
|
|
60
|
+
# YAML are NOT permitted (`safe_load`).
|
|
61
|
+
class RailsI18n < Rigor::Plugin::Base
|
|
62
|
+
manifest(
|
|
63
|
+
id: "rails-i18n",
|
|
64
|
+
# Bumped 2026-05-28 — skip `unknown-key` on Rails / rails-
|
|
65
|
+
# i18n shipped defaults (`date.order`, `time.am`,
|
|
66
|
+
# `support.array.*`, `errors.format`, …).
|
|
67
|
+
version: "0.2.0",
|
|
68
|
+
description: "Validates I18n `t(key)` calls against `config/locales/*.yml`.",
|
|
69
|
+
config_schema: {
|
|
70
|
+
"locale_search_paths" => :array,
|
|
71
|
+
"configured_locales" => :array
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
DEFAULT_LOCALE_SEARCH_PATHS = ["config/locales"].freeze
|
|
76
|
+
DEFAULT_CONFIGURED_LOCALES = ["en"].freeze
|
|
77
|
+
|
|
78
|
+
producer :locale_index do |_params|
|
|
79
|
+
loader = LocaleLoader.new(
|
|
80
|
+
io_boundary: io_boundary,
|
|
81
|
+
search_paths: @locale_search_paths
|
|
82
|
+
)
|
|
83
|
+
index = loader.load
|
|
84
|
+
@load_errors = loader.load_errors
|
|
85
|
+
index
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
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)
|
|
91
|
+
@locale_index = nil
|
|
92
|
+
@load_errors = []
|
|
93
|
+
@load_errors_emitted = false
|
|
94
|
+
@runtime_error = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
98
|
+
index = locale_index_or_nil
|
|
99
|
+
diagnostics = []
|
|
100
|
+
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
|
+
)
|
|
112
|
+
diagnostics
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def locale_index_or_nil
|
|
118
|
+
return @locale_index if @locale_index
|
|
119
|
+
|
|
120
|
+
# Pass an explicit descriptor covering every `.yml` / `.yaml`
|
|
121
|
+
# file under the configured locale search paths so the cache
|
|
122
|
+
# invalidates when locale files are added, removed, or edited.
|
|
123
|
+
# Without it the auto-built descriptor depends on the
|
|
124
|
+
# `IoBoundary`'s in-process read history — empty on the
|
|
125
|
+
# first call of a fresh process — so warm cache hits would
|
|
126
|
+
# serve stale `LocaleIndex` data and hide per-call load
|
|
127
|
+
# errors (a malformed YAML in one run would not surface
|
|
128
|
+
# when a healthy cache entry from an earlier run exists).
|
|
129
|
+
descriptor = glob_descriptor(@locale_search_paths, "**/*.yml", "**/*.yaml")
|
|
130
|
+
@locale_index = cache_for(:locale_index, params: {}, descriptor: descriptor).call
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
@runtime_error = "rigor-rails-i18n: failed to load locales: #{e.class}: #{e.message}"
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The runner only invokes `diagnostics_for_file` for
|
|
137
|
+
# Ruby files (`paths:` is filtered to `.rb`). YAML
|
|
138
|
+
# parse errors therefore can't be anchored on the
|
|
139
|
+
# offending locale file directly; instead, we emit
|
|
140
|
+
# them once per run on the first analyzed Ruby file,
|
|
141
|
+
# naming the offending YAML path in the message.
|
|
142
|
+
def consume_load_error_diagnostics(path)
|
|
143
|
+
return [] if @load_errors_emitted
|
|
144
|
+
|
|
145
|
+
@load_errors_emitted = true
|
|
146
|
+
@load_errors.map do |err|
|
|
147
|
+
Rigor::Analysis::Diagnostic.new(
|
|
148
|
+
path: path, line: 1, column: 1,
|
|
149
|
+
message: "rigor-rails-i18n: failed to parse `#{err.path}`: #{err.message}",
|
|
150
|
+
severity: :warning,
|
|
151
|
+
rule: "load-error"
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def runtime_error_diagnostic(path)
|
|
157
|
+
Rigor::Analysis::Diagnostic.new(
|
|
158
|
+
path: path, line: 1, column: 1,
|
|
159
|
+
message: @runtime_error,
|
|
160
|
+
severity: :warning,
|
|
161
|
+
rule: "load-error"
|
|
162
|
+
)
|
|
163
|
+
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
|
+
end
|
|
172
|
+
|
|
173
|
+
Rigor::Plugin.register(RailsI18n)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class RailsRoutes < Rigor::Plugin::Base
|
|
8
|
+
# Walks a parsed file's AST looking for `*_path` /
|
|
9
|
+
# `*_url` calls and validates each against the
|
|
10
|
+
# plugin's {HelperTable}. Emits info diagnostics for
|
|
11
|
+
# recognised helpers and error diagnostics for typos /
|
|
12
|
+
# arity mismatches.
|
|
13
|
+
module Analyzer
|
|
14
|
+
DID_YOU_MEAN_DISTANCE = 3
|
|
15
|
+
|
|
16
|
+
# Built-in Rails helpers we don't want to flag as
|
|
17
|
+
# unknown. The plugin's HelperTable describes
|
|
18
|
+
# user-declared routes; Rails (and a small set of
|
|
19
|
+
# widely-used asset gems) ship built-in helpers
|
|
20
|
+
# (`url_for`, `polymorphic_path`, vite_ruby's
|
|
21
|
+
# `vite_asset_path`, …) the plugin deliberately ignores.
|
|
22
|
+
BUILTIN_PASSTHROUGH = %w[
|
|
23
|
+
url_for_path url_for_url
|
|
24
|
+
polymorphic_path polymorphic_url
|
|
25
|
+
vite_asset_path vite_asset_url
|
|
26
|
+
expose_path expose_url
|
|
27
|
+
asset_path asset_url
|
|
28
|
+
image_path image_url
|
|
29
|
+
javascript_path javascript_url
|
|
30
|
+
stylesheet_path stylesheet_url
|
|
31
|
+
font_path font_url
|
|
32
|
+
video_path video_url
|
|
33
|
+
audio_path audio_url
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# RSpec / Minitest DSL methods that DECLARE a memoized
|
|
41
|
+
# local — `let(:foo) { ... }`, `subject(:foo) { ... }`,
|
|
42
|
+
# plus the bang and let_it_be variants. The first
|
|
43
|
+
# positional Symbol argument is the local name. A bare
|
|
44
|
+
# `subject { ... }` (no arg) defines `:subject` itself.
|
|
45
|
+
SHADOWING_DSL = %i[
|
|
46
|
+
let let! let_it_be let_it_be!
|
|
47
|
+
subject subject!
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
# Paths under which `unknown-helper` is suppressed. Route
|
|
51
|
+
# helpers are not customarily resolved through these
|
|
52
|
+
# directories, so a bare `shared_inbox_url` call inside
|
|
53
|
+
# `app/models/account.rb` (the call-site is the
|
|
54
|
+
# `accounts.shared_inbox_url` AR column accessor — same
|
|
55
|
+
# name as a hypothetical route helper) or a `default_url`
|
|
56
|
+
# inside `lib/paperclip/url_generator_extensions.rb` (the
|
|
57
|
+
# call-site is `Paperclip::UrlGenerator#default_url`, a
|
|
58
|
+
# gem-side instance method) would be a false positive.
|
|
59
|
+
# The core engine's `call.undefined-method` still catches
|
|
60
|
+
# a genuinely unreachable receiver. Cheap path-prefix
|
|
61
|
+
# check; no AR-column / gem-include analysis required.
|
|
62
|
+
SKIP_UNKNOWN_HELPER_PATHS = [
|
|
63
|
+
%r{(?:\A|/)app/models/},
|
|
64
|
+
%r{(?:\A|/)app/services/},
|
|
65
|
+
%r{(?:\A|/)app/workers/},
|
|
66
|
+
%r{(?:\A|/)app/finders/},
|
|
67
|
+
%r{(?:\A|/)app/policies/},
|
|
68
|
+
%r{(?:\A|/)app/validators/},
|
|
69
|
+
%r{(?:\A|/)app/uploaders/},
|
|
70
|
+
%r{(?:\A|/)lib/},
|
|
71
|
+
%r{(?:\A|/)db/},
|
|
72
|
+
%r{(?:\A|/)config/}
|
|
73
|
+
].freeze
|
|
74
|
+
|
|
75
|
+
# @param path [String] file being analysed
|
|
76
|
+
# @param root [Prism::Node]
|
|
77
|
+
# @param helper_table [HelperTable]
|
|
78
|
+
# @return [Array<Diagnostic>]
|
|
79
|
+
def diagnose(path:, root:, helper_table:)
|
|
80
|
+
suppress_unknown = SKIP_UNKNOWN_HELPER_PATHS.any? { |re| path.match?(re) }
|
|
81
|
+
# Same SKIP set silences `wrong-arity` too. A call
|
|
82
|
+
# like `group_path` inside `app/services/groups/nested_
|
|
83
|
+
# create_service.rb` is almost certainly the service's
|
|
84
|
+
# own instance method (`attr_accessor :group_path`) —
|
|
85
|
+
# both the unknown-helper check and the arity check
|
|
86
|
+
# against a registered route helper would FP.
|
|
87
|
+
diagnostics = []
|
|
88
|
+
# Pre-walk the file to collect every name that
|
|
89
|
+
# shadows a route helper at call time: `let(:foo)`,
|
|
90
|
+
# `subject(:foo)`, `def foo`, and explicit local
|
|
91
|
+
# assignments (`foo_url = "..."`). At the call site
|
|
92
|
+
# `foo` then resolves to the shadowing local, not to
|
|
93
|
+
# the registered route helper — firing `unknown-helper`
|
|
94
|
+
# / `wrong-arity` against the helper would be a false
|
|
95
|
+
# positive against canonical RSpec idioms (Mastodon
|
|
96
|
+
# has 200+ such patterns in `spec/`).
|
|
97
|
+
shadowing = collect_shadowing_names(root)
|
|
98
|
+
|
|
99
|
+
walk(root) do |call_node|
|
|
100
|
+
name = call_node.name.to_s
|
|
101
|
+
next unless name.end_with?("_path") || name.end_with?("_url")
|
|
102
|
+
next if BUILTIN_PASSTHROUGH.include?(name)
|
|
103
|
+
next if shadowing.include?(name)
|
|
104
|
+
|
|
105
|
+
entry = helper_table.find(name)
|
|
106
|
+
if entry
|
|
107
|
+
# When the file is in a `SKIP_UNKNOWN_HELPER_PATHS`
|
|
108
|
+
# directory (models / services / workers / lib /
|
|
109
|
+
# …), don't emit the info or arity diagnostic
|
|
110
|
+
# against a registered helper either — the call
|
|
111
|
+
# is more likely the file's own instance method
|
|
112
|
+
# (e.g. `group_path` as an `attr_accessor`) that
|
|
113
|
+
# happens to share a name with a registered
|
|
114
|
+
# route helper. The unknown-helper suppression
|
|
115
|
+
# already silences the inverse case.
|
|
116
|
+
next if suppress_unknown
|
|
117
|
+
|
|
118
|
+
diagnostics << info_diagnostic(path, call_node, entry)
|
|
119
|
+
arity_diagnostic = arity_check(path, call_node, entry, helper_table)
|
|
120
|
+
diagnostics << arity_diagnostic if arity_diagnostic
|
|
121
|
+
elsif helper_table.recognised?(name)
|
|
122
|
+
# Custom helper (discovered via
|
|
123
|
+
# `app/helpers/**/*.rb`) or a dynamic-provider
|
|
124
|
+
# Devise OmniAuth helper. We do NOT have an
|
|
125
|
+
# arity / path to validate — the helper is just
|
|
126
|
+
# known-to-exist. Skip the arity / info
|
|
127
|
+
# diagnostic; the absence of an `unknown-helper`
|
|
128
|
+
# error is the user-visible outcome.
|
|
129
|
+
next
|
|
130
|
+
elsif suppress_unknown
|
|
131
|
+
# File is in a directory where route helpers are
|
|
132
|
+
# not customarily resolved (models / lib / db /
|
|
133
|
+
# config). Skip `unknown-helper` to avoid false
|
|
134
|
+
# positives on AR column accessors / gem-side
|
|
135
|
+
# instance methods that happen to end in `_url` /
|
|
136
|
+
# `_path`.
|
|
137
|
+
next
|
|
138
|
+
else
|
|
139
|
+
diagnostics << unknown_helper_diagnostic(path, call_node, name, helper_table)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
diagnostics
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Walks the AST once and returns the Set of names that
|
|
146
|
+
# shadow a route helper for this file. Includes:
|
|
147
|
+
#
|
|
148
|
+
# - `def name` declarations (any level — the
|
|
149
|
+
# per-method-scope visibility model Ruby uses means a
|
|
150
|
+
# local `def` shadows the helper at every call site
|
|
151
|
+
# reachable from where it is defined; we approximate
|
|
152
|
+
# "reachable" with "anywhere in the same file").
|
|
153
|
+
# - RSpec `let` / `let!` / `let_it_be` / `let_it_be!` /
|
|
154
|
+
# `subject` / `subject!` declarations.
|
|
155
|
+
# - Local assignments at any scope
|
|
156
|
+
# (`foo_url = "..."`).
|
|
157
|
+
def collect_shadowing_names(root)
|
|
158
|
+
names = Set.new
|
|
159
|
+
walk_for_shadowing(root, names)
|
|
160
|
+
names
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def walk_for_shadowing(node, names)
|
|
164
|
+
return unless node.is_a?(Prism::Node)
|
|
165
|
+
|
|
166
|
+
case node
|
|
167
|
+
when Prism::DefNode
|
|
168
|
+
names << node.name.to_s if node.receiver.nil?
|
|
169
|
+
when Prism::LocalVariableWriteNode
|
|
170
|
+
names << node.name.to_s
|
|
171
|
+
when Prism::CallNode
|
|
172
|
+
record_let_like_name(node, names)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
node.compact_child_nodes.each { |child| walk_for_shadowing(child, names) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def record_let_like_name(call_node, names)
|
|
179
|
+
return unless call_node.receiver.nil?
|
|
180
|
+
return unless SHADOWING_DSL.include?(call_node.name)
|
|
181
|
+
|
|
182
|
+
arg = call_node.arguments&.arguments&.first
|
|
183
|
+
if arg.is_a?(Prism::SymbolNode)
|
|
184
|
+
names << arg.unescaped
|
|
185
|
+
elsif call_node.name == :subject && call_node.arguments.nil?
|
|
186
|
+
# Bare `subject { ... }` defines `:subject` itself.
|
|
187
|
+
names << "subject"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def walk(node, &)
|
|
192
|
+
return unless node.is_a?(Prism::Node)
|
|
193
|
+
|
|
194
|
+
yield node if node.is_a?(Prism::CallNode) && implicit_helper_call?(node)
|
|
195
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# `*_path` / `*_url` calls without an explicit
|
|
199
|
+
# receiver. Calls like `obj.users_path` or
|
|
200
|
+
# `Foo::users_path` are NOT route-helper invocations
|
|
201
|
+
# in Rails — controllers / views call helpers
|
|
202
|
+
# implicitly.
|
|
203
|
+
def implicit_helper_call?(node)
|
|
204
|
+
node.receiver.nil? && (node.name.to_s.end_with?("_path") || node.name.to_s.end_with?("_url"))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Paths where the implicit-params-fill pattern is
|
|
208
|
+
# idiomatic — controllers / mailers / views routinely
|
|
209
|
+
# call `*_path` / `*_url` helpers with fewer args than
|
|
210
|
+
# the route's static placeholder count because Rails
|
|
211
|
+
# fills `:foo_id` segments from `request.params` at
|
|
212
|
+
# runtime (controllers / views) or from the call's
|
|
213
|
+
# polymorphic-friendly receiver (mailers).
|
|
214
|
+
IMPLICIT_FILL_PATHS = [
|
|
215
|
+
%r{(?:\A|/)app/controllers/},
|
|
216
|
+
%r{(?:\A|/)app/mailers/},
|
|
217
|
+
%r{(?:\A|/)app/views/},
|
|
218
|
+
%r{(?:\A|/)app/components/},
|
|
219
|
+
%r{(?:\A|/)app/helpers/}
|
|
220
|
+
].freeze
|
|
221
|
+
|
|
222
|
+
def implicit_fill_path?(path)
|
|
223
|
+
IMPLICIT_FILL_PATHS.any? { |re| path.match?(re) }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def info_diagnostic(path, call_node, entry)
|
|
227
|
+
location = call_node.location
|
|
228
|
+
method_label = entry.http_method ? entry.http_method.to_s.upcase : "*"
|
|
229
|
+
Diagnostic.new(
|
|
230
|
+
path: path,
|
|
231
|
+
line: location.start_line,
|
|
232
|
+
column: location.start_column + 1,
|
|
233
|
+
severity: :info,
|
|
234
|
+
rule: "helper",
|
|
235
|
+
message: "`#{entry.name}` → #{method_label} #{entry.path}"
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def arity_check(path, call_node, entry, helper_table)
|
|
240
|
+
args = call_node.arguments&.arguments || []
|
|
241
|
+
actual = args.size
|
|
242
|
+
# Uncountable nouns (`news` / `series` / `media`) cause
|
|
243
|
+
# Rails to register two entries under the same helper
|
|
244
|
+
# name — `news_path` accepts both arity 0 (index) and
|
|
245
|
+
# arity 1 (show). The HelperTable multimap stores both;
|
|
246
|
+
# accepts_arity? checks the full set.
|
|
247
|
+
return nil if helper_table.accepts_arity?(entry.name, actual)
|
|
248
|
+
|
|
249
|
+
# Rails accepts a kwargs-only call shape that supplies
|
|
250
|
+
# route segments by name:
|
|
251
|
+
# short_account_status_url(account_username: u, id: i)
|
|
252
|
+
# for the route `/@:account_username/:id` (arity 2).
|
|
253
|
+
# Our positional-arg count is 1 (the KeywordHashNode),
|
|
254
|
+
# so the strict check rejects — but Rails would
|
|
255
|
+
# resolve every segment from the hash. When the call
|
|
256
|
+
# has a trailing KeywordHashNode AND the positional
|
|
257
|
+
# count (excluding it) is `<= expected_arity`, accept
|
|
258
|
+
# — the kwargs may carry the missing segments.
|
|
259
|
+
if args.last.is_a?(Prism::KeywordHashNode)
|
|
260
|
+
positional = actual - 1
|
|
261
|
+
return nil if helper_table.acceptable_arities(entry.name).any? { |exp| positional <= exp }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Underflow tolerance for controllers / mailers /
|
|
265
|
+
# views. A `redirect_to namespace_project_milestones_path`
|
|
266
|
+
# inside `Projects::MilestonesController` legitimately
|
|
267
|
+
# passes 0 args — Rails fills the missing `:namespace_id`
|
|
268
|
+
# / `:project_id` segments from `request.params` at
|
|
269
|
+
# runtime. Mailers reach helpers polymorphically too
|
|
270
|
+
# (`project_commit_url(commit)` resolves project from
|
|
271
|
+
# commit.project). Silence when `actual < min_arity`
|
|
272
|
+
# AND the call site is in a controller / mailer / view
|
|
273
|
+
# directory (where the implicit-params-fill pattern is
|
|
274
|
+
# idiomatic). Overflow (`actual > max_arity`) still
|
|
275
|
+
# fires — that's almost always a typo.
|
|
276
|
+
arities_set = helper_table.acceptable_arities(entry.name)
|
|
277
|
+
min_arity = arities_set.min
|
|
278
|
+
return nil if actual < min_arity && implicit_fill_path?(path)
|
|
279
|
+
|
|
280
|
+
arities = arities_set.sort
|
|
281
|
+
expected = arities.length == 1 ? arities.first.to_s : "#{arities.first}..#{arities.last}"
|
|
282
|
+
location = call_node.location
|
|
283
|
+
Diagnostic.new(
|
|
284
|
+
path: path,
|
|
285
|
+
line: location.start_line,
|
|
286
|
+
column: location.start_column + 1,
|
|
287
|
+
severity: :error,
|
|
288
|
+
rule: "wrong-arity",
|
|
289
|
+
message: "`#{entry.name}` expects #{expected} argument(s), got #{actual}"
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def unknown_helper_diagnostic(path, call_node, name, helper_table)
|
|
294
|
+
location = call_node.location
|
|
295
|
+
suggestion = did_you_mean(name, helper_table.names)
|
|
296
|
+
message = "no route helper `#{name}`"
|
|
297
|
+
message += " (did you mean `#{suggestion}`?)" if suggestion
|
|
298
|
+
|
|
299
|
+
Diagnostic.new(
|
|
300
|
+
path: path,
|
|
301
|
+
line: location.start_line,
|
|
302
|
+
column: location.start_column + 1,
|
|
303
|
+
severity: :error,
|
|
304
|
+
rule: "unknown-helper",
|
|
305
|
+
message: message
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Levenshtein-style nearest neighbour. Returns the
|
|
310
|
+
# closest known helper within {DID_YOU_MEAN_DISTANCE}
|
|
311
|
+
# edits, or nil.
|
|
312
|
+
def did_you_mean(name, candidates)
|
|
313
|
+
best = nil
|
|
314
|
+
best_distance = DID_YOU_MEAN_DISTANCE + 1
|
|
315
|
+
candidates.each do |candidate|
|
|
316
|
+
d = levenshtein(name, candidate)
|
|
317
|
+
if d < best_distance
|
|
318
|
+
best = candidate
|
|
319
|
+
best_distance = d
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
best
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Standard iterative Levenshtein. Lifted from
|
|
326
|
+
# rigor-routes' equivalent helper for parity.
|
|
327
|
+
def levenshtein(left, right)
|
|
328
|
+
return right.length if left.empty?
|
|
329
|
+
return left.length if right.empty?
|
|
330
|
+
|
|
331
|
+
rows = Array.new(left.length + 1) { Array.new(right.length + 1, 0) }
|
|
332
|
+
(0..left.length).each { |i| rows[i][0] = i }
|
|
333
|
+
(0..right.length).each { |j| rows[0][j] = j }
|
|
334
|
+
|
|
335
|
+
(1..left.length).each do |i|
|
|
336
|
+
(1..right.length).each do |j|
|
|
337
|
+
cost = left[i - 1] == right[j - 1] ? 0 : 1
|
|
338
|
+
rows[i][j] = [
|
|
339
|
+
rows[i - 1][j] + 1,
|
|
340
|
+
rows[i][j - 1] + 1,
|
|
341
|
+
rows[i - 1][j - 1] + cost
|
|
342
|
+
].min
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
rows[left.length][right.length]
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|