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,713 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+ require "prism"
5
+
6
+ module Rigor
7
+ module Plugin
8
+ class Actionpack < Rigor::Plugin::Base
9
+ # Per-file walker — the controller's parsed AST is searched
10
+ # for `*_path` / `*_url` calls and each is validated against
11
+ # the helper table the upstream `rigor-rails-routes` plugin
12
+ # publishes via `services.fact_store`.
13
+ #
14
+ # The recogniser keys on call-method-name suffix:
15
+ #
16
+ # - `users_path`, `edit_user_path(@user)` → `_path` family.
17
+ # - `users_url`, `edit_user_url(@user)` → `_url` family.
18
+ #
19
+ # Any call whose name doesn't end in `_path` / `_url` is
20
+ # silently passed through. Calls with an explicit non-self
21
+ # receiver (`other_helper.users_path`) are also skipped —
22
+ # the helper is implicit-self in real controllers, and a
23
+ # custom-receiver call is almost certainly someone's own
24
+ # method that happens to share the suffix.
25
+ module Analyzer
26
+ SUFFIXES = %w[_path _url].freeze
27
+
28
+ # Phase 2 — filter-chain DSL methods. Each takes a
29
+ # variadic list of filter names (Symbols / Strings) plus
30
+ # optional `only:` / `except:` / `if:` / `unless:`
31
+ # modifiers. The validation key is the filter NAMES; the
32
+ # modifiers are accepted but their action-name argument
33
+ # is not yet validated (Phase 2.5).
34
+ FILTER_DSL_METHODS = %i[
35
+ before_action after_action around_action
36
+ skip_before_action skip_after_action skip_around_action
37
+ prepend_before_action prepend_after_action prepend_around_action
38
+ ].freeze
39
+
40
+ # Phase 3 — render-target template extensions checked in
41
+ # priority order. The first six cover the templating
42
+ # engines used by the projects this plugin is regularly
43
+ # exercised against: ERB (Rails default — `.html.erb`,
44
+ # `.text.erb`), HAML (Mastodon, Solidus admin —
45
+ # `.html.haml`), Slim, and JSON (`.json.jbuilder` plus a
46
+ # raw `.json.erb` for hand-rolled API responses). When a
47
+ # template exists under any of these extensions, the
48
+ # missing-template diagnostic stays silent.
49
+ # Configurable extension list is queued — see the
50
+ # `external-author plugin SKILL` track (v0.2.0). For now
51
+ # this set is wide enough to cover the surveyed real-world
52
+ # projects without leaking FPs.
53
+ RENDER_TEMPLATE_EXTENSIONS = %w[
54
+ .html.erb
55
+ .text.erb
56
+ .html.haml
57
+ .text.haml
58
+ .html.slim
59
+ .json.jbuilder
60
+ .json.erb
61
+ .xml.builder
62
+ .xml.erb
63
+ ].freeze
64
+
65
+ # Phase 1 — strong-parameter call shapes that begin a
66
+ # validatable chain. The walker matches the
67
+ # `params.require(:user).permit(:name, :email)` chain
68
+ # by looking for `:permit` call sites whose receiver is
69
+ # `params.require(:symbol)`.
70
+ STRONG_PARAMS_RECEIVER_NAMES = %i[require permit_params strong_params].freeze
71
+
72
+ Diagnostic = Data.define(:path, :line, :column, :message, :severity, :rule)
73
+
74
+ module_function
75
+
76
+ # @param path [String] absolute path to the file being
77
+ # analysed (used for diagnostic locations).
78
+ # @param root [Prism::Node] the parsed AST root.
79
+ # @param helper_table [Hash{String => Hash}] the value
80
+ # `services.fact_store.read(plugin_id: "rails-routes",
81
+ # name: :helper_table)` returns. Each entry carries
82
+ # `name`, `arity`, `path`, `http_method`, `action`.
83
+ # @return [Array<Diagnostic>]
84
+ def diagnose(path:, root:, helper_table:)
85
+ diagnostics = []
86
+ known_names = helper_table.keys.freeze
87
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: known_names)
88
+
89
+ walk(root) do |call_node|
90
+ entry, suggestion = lookup(call_node, helper_table, spell_checker)
91
+ diagnostic = diagnostic_for(path, call_node, entry, suggestion)
92
+ diagnostics << diagnostic if diagnostic
93
+ end
94
+
95
+ diagnostics
96
+ end
97
+
98
+ # Phase 2 — filter-chain validation. Walks the file's
99
+ # top-level class node, looks it up in the controller
100
+ # index to get the effective method set (including
101
+ # one level of inheritance), and validates that every
102
+ # `before_action :name` reference resolves to a defined
103
+ # method. Files that don't contain a known controller
104
+ # contribute no diagnostics.
105
+ def diagnose_filters(path:, root:, controller_index:)
106
+ class_node, enclosing = first_class_node_with_namespace(root)
107
+ return [] if class_node.nil?
108
+
109
+ class_name = qualified_name_with_enclosing(class_node.constant_path, enclosing)
110
+ return [] if class_name.nil?
111
+ return [] unless controller_index.known?(class_name)
112
+
113
+ methods = controller_index.effective_methods_for(class_name)
114
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: methods.map(&:to_s))
115
+ # When the controller (or its parent) `include`s a
116
+ # module the discoverer couldn't resolve — typically a
117
+ # gem-shipped concern such as `Devise::Controllers::
118
+ # Helpers` or `Pundit::Authorization` — any
119
+ # `before_action :name` MIGHT be defined in that
120
+ # unresolved module. Suppress `unknown-filter-method`
121
+ # in that case rather than FPing on legitimate
122
+ # gem-provided callback names.
123
+ ambiguous_filters = controller_index.unresolved_include?(class_name)
124
+
125
+ collect_filter_diagnostics(path, class_node.body, methods, spell_checker, ambiguous_filters: ambiguous_filters)
126
+ end
127
+
128
+ def collect_filter_diagnostics(path, body, methods, spell_checker, ambiguous_filters:)
129
+ diagnostics = []
130
+ walk_filter_calls(body) do |call_node|
131
+ filter_name_args(call_node).each do |arg_node|
132
+ filter_name = literal_symbol_or_string(arg_node)
133
+ next if filter_name.nil?
134
+
135
+ diag = filter_lookup_diagnostic(
136
+ path, call_node, arg_node, filter_name, methods, spell_checker,
137
+ ambiguous_filters: ambiguous_filters
138
+ )
139
+ diagnostics << diag if diag
140
+ end
141
+ end
142
+ diagnostics
143
+ end
144
+
145
+ def filter_lookup_diagnostic(path, call_node, arg_node, filter_name, methods, spell_checker, ambiguous_filters:)
146
+ if methods.include?(filter_name.to_sym)
147
+ filter_call_diagnostic(path, call_node, filter_name)
148
+ elsif ambiguous_filters
149
+ # An unresolved include shadows our judgment — emit
150
+ # the recognized-filter info anyway so the call site
151
+ # is still indexed, but skip the error.
152
+ filter_call_diagnostic(path, call_node, filter_name)
153
+ else
154
+ unknown_filter_diagnostic(path, arg_node, call_node, filter_name, spell_checker)
155
+ end
156
+ end
157
+
158
+ def walk_filter_calls(node, &)
159
+ return unless node.is_a?(Prism::Node)
160
+
161
+ yield node if node.is_a?(Prism::CallNode) && node.receiver.nil? && FILTER_DSL_METHODS.include?(node.name)
162
+ node.compact_child_nodes.each { |child| walk_filter_calls(child, &) }
163
+ end
164
+
165
+ # Drops the trailing keyword hash (`only:` / `except:` /
166
+ # `if:` / `unless:`) so the modifier args don't get
167
+ # treated as filter names.
168
+ def filter_name_args(call_node)
169
+ args = call_node.arguments&.arguments || []
170
+ args = args[0..-2] if args.last.is_a?(Prism::KeywordHashNode)
171
+ args
172
+ end
173
+
174
+ def literal_symbol_or_string(node)
175
+ case node
176
+ when Prism::SymbolNode then node.value
177
+ when Prism::StringNode then node.unescaped
178
+ end
179
+ end
180
+
181
+ def first_class_node(node)
182
+ class_node, = first_class_node_with_namespace(node)
183
+ class_node
184
+ end
185
+
186
+ # Returns `[class_node, enclosing_namespace_array]` for
187
+ # the first `ClassNode` reachable from `node`. The
188
+ # namespace chain accumulates every enclosing
189
+ # `ModuleNode` / `ClassNode` qualifier, so the
190
+ # nested-module declaration shape
191
+ #
192
+ # module Admin
193
+ # class DomainBlocksController < BaseController
194
+ # end
195
+ # end
196
+ #
197
+ # is recovered as the qualified name
198
+ # `Admin::DomainBlocksController` — the same name the
199
+ # `ControllerDiscoverer` registers under (see the
200
+ # nested-module qualification fix on
201
+ # `ControllerDiscoverer#walk_declarations`). Without
202
+ # this, the analyzer used the bare inner-class name
203
+ # for index lookups + `controller_path_for`, silently
204
+ # skipping filter validation and pointing render
205
+ # template paths at the wrong directory (Mastodon's
206
+ # `admin/domain_blocks` rendered as bare
207
+ # `domain_blocks`).
208
+ def first_class_node_with_namespace(node, namespace = [])
209
+ return [nil, namespace] unless node.is_a?(Prism::Node)
210
+ return [node, namespace] if node.is_a?(Prism::ClassNode)
211
+
212
+ inner_namespace = if node.is_a?(Prism::ModuleNode)
213
+ namespace + namespace_segments_for(node)
214
+ else
215
+ namespace
216
+ end
217
+ node.compact_child_nodes.each do |child|
218
+ found, found_namespace = first_class_node_with_namespace(child, inner_namespace)
219
+ return [found, found_namespace] if found
220
+ end
221
+ [nil, namespace]
222
+ end
223
+
224
+ def namespace_segments_for(declaration_node)
225
+ path = qualified_name_for(declaration_node.constant_path)
226
+ path ? path.split("::") : []
227
+ end
228
+
229
+ # Resolves a class-name AST node against an enclosing
230
+ # namespace chain. A `ConstantPathNode` (e.g.
231
+ # `class Admin::Foo`) is already absolute and ignores
232
+ # the chain; a `ConstantReadNode` (bare `class Foo`
233
+ # inside `module Admin`) is qualified against it.
234
+ def qualified_name_with_enclosing(node, enclosing)
235
+ return nil unless node.is_a?(Prism::Node)
236
+
237
+ local = qualified_name_for(node)
238
+ return nil if local.nil?
239
+ return local if node.is_a?(Prism::ConstantPathNode) && !node.parent.nil?
240
+ return local if enclosing.empty?
241
+
242
+ "#{enclosing.join('::')}::#{local}"
243
+ end
244
+
245
+ def qualified_name_for(node)
246
+ case node
247
+ when Prism::ConstantReadNode then node.name.to_s
248
+ when Prism::ConstantPathNode
249
+ parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
250
+ return nil if !node.parent.nil? && parent.nil?
251
+
252
+ parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
253
+ end
254
+ end
255
+
256
+ # Phase 1 — strong-parameter validation. Recognises
257
+ # the `params.require(:symbol).permit(:key, :key, ...)`
258
+ # chain and validates each `:key` against the AR
259
+ # model's column list (looked up via the model_index
260
+ # fact published by `rigor-activerecord`). Calls whose
261
+ # `:require` argument is a non-literal Symbol are
262
+ # passed through; namespaced models
263
+ # (`params.require(:admin_user)` →
264
+ # `Admin::User`) are deferred to a Phase 1.5 follow-up.
265
+ def diagnose_permits(path:, root:, model_index:)
266
+ diagnostics = []
267
+ walk_permit_calls(root) do |permit_call, model_class|
268
+ entry = model_index[model_class]
269
+ next if entry.nil? # unknown model — skip; the model lookup is best-effort.
270
+
271
+ columns = entry[:columns]
272
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: columns)
273
+ literal_permit_keys(permit_call).each do |key_node, key_name|
274
+ diagnostics << if columns.include?(key_name)
275
+ permit_call_diagnostic(path, permit_call, model_class, key_name)
276
+ else
277
+ unknown_permit_key_diagnostic(path, key_node, model_class, key_name, spell_checker)
278
+ end
279
+ end
280
+ end
281
+ diagnostics
282
+ end
283
+
284
+ # Walks the AST yielding `[permit_call, model_class]`
285
+ # pairs for every `params.require(:symbol).permit(...)`
286
+ # chain. The match keys on:
287
+ #
288
+ # - method name `:permit` (with any positional args).
289
+ # - receiver shape: a `:require` call whose first
290
+ # positional arg is a literal `Prism::SymbolNode`.
291
+ #
292
+ # The literal symbol's `to_s.capitalize` is the
293
+ # candidate model class name. Namespaced models
294
+ # (`:admin_user` → `Admin::User`) are deferred — the
295
+ # mapping for them needs the inflector and a
296
+ # convention call we don't ship in Phase 1.
297
+ def walk_permit_calls(node, &)
298
+ return unless node.is_a?(Prism::Node)
299
+
300
+ yield node, model_class_for_permit(node) if permit_chain?(node)
301
+ node.compact_child_nodes.each { |child| walk_permit_calls(child, &) }
302
+ end
303
+
304
+ def permit_chain?(node)
305
+ return false unless node.is_a?(Prism::CallNode) && node.name == :permit
306
+
307
+ require_call = node.receiver
308
+ return false unless require_call.is_a?(Prism::CallNode) && require_call.name == :require
309
+
310
+ first_arg = require_call.arguments&.arguments&.first
311
+ first_arg.is_a?(Prism::SymbolNode)
312
+ end
313
+
314
+ def model_class_for_permit(permit_call)
315
+ require_call = permit_call.receiver
316
+ symbol_node = require_call.arguments.arguments.first
317
+ # Phase 1 convention: `:user` → `User`; namespaced
318
+ # mapping deferred. The capitalize call is sufficient
319
+ # for the typical single-word model names; users with
320
+ # multi-word camelcase shape (`:order_item` →
321
+ # `OrderItem`) need the inflector follow-up.
322
+ symbol_node.value.to_s.split("_").map(&:capitalize).join
323
+ end
324
+
325
+ def literal_permit_keys(permit_call)
326
+ (permit_call.arguments&.arguments || []).filter_map do |arg|
327
+ next [arg, arg.value] if arg.is_a?(Prism::SymbolNode)
328
+ end
329
+ end
330
+
331
+ def permit_call_diagnostic(path, permit_call, model_class, key_name)
332
+ loc = permit_call.message_loc || permit_call.location
333
+ Diagnostic.new(
334
+ path: path, line: loc.start_line, column: loc.start_column + 1,
335
+ message: "Action Pack permit `#{key_name}` resolves to a column on `#{model_class}`.",
336
+ severity: :info, rule: "permit-call"
337
+ )
338
+ end
339
+
340
+ def unknown_permit_key_diagnostic(path, key_node, model_class, key_name, spell_checker)
341
+ loc = key_node.location
342
+ base = "Action Pack permit `#{key_name}` is not a column on `#{model_class}`."
343
+ suggestion = spell_checker.correct(key_name).first
344
+ message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
345
+ Diagnostic.new(
346
+ path: path, line: loc.start_line, column: loc.start_column + 1,
347
+ message: message, severity: :error, rule: "unknown-permit-key"
348
+ )
349
+ end
350
+
351
+ # Phase 3 — render-target validation. For each
352
+ # explicit `render` call inside a controller method,
353
+ # derive the candidate view template path(s) from the
354
+ # controller class name + the render argument shape,
355
+ # then check existence under the configured
356
+ # `view_search_paths` (default `["app/views"]`). Recognised
357
+ # call shapes:
358
+ #
359
+ # - `render :symbol` — `<views>/<controller_path>/<symbol>.html.erb`
360
+ # - `render "string/path"` — `<views>/<string_path>.html.erb`
361
+ # - `render partial: "name"` — `<views>/<controller_path>/_<name>.html.erb`
362
+ # - `render partial: "string/path"` — `<views>/<string_path with _ prefix>.html.erb`
363
+ #
364
+ # `render layout:`, `render plain:`, `render json:`,
365
+ # `render text:`, `render inline:`, `render :nothing
366
+ # => true`, etc. are pass-through (no template
367
+ # lookup). Implicit-render (a controller method that
368
+ # doesn't call `render`) is also skipped — Phase 3
369
+ # validates explicit renders only, since the implicit
370
+ # path would false-positive on `redirect_to` / `head`
371
+ # / early returns.
372
+ def diagnose_renders(path:, root:, view_search_roots:, controller_index: nil)
373
+ class_node, enclosing = first_class_node_with_namespace(root)
374
+ return [] if class_node.nil?
375
+
376
+ class_name = qualified_name_with_enclosing(class_node.constant_path, enclosing)
377
+ return [] if class_name.nil?
378
+
379
+ # Prefer the file-path-derived controller path when the
380
+ # source lives under `app/controllers/` — Rails autoload
381
+ # is the runtime authority on `Admin::Users::RolesController`
382
+ # vs `Users::RolesController` (a declaration like
383
+ # `module Admin; class Users::RolesController` resolves
384
+ # to whichever `Users` constant Ruby finds first, and
385
+ # the file path is the disambiguator Rails picks). Fall
386
+ # back to the AST-derived class name for files outside
387
+ # `app/controllers/` (libraries, test fixtures).
388
+ controller_path = controller_path_from_file(path) || controller_path_for(class_name)
389
+ return [] if controller_path.nil?
390
+
391
+ # Render checks silence when the controller (or its
392
+ # ancestor chain) inherits from a gem-shipped parent
393
+ # (Devise::ConfirmationsController,
394
+ # Doorkeeper::ApplicationsController, …). The gem
395
+ # ships its own views; the local subclass calling
396
+ # `render :show` resolves through the gem's view path,
397
+ # which our static analyser doesn't know about.
398
+ if controller_index&.known?(class_name) &&
399
+ controller_index.unresolved_include?(class_name)
400
+ return []
401
+ end
402
+
403
+ # Abstract base controllers: a `*BaseController` (`Admin::
404
+ # BaseController`, `Settings::Preferences::BaseController`,
405
+ # …) typically has no view of its own; its `render :show`
406
+ # bodies are resolved by Rails against the calling
407
+ # subclass's controller path at request time, NOT the
408
+ # base's. Same for parent controllers whose view
409
+ # directory exists but contains only subdirectories
410
+ # (Mastodon's `Admin::SettingsController` whose
411
+ # `app/views/admin/settings/` holds about/, appearance/,
412
+ # … but no top-level templates). Skip render checks for
413
+ # these — the diagnostic would be a false positive
414
+ # against intentional Rails abstract-base layouts.
415
+ return [] if abstract_base_controller?(class_name, controller_path, view_search_roots)
416
+
417
+ collect_render_diagnostics(path, class_node.body, controller_path, view_search_roots)
418
+ end
419
+
420
+ # Convert `<root>/app/controllers/admin/users/roles_controller.rb`
421
+ # to `admin/users/roles`. Returns nil if the path is not
422
+ # under an `app/controllers/` segment.
423
+ def controller_path_from_file(path)
424
+ match = path.match(%r{(?:\A|/)app/controllers/(.+)_controller\.rb\z})
425
+ match&.[](1)
426
+ end
427
+
428
+ # An abstract base controller — its `render` bodies don't
429
+ # validate against its own controller_path because Rails
430
+ # resolves at request time against the actual subclass.
431
+ # Two conservative heuristics:
432
+ # (1) The class name ends with `BaseController`. Strong
433
+ # Rails-convention signal (Settings::Preferences::
434
+ # BaseController, Admin::BaseController, …).
435
+ # (2) The controller's view directory exists but contains
436
+ # ONLY subdirectories (no template files at its
437
+ # top). Mastodon's Admin::SettingsController whose
438
+ # app/views/admin/settings/ holds only about/,
439
+ # appearance/, … — the parent of nested controllers.
440
+ # Deliberately NOT triggered by "no view directory at
441
+ # all" because that fires the diagnostic we DO want for
442
+ # genuinely-missing views (the typo / forgot-to-create
443
+ # case).
444
+ def abstract_base_controller?(class_name, controller_path, view_search_roots)
445
+ return true if class_name.end_with?("BaseController")
446
+
447
+ view_search_roots.any? do |root|
448
+ dir = File.join(root, controller_path)
449
+ next false unless File.directory?(dir)
450
+
451
+ entries = Dir.children(dir)
452
+ entries.any? && entries.all? { |e| File.directory?(File.join(dir, e)) }
453
+ end
454
+ end
455
+
456
+ def collect_render_diagnostics(path, body, controller_path, view_search_roots)
457
+ diagnostics = []
458
+ walk_render_calls(body) do |call_node|
459
+ target = render_target_for(call_node, controller_path)
460
+ next if target.nil?
461
+
462
+ diag = render_diagnostic(path, call_node, target, view_search_roots)
463
+ diagnostics << diag if diag
464
+ end
465
+ diagnostics
466
+ end
467
+
468
+ def walk_render_calls(node, &)
469
+ return unless node.is_a?(Prism::Node)
470
+
471
+ yield node if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :render
472
+ node.compact_child_nodes.each { |child| walk_render_calls(child, &) }
473
+ end
474
+
475
+ # Returns `[kind, view_relative_path]` where kind is
476
+ # `:template` or `:partial`, and view_relative_path is
477
+ # the path under view_search_roots WITHOUT extension
478
+ # (the extension family is appended at lookup time).
479
+ # Returns nil for shapes Phase 3 doesn't validate
480
+ # (`layout:` / `plain:` / `json:` / `text:` / `inline:`
481
+ # / `:nothing` / no parseable target).
482
+ def render_target_for(call_node, controller_path)
483
+ args = call_node.arguments&.arguments || []
484
+ return nil if args.empty?
485
+
486
+ first = args.first
487
+ # `render partial: "..."` — the keyword form.
488
+ return partial_target_from_kwargs(first, controller_path) if first.is_a?(Prism::KeywordHashNode)
489
+
490
+ # `render :symbol` / `render "path"`. A trailing
491
+ # KeywordHashNode is allowed (e.g. `render :show,
492
+ # status: :ok`); the leading positional carries the
493
+ # template name.
494
+ template_target_from_positional(first, controller_path)
495
+ end
496
+
497
+ def template_target_from_positional(node, controller_path)
498
+ case node
499
+ when Prism::SymbolNode then [:template, "#{controller_path}/#{node.value}"]
500
+ when Prism::StringNode
501
+ stripped = node.unescaped
502
+ stripped.include?("/") ? [:template, stripped] : [:template, "#{controller_path}/#{stripped}"]
503
+ end
504
+ end
505
+
506
+ def partial_target_from_kwargs(hash_node, controller_path)
507
+ partial_value = hash_node.elements.find do |elem|
508
+ elem.is_a?(Prism::AssocNode) &&
509
+ elem.key.is_a?(Prism::SymbolNode) &&
510
+ elem.key.value == "partial"
511
+ end&.value
512
+ return nil unless partial_value.is_a?(Prism::StringNode)
513
+
514
+ stripped = partial_value.unescaped
515
+ if stripped.include?("/")
516
+ dir, base = File.split(stripped)
517
+ [:partial, "#{dir}/_#{base}"]
518
+ else
519
+ [:partial, "#{controller_path}/_#{stripped}"]
520
+ end
521
+ end
522
+
523
+ # `UsersController` → "users".
524
+ # `Admin::WidgetsController` → "admin/widgets".
525
+ # Returns nil for class names that don't end with the
526
+ # `Controller` suffix.
527
+ def controller_path_for(class_name)
528
+ return nil unless class_name.end_with?("Controller")
529
+
530
+ stripped = class_name.delete_suffix("Controller")
531
+ stripped.split("::").map { |segment| underscore(segment) }.join("/")
532
+ end
533
+
534
+ # Tiny inflector — sufficient for the typical
535
+ # `WordWord` → `word_word` mapping. Doesn't try to
536
+ # handle acronyms (`HTTPController` would inflect to
537
+ # `h_t_t_p`); users with that need can ship a
538
+ # configured override in a follow-up slice.
539
+ def underscore(camel)
540
+ camel.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
541
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
542
+ .downcase
543
+ end
544
+
545
+ def render_diagnostic(path, call_node, target, view_search_roots)
546
+ kind, relative = target
547
+ existing = locate_template(relative, view_search_roots)
548
+ if existing
549
+ render_target_diagnostic(path, call_node, kind, relative, existing)
550
+ else
551
+ missing_template_diagnostic(path, call_node, kind, relative, view_search_roots)
552
+ end
553
+ end
554
+
555
+ def locate_template(relative, view_search_roots)
556
+ view_search_roots.each do |root|
557
+ RENDER_TEMPLATE_EXTENSIONS.each do |ext|
558
+ candidate = File.join(root, "#{relative}#{ext}")
559
+ return candidate if File.file?(candidate)
560
+ end
561
+ end
562
+ nil
563
+ end
564
+
565
+ def render_target_diagnostic(path, call_node, kind, relative, located)
566
+ loc = call_node.message_loc || call_node.location
567
+ Diagnostic.new(
568
+ path: path, line: loc.start_line, column: loc.start_column + 1,
569
+ message: "Action Pack render #{kind} `#{relative}` resolved to `#{located}`.",
570
+ severity: :info, rule: "render-target"
571
+ )
572
+ end
573
+
574
+ def missing_template_diagnostic(path, call_node, kind, relative, view_search_roots)
575
+ loc = call_node.message_loc || call_node.location
576
+ tried = RENDER_TEMPLATE_EXTENSIONS.map { |ext| "#{relative}#{ext}" }.join(", ")
577
+ roots = view_search_roots.join(", ")
578
+ Diagnostic.new(
579
+ path: path, line: loc.start_line, column: loc.start_column + 1,
580
+ message: "Action Pack render #{kind} `#{relative}` not found under #{roots} " \
581
+ "(tried #{tried}).",
582
+ severity: :error, rule: "missing-template"
583
+ )
584
+ end
585
+
586
+ def filter_call_diagnostic(path, call_node, filter_name)
587
+ loc = call_node.message_loc || call_node.location
588
+ Diagnostic.new(
589
+ path: path, line: loc.start_line, column: loc.start_column + 1,
590
+ message: "Action Pack filter `#{call_node.name} :#{filter_name}` resolves to a defined method.",
591
+ severity: :info, rule: "filter-call"
592
+ )
593
+ end
594
+
595
+ def unknown_filter_diagnostic(path, arg_node, call_node, filter_name, spell_checker)
596
+ loc = arg_node.location
597
+ base = "Action Pack filter `#{call_node.name} :#{filter_name}` references no method " \
598
+ "defined on this controller (or its parent)."
599
+ suggestion = spell_checker.correct(filter_name.to_s).first
600
+ message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
601
+ Diagnostic.new(
602
+ path: path, line: loc.start_line, column: loc.start_column + 1,
603
+ message: message, severity: :error, rule: "unknown-filter-method"
604
+ )
605
+ end
606
+
607
+ # Walk the AST yielding only call nodes whose method
608
+ # name ends in `_path` / `_url` and whose receiver is
609
+ # implicit-self (no explicit receiver). Constants are
610
+ # skipped — `Rails.application.routes.url_helpers` is
611
+ # not what Phase 4 validates.
612
+ def walk(node, &)
613
+ return unless node.is_a?(Prism::Node)
614
+
615
+ yield node if node.is_a?(Prism::CallNode) && helper_suffix?(node.name) && node.receiver.nil?
616
+ node.compact_child_nodes.each { |child| walk(child, &) }
617
+ end
618
+
619
+ def helper_suffix?(name)
620
+ name_str = name.to_s
621
+ SUFFIXES.any? { |suffix| name_str.end_with?(suffix) && name_str.length > suffix.length }
622
+ end
623
+
624
+ # Returns `[entry, suggestion]`:
625
+ #
626
+ # - `[entry, nil]` — known helper.
627
+ # - `[nil, nil]` — unknown helper, no spell-checker match.
628
+ # - `[nil, "user_path"]` — unknown helper, did-you-mean
629
+ # suggestion to surface in the diagnostic.
630
+ def lookup(call_node, helper_table, spell_checker)
631
+ name = call_node.name.to_s
632
+ entry = helper_table[name]
633
+ return [entry, nil] if entry
634
+
635
+ [nil, spell_checker.correct(name).first]
636
+ end
637
+
638
+ # Builds the diagnostic. Only the **info-level** route
639
+ # resolution (`helper-call`) is the value this plugin
640
+ # adds — it surfaces the resolved HTTP method + path +
641
+ # action alongside the call site, which `rigor-rails-routes`
642
+ # does not. Unknown-helper and wrong-arity diagnostics are
643
+ # produced canonically by `rigor-rails-routes`'s own analyzer
644
+ # (same `:helper_table` source of truth), so emitting them
645
+ # here would double every call-site error. Real-world
646
+ # impact pre-fix: ~301 duplicates on Mastodon and ~119 on
647
+ # Redmine, exactly stacked with the rails-routes diagnostics.
648
+ # Return `nil` for unknown / arity-mismatch shapes so the
649
+ # caller filters them out.
650
+ def diagnostic_for(path, call_node, entry, _suggestion)
651
+ return nil if entry.nil?
652
+
653
+ actual_arity = positional_arg_count(call_node)
654
+ # The `:helper_table` fact entry carries both
655
+ # `:arity` (the first registered entry's arity) and
656
+ # `:acceptable_arities` (the full Array — populated
657
+ # for uncountable-noun resources like `resources :news`
658
+ # which share a helper name across index/show). Honour
659
+ # the full set; fall back to the single arity for
660
+ # consumers compiled against an older fact shape.
661
+ acceptable = entry[:acceptable_arities] || [entry[:arity]]
662
+ return nil unless acceptable.include?(actual_arity)
663
+
664
+ helper_call_diagnostic(path, call_node, entry)
665
+ end
666
+
667
+ def positional_arg_count(call_node)
668
+ args = call_node.arguments&.arguments || []
669
+ # Drop a trailing `KeywordHashNode` so call sites that
670
+ # pass `users_path(format: :json)` don't get counted as
671
+ # arity 1. Same convention rigor-rails-routes' helper-
672
+ # table arity uses (positional only).
673
+ args = args[0..-2] if args.last.is_a?(Prism::KeywordHashNode)
674
+ args.size
675
+ end
676
+
677
+ def location(call_node)
678
+ call_node.message_loc || call_node.location
679
+ end
680
+
681
+ def helper_call_diagnostic(path, call_node, entry)
682
+ loc = location(call_node)
683
+ method = entry[:http_method] ? entry[:http_method].to_s.upcase : "(any)"
684
+ Diagnostic.new(
685
+ path: path, line: loc.start_line, column: loc.start_column + 1,
686
+ message: "Action Pack helper `#{call_node.name}` → #{method} #{entry[:path]} (action: #{entry[:action]}).",
687
+ severity: :info, rule: "helper-call"
688
+ )
689
+ end
690
+
691
+ def unknown_helper_diagnostic(path, call_node, suggestion)
692
+ loc = location(call_node)
693
+ base = "Unknown route helper `#{call_node.name}` — not registered in `config/routes.rb`."
694
+ message = suggestion ? "#{base} Did you mean `#{suggestion}`?" : base
695
+ Diagnostic.new(
696
+ path: path, line: loc.start_line, column: loc.start_column + 1,
697
+ message: message, severity: :error, rule: "unknown-helper"
698
+ )
699
+ end
700
+
701
+ def wrong_arity_diagnostic(path, call_node, entry, actual_arity)
702
+ loc = location(call_node)
703
+ Diagnostic.new(
704
+ path: path, line: loc.start_line, column: loc.start_column + 1,
705
+ message: "Route helper `#{call_node.name}` expects #{entry[:arity]} positional " \
706
+ "argument(s) but the call passes #{actual_arity}.",
707
+ severity: :error, rule: "wrong-helper-arity"
708
+ )
709
+ end
710
+ end
711
+ end
712
+ end
713
+ end