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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +44 -3
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/scope.rbs +22 -0
- 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
|