rigortype 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,150 @@
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
+ # immediate parent.
16
+ #
17
+ # Limitations (per the Phase 2 design):
18
+ #
19
+ # - Single-class-per-file is the assumption — the walker
20
+ # records the first top-level class node it encounters
21
+ # per file. Files with multiple classes (rare in
22
+ # `app/controllers/` outside of nested namespaces) only
23
+ # contribute their first class.
24
+ # - One level of inheritance only. `class FooController <
25
+ # ApplicationController` records `FooController`'s
26
+ # methods + parent_class_name `"ApplicationController"`,
27
+ # and the index resolves the inherited methods at lookup
28
+ # time. Two-level chains (`AdminController <
29
+ # AdminBaseController < ApplicationController`) are not
30
+ # walked transitively in Phase 2; `AdminController`'s
31
+ # inherited methods are limited to what
32
+ # `AdminBaseController` directly defines, not what
33
+ # `AdminBaseController` inherits.
34
+ # - Modules / `concerning :Auth` blocks are not walked.
35
+ class ControllerDiscoverer
36
+ def initialize(io_boundary:, search_paths:)
37
+ @io_boundary = io_boundary
38
+ @search_paths = search_paths
39
+ end
40
+
41
+ # @return [ControllerIndex]
42
+ def discover
43
+ entries = {}
44
+ ruby_files_under(@search_paths).each do |path|
45
+ harvest(path, entries)
46
+ end
47
+ ControllerIndex.new(entries.freeze)
48
+ end
49
+
50
+ private
51
+
52
+ def ruby_files_under(roots)
53
+ roots.flat_map do |root|
54
+ absolute = File.expand_path(root)
55
+ next [] unless File.directory?(absolute)
56
+
57
+ Dir.glob(File.join(absolute, "**", "*.rb"))
58
+ end
59
+ end
60
+
61
+ def harvest(path, entries)
62
+ contents = @io_boundary.read_file(path)
63
+ parse_result = Prism.parse(contents)
64
+ return unless parse_result.errors.empty?
65
+
66
+ locate_classes_and_modules(parse_result.value).each do |declaration_node|
67
+ entry = build_entry(declaration_node)
68
+ entries[entry.class_name] = entry if entry.class_name
69
+ end
70
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
71
+ nil
72
+ end
73
+
74
+ # Recursive top-level descent. Returns every `ClassNode`
75
+ # and `ModuleNode` reachable through nested `module` /
76
+ # `class` blocks. Pre-fix only the **first** ClassNode
77
+ # was harvested, which meant controller files that
78
+ # define multiple classes lost coverage AND concern
79
+ # modules under `app/controllers/concerns/` were ignored
80
+ # entirely. The latter was the dominant Mastodon /
81
+ # Redmine FP: `before_action :require_account_signature!`
82
+ # references a method defined in a concern module that
83
+ # the harvester never visited.
84
+ def locate_classes_and_modules(node, into = [])
85
+ return into unless node.is_a?(Prism::Node)
86
+
87
+ into << node if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
88
+ node.compact_child_nodes.each do |child|
89
+ locate_classes_and_modules(child, into)
90
+ end
91
+ into
92
+ end
93
+
94
+ def build_entry(declaration_node)
95
+ name = qualified_name_for(declaration_node.constant_path)
96
+ parent_name = if declaration_node.is_a?(Prism::ClassNode) && declaration_node.superclass
97
+ qualified_name_for(declaration_node.superclass)
98
+ end
99
+ methods = collect_def_names(declaration_node.body)
100
+ includes = collect_include_targets(declaration_node.body)
101
+ ControllerIndex::Entry.new(
102
+ class_name: name,
103
+ defined_methods: methods.freeze,
104
+ parent_class_name: parent_name,
105
+ included_module_names: includes.freeze
106
+ )
107
+ end
108
+
109
+ def collect_def_names(node, accumulator = [])
110
+ return accumulator unless node.is_a?(Prism::Node)
111
+
112
+ accumulator << node.name if node.is_a?(Prism::DefNode) && node.receiver.nil?
113
+ node.compact_child_nodes.each { |child| collect_def_names(child, accumulator) }
114
+ accumulator
115
+ end
116
+
117
+ # Collects the qualified-constant targets passed to
118
+ # `include` calls inside the body. Stops at nested
119
+ # `ClassNode` / `ModuleNode` boundaries so a class
120
+ # declared inside a concern doesn't pull the concern's
121
+ # includes into itself.
122
+ def collect_include_targets(node, accumulator = [])
123
+ return accumulator unless node.is_a?(Prism::Node)
124
+ return accumulator if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
125
+
126
+ if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :include
127
+ (node.arguments&.arguments || []).each do |arg|
128
+ name = qualified_name_for(arg)
129
+ accumulator << name if name
130
+ end
131
+ end
132
+
133
+ node.compact_child_nodes.each { |child| collect_include_targets(child, accumulator) }
134
+ accumulator
135
+ end
136
+
137
+ def qualified_name_for(node)
138
+ case node
139
+ when Prism::ConstantReadNode then node.name.to_s
140
+ when Prism::ConstantPathNode
141
+ parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
142
+ return nil if !node.parent.nil? && parent.nil?
143
+
144
+ parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,123 @@
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
+ Entry = Data.define(:class_name, :defined_methods, :parent_class_name, :included_module_names)
32
+
33
+ attr_reader :entries
34
+
35
+ def initialize(entries)
36
+ @entries = entries.freeze
37
+ freeze
38
+ end
39
+
40
+ # @return [Entry, nil]
41
+ def find(class_name)
42
+ @entries[class_name]
43
+ end
44
+
45
+ # Resolves the **effective** method set for a controller,
46
+ # including methods inherited from its parent class
47
+ # (one level) and methods contributed by every module the
48
+ # controller / its parent transitively `include`s
49
+ # (unbounded depth, cycle-safe via a visited set).
50
+ def effective_methods_for(class_name)
51
+ seen = {}
52
+ methods = []
53
+ collect_methods(class_name, seen, methods)
54
+ if (parent = @entries[class_name]&.parent_class_name)
55
+ collect_methods(parent, seen, methods)
56
+ end
57
+ methods.uniq.freeze
58
+ end
59
+
60
+ # @return [Boolean] true when the class has at least one
61
+ # include we couldn't resolve in the index (typically
62
+ # a gem-shipped concern such as Devise's
63
+ # `Devise::Controllers::Helpers`). Phase 2 uses this
64
+ # to downgrade `unknown-filter-method` to silence —
65
+ # the unresolved module may legitimately contribute
66
+ # the filter, and there's no way for the static
67
+ # analyzer to verify.
68
+ def unresolved_include?(class_name)
69
+ entry = @entries[class_name]
70
+ return false if entry.nil?
71
+
72
+ chain = [class_name]
73
+ chain << entry.parent_class_name if entry.parent_class_name
74
+ chain.any? do |c|
75
+ walk_includes(c, {}) { |m| return true unless @entries.key?(m) }
76
+ false
77
+ end
78
+ end
79
+
80
+ def empty?
81
+ @entries.empty?
82
+ end
83
+
84
+ def known?(class_name)
85
+ @entries.key?(class_name)
86
+ end
87
+
88
+ def class_names
89
+ @entries.keys
90
+ end
91
+
92
+ private
93
+
94
+ def collect_methods(name, seen, into)
95
+ entry = @entries[name]
96
+ return if entry.nil? || seen[name]
97
+
98
+ seen[name] = true
99
+ into.concat(entry.defined_methods)
100
+ entry.included_module_names.each do |included|
101
+ collect_methods(included, seen, into)
102
+ end
103
+ end
104
+
105
+ # Yields each transitively-included module name (whether
106
+ # we have an entry for it or not). Returns nil; callers
107
+ # use it for visit-and-classify, not to collect.
108
+ def walk_includes(name, seen, &)
109
+ return if seen[name]
110
+
111
+ seen[name] = true
112
+ entry = @entries[name]
113
+ return unless entry
114
+
115
+ entry.included_module_names.each do |included|
116
+ yield included
117
+ walk_includes(included, seen, &)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,247 @@
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
+ version: "0.1.0",
72
+ description: "Validates Action Pack route-helper calls and filter chains inside controllers.",
73
+ config_schema: {
74
+ "controller_search_paths" => :array,
75
+ "view_search_paths" => :array
76
+ },
77
+ consumes: [
78
+ { plugin_id: "rails-routes", name: :helper_table, optional: true },
79
+ { plugin_id: "activerecord", name: :model_index, optional: true }
80
+ ]
81
+ )
82
+
83
+ DEFAULT_CONTROLLER_SEARCH_PATHS = ["app/controllers"].freeze
84
+ DEFAULT_VIEW_SEARCH_PATHS = ["app/views"].freeze
85
+
86
+ # Phase 2 cached producer — the controller index built
87
+ # from `controller_search_paths`. The IoBoundary records
88
+ # a `FileEntry` digest for every file the discoverer
89
+ # reads, so the cache invalidates when any controller
90
+ # file changes.
91
+ producer :controller_index do |_params|
92
+ ControllerDiscoverer.new(
93
+ io_boundary: io_boundary,
94
+ search_paths: @controller_search_paths
95
+ ).discover
96
+ end
97
+
98
+ def init(services)
99
+ @services = services
100
+ @controller_search_paths = Array(
101
+ config.fetch("controller_search_paths", DEFAULT_CONTROLLER_SEARCH_PATHS)
102
+ ).map(&:to_s)
103
+ @view_search_paths = Array(
104
+ config.fetch("view_search_paths", DEFAULT_VIEW_SEARCH_PATHS)
105
+ ).map(&:to_s)
106
+ @helper_table = nil
107
+ @helper_table_resolved = false
108
+ @controller_index = nil
109
+ @model_index_value = nil
110
+ @model_index_resolved = false
111
+ end
112
+
113
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
114
+ return [] unless controller_file?(path)
115
+
116
+ helper_diagnostics(path, root) +
117
+ filter_diagnostics(path, root) +
118
+ render_diagnostics(path, root) +
119
+ permit_diagnostics(path, root)
120
+ end
121
+
122
+ private
123
+
124
+ def helper_diagnostics(path, root)
125
+ table = helper_table
126
+ return [] if table.nil? || table.empty?
127
+
128
+ Analyzer.diagnose(path: path, root: root, helper_table: table)
129
+ .map { |diag| build_diagnostic(diag) }
130
+ end
131
+
132
+ # Phase 2 — runs the filter-chain validator over the
133
+ # controller's class body using the cached
134
+ # {ControllerIndex}. Skips silently when the index is
135
+ # absent or doesn't recognise the file's top-level class.
136
+ def filter_diagnostics(path, root)
137
+ index = controller_index_or_nil
138
+ return [] if index.nil? || index.empty?
139
+
140
+ Analyzer.diagnose_filters(path: path, root: root, controller_index: index)
141
+ .map { |diag| build_diagnostic(diag) }
142
+ end
143
+
144
+ # Phase 3 — runs the render-target validator against the
145
+ # configured `view_search_paths`. Always invoked
146
+ # regardless of whether the controller is in the index;
147
+ # render shapes are recognised purely from the call site
148
+ # + class name, no per-controller pre-discovery needed.
149
+ def render_diagnostics(path, root)
150
+ Analyzer.diagnose_renders(path: path, root: root, view_search_roots: @view_search_paths)
151
+ .map { |diag| build_diagnostic(diag) }
152
+ end
153
+
154
+ # Phase 1 — strong-parameter validation. Reads the
155
+ # `:model_index` fact from the cross-plugin fact store
156
+ # (published by rigor-activerecord) and validates every
157
+ # `params.require(:user).permit(:name, :email)` chain
158
+ # against the User model's column list.
159
+ def permit_diagnostics(path, root)
160
+ index = model_index
161
+ return [] if index.nil? || index.empty?
162
+
163
+ Analyzer.diagnose_permits(path: path, root: root, model_index: index)
164
+ .map { |diag| build_diagnostic(diag) }
165
+ end
166
+
167
+ def controller_index_or_nil
168
+ return @controller_index if @controller_index
169
+
170
+ # Read project source first so the IoBoundary's
171
+ # FileEntry digests get captured into the descriptor
172
+ # before `cache_for` snapshots it (mirrors
173
+ # rigor-rails-routes / rigor-pundit's pattern).
174
+ prime_io_boundary_for_index
175
+ @controller_index = cache_for(:controller_index, params: {}).call
176
+ rescue StandardError
177
+ nil
178
+ end
179
+
180
+ def prime_io_boundary_for_index
181
+ @controller_search_paths.each do |root|
182
+ absolute = File.expand_path(root)
183
+ next unless File.directory?(absolute)
184
+
185
+ Dir.glob(File.join(absolute, "**", "*.rb")).each do |path|
186
+ io_boundary.read_file(path)
187
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
188
+ nil
189
+ end
190
+ end
191
+ end
192
+
193
+ # Lazily resolves the helper table from the cross-plugin
194
+ # fact store. The cache is per-run because the runner
195
+ # builds a fresh `FactStore` per invocation; memoizing on
196
+ # the plugin instance saves the per-file `read` while
197
+ # still picking up a freshly-published table on the next
198
+ # `bundle exec rigor check` run.
199
+ def helper_table
200
+ return @helper_table if @helper_table_resolved
201
+
202
+ @helper_table = @services.fact_store.read(
203
+ plugin_id: "rails-routes", name: :helper_table
204
+ )
205
+ @helper_table_resolved = true
206
+ @helper_table
207
+ end
208
+
209
+ # Phase 1 — lazily reads the cross-plugin :model_index
210
+ # fact from rigor-activerecord. The cache is per-run
211
+ # because the runner builds a fresh FactStore per
212
+ # invocation; memoizing on the plugin instance saves the
213
+ # per-file read while still picking up a freshly
214
+ # published index on the next `bundle exec rigor check`.
215
+ def model_index
216
+ return @model_index_value if @model_index_resolved
217
+
218
+ @model_index_value = @services.fact_store.read(
219
+ plugin_id: "activerecord", name: :model_index
220
+ )
221
+ @model_index_resolved = true
222
+ @model_index_value
223
+ end
224
+
225
+ def controller_file?(path)
226
+ @controller_search_paths.any? do |root|
227
+ # The runner may pass `path` as either an absolute
228
+ # path (when `paths:` was configured absolutely) or a
229
+ # relative one (when configured relatively). The
230
+ # `controller_search_paths` knob is always project-
231
+ # root-relative. Match the configured root as a
232
+ # /-bracketed substring so both shapes resolve.
233
+ path.include?("/#{root}/") || path.start_with?("#{root}/") || path == root
234
+ end
235
+ end
236
+
237
+ def build_diagnostic(diag)
238
+ Rigor::Analysis::Diagnostic.new(
239
+ path: diag.path, line: diag.line, column: diag.column,
240
+ message: diag.message, severity: diag.severity, rule: diag.rule
241
+ )
242
+ end
243
+ end
244
+
245
+ Rigor::Plugin.register(Actionpack)
246
+ end
247
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/actionpack"
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activejob < Rigor::Plugin::Base
8
+ # Walks a parsed file's AST looking for
9
+ # `<JobClass>.perform_later(...)` /
10
+ # `.perform_now(...)` / `.perform(...)` calls and
11
+ # validates each against the {JobIndex}.
12
+ #
13
+ # The plugin recognises a call as job-shaped when the
14
+ # receiver is a `ConstantReadNode` / `ConstantPathNode`
15
+ # whose resolved name appears in the index, and the
16
+ # method name is one of the three ActiveJob entry
17
+ # points.
18
+ module Analyzer
19
+ # Methods that delegate to the job's `#perform`. All
20
+ # three accept the same argument shape — `perform_later`
21
+ # is the most common (queues for later execution),
22
+ # `perform_now` runs synchronously, and `perform` is
23
+ # the bare execution path.
24
+ ENTRY_METHODS = %i[perform_later perform_now perform].freeze
25
+
26
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
27
+
28
+ module_function
29
+
30
+ # @param path [String]
31
+ # @param root [Prism::Node]
32
+ # @param job_index [JobIndex]
33
+ # @return [Array<Diagnostic>]
34
+ def diagnose(path:, root:, job_index:)
35
+ diagnostics = []
36
+ walk(root) do |call_node|
37
+ class_name = constant_receiver_name(call_node.receiver)
38
+ next if class_name.nil?
39
+
40
+ entry = job_index.find(class_name) || job_index.find("::#{class_name}")
41
+ next if entry.nil?
42
+
43
+ diagnostics << info_diagnostic(path, call_node, entry)
44
+ arity = arity_check(path, call_node, entry)
45
+ diagnostics << arity if arity
46
+ end
47
+ diagnostics
48
+ end
49
+
50
+ def walk(node, &)
51
+ return unless node.is_a?(Prism::Node)
52
+
53
+ yield node if node.is_a?(Prism::CallNode) && entry_call?(node)
54
+ node.compact_child_nodes.each { |child| walk(child, &) }
55
+ end
56
+
57
+ def entry_call?(node)
58
+ ENTRY_METHODS.include?(node.name) &&
59
+ (node.receiver.is_a?(Prism::ConstantReadNode) || node.receiver.is_a?(Prism::ConstantPathNode))
60
+ end
61
+
62
+ def info_diagnostic(path, call_node, entry)
63
+ location = call_node.location
64
+ Diagnostic.new(
65
+ path: path,
66
+ line: location.start_line,
67
+ column: location.start_column + 1,
68
+ severity: :info,
69
+ rule: "job-call",
70
+ message: "`#{entry.class_name}.#{call_node.name}` matches `#perform` (arity #{entry.arity_label})"
71
+ )
72
+ end
73
+
74
+ def arity_check(path, call_node, entry)
75
+ actual = (call_node.arguments&.arguments || []).size
76
+ return nil if entry.accepts?(actual)
77
+
78
+ location = call_node.location
79
+ Diagnostic.new(
80
+ path: path,
81
+ line: location.start_line,
82
+ column: location.start_column + 1,
83
+ severity: :error,
84
+ rule: "wrong-arity",
85
+ message: "`#{entry.class_name}.#{call_node.name}` expects #{entry.arity_label} argument(s), got #{actual}"
86
+ )
87
+ end
88
+
89
+ # Renders a constant-path receiver as a String.
90
+ # Mirrors the helpers in rigor-activerecord /
91
+ # rigor-rails-routes for parity.
92
+ def constant_receiver_name(node)
93
+ case node
94
+ when Prism::ConstantReadNode then node.name.to_s
95
+ when Prism::ConstantPathNode then constant_path_name(node)
96
+ end
97
+ end
98
+
99
+ def constant_path_name(node)
100
+ parts = []
101
+ current = node
102
+ while current.is_a?(Prism::ConstantPathNode)
103
+ parts.unshift(current.name.to_s)
104
+ current = current.parent
105
+ end
106
+ case current
107
+ when nil then "::#{parts.join('::')}"
108
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end