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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- 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 +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -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 +273 -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 +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -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 +34 -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 +463 -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 +277 -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 +167 -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 +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -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/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- 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,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
|