rigortype 0.1.15 → 0.1.17

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 (220) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  6. data/lib/rigor/analysis/check_rules.rb +174 -71
  7. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  8. data/lib/rigor/analysis/diagnostic.rb +58 -0
  9. data/lib/rigor/analysis/incremental.rb +162 -0
  10. data/lib/rigor/analysis/incremental_session.rb +337 -0
  11. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  12. data/lib/rigor/analysis/runner.rb +485 -29
  13. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  14. data/lib/rigor/analysis/worker_session.rb +3 -2
  15. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  16. data/lib/rigor/cache/descriptor.rb +56 -51
  17. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  18. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  19. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  20. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  21. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  22. data/lib/rigor/cache/rbs_environment.rb +2 -8
  23. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  24. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  25. data/lib/rigor/cache/store.rb +99 -1
  26. data/lib/rigor/cli/annotate_command.rb +2 -7
  27. data/lib/rigor/cli/baseline_command.rb +2 -7
  28. data/lib/rigor/cli/command.rb +47 -0
  29. data/lib/rigor/cli/coverage_command.rb +3 -23
  30. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  31. data/lib/rigor/cli/diff_command.rb +3 -7
  32. data/lib/rigor/cli/explain_command.rb +2 -7
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mcp_command.rb +3 -7
  35. data/lib/rigor/cli/options.rb +57 -0
  36. data/lib/rigor/cli/plugin_command.rb +3 -7
  37. data/lib/rigor/cli/plugins_command.rb +52 -10
  38. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  39. data/lib/rigor/cli/renderable.rb +26 -0
  40. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  41. data/lib/rigor/cli/skill_command.rb +3 -7
  42. data/lib/rigor/cli/triage_command.rb +2 -7
  43. data/lib/rigor/cli/type_of_command.rb +5 -38
  44. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  45. data/lib/rigor/cli/type_scan_command.rb +3 -23
  46. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  47. data/lib/rigor/cli.rb +260 -48
  48. data/lib/rigor/configuration/dependencies.rb +18 -1
  49. data/lib/rigor/configuration/severity_profile.rb +22 -3
  50. data/lib/rigor/configuration.rb +13 -3
  51. data/lib/rigor/environment/rbs_loader.rb +335 -4
  52. data/lib/rigor/environment.rb +8 -2
  53. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  54. data/lib/rigor/inference/budget_trace.rb +137 -0
  55. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  58. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  59. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  64. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  65. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  73. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  74. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  75. data/lib/rigor/inference/expression_typer.rb +149 -22
  76. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  77. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  78. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  79. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  80. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  81. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  82. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  83. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  86. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  87. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  88. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  89. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +100 -23
  90. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  91. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  93. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  94. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  95. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  96. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  97. data/lib/rigor/inference/method_dispatcher.rb +147 -60
  98. data/lib/rigor/inference/narrowing.rb +202 -5
  99. data/lib/rigor/inference/precision_scanner.rb +60 -1
  100. data/lib/rigor/inference/scope_indexer.rb +257 -11
  101. data/lib/rigor/inference/statement_evaluator.rb +110 -26
  102. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  103. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  104. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  105. data/lib/rigor/language_server/completion_provider.rb +4 -4
  106. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  107. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  108. data/lib/rigor/language_server/hover_provider.rb +4 -4
  109. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  110. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  111. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  112. data/lib/rigor/plugin/base.rb +337 -2
  113. data/lib/rigor/plugin/box.rb +64 -0
  114. data/lib/rigor/plugin/inflector.rb +121 -0
  115. data/lib/rigor/plugin/isolation.rb +191 -0
  116. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  117. data/lib/rigor/plugin/macro.rb +1 -0
  118. data/lib/rigor/plugin/manifest.rb +120 -23
  119. data/lib/rigor/plugin/node_context.rb +62 -0
  120. data/lib/rigor/plugin/registry.rb +49 -1
  121. data/lib/rigor/plugin.rb +3 -0
  122. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  123. data/lib/rigor/rbs_extended.rb +39 -0
  124. data/lib/rigor/scope.rb +123 -9
  125. data/lib/rigor/sig_gen/generator.rb +2 -3
  126. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  127. data/lib/rigor/source/literals.rb +118 -0
  128. data/lib/rigor/source/node_walker.rb +26 -0
  129. data/lib/rigor/source.rb +1 -0
  130. data/lib/rigor/type/acceptance_router.rb +19 -0
  131. data/lib/rigor/type/accepts_result.rb +3 -10
  132. data/lib/rigor/type/app.rb +3 -7
  133. data/lib/rigor/type/bot.rb +2 -3
  134. data/lib/rigor/type/bound_method.rb +5 -12
  135. data/lib/rigor/type/combinator.rb +23 -1
  136. data/lib/rigor/type/constant.rb +2 -3
  137. data/lib/rigor/type/data_class.rb +80 -0
  138. data/lib/rigor/type/data_instance.rb +100 -0
  139. data/lib/rigor/type/difference.rb +5 -10
  140. data/lib/rigor/type/dynamic.rb +5 -10
  141. data/lib/rigor/type/hash_shape.rb +5 -15
  142. data/lib/rigor/type/integer_range.rb +5 -10
  143. data/lib/rigor/type/intersection.rb +5 -10
  144. data/lib/rigor/type/nominal.rb +5 -10
  145. data/lib/rigor/type/refined.rb +5 -10
  146. data/lib/rigor/type/singleton.rb +5 -10
  147. data/lib/rigor/type/top.rb +2 -3
  148. data/lib/rigor/type/tuple.rb +5 -10
  149. data/lib/rigor/type/union.rb +69 -10
  150. data/lib/rigor/type.rb +2 -0
  151. data/lib/rigor/value_semantics.rb +77 -0
  152. data/lib/rigor/version.rb +1 -1
  153. data/lib/rigor.rb +2 -0
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  156. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  158. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  160. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  164. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  167. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  168. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  170. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  171. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  173. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  174. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  175. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  176. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  177. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  178. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  179. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  180. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  181. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  182. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  183. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  184. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  185. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  186. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  187. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  188. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +48 -33
  189. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  190. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  191. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  192. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  193. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  194. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  195. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  196. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  202. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  203. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  204. data/sig/rigor/cache.rbs +19 -0
  205. data/sig/rigor/inference.rbs +22 -0
  206. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  207. data/sig/rigor/plugin/base.rbs +58 -3
  208. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  209. data/sig/rigor/plugin/manifest.rbs +31 -1
  210. data/sig/rigor/rbs_extended.rbs +2 -0
  211. data/sig/rigor/scope.rbs +5 -0
  212. data/sig/rigor/source.rbs +12 -0
  213. data/sig/rigor/type.rbs +58 -1
  214. data/sig/rigor.rbs +11 -1
  215. data/skills/rigor-plugin-author/SKILL.md +13 -9
  216. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  217. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  218. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  219. metadata +73 -2
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -2,14 +2,19 @@
2
2
 
