rigortype 0.1.10 → 0.1.11

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/cli/baseline_command.rb +4 -3
  4. data/lib/rigor/cli.rb +16 -3
  5. data/lib/rigor/version.rb +1 -1
  6. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  7. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  8. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  9. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  10. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  11. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  12. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  13. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  14. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  15. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  16. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  17. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  18. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  19. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  20. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  21. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  22. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  23. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  24. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  25. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  26. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  27. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  28. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  29. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  33. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  34. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  35. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  36. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  37. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  38. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  39. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  40. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  41. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  42. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  43. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  44. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  45. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  46. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  47. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  48. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  49. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  50. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  51. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  52. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  53. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  54. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  55. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  56. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  57. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  58. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  59. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  60. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  61. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  62. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  63. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  64. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  65. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  66. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  67. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  68. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  69. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  71. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  74. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  75. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  76. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  77. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  78. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  81. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  82. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  85. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  86. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  87. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  88. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  89. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  90. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  91. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  92. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  93. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  94. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  95. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  96. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  97. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  98. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  99. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  100. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  102. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  103. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  104. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  105. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  106. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  107. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  108. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  109. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  110. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  111. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  112. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  113. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  114. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  115. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  116. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  117. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  118. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  119. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  120. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  121. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  122. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  123. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  124. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  125. metadata +149 -1
