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,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "controller_index"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class Actionpack < Rigor::Plugin::Base
10
+ # Walks `controller_search_paths` building a
11
+ # {ControllerIndex} of `(class_name, methods,
12
+ # parent_class_name)` triples. Used by Phase 2 (filter
13
+ # chains) to validate that `before_action :name`
14
+ # references a method defined on the controller or its
15
+ # ancestor chain.
16
+ #
17
+ # Two declaration shapes are recognised and qualified
18
+ # the same way:
19
+ #
20
+ # class Admin::AccountsController < BaseController
21
+ # # → registered as "Admin::AccountsController"
22
+ #
23
+ # module Admin
24
+ # class AccountsController < BaseController
25
+ # # → ALSO registered as "Admin::AccountsController"
26
+ # end
27
+ # end
28
+ #
29
+ # Pre-fix the nested form ignored the enclosing `module
30
+ # Admin` and registered the inner class as bare
31
+ # `"AccountsController"`, overwriting the top-level
32
+ # `app/controllers/accounts_controller.rb` entry. Mastodon
33
+ # has both shapes for the same logical name (top-level
34
+ # `AccountsController` + `Admin::AccountsController` +
35
+ # `Api::V1::AccountsController`) and the overwrite caused
36
+ # the wrong inheritance / method set to flow downstream,
37
+ # producing dozens of false `unknown-filter-method`
38
+ # diagnostics.
39
+ #
40
+ # Multi-level inheritance is also walked: `Admin::AccountsController
41
+ # < BaseController < ApplicationController` resolves
42
+ # methods from all three. Cycle-safe via a visited set.
43
+ # The parent-name lookup is **lexically scoped** — a bare
44
+ # `BaseController` reference inside `module Admin` first
45
+ # tries `Admin::BaseController`, then falls through to
46
+ # top-level `BaseController`, matching Ruby's constant-
47
+ # resolution semantics.
48
+ class ControllerDiscoverer
49
+ def initialize(io_boundary:, search_paths:)
50
+ @io_boundary = io_boundary
51
+ @search_paths = search_paths
52
+ end
53
+
54
+ # @return [ControllerIndex]
55
+ def discover
56
+ entries = {}
57
+ ruby_files_under(@search_paths).each do |path|
58
+ harvest(path, entries)
59
+ end
60
+ ControllerIndex.new(entries.freeze)
61
+ end
62
+
63
+ private
64
+
65
+ def ruby_files_under(roots)
66
+ roots.flat_map do |root|
67
+ absolute = File.expand_path(root)
68
+ next [] unless File.directory?(absolute)
69
+
70
+ Dir.glob(File.join(absolute, "**", "*.rb"))
71
+ end
72
+ end
73
+
74
+ def harvest(path, entries)
75
+ contents = @io_boundary.read_file(path)
76
+ parse_result = Prism.parse(contents)
77
+ return unless parse_result.errors.empty?
78
+
79
+ walk_declarations(parse_result.value, namespace: []) do |declaration_node, enclosing|
80
+ entry = build_entry(declaration_node, enclosing)
81
+ entries[entry.class_name] = entry if entry.class_name
82
+ end
83
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
84
+ nil
85
+ end
86
+
87
+ # Walks every `ClassNode` / `ModuleNode` in the AST,
88
+ # yielding `[declaration_node, enclosing_namespace_array]`
89
+ # for each. `enclosing` is the chain of module / class
90
+ # qualifiers OUTSIDE the yielded declaration (e.g. for
91
+ # `module Admin; class AccountsController; end; end`
92
+ # the inner class yields with enclosing
93
+ # `["Admin"]`). The yielded declaration's own segment
94
+ # is added to the chain for its body's recursion so
95
+ # constants nested two levels deep qualify correctly.
96
+ def walk_declarations(node, namespace:, &)
97
+ return unless node.is_a?(Prism::Node)
98
+
99
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
100
+ yield node, namespace
101
+ inner_namespace = namespace + namespace_segments_for(node)
102
+ return if node.body.nil?
103
+
104
+ walk_declarations(node.body, namespace: inner_namespace, &)
105
+ else
106
+ node.compact_child_nodes.each do |child|
107
+ walk_declarations(child, namespace: namespace, &)
108
+ end
109
+ end
110
+ end
111
+
112
+ # The segments contributed by a declaration to the
113
+ # qualifier chain of its body. For `class A::B` this is
114
+ # `["A", "B"]`; for `module Outer` it is `["Outer"]`.
115
+ # Returns `[]` for an anonymous declaration the walker
116
+ # cannot qualify.
117
+ def namespace_segments_for(declaration_node)
118
+ path = qualified_name_for(declaration_node.constant_path)
119
+ path ? path.split("::") : []
120
+ end
121
+
122
+ def build_entry(declaration_node, enclosing)
123
+ name = qualified_name_with_enclosing(declaration_node.constant_path, enclosing)
124
+ parent_name = parent_name_for(declaration_node)
125
+ methods = collect_def_names(declaration_node.body)
126
+ includes = collect_include_targets(declaration_node.body)
127
+ ControllerIndex::Entry.new(
128
+ class_name: name,
129
+ defined_methods: methods.freeze,
130
+ parent_class_name: parent_name,
131
+ included_module_names: includes.freeze,
132
+ enclosing_namespace: enclosing.dup.freeze
133
+ )
134
+ end
135
+
136
+ # Resolves the declared name against the enclosing
137
+ # namespace chain. A `ConstantPathNode` (e.g.
138
+ # `Admin::Foo` in `class Admin::Foo`) is already
139
+ # absolute and ignores the enclosing chain. A
140
+ # `ConstantReadNode` (bare `Foo` in `module Admin;
141
+ # class Foo`) is qualified against the chain so the
142
+ # name becomes `Admin::Foo`.
143
+ def qualified_name_with_enclosing(node, enclosing)
144
+ return nil unless node.is_a?(Prism::Node)
145
+
146
+ local = qualified_name_for(node)
147
+ return nil if local.nil?
148
+ return local if node.is_a?(Prism::ConstantPathNode) && !node.parent.nil?
149
+ return local if enclosing.empty?
150
+
151
+ "#{enclosing.join('::')}::#{local}"
152
+ end
153
+
154
+ def parent_name_for(declaration_node)
155
+ return nil unless declaration_node.is_a?(Prism::ClassNode) && declaration_node.superclass
156
+
157
+ qualified_name_for(declaration_node.superclass)
158
+ end
159
+
160
+ def collect_def_names(node, accumulator = [])
161
+ return accumulator unless node.is_a?(Prism::Node)
162
+
163
+ accumulator << node.name if node.is_a?(Prism::DefNode) && node.receiver.nil?
164
+ node.compact_child_nodes.each { |child| collect_def_names(child, accumulator) }
165
+ accumulator
166
+ end
167
+
168
+ # Collects the qualified-constant targets passed to
169
+ # `include` calls inside the body. Stops at nested
170
+ # `ClassNode` / `ModuleNode` boundaries so a class
171
+ # declared inside a concern doesn't pull the concern's
172
+ # includes into itself.
173
+ def collect_include_targets(node, accumulator = [])
174
+ return accumulator unless node.is_a?(Prism::Node)
175
+ return accumulator if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
176
+
177
+ if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :include
178
+ (node.arguments&.arguments || []).each do |arg|
179
+ name = qualified_name_for(arg)
180
+ accumulator << name if name
181
+ end
182
+ end
183
+
184
+ node.compact_child_nodes.each { |child| collect_include_targets(child, accumulator) }
185
+ accumulator
186
+ end
187
+
188
+ def qualified_name_for(node)
189
+ case node
190
+ when Prism::ConstantReadNode then node.name.to_s
191
+ when Prism::ConstantPathNode
192
+ parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
193
+ return nil if !node.parent.nil? && parent.nil?
194
+
195
+ parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Actionpack < Rigor::Plugin::Base
6
+ # Per-run frozen index of discovered controllers AND
7
+ # concerns (modules) in `controller_search_paths`. Phase 2
8
+ # (filter-chain validation) consults the index at every
9
+ # call site to check that `before_action :name` references
10
+ # a method that actually exists on the controller, its
11
+ # parent (one level of inheritance), or any module the
12
+ # controller or its ancestor chain transitively `include`s.
13
+ #
14
+ # Real-world example (Mastodon):
15
+ # AccountsController < ApplicationController
16
+ # include SignatureAuthentication # local concern
17
+ # SignatureAuthentication < module
18
+ # include SignatureVerification # local concern
19
+ # SignatureVerification < module
20
+ # def require_account_signature! # ← the filter target
21
+ #
22
+ # The include chain spans three concern modules.
23
+ # `effective_methods_for("AccountsController")` walks all
24
+ # of them transitively to collect `require_account_signature!`.
25
+ class ControllerIndex
26
+ # `defined_methods` carries the discovered method names
27
+ # (Symbols). `included_module_names` carries the constant
28
+ # names passed to `include X` calls inside the
29
+ # class / module body (Strings). `parent_class_name` is
30
+ # the immediate superclass (nil for plain modules).
31
+ # `enclosing_namespace` is the qualifier chain of the
32
+ # declaration's *lexical* enclosing scope (e.g.
33
+ # `["Admin"]` for an `Admin::Foo` declared via
34
+ # `module Admin; class Foo; end; end`). Used by the
35
+ # index's parent / module lookup to try lexically-scoped
36
+ # constant resolution before falling through to the
37
+ # top-level form — Ruby's constant lookup walks the
38
+ # enclosing scope chain before `Object`.
39
+ Entry = Data.define(:class_name, :defined_methods, :parent_class_name, :included_module_names,
40
+ :enclosing_namespace)
41
+
42
+ attr_reader :entries
43
+
44
+ def initialize(entries)
45
+ @entries = entries.freeze
46
+ freeze
47
+ end
48
+
49
+ # @return [Entry, nil]
50
+ def find(class_name)
51
+ @entries[class_name]
52
+ end
53
+
54
+ # Resolves the **effective** method set for a controller,
55
+ # including methods contributed by every module the
56
+ # controller / any ancestor transitively `include`s AND
57
+ # methods inherited from the ancestor chain (unbounded
58
+ # depth, cycle-safe via a visited set).
59
+ #
60
+ # Parent / module lookups are **lexically scoped** — a
61
+ # bare `BaseController` reference inside `module Admin`
62
+ # first tries `Admin::BaseController`, then falls
63
+ # through to the top-level `BaseController`. Matches
64
+ # Ruby's constant-resolution semantics. Without this an
65
+ # `Admin::Foo < BaseController` declaration would
66
+ # incorrectly walk the top-level `BaseController` even
67
+ # when an `Admin::BaseController` exists.
68
+ def effective_methods_for(class_name)
69
+ seen_classes = {}
70
+ seen_modules = {}
71
+ methods = []
72
+ current = class_name
73
+ while current && !seen_classes[current]
74
+ seen_classes[current] = true
75
+ entry = @entries[current]
76
+ break if entry.nil?
77
+
78
+ methods.concat(entry.defined_methods)
79
+ entry.included_module_names.each do |included|
80
+ resolved_include = resolve_constant_lexically(included, entry.enclosing_namespace)
81
+ collect_methods(resolved_include, seen_modules, methods)
82
+ end
83
+ next_parent = entry.parent_class_name
84
+ # Same self-reference guard as `unresolved_include?`:
85
+ # `class ActivityPub::ApplicationController <
86
+ # ::ApplicationController` lexically resolves
87
+ # `ApplicationController` to itself; fall back to
88
+ # the unprefixed top-level name in that case.
89
+ current = if next_parent
90
+ resolved = resolve_constant_lexically(next_parent, entry.enclosing_namespace)
91
+ resolved == current ? next_parent.sub(/\A::/, "") : resolved
92
+ end
93
+ end
94
+ methods.uniq.freeze
95
+ end
96
+
97
+ # @return [Boolean] true when the class has at least one
98
+ # include OR parent class we couldn't resolve in the
99
+ # index (typically a gem-shipped concern such as Devise's
100
+ # `Devise::Controllers::Helpers`, or a gem-shipped
101
+ # parent controller such as `Devise::ConfirmationsController`
102
+ # or `Doorkeeper::AuthorizedApplicationsController`).
103
+ # Phase 2 uses this to downgrade `unknown-filter-method`
104
+ # to silence — the unresolved module / parent may
105
+ # legitimately contribute the filter (either directly,
106
+ # or via its own ancestor chain which the static
107
+ # analyzer cannot follow), and there's no way to verify.
108
+ def unresolved_include?(class_name)
109
+ entry = @entries[class_name]
110
+ return false if entry.nil?
111
+
112
+ # Walk the full ancestor chain. `Admin::Foo <
113
+ # BaseController < ApplicationController` should report
114
+ # an unresolved include if ANY of the three references
115
+ # a gem-shipped concern OR a gem-shipped parent class
116
+ # we cannot index. The first iteration's `current_entry`
117
+ # is always resolved (caller verified `known?`); a nil
118
+ # `current_entry` on a subsequent iteration means the
119
+ # AST-side `< Parent` reached a gem-shipped class
120
+ # whose ancestor methods are invisible to us.
121
+ seen_classes = {}
122
+ current = class_name
123
+ first = true
124
+ while current && !seen_classes[current]
125
+ seen_classes[current] = true
126
+ current_entry = @entries[current]
127
+ if current_entry.nil?
128
+ return true unless first
129
+
130
+ break
131
+ end
132
+ first = false
133
+
134
+ current_entry.included_module_names.each do |included|
135
+ resolved = resolve_constant_lexically(included, current_entry.enclosing_namespace)
136
+ return true if resolved.nil? || !@entries.key?(resolved)
137
+ end
138
+ next_parent = current_entry.parent_class_name
139
+ # Avoid lexically resolving to ourselves. `class
140
+ # ActivityPub::ApplicationController < ::ApplicationController`
141
+ # would otherwise resolve `ApplicationController` (in
142
+ # the lexical scope `["ActivityPub"]`) to
143
+ # `ActivityPub::ApplicationController` and short-
144
+ # circuit the walk before reaching the top-level
145
+ # parent. When the lexical match is the current
146
+ # class itself, fall back to the unprefixed
147
+ # top-level name.
148
+ current = if next_parent
149
+ resolved = resolve_constant_lexically(next_parent, current_entry.enclosing_namespace)
150
+ resolved == current ? next_parent.sub(/\A::/, "") : resolved
151
+ end
152
+ end
153
+ false
154
+ end
155
+
156
+ def empty?
157
+ @entries.empty?
158
+ end
159
+
160
+ def known?(class_name)
161
+ @entries.key?(class_name)
162
+ end
163
+
164
+ def class_names
165
+ @entries.keys
166
+ end
167
+
168
+ private
169
+
170
+ def collect_methods(name, seen, into)
171
+ return if name.nil?
172
+
173
+ entry = @entries[name]
174
+ return if entry.nil? || seen[name]
175
+
176
+ seen[name] = true
177
+ into.concat(entry.defined_methods)
178
+ entry.included_module_names.each do |included|
179
+ resolved = resolve_constant_lexically(included, entry.enclosing_namespace)
180
+ collect_methods(resolved, seen, into)
181
+ end
182
+ end
183
+
184
+ # Ruby's constant-lookup walk: a bare constant name
185
+ # inside `module Admin` first tries `Admin::Const`,
186
+ # then walks outward, and finally falls through to
187
+ # top-level `Const`. We approximate that by trying
188
+ # the candidate `enclosing + name` chains from
189
+ # deepest to shallowest, and returning the first
190
+ # candidate that has an entry in the index. When
191
+ # nothing resolves, return the original name
192
+ # unchanged — the caller treats unresolved entries as
193
+ # "gem-shipped concerns we cannot see" (the
194
+ # `unresolved_include?` predicate).
195
+ #
196
+ # `name` may already be qualified (`Foo::Bar`); we
197
+ # only try lexical prefixing when the unqualified
198
+ # first segment doesn't match a top-level entry.
199
+ def resolve_constant_lexically(name, enclosing)
200
+ return nil if name.nil?
201
+
202
+ # A leading `::` denotes the top-level constant
203
+ # explicitly (`< ::ApplicationController`). Strip it
204
+ # for index lookup — the discoverer registers entries
205
+ # under their unprefixed name. Without this strip a
206
+ # `class ActivityPub::ApplicationController <
207
+ # ::ApplicationController` parent never resolved.
208
+ name = name.sub(/\A::/, "")
209
+
210
+ # Constant already absolute or no enclosing scope.
211
+ return name if enclosing.nil? || enclosing.empty?
212
+
213
+ # Try the deepest enclosing scope first, walking
214
+ # outward. `enclosing = ["A", "B"]` produces
215
+ # candidates `["A::B::name", "A::name", "name"]`.
216
+ enclosing.length.downto(0).each do |depth|
217
+ prefix = enclosing[0, depth]
218
+ candidate = prefix.empty? ? name : "#{prefix.join('::')}::#{name}"
219
+ return candidate if @entries.key?(candidate)
220
+ end
221
+ name
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "actionpack/analyzer"
6
+ require_relative "actionpack/controller_discoverer"
7
+ require_relative "actionpack/controller_index"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-actionpack — validates Action Pack DSL calls in
12
+ # controller files.
13
+ #
14
+ # **Phase 4 of the Action Pack plugin family** (route-helper
15
+ # consumption). Reads the `:helper_table` fact published by
16
+ # `rigor-rails-routes` (ADR-9 cross-plugin API) and validates
17
+ # every implicit-self `*_path` / `*_url` call inside files
18
+ # under `controller_search_paths` (default `app/controllers`).
19
+ #
20
+ # Tier 2 of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
21
+ # Phase 1 (strong-parameters → AR column validation), Phase 2
22
+ # (filter chains), and Phase 3 (render targets) ship as
23
+ # separate slices; each phase composes additively under the
24
+ # same plugin id.
25
+ #
26
+ # ## Configuration
27
+ #
28
+ # plugins:
29
+ # - gem: rigor-rails-routes # producer (must come first
30
+ # # in `Configuration#plugins`
31
+ # # ordering, OR the loader's
32
+ # # ADR-9 topo sort handles it)
33
+ # - gem: rigor-actionpack
34
+ # config:
35
+ # controller_search_paths: ["app/controllers"] # default; optional
36
+ #
37
+ # ## What it checks
38
+ #
39
+ # - **Helper existence** — every `*_path` / `*_url` call
40
+ # inside a controller file is looked up in the helper
41
+ # table. Missing entries emit `unknown-helper` with a
42
+ # `DidYouMean` suggestion drawn from the table.
43
+ # - **Helper arity** — the call's positional-argument count
44
+ # is matched against the helper's recorded arity (a
45
+ # trailing `KeywordHashNode` like `users_path(format: :json)`
46
+ # is excluded; same convention `rigor-rails-routes` uses).
47
+ # Mismatches emit `wrong-helper-arity`.
48
+ # - **Trace** — recognised helpers also emit a
49
+ # `helper-call` info diagnostic naming the action and
50
+ # path, mirroring the trace shape of the upstream plugin.
51
+ #
52
+ # ## Limitations
53
+ #
54
+ # - Implicit-self calls only. `Rails.application.routes.url_helpers.users_path`
55
+ # and other explicit-receiver shapes are passed through;
56
+ # they're rare in controller code and the helper table
57
+ # doesn't include any extra context to validate them.
58
+ # - Files outside `controller_search_paths` are skipped.
59
+ # The plugin doesn't try to detect "is this a controller?"
60
+ # by class hierarchy — Phase 1's strong-parameters work
61
+ # needs that, so it lives there. Phase 4's job is the
62
+ # single-purpose helper check.
63
+ # - When `rigor-rails-routes` is not installed (or its
64
+ # helper table is empty), Phase 4 silently degrades to a
65
+ # no-op. No load-error diagnostic is emitted; the user
66
+ # gets the "no checks happened" failure mode rather than
67
+ # a wall of "is this configured right?" warnings.
68
+ class Actionpack < Rigor::Plugin::Base
69
+ manifest(
70
+ id: "actionpack",
71
+ # Bumped 2026-05-27 — analyzer-side nested-module
72
+ # qualification slice. `diagnose_filters` and
73
+ # `diagnose_renders` now thread the enclosing
74
+ # namespace through the AST walk so a
75
+ # `module Admin; class DomainBlocksController; end`
76
+ # file resolves as `Admin::DomainBlocksController`
77
+ # — matching the qualification the
78
+ # `ControllerDiscoverer` already records. Fixes render
79
+ # paths (`admin/domain_blocks/new` not bare
80
+ # `domain_blocks/new`) and filter-chain validation
81
+ # silently skipping nested controllers.
82
+ version: "0.7.0",
83
+ description: "Validates Action Pack route-helper calls and filter chains inside controllers.",
84
+ config_schema: {
85
+ "controller_search_paths" => :array,
86
+ "view_search_paths" => :array
87
+ },
88
+ consumes: [
89
+ { plugin_id: "rails-routes", name: :helper_table, optional: true },
90
+ { plugin_id: "activerecord", name: :model_index, optional: true }
91
+ ]
92
+ )
93
+
94
+ DEFAULT_CONTROLLER_SEARCH_PATHS = ["app/controllers"].freeze
95
+ DEFAULT_VIEW_SEARCH_PATHS = ["app/views"].freeze
96
+
97
+ # Phase 2 cached producer — the controller index built
98
+ # from `controller_search_paths`. The IoBoundary records
99
+ # a `FileEntry` digest for every file the discoverer
100
+ # reads, so the cache invalidates when any controller
101
+ # file changes.
102
+ producer :controller_index do |_params|
103
+ ControllerDiscoverer.new(
104
+ io_boundary: io_boundary,
105
+ search_paths: @controller_search_paths
106
+ ).discover
107
+ end
108
+
109
+ def init(services)
110
+ @services = services
111
+ @controller_search_paths = Array(
112
+ config.fetch("controller_search_paths", DEFAULT_CONTROLLER_SEARCH_PATHS)
113
+ ).map(&:to_s)
114
+ @view_search_paths = Array(
115
+ config.fetch("view_search_paths", DEFAULT_VIEW_SEARCH_PATHS)
116
+ ).map(&:to_s)
117
+ @helper_table = nil
118
+ @helper_table_resolved = false
119
+ @controller_index = nil
120
+ @model_index_value = nil
121
+ @model_index_resolved = false
122
+ end
123
+
124
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
125
+ return [] unless controller_file?(path)
126
+
127
+ helper_diagnostics(path, root) +
128
+ filter_diagnostics(path, root) +
129
+ render_diagnostics(path, root) +
130
+ permit_diagnostics(path, root)
131
+ end
132
+
133
+ private
134
+
135
+ def helper_diagnostics(path, root)
136
+ table = helper_table
137
+ return [] if table.nil? || table.empty?
138
+
139
+ Analyzer.diagnose(path: path, root: root, helper_table: table)
140
+ .map { |diag| build_diagnostic(diag) }
141
+ end
142
+
143
+ # Phase 2 — runs the filter-chain validator over the
144
+ # controller's class body using the cached
145
+ # {ControllerIndex}. Skips silently when the index is
146
+ # absent or doesn't recognise the file's top-level class.
147
+ def filter_diagnostics(path, root)
148
+ index = controller_index_or_nil
149
+ return [] if index.nil? || index.empty?
150
+
151
+ Analyzer.diagnose_filters(path: path, root: root, controller_index: index)
152
+ .map { |diag| build_diagnostic(diag) }
153
+ end
154
+
155
+ # Phase 3 — runs the render-target validator against the
156
+ # configured `view_search_paths`. Always invoked
157
+ # regardless of whether the controller is in the index;
158
+ # render shapes are recognised purely from the call site
159
+ # + class name, no per-controller pre-discovery needed.
160
+ def render_diagnostics(path, root)
161
+ Analyzer.diagnose_renders(
162
+ path: path, root: root,
163
+ view_search_roots: @view_search_paths,
164
+ controller_index: controller_index_or_nil
165
+ ).map { |diag| build_diagnostic(diag) }
166
+ end
167
+
168
+ # Phase 1 — strong-parameter validation. Reads the
169
+ # `:model_index` fact from the cross-plugin fact store
170
+ # (published by rigor-activerecord) and validates every
171
+ # `params.require(:user).permit(:name, :email)` chain
172
+ # against the User model's column list.
173
+ def permit_diagnostics(path, root)
174
+ index = model_index
175
+ return [] if index.nil? || index.empty?
176
+
177
+ Analyzer.diagnose_permits(path: path, root: root, model_index: index)
178
+ .map { |diag| build_diagnostic(diag) }
179
+ end
180
+
181
+ def controller_index_or_nil
182
+ return @controller_index if @controller_index
183
+
184
+ # Read project source first so the IoBoundary's
185
+ # FileEntry digests get captured into the descriptor
186
+ # before `cache_for` snapshots it (mirrors
187
+ # rigor-rails-routes / rigor-pundit's pattern).
188
+ prime_io_boundary_for_index
189
+ @controller_index = cache_for(:controller_index, params: {}).call
190
+ rescue StandardError
191
+ nil
192
+ end
193
+
194
+ def prime_io_boundary_for_index
195
+ @controller_search_paths.each do |root|
196
+ absolute = File.expand_path(root)
197
+ next unless File.directory?(absolute)
198
+
199
+ Dir.glob(File.join(absolute, "**", "*.rb")).each do |path|
200
+ io_boundary.read_file(path)
201
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
202
+ nil
203
+ end
204
+ end
205
+ end
206
+
207
+ # Lazily resolves the helper table from the cross-plugin
208
+ # fact store. The cache is per-run because the runner
209
+ # builds a fresh `FactStore` per invocation; memoizing on
210
+ # the plugin instance saves the per-file `read` while
211
+ # still picking up a freshly-published table on the next
212
+ # `bundle exec rigor check` run.
213
+ def helper_table
214
+ return @helper_table if @helper_table_resolved
215
+
216
+ @helper_table = @services.fact_store.read(
217
+ plugin_id: "rails-routes", name: :helper_table
218
+ )
219
+ @helper_table_resolved = true
220
+ @helper_table
221
+ end
222
+
223
+ # Phase 1 — lazily reads the cross-plugin :model_index
224
+ # fact from rigor-activerecord. The cache is per-run
225
+ # because the runner builds a fresh FactStore per
226
+ # invocation; memoizing on the plugin instance saves the
227
+ # per-file read while still picking up a freshly
228
+ # published index on the next `bundle exec rigor check`.
229
+ def model_index
230
+ return @model_index_value if @model_index_resolved
231
+
232
+ @model_index_value = @services.fact_store.read(
233
+ plugin_id: "activerecord", name: :model_index
234
+ )
235
+ @model_index_resolved = true
236
+ @model_index_value
237
+ end
238
+
239
+ def controller_file?(path)
240
+ @controller_search_paths.any? do |root|
241
+ # The runner may pass `path` as either an absolute
242
+ # path (when `paths:` was configured absolutely) or a
243
+ # relative one (when configured relatively). The
244
+ # `controller_search_paths` knob is always project-
245
+ # root-relative. Match the configured root as a
246
+ # /-bracketed substring so both shapes resolve.
247
+ path.include?("/#{root}/") || path.start_with?("#{root}/") || path == root
248
+ end
249
+ end
250
+
251
+ def build_diagnostic(diag)
252
+ Rigor::Analysis::Diagnostic.new(
253
+ path: diag.path, line: diag.line, column: diag.column,
254
+ message: diag.message, severity: diag.severity, rule: diag.rule
255
+ )
256
+ end
257
+ end
258
+
259
+ Rigor::Plugin.register(Actionpack)
260
+ end
261
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/actionpack"