3
3
  require "did_you_mean"
4
4
  require "prism"
5
+ require "rigor/source/literals"
5
6
 
6
7
  module Rigor
7
8
  module Plugin
8
9
  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`.
10
+ # Per-node validators for the Action Pack DSL inside controller
11
+ # files. ADR-37: the engine owns the single AST walk (the plugin
12
+ # declares `node_rule(Prism::CallNode)`), so each method here
13
+ # validates ONE call node and returns location-bearing
14
+ # {Violation}s — the `node_rule` block positions them via
15
+ # `Plugin::Base#diagnostic`. The enclosing-controller context the
16
+ # filter / render phases need is read from the node-rule
17
+ # `NodeContext` ancestors rather than re-derived by a forward walk.
13
18
  #
14
19
  # The recogniser keys on call-method-name suffix:
15
20
  #
@@ -69,124 +74,204 @@ module Rigor
69
74
  # `params.require(:symbol)`.
70
75
  STRONG_PARAMS_RECEIVER_NAMES = %i[require permit_params strong_params].freeze
71
76
 
72
- Diagnostic = Data.define(:path, :line, :column, :message, :severity, :rule)
77
+ # One Action Pack observation. Carries no path the caller
78
+ # (the `node_rule` block) positions it via
79
+ # `Plugin::Base#diagnostic`. `location` is the Prism location the
80
+ # diagnostic should point at (a call's `message_loc` for the call
81
+ # itself, or a sub-argument's location for `unknown-*-key`-style
82
+ # diagnostics that point at the offending Symbol).
83
+ Violation = Data.define(:location, :message, :severity, :rule)
73
84
 