@@ -0,0 +1,589 @@
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 = first_class_node(root)
107
+ return [] if class_node.nil?
108
+
109
+ class_name = qualified_name_for(class_node.constant_path)
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
+ return nil unless node.is_a?(Prism::Node)
183
+ return node if node.is_a?(Prism::ClassNode)
184
+
185
+ node.compact_child_nodes.each do |child|
186
+ found = first_class_node(child)
187
+ return found if found
188
+ end
189
+ nil
190
+ end
191
+
192
+ def qualified_name_for(node)
193
+ case node
194
+ when Prism::ConstantReadNode then node.name.to_s
195
+ when Prism::ConstantPathNode
196
+ parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
197
+ return nil if !node.parent.nil? && parent.nil?
198
+
199
+ parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
200
+ end
201
+ end
202
+
203
+ # Phase 1 — strong-parameter validation. Recognises
204
+ # the `params.require(:symbol).permit(:key, :key, ...)`
205
+ # chain and validates each `:key` against the AR
206
+ # model's column list (looked up via the model_index
207
+ # fact published by `rigor-activerecord`). Calls whose
208
+ # `:require` argument is a non-literal Symbol are
209
+ # passed through; namespaced models
210
+ # (`params.require(:admin_user)` →
211
+ # `Admin::User`) are deferred to a Phase 1.5 follow-up.
212
+ def diagnose_permits(path:, root:, model_index:)
213
+ diagnostics = []
214
+ walk_permit_calls(root) do |permit_call, model_class|
215
+ entry = model_index[model_class]
216
+ next if entry.nil? # unknown model — skip; the model lookup is best-effort.
217
+
218
+ columns = entry[:columns]
219
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: columns)
220
+ literal_permit_keys(permit_call).each do |key_node, key_name|
221
+ diagnostics << if columns.include?(key_name)
222
+ permit_call_diagnostic(path, permit_call, model_class, key_name)
223
+ else
224
+ unknown_permit_key_diagnostic(path, key_node, model_class, key_name, spell_checker)
225
+ end
226
+ end
227
+ end
228
+ diagnostics
229
+ end
230
+
231
+ # Walks the AST yielding `[permit_call, model_class]`
232
+ # pairs for every `params.require(:symbol).permit(...)`
233
+ # chain. The match keys on:
234
+ #
235
+ # - method name `:permit` (with any positional args).
236
+ # - receiver shape: a `:require` call whose first
237
+ # positional arg is a literal `Prism::SymbolNode`.
238
+ #
239
+ # The literal symbol's `to_s.capitalize` is the
240
+ # candidate model class name. Namespaced models
241
+ # (`:admin_user` → `Admin::User`) are deferred — the
242
+ # mapping for them needs the inflector and a
243
+ # convention call we don't ship in Phase 1.
244
+ def walk_permit_calls(node, &)
245
+ return unless node.is_a?(Prism::Node)
246
+
247
+ yield node, model_class_for_permit(node) if permit_chain?(node)
248
+ node.compact_child_nodes.each { |child| walk_permit_calls(child, &) }
249
+ end
250
+
251
+ def permit_chain?(node)
252
+ return false unless node.is_a?(Prism::CallNode) && node.name == :permit
253
+
254
+ require_call = node.receiver
255
+ return false unless require_call.is_a?(Prism::CallNode) && require_call.name == :require
256
+
257
+ first_arg = require_call.arguments&.arguments&.first
258
+ first_arg.is_a?(Prism::SymbolNode)
259
+ end
260
+
261
+ def model_class_for_permit(permit_call)
262
+ require_call = permit_call.receiver
263
+ symbol_node = require_call.arguments.arguments.first
264
+ # Phase 1 convention: `:user` → `User`; namespaced
265
+ # mapping deferred. The capitalize call is sufficient
266
+ # for the typical single-word model names; users with
267
+ # multi-word camelcase shape (`:order_item` →
268
+ # `OrderItem`) need the inflector follow-up.
269
+ symbol_node.value.to_s.split("_").map(&:capitalize).join
270
+ end
271
+
272
+ def literal_permit_keys(permit_call)
273
+ (permit_call.arguments&.arguments || []).filter_map do |arg|
274
+ next [arg, arg.value] if arg.is_a?(Prism::SymbolNode)
275
+ end
276
+ end
277
+
278
+ def permit_call_diagnostic(path, permit_call, model_class, key_name)
279
+ loc = permit_call.message_loc || permit_call.location
280
+ Diagnostic.new(
281
+ path: path, line: loc.start_line, column: loc.start_column + 1,
282
+ message: "Action Pack permit `#{key_name}` resolves to a column on `#{model_class}`.",
283
+ severity: :info, rule: "permit-call"
284
+ )
285
+ end
286
+
287
+ def unknown_permit_key_diagnostic(path, key_node, model_class, key_name, spell_checker)
288
+ loc = key_node.location
289
+ base = "Action Pack permit `#{key_name}` is not a column on `#{model_class}`."
290
+ suggestion = spell_checker.correct(key_name).first
291
+ message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
292
+ Diagnostic.new(
293
+ path: path, line: loc.start_line, column: loc.start_column + 1,
294
+ message: message, severity: :error, rule: "unknown-permit-key"
295
+ )
296
+ end
297
+
298
+ # Phase 3 — render-target validation. For each
299
+ # explicit `render` call inside a controller method,
300
+ # derive the candidate view template path(s) from the
301
+ # controller class name + the render argument shape,
302
+ # then check existence under the configured
303
+ # `view_search_paths` (default `["app/views"]`). Recognised
304
+ # call shapes:
305
+ #
306
+ # - `render :symbol` — `<views>/<controller_path>/<symbol>.html.erb`
307
+ # - `render "string/path"` — `<views>/<string_path>.html.erb`
308
+ # - `render partial: "name"` — `<views>/<controller_path>/_<name>.html.erb`
309
+ # - `render partial: "string/path"` — `<views>/<string_path with _ prefix>.html.erb`
310
+ #
311
+ # `render layout:`, `render plain:`, `render json:`,
312
+ # `render text:`, `render inline:`, `render :nothing
313
+ # => true`, etc. are pass-through (no template
314
+ # lookup). Implicit-render (a controller method that
315
+ # doesn't call `render`) is also skipped — Phase 3
316
+ # validates explicit renders only, since the implicit
317
+ # path would false-positive on `redirect_to` / `head`
318
+ # / early returns.
319
+ def diagnose_renders(path:, root:, view_search_roots:)
320
+ class_node = first_class_node(root)
321
+ return [] if class_node.nil?
322
+
323
+ class_name = qualified_name_for(class_node.constant_path)
324
+ return [] if class_name.nil?
325
+
326
+ controller_path = controller_path_for(class_name)
327
+ return [] if controller_path.nil?
328
+
329
+ collect_render_diagnostics(path, class_node.body, controller_path, view_search_roots)
330
+ end
331
+
332
+ def collect_render_diagnostics(path, body, controller_path, view_search_roots)
333
+ diagnostics = []
334
+ walk_render_calls(body) do |call_node|
335
+ target = render_target_for(call_node, controller_path)
336
+ next if target.nil?
337
+
338
+ diag = render_diagnostic(path, call_node, target, view_search_roots)
339
+ diagnostics << diag if diag
340
+ end
341
+ diagnostics
342
+ end
343
+
344
+ def walk_render_calls(node, &)
345
+ return unless node.is_a?(Prism::Node)
346
+
347
+ yield node if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :render
348
+ node.compact_child_nodes.each { |child| walk_render_calls(child, &) }
349
+ end
350
+
351
+ # Returns `[kind, view_relative_path]` where kind is
352
+ # `:template` or `:partial`, and view_relative_path is
353
+ # the path under view_search_roots WITHOUT extension
354
+ # (the extension family is appended at lookup time).
355
+ # Returns nil for shapes Phase 3 doesn't validate
356
+ # (`layout:` / `plain:` / `json:` / `text:` / `inline:`
357
+ # / `:nothing` / no parseable target).
358
+ def render_target_for(call_node, controller_path)
359
+ args = call_node.arguments&.arguments || []
360
+ return nil if args.empty?
361
+
362
+ first = args.first
363
+ # `render partial: "..."` — the keyword form.
364
+ return partial_target_from_kwargs(first, controller_path) if first.is_a?(Prism::KeywordHashNode)
365
+
366
+ # `render :symbol` / `render "path"`. A trailing
367
+ # KeywordHashNode is allowed (e.g. `render :show,
368
+ # status: :ok`); the leading positional carries the
369
+ # template name.
370
+ template_target_from_positional(first, controller_path)
371
+ end
372
+
373
+ def template_target_from_positional(node, controller_path)
374
+ case node
375
+ when Prism::SymbolNode then [:template, "#{controller_path}/#{node.value}"]
376
+ when Prism::StringNode
377
+ stripped = node.unescaped
378
+ stripped.include?("/") ? [:template, stripped] : [:template, "#{controller_path}/#{stripped}"]
379
+ end
380
+ end
381
+
382
+ def partial_target_from_kwargs(hash_node, controller_path)
383
+ partial_value = hash_node.elements.find do |elem|
384
+ elem.is_a?(Prism::AssocNode) &&
385
+ elem.key.is_a?(Prism::SymbolNode) &&
386
+ elem.key.value == "partial"
387
+ end&.value
388
+ return nil unless partial_value.is_a?(Prism::StringNode)
389
+
390
+ stripped = partial_value.unescaped
391
+ if stripped.include?("/")
392
+ dir, base = File.split(stripped)
393
+ [:partial, "#{dir}/_#{base}"]
394
+ else
395
+ [:partial, "#{controller_path}/_#{stripped}"]
396
+ end
397
+ end
398
+
399
+ # `UsersController` → "users".
400
+ # `Admin::WidgetsController` → "admin/widgets".
401
+ # Returns nil for class names that don't end with the
402
+ # `Controller` suffix.
403
+ def controller_path_for(class_name)
404
+ return nil unless class_name.end_with?("Controller")
405
+
406
+ stripped = class_name.delete_suffix("Controller")
407
+ stripped.split("::").map { |segment| underscore(segment) }.join("/")
408
+ end
409
+
410
+ # Tiny inflector — sufficient for the typical
411
+ # `WordWord` → `word_word` mapping. Doesn't try to
412
+ # handle acronyms (`HTTPController` would inflect to
413
+ # `h_t_t_p`); users with that need can ship a
414
+ # configured override in a follow-up slice.
415
+ def underscore(camel)
416
+ camel.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
417
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
418
+ .downcase
419
+ end
420
+
421
+ def render_diagnostic(path, call_node, target, view_search_roots)
422
+ kind, relative = target
423
+ existing = locate_template(relative, view_search_roots)
424
+ if existing
425
+ render_target_diagnostic(path, call_node, kind, relative, existing)
426
+ else
427
+ missing_template_diagnostic(path, call_node, kind, relative, view_search_roots)
428
+ end
429
+ end
430
+
431
+ def locate_template(relative, view_search_roots)
432
+ view_search_roots.each do |root|
433
+ RENDER_TEMPLATE_EXTENSIONS.each do |ext|
434
+ candidate = File.join(root, "#{relative}#{ext}")
435
+ return candidate if File.file?(candidate)
436
+ end
437
+ end
438
+ nil
439
+ end
440
+
441
+ def render_target_diagnostic(path, call_node, kind, relative, located)
442
+ loc = call_node.message_loc || call_node.location
443
+ Diagnostic.new(
444
+ path: path, line: loc.start_line, column: loc.start_column + 1,
445
+ message: "Action Pack render #{kind} `#{relative}` resolved to `#{located}`.",
446
+ severity: :info, rule: "render-target"
447
+ )
448
+ end
449
+
450
+ def missing_template_diagnostic(path, call_node, kind, relative, view_search_roots)
451
+ loc = call_node.message_loc || call_node.location
452
+ tried = RENDER_TEMPLATE_EXTENSIONS.map { |ext| "#{relative}#{ext}" }.join(", ")
453
+ roots = view_search_roots.join(", ")
454
+ Diagnostic.new(
455
+ path: path, line: loc.start_line, column: loc.start_column + 1,
456
+ message: "Action Pack render #{kind} `#{relative}` not found under #{roots} " \
457
+ "(tried #{tried}).",
458
+ severity: :error, rule: "missing-template"
459
+ )
460
+ end
461
+
462
+ def filter_call_diagnostic(path, call_node, filter_name)
463
+ loc = call_node.message_loc || call_node.location
464
+ Diagnostic.new(
465
+ path: path, line: loc.start_line, column: loc.start_column + 1,
466
+ message: "Action Pack filter `#{call_node.name} :#{filter_name}` resolves to a defined method.",
467
+ severity: :info, rule: "filter-call"
468
+ )
469
+ end
470
+
471
+ def unknown_filter_diagnostic(path, arg_node, call_node, filter_name, spell_checker)
472
+ loc = arg_node.location
473
+ base = "Action Pack filter `#{call_node.name} :#{filter_name}` references no method " \
474
+ "defined on this controller (or its parent)."
475
+ suggestion = spell_checker.correct(filter_name.to_s).first
476
+ message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
477
+ Diagnostic.new(
478
+ path: path, line: loc.start_line, column: loc.start_column + 1,
479
+ message: message, severity: :error, rule: "unknown-filter-method"
480
+ )
481
+ end
482
+
483
+ # Walk the AST yielding only call nodes whose method
484
+ # name ends in `_path` / `_url` and whose receiver is
485
+ # implicit-self (no explicit receiver). Constants are
486
+ # skipped — `Rails.application.routes.url_helpers` is
487
+ # not what Phase 4 validates.
488
+ def walk(node, &)
489
+ return unless node.is_a?(Prism::Node)
490
+
491
+ yield node if node.is_a?(Prism::CallNode) && helper_suffix?(node.name) && node.receiver.nil?
492
+ node.compact_child_nodes.each { |child| walk(child, &) }
493
+ end
494
+
495
+ def helper_suffix?(name)
496
+ name_str = name.to_s
497
+ SUFFIXES.any? { |suffix| name_str.end_with?(suffix) && name_str.length > suffix.length }
498
+ end
499
+
500
+ # Returns `[entry, suggestion]`:
501
+ #
502
+ # - `[entry, nil]` — known helper.
503
+ # - `[nil, nil]` — unknown helper, no spell-checker match.
504
+ # - `[nil, "user_path"]` — unknown helper, did-you-mean
505
+ # suggestion to surface in the diagnostic.
506
+ def lookup(call_node, helper_table, spell_checker)
507
+ name = call_node.name.to_s
508
+ entry = helper_table[name]
509
+ return [entry, nil] if entry
510
+
511
+ [nil, spell_checker.correct(name).first]
512
+ end
513
+
514
+ # Builds the diagnostic. Only the **info-level** route
515
+ # resolution (`helper-call`) is the value this plugin
516
+ # adds — it surfaces the resolved HTTP method + path +
517
+ # action alongside the call site, which `rigor-rails-routes`
518
+ # does not. Unknown-helper and wrong-arity diagnostics are
519
+ # produced canonically by `rigor-rails-routes`'s own analyzer
520
+ # (same `:helper_table` source of truth), so emitting them
521
+ # here would double every call-site error. Real-world
522
+ # impact pre-fix: ~301 duplicates on Mastodon and ~119 on
523
+ # Redmine, exactly stacked with the rails-routes diagnostics.
524
+ # Return `nil` for unknown / arity-mismatch shapes so the
525
+ # caller filters them out.
526
+ def diagnostic_for(path, call_node, entry, _suggestion)
527
+ return nil if entry.nil?
528
+
529
+ actual_arity = positional_arg_count(call_node)
530
+ # The `:helper_table` fact entry carries both
531
+ # `:arity` (the first registered entry's arity) and
532
+ # `:acceptable_arities` (the full Array — populated
533
+ # for uncountable-noun resources like `resources :news`
534
+ # which share a helper name across index/show). Honour
535
+ # the full set; fall back to the single arity for
536
+ # consumers compiled against an older fact shape.
537
+ acceptable = entry[:acceptable_arities] || [entry[:arity]]
538
+ return nil unless acceptable.include?(actual_arity)
539
+
540
+ helper_call_diagnostic(path, call_node, entry)
541
+ end
542
+
543
+ def positional_arg_count(call_node)
544
+ args = call_node.arguments&.arguments || []
545
+ # Drop a trailing `KeywordHashNode` so call sites that
546
+ # pass `users_path(format: :json)` don't get counted as
547
+ # arity 1. Same convention rigor-rails-routes' helper-
548
+ # table arity uses (positional only).
549
+ args = args[0..-2] if args.last.is_a?(Prism::KeywordHashNode)
550
+ args.size
551
+ end
552
+
553
+ def location(call_node)
554
+ call_node.message_loc || call_node.location
555
+ end
556
+
557
+ def helper_call_diagnostic(path, call_node, entry)
558
+ loc = location(call_node)
559
+ method = entry[:http_method] ? entry[:http_method].to_s.upcase : "(any)"
560
+ Diagnostic.new(
561
+ path: path, line: loc.start_line, column: loc.start_column + 1,
562
+ message: "Action Pack helper `#{call_node.name}` → #{method} #{entry[:path]} (action: #{entry[:action]}).",
563
+ severity: :info, rule: "helper-call"
564
+ )
565
+ end
566
+
567
+ def unknown_helper_diagnostic(path, call_node, suggestion)
568
+ loc = location(call_node)
569
+ base = "Unknown route helper `#{call_node.name}` — not registered in `config/routes.rb`."
570
+ message = suggestion ? "#{base} Did you mean `#{suggestion}`?" : base
571
+ Diagnostic.new(
572
+ path: path, line: loc.start_line, column: loc.start_column + 1,
573
+ message: message, severity: :error, rule: "unknown-helper"
574
+ )
575
+ end
576
+
577
+ def wrong_arity_diagnostic(path, call_node, entry, actual_arity)
578
+ loc = location(call_node)
579
+ Diagnostic.new(
580
+ path: path, line: loc.start_line, column: loc.start_column + 1,
581
+ message: "Route helper `#{call_node.name}` expects #{entry[:arity]} positional " \
582
+ "argument(s) but the call passes #{actual_arity}.",
583
+ severity: :error, rule: "wrong-helper-arity"
584
+ )
585
+ end
586
+ end
587
+ end
588
+ end
589
+ end