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,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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/rails_i18n"
@@ -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