74
85
  module_function
75
86
 
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
87
+ # Phase 4 route-helper consumption. The helper-existence /
88
+ # arity check for ONE call node. Only the **info-level** route
89
+ # resolution (`helper-call`) is the value this plugin adds — it
90
+ # surfaces the resolved HTTP method + path + action alongside the
91
+ # call site, which `rigor-rails-routes` does not. Unknown-helper
92
+ # and wrong-arity diagnostics are produced canonically by
93
+ # `rigor-rails-routes`'s own analyzer (same `:helper_table` source
94
+ # of truth), so emitting them here would double every call-site
95
+ # error. Real-world impact pre-fix: ~301 duplicates on Mastodon and
96
+ # ~119 on Redmine, exactly stacked with the rails-routes
97
+ # diagnostics. Returns `[]` for unknown / arity-mismatch shapes.
98
+ #
99
+ # @param call_node [Prism::Node]
100
+ # @param helper_table [Hash{String => Hash}] each entry carries
101
+ # `name`, `arity`, `acceptable_arities`, `path`, `http_method`,
102
+ # `action`.
103
+ # @return [Array<Violation>]
104
+ def helper_violations_for(call_node:, helper_table:)
105
+ return [] unless implicit_helper_call?(call_node)
94
106
 
95
- diagnostics
96
- end
107
+ entry = helper_table[call_node.name.to_s]
108
+ return [] if entry.nil?
97
109
 
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?
110
+ actual_arity = positional_arg_count(call_node)
111
+ # The `:helper_table` fact entry carries both `:arity` (the
112
+ # first registered entry's arity) and `:acceptable_arities`
113
+ # (the full Array populated for uncountable-noun resources
114
+ # like `resources :news` which share a helper name across
115
+ # index/show). Honour the full set; fall back to the single
116
+ # arity for consumers compiled against an older fact shape.
117
+ acceptable = entry[:acceptable_arities] || [entry[:arity]]
118
+ return [] unless acceptable.include?(actual_arity)
119
+
120
+ [helper_call_violation(call_node, entry)]
121
+ end
108
122
 
109
- class_name = qualified_name_with_enclosing(class_node.constant_path, enclosing)
123
+ # Phase 2 — filter-chain validation for ONE filter-DSL call
124
+ # (`before_action :name`, …). The enclosing controller is read
125
+ # from the node-rule `NodeContext` ancestors, looked up in the
126
+ # controller index to get the effective method set (including one
127
+ # level of inheritance), and each filter name reference is
128
+ # validated against it. Calls outside a known controller
129
+ # contribute nothing.
130
+ #
131
+ # @param call_node [Prism::Node]
132
+ # @param ancestors [Array<Prism::Node>] the lexical ancestor chain
133
+ # @param controller_index [ControllerIndex]
134
+ # @return [Array<Violation>]
135
+ def filter_violations_for(call_node:, ancestors:, controller_index:)
136
+ return [] unless filter_call?(call_node)
137
+
138
+ class_name = enclosing_controller_name(ancestors)
110
139
  return [] if class_name.nil?
111
140
  return [] unless controller_index.known?(class_name)
112
141
 
113
142
  methods = controller_index.effective_methods_for(class_name)
114
143
  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.
144
+ # When the controller (or its parent) `include`s a module the
145
+ # discoverer couldn't resolve — typically a gem-shipped concern
146
+ # such as `Devise::Controllers::Helpers` or
147
+ # `Pundit::Authorization` — any `before_action :name` MIGHT be
148
+ # defined in that unresolved module. Suppress
149
+ # `unknown-filter-method` in that case rather than FPing on
150
+ # legitimate gem-provided callback names.
123
151
  ambiguous_filters = controller_index.unresolved_include?(class_name)
124
152
 
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?
153
+ filter_name_args(call_node).filter_map do |arg_node|
154
+ filter_name = literal_symbol_or_string(arg_node)
155
+ next if filter_name.nil?
134
156
 
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
157
+ filter_lookup_violation(
158
+ call_node, arg_node, filter_name, methods, spell_checker,
159
+ ambiguous_filters: ambiguous_filters
160
+ )
141
161
  end
142
- diagnostics
143
162
  end
144
163
 
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)
164
+ # Phase 1 strong-parameter validation for ONE `permit` call.
165
+ # Recognises the `params.require(:symbol).permit(:key, :key, ...)`
166
+ # chain and validates each `:key` against the AR model's column
167
+ # list (looked up via the model_index fact published by
168
+ # `rigor-activerecord`). Calls whose `:require` argument is a
169
+ # non-literal Symbol are passed through; namespaced models
170
+ # (`params.require(:admin_user)` `Admin::User`) are deferred to a
171
+ # Phase 1.5 follow-up.
172
+ #
173
+ # @param call_node [Prism::Node]
174
+ # @param model_index [Hash{String => Hash}]
175
+ # @return [Array<Violation>]
176
+ def permit_violations_for(call_node:, model_index:)
177
+ return [] unless permit_chain?(call_node)
178
+
179
+ model_class = model_class_for_permit(call_node)
180
+ entry = model_index[model_class]
181
+ return [] if entry.nil? # unknown model — skip; the model lookup is best-effort.
182
+
183
+ columns = entry[:columns]
184
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: columns)
185
+ literal_permit_keys(call_node).map do |key_node, key_name|
186
+ if columns.include?(key_name)
187
+ permit_call_violation(call_node, model_class, key_name)
188
+ else
189
+ unknown_permit_key_violation(key_node, model_class, key_name, spell_checker)
190
+ end
155
191
  end
156
192
  end
157
193
 
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
194
+ # Phase 3 — render-target validation for ONE `render` call. Derive
195
+ # the candidate view template path(s) from the controller class
196
+ # name + the render argument shape, then check existence under the
197
+ # configured `view_search_paths` (default `["app/views"]`).
198
+ # Recognised call shapes:
199
+ #
200
+ # - `render :symbol` — `<views>/<controller_path>/<symbol>.html.erb`
201
+ # - `render "string/path"` — `<views>/<string_path>.html.erb`
202
+ # - `render partial: "name"` — `<views>/<controller_path>/_<name>.html.erb`
203
+ # - `render partial: "string/path"` — `<views>/<string_path with _ prefix>.html.erb`
204
+ #
205
+ # `render layout:`, `render plain:`, `render json:`, `render
206
+ # text:`, `render inline:`, `render :nothing => true`, etc. are
207
+ # pass-through (no template lookup). Implicit-render (a controller
208
+ # method that doesn't call `render`) is also skipped — Phase 3
209
+ # validates explicit renders only, since the implicit path would
210
+ # false-positive on `redirect_to` / `head` / early returns.
211
+ #
212
+ # @param call_node [Prism::Node]
213
+ # @param ancestors [Array<Prism::Node>] the lexical ancestor chain
214
+ # @param path [String] file being analysed
215
+ # @param view_search_roots [Array<String>]
216
+ # @param controller_index [ControllerIndex, nil]
217
+ # @return [Array<Violation>]
218
+ def render_violations_for(call_node:, ancestors:, path:, view_search_roots:, controller_index: nil)
219
+ return [] unless render_call?(call_node)
220
+
221
+ class_name = enclosing_controller_name(ancestors)
222
+ return [] if class_name.nil?
164
223
 
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
224
+ # Prefer the file-path-derived controller path when the source
225
+ # lives under `app/controllers/` Rails autoload is the runtime
226
+ # authority on `Admin::Users::RolesController` vs
227
+ # `Users::RolesController` (a declaration like `module Admin;
228
+ # class Users::RolesController` resolves to whichever `Users`
229
+ # constant Ruby finds first, and the file path is the
230
+ # disambiguator Rails picks). Fall back to the AST-derived class
231
+ # name for files outside `app/controllers/` (libraries, test
232
+ # fixtures).
233
+ controller_path = controller_path_from_file(path) || controller_path_for(class_name)
234
+ return [] if controller_path.nil?
173
235
 
174
- def literal_symbol_or_string(node)
175
- case node
176
- when Prism::SymbolNode then node.value
177
- when Prism::StringNode then node.unescaped
236
+ # Render checks silence when the controller (or its ancestor
237
+ # chain) inherits from a gem-shipped parent
238
+ # (Devise::ConfirmationsController,
239
+ # Doorkeeper::ApplicationsController, …). The gem ships its own
240
+ # views; the local subclass calling `render :show` resolves
241
+ # through the gem's view path, which our static analyser doesn't
242
+ # know about.
243
+ if controller_index&.known?(class_name) &&
244
+ controller_index.unresolved_include?(class_name)
245
+ return []
178
246
  end
179
- end
180
247
 
181
- def first_class_node(node)
182
- class_node, = first_class_node_with_namespace(node)
183
- class_node
248
+ # Abstract base controllers: a `*BaseController` (`Admin::
249
+ # BaseController`, `Settings::Preferences::BaseController`, …)
250
+ # typically has no view of its own; its `render :show` bodies
251
+ # are resolved by Rails against the calling subclass's
252
+ # controller path at request time, NOT the base's. Same for
253
+ # parent controllers whose view directory exists but contains
254
+ # only subdirectories (Mastodon's `Admin::SettingsController`
255
+ # whose `app/views/admin/settings/` holds about/, appearance/,
256
+ # … but no top-level templates). Skip render checks for these —
257
+ # the diagnostic would be a false positive against intentional
258
+ # Rails abstract-base layouts.
259
+ return [] if abstract_base_controller?(class_name, controller_path, view_search_roots)
260
+
261
+ target = render_target_for(call_node, controller_path)
262
+ return [] if target.nil?
263
+
264
+ violation = render_violation(call_node, target, view_search_roots)
265
+ violation ? [violation] : []
184
266
  end
185
267
 
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
268
+ # Resolves the qualified name of the controller class the node
269
+ # sits in from its lexical ancestors (the node-rule
270
+ # `NodeContext#ancestors`, outermost first). The OUTERMOST
271
+ # `ClassNode` is the controller matching the old forward walk,
272
+ # which returned the first `ClassNode` reachable from the file
273
+ # root and validated nested-class call sites against it. Enclosing
274
+ # `ModuleNode`s above it contribute the namespace, so the
190
275
  # nested-module declaration shape
191
276
  #
192
277
  # module Admin
@@ -194,31 +279,22 @@ module Rigor
194
279
  # end
195
280
  # end
196
281
  #
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]
282
+ # is recovered as `Admin::DomainBlocksController` — the same name
283
+ # the `ControllerDiscoverer` registers under. Without this, a bare
284
+ # inner-class name would be used for index lookups +
285
+ # `controller_path_for`, silently skipping filter validation and
286
+ # pointing render template paths at the wrong directory
287
+ # (Mastodon's `admin/domain_blocks` rendered as bare
288
+ # `domain_blocks`). Returns nil when no enclosing class exists.
289
+ def enclosing_controller_name(ancestors)
290
+ class_index = ancestors.index { |n| n.is_a?(Prism::ClassNode) }
291
+ return nil if class_index.nil?
292
+
293
+ class_node = ancestors[class_index]
294
+ enclosing = ancestors[0...class_index]
295
+ .grep(Prism::ModuleNode)
296
+ .flat_map { |n| namespace_segments_for(n) }
297
+ qualified_name_with_enclosing(class_node.constant_path, enclosing)
222
298
  end
223
299
 
224
300
  def namespace_segments_for(declaration_node)
@@ -226,11 +302,10 @@ module Rigor
226
302
  path ? path.split("::") : []
227
303
  end
228
304
 
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.
305
+ # Resolves a class-name AST node against an enclosing namespace
306
+ # chain. A `ConstantPathNode` (e.g. `class Admin::Foo`) is already
307
+ # absolute and ignores the chain; a `ConstantReadNode` (bare
308
+ # `class Foo` inside `module Admin`) is qualified against it.
234
309
  def qualified_name_with_enclosing(node, enclosing)
235
310
  return nil unless node.is_a?(Prism::Node)
236
311
 
@@ -253,52 +328,42 @@ module Rigor
253
328
  end
254
329
  end
255
330
 
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
331
+ def implicit_helper_call?(node)
332
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && helper_suffix?(node.name)
282
333
  end
283
334
 
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)
335
+ def filter_call?(node)
336
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && FILTER_DSL_METHODS.include?(node.name)
337
+ end
338
+
339
+ def render_call?(node)
340
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :render
341
+ end
342
+
343
+ # Drops the trailing keyword hash (`only:` / `except:` / `if:` /
344
+ # `unless:`) so the modifier args don't get treated as filter
345
+ # names.
346
+ def filter_name_args(call_node)
347
+ args = call_node.arguments&.arguments || []
348
+ args = args[0..-2] if args.last.is_a?(Prism::KeywordHashNode)
349
+ args
350
+ end
299
351
 
300
- yield node, model_class_for_permit(node) if permit_chain?(node)
301
- node.compact_child_nodes.each { |child| walk_permit_calls(child, &) }
352
+ def literal_symbol_or_string(node)
353
+ Rigor::Source::Literals.symbol_or_string_name(node)
354
+ end
355
+
356
+ def filter_lookup_violation(call_node, arg_node, filter_name, methods, spell_checker, ambiguous_filters:)
357
+ if methods.include?(filter_name.to_sym)
358
+ filter_call_violation(call_node, filter_name)
359
+ elsif ambiguous_filters
360
+ # An unresolved include shadows our judgment — emit the
361
+ # recognized-filter info anyway so the call site is still
362
+ # indexed, but skip the error.
363
+ filter_call_violation(call_node, filter_name)
364
+ else
365
+ unknown_filter_violation(arg_node, call_node, filter_name, spell_checker)
366
+ end
302
367
  end
303
368
 
304
369
  def permit_chain?(node)
@@ -314,12 +379,12 @@ module Rigor
314
379
  def model_class_for_permit(permit_call)
315
380
  require_call = permit_call.receiver
316
381
  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
382
+ # `:user` `User`, `:order_item` → `OrderItem`. ADR-39: the
383
+ # shared inflector camelizes through the real
384
+ # `ActiveSupport::Inflector` when present (so the multi-word
385
+ # case the former `split("_").map(&:capitalize)` only
386
+ # approximated is authoritative), falling back otherwise.
387
+ Rigor::Plugin::Inflector.camelize(symbol_node.value.to_s)
323
388
  end
324
389
 
325
390
  def literal_permit_keys(permit_call)
@@ -328,119 +393,47 @@ module Rigor
328
393
  end
329
394
  end
330
395
 
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,
396
+ def permit_call_violation(permit_call, model_class, key_name)
397
+ Violation.new(
398
+ location: permit_call.message_loc || permit_call.location,
335
399
  message: "Action Pack permit `#{key_name}` resolves to a column on `#{model_class}`.",
336
400
  severity: :info, rule: "permit-call"
337
401
  )
338
402
  end
339
403
 
340
- def unknown_permit_key_diagnostic(path, key_node, model_class, key_name, spell_checker)
341
- loc = key_node.location
404
+ def unknown_permit_key_violation(key_node, model_class, key_name, spell_checker)
342
405
  base = "Action Pack permit `#{key_name}` is not a column on `#{model_class}`."
343
406
  suggestion = spell_checker.correct(key_name).first
344
407
  message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
345
- Diagnostic.new(
346
- path: path, line: loc.start_line, column: loc.start_column + 1,
408
+ Violation.new(
409
+ location: key_node.location,
347
410
  message: message, severity: :error, rule: "unknown-permit-key"
348
411
  )
349
412
  end
350
413
 
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
414
  # 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.
415
+ # to `admin/users/roles`. Returns nil if the path is not under an
416
+ # `app/controllers/` segment.
423
417
  def controller_path_from_file(path)
424
418
  match = path.match(%r{(?:\A|/)app/controllers/(.+)_controller\.rb\z})
425
419
  match&.[](1)
426
420
  end
427
421
 
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:
422
+ # An abstract base controller — its `render` bodies don't validate
423
+ # against its own controller_path because Rails resolves at
424
+ # request time against the actual subclass. Two conservative
425
+ # heuristics:
432
426
  # (1) The class name ends with `BaseController`. Strong
433
427
  # Rails-convention signal (Settings::Preferences::
434
428
  # 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).
429
+ # (2) The controller's view directory exists but contains ONLY
430
+ # subdirectories (no template files at its top). Mastodon's
431
+ # Admin::SettingsController whose app/views/admin/settings/
432
+ # holds only about/, appearance/, … — the parent of nested
433
+ # controllers.
434
+ # Deliberately NOT triggered by "no view directory at all" because
435
+ # that fires the diagnostic we DO want for genuinely-missing views
436
+ # (the typo / forgot-to-create case).
444
437
  def abstract_base_controller?(class_name, controller_path, view_search_roots)
445
438
  return true if class_name.end_with?("BaseController")
446
439
 
@@ -453,32 +446,12 @@ module Rigor
453
446
  end
454
447
  end
455
448
 
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).
449
+ # Returns `[kind, view_relative_path]` where kind is `:template`
450
+ # or `:partial`, and view_relative_path is the path under
451
+ # view_search_roots WITHOUT extension (the extension family is
452
+ # appended at lookup time). Returns nil for shapes Phase 3 doesn't
453
+ # validate (`layout:` / `plain:` / `json:` / `text:` / `inline:` /
454
+ # `:nothing` / no parseable target).
482
455
  def render_target_for(call_node, controller_path)
483
456
  args = call_node.arguments&.arguments || []
484
457
  return nil if args.empty?
@@ -487,10 +460,9 @@ module Rigor
487
460
  # `render partial: "..."` — the keyword form.
488
461
  return partial_target_from_kwargs(first, controller_path) if first.is_a?(Prism::KeywordHashNode)
489
462
 
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.
463
+ # `render :symbol` / `render "path"`. A trailing KeywordHashNode
464
+ # is allowed (e.g. `render :show, status: :ok`); the leading
465
+ # positional carries the template name.
494
466
  template_target_from_positional(first, controller_path)
495
467
  end
496
468
 
@@ -522,33 +494,26 @@ module Rigor
522
494
 
523
495
  # `UsersController` → "users".
524
496
  # `Admin::WidgetsController` → "admin/widgets".
525
- # Returns nil for class names that don't end with the
526
- # `Controller` suffix.
497
+ # Returns nil for class names that don't end with the `Controller`
498
+ # suffix.
527
499
  def controller_path_for(class_name)
528
500
  return nil unless class_name.end_with?("Controller")
529
501
 
530
502
  stripped = class_name.delete_suffix("Controller")
531
- stripped.split("::").map { |segment| underscore(segment) }.join("/")
503
+ # ADR-39: the shared inflector flattens `::` → `/` and applies
504
+ # Rails' real underscore (the per-segment hand-rolled version
505
+ # produced the same result for the common case but missed
506
+ # acronym handling). `Admin::Widgets` → `admin/widgets`.
507
+ Rigor::Plugin::Inflector.underscore(stripped)
532
508
  end
533
509
 
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)
510
+ def render_violation(call_node, target, view_search_roots)
546
511
  kind, relative = target
547
512
  existing = locate_template(relative, view_search_roots)
548
513
  if existing
549
- render_target_diagnostic(path, call_node, kind, relative, existing)
514
+ render_target_violation(call_node, kind, relative, existing)
550
515
  else
551
- missing_template_diagnostic(path, call_node, kind, relative, view_search_roots)
516
+ missing_template_violation(call_node, kind, relative, view_search_roots)
552
517
  end
553
518
  end
554
519
 
@@ -562,114 +527,55 @@ module Rigor
562
527
  nil
563
528
  end
564
529
 
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,
530
+ def render_target_violation(call_node, kind, relative, located)
531
+ Violation.new(
532
+ location: location(call_node),
569
533
  message: "Action Pack render #{kind} `#{relative}` resolved to `#{located}`.",
570
534
  severity: :info, rule: "render-target"
571
535
  )
572
536
  end
573
537
 
574
- def missing_template_diagnostic(path, call_node, kind, relative, view_search_roots)
575
- loc = call_node.message_loc || call_node.location
538
+ def missing_template_violation(call_node, kind, relative, view_search_roots)
576
539
  tried = RENDER_TEMPLATE_EXTENSIONS.map { |ext| "#{relative}#{ext}" }.join(", ")
577
540
  roots = view_search_roots.join(", ")
578
- Diagnostic.new(
579
- path: path, line: loc.start_line, column: loc.start_column + 1,
541
+ Violation.new(
542
+ location: location(call_node),
580
543
  message: "Action Pack render #{kind} `#{relative}` not found under #{roots} " \
581
544
  "(tried #{tried}).",
582
545
  severity: :error, rule: "missing-template"
583
546
  )
584
547
  end
585
548
 
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,
549
+ def filter_call_violation(call_node, filter_name)
550
+ Violation.new(
551
+ location: location(call_node),
590
552
  message: "Action Pack filter `#{call_node.name} :#{filter_name}` resolves to a defined method.",
591
553
  severity: :info, rule: "filter-call"
592
554
  )
593
555
  end
594
556
 
595
- def unknown_filter_diagnostic(path, arg_node, call_node, filter_name, spell_checker)
596
- loc = arg_node.location
557
+ def unknown_filter_violation(arg_node, call_node, filter_name, spell_checker)
597
558
  base = "Action Pack filter `#{call_node.name} :#{filter_name}` references no method " \
598
559
  "defined on this controller (or its parent)."
599
560
  suggestion = spell_checker.correct(filter_name.to_s).first
600
561
  message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
601
- Diagnostic.new(
602
- path: path, line: loc.start_line, column: loc.start_column + 1,
562
+ Violation.new(
563
+ location: arg_node.location,
603
564
  message: message, severity: :error, rule: "unknown-filter-method"
604
565
  )
605
566
  end
606
567
 
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
568
  def helper_suffix?(name)
620
569
  name_str = name.to_s
621
570
  SUFFIXES.any? { |suffix| name_str.end_with?(suffix) && name_str.length > suffix.length }
622
571
  end
623
572
 
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
573
  def positional_arg_count(call_node)
668
574
  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).
575
+ # Drop a trailing `KeywordHashNode` so call sites that pass
576
+ # `users_path(format: :json)` don't get counted as arity 1. Same
577
+ # convention rigor-rails-routes' helper-table arity uses
578
+ # (positional only).
673
579
  args = args[0..-2] if args.last.is_a?(Prism::KeywordHashNode)
674
580
  args.size
675
581
  end
@@ -678,35 +584,14 @@ module Rigor
678
584
  call_node.message_loc || call_node.location
679
585
  end
680
586
 
681
- def helper_call_diagnostic(path, call_node, entry)
682
- loc = location(call_node)
587
+ def helper_call_violation(call_node, entry)
683
588
  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,
589
+ Violation.new(
590
+ location: location(call_node),
686
591
  message: "Action Pack helper `#{call_node.name}` → #{method} #{entry[:path]} (action: #{entry[:action]}).",
687
592
  severity: :info, rule: "helper-call"
688
593
  )
689
594
  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
595
  end
711
596
  end
712
597
  end