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,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
class Pundit < Rigor::Plugin::Base
|
|
9
|
+
# Walks a parsed file's AST looking for Pundit
|
|
10
|
+
# entry-point calls and validates each against the
|
|
11
|
+
# {PolicyIndex}.
|
|
12
|
+
#
|
|
13
|
+
# Recognised shapes:
|
|
14
|
+
#
|
|
15
|
+
# - `authorize(record, :action)` — record's inferred
|
|
16
|
+
# type → `<Type>Policy#<action>?` lookup. Both the
|
|
17
|
+
# policy class and the predicate must exist.
|
|
18
|
+
# - `authorize(record)` — without an action argument,
|
|
19
|
+
# we only validate that `<Type>Policy` exists. The
|
|
20
|
+
# action name is determined at runtime from the
|
|
21
|
+
# controller's current action; static validation
|
|
22
|
+
# isn't possible without controller context.
|
|
23
|
+
# - `policy(record)` / `policy_scope(scope)` — same
|
|
24
|
+
# `<Type>Policy` existence check.
|
|
25
|
+
#
|
|
26
|
+
# When the first argument's inferred type is NOT a
|
|
27
|
+
# `Nominal[T]` (e.g. an untyped local variable), the
|
|
28
|
+
# call is silently passed through. The plugin only
|
|
29
|
+
# validates what it can prove from the static type
|
|
30
|
+
# carrier.
|
|
31
|
+
module Analyzer
|
|
32
|
+
ENTRY_METHODS = %i[authorize policy policy_scope].freeze
|
|
33
|
+
|
|
34
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @param path [String]
|
|
39
|
+
# @param root [Prism::Node]
|
|
40
|
+
# @param policy_index [PolicyIndex]
|
|
41
|
+
# @param scope [Rigor::Inference::Scope, nil]
|
|
42
|
+
# @return [Array<Diagnostic>]
|
|
43
|
+
def diagnose(path:, root:, policy_index:, scope:)
|
|
44
|
+
diagnostics = []
|
|
45
|
+
walk(root) do |call_node|
|
|
46
|
+
record_node = call_node.arguments&.arguments&.first
|
|
47
|
+
next if record_node.nil?
|
|
48
|
+
|
|
49
|
+
policy_class_name = derive_policy_class_name(record_node, scope)
|
|
50
|
+
next if policy_class_name.nil?
|
|
51
|
+
|
|
52
|
+
policy_entry = policy_index.find(policy_class_name)
|
|
53
|
+
if policy_entry.nil?
|
|
54
|
+
diagnostics << unknown_policy_class_diagnostic(path, call_node, policy_class_name, policy_index)
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
diagnostics << policy_call_info(path, call_node, policy_class_name)
|
|
59
|
+
|
|
60
|
+
next unless call_node.name == :authorize
|
|
61
|
+
|
|
62
|
+
action_diag = action_check(path, call_node, policy_entry)
|
|
63
|
+
diagnostics << action_diag if action_diag
|
|
64
|
+
end
|
|
65
|
+
diagnostics
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def walk(node, &)
|
|
69
|
+
return unless node.is_a?(Prism::Node)
|
|
70
|
+
|
|
71
|
+
yield node if node.is_a?(Prism::CallNode) && entry_call?(node)
|
|
72
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def entry_call?(node)
|
|
76
|
+
ENTRY_METHODS.include?(node.name) && node.receiver.nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Resolves the first-argument expression to a policy
|
|
80
|
+
# class name. The candidates are:
|
|
81
|
+
# - `Foo` (a constant) → `FooPolicy`
|
|
82
|
+
# - `Foo.method(...)` whose inferred type is
|
|
83
|
+
# `Nominal[Bar]` → `BarPolicy`
|
|
84
|
+
# - any other expression whose inferred type is
|
|
85
|
+
# `Nominal[Bar]` → `BarPolicy`
|
|
86
|
+
# Returns `nil` when the type isn't statically
|
|
87
|
+
# determinable.
|
|
88
|
+
def derive_policy_class_name(record_node, scope)
|
|
89
|
+
if record_node.is_a?(Prism::ConstantReadNode) || record_node.is_a?(Prism::ConstantPathNode)
|
|
90
|
+
constant_name = constant_receiver_name(record_node)
|
|
91
|
+
return "#{constant_name.delete_prefix('::')}Policy" if constant_name
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return nil if scope.nil?
|
|
95
|
+
|
|
96
|
+
type = safe_type_of(scope, record_node)
|
|
97
|
+
return nil unless type.is_a?(Rigor::Type::Nominal)
|
|
98
|
+
|
|
99
|
+
"#{type.class_name.to_s.delete_prefix('::')}Policy"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def safe_type_of(scope, node)
|
|
103
|
+
scope.type_of(node)
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def policy_call_info(path, call_node, policy_class_name)
|
|
109
|
+
location = call_node.location
|
|
110
|
+
Diagnostic.new(
|
|
111
|
+
path: path,
|
|
112
|
+
line: location.start_line,
|
|
113
|
+
column: location.start_column + 1,
|
|
114
|
+
severity: :info,
|
|
115
|
+
rule: "policy-call",
|
|
116
|
+
message: "`#{call_node.name}(...)` resolves to `#{policy_class_name}`"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def unknown_policy_class_diagnostic(path, call_node, policy_class_name, policy_index)
|
|
121
|
+
location = call_node.location
|
|
122
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: policy_index.names).correct(policy_class_name)
|
|
123
|
+
suggestion_part = suggestions.empty? ? "" : " (did you mean `#{suggestions.first}`?)"
|
|
124
|
+
Diagnostic.new(
|
|
125
|
+
path: path,
|
|
126
|
+
line: location.start_line,
|
|
127
|
+
column: location.start_column + 1,
|
|
128
|
+
severity: :error,
|
|
129
|
+
rule: "unknown-policy-class",
|
|
130
|
+
message: "no policy class `#{policy_class_name}` for `#{call_node.name}` call#{suggestion_part}"
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Validates the `authorize(record, :action)` form.
|
|
135
|
+
# Returns nil when the call has no second argument
|
|
136
|
+
# (the runtime infers it from the controller — out
|
|
137
|
+
# of scope here) or when the second argument isn't
|
|
138
|
+
# a literal symbol / string.
|
|
139
|
+
def action_check(path, call_node, policy_entry)
|
|
140
|
+
args = call_node.arguments&.arguments || []
|
|
141
|
+
return nil if args.size < 2
|
|
142
|
+
|
|
143
|
+
action_node = args[1]
|
|
144
|
+
action_name = literal_symbol_or_string(action_node)
|
|
145
|
+
return nil if action_name.nil?
|
|
146
|
+
|
|
147
|
+
predicate = policy_entry.normalize(action_name)
|
|
148
|
+
return nil if policy_entry.includes_method?(predicate)
|
|
149
|
+
|
|
150
|
+
location = call_node.location
|
|
151
|
+
dictionary = policy_entry.predicate_methods.map(&:to_s)
|
|
152
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(predicate.to_s)
|
|
153
|
+
suggestion_part = suggestions.empty? ? "" : " (did you mean `:#{suggestions.first.delete_suffix('?')}`?)"
|
|
154
|
+
Diagnostic.new(
|
|
155
|
+
path: path,
|
|
156
|
+
line: location.start_line,
|
|
157
|
+
column: location.start_column + 1,
|
|
158
|
+
severity: :error,
|
|
159
|
+
rule: "unknown-policy-method",
|
|
160
|
+
message: "`#{policy_entry.policy_class_name}##{predicate}` is not defined " \
|
|
161
|
+
"(known: #{policy_entry.known_methods.join(', ')})#{suggestion_part}"
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def literal_symbol_or_string(node)
|
|
166
|
+
case node
|
|
167
|
+
when Prism::SymbolNode then node.unescaped
|
|
168
|
+
when Prism::StringNode then node.unescaped
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def constant_receiver_name(node)
|
|
173
|
+
case node
|
|
174
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
175
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def constant_path_name(node)
|
|
180
|
+
parts = []
|
|
181
|
+
current = node
|
|
182
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
183
|
+
parts.unshift(current.name.to_s)
|
|
184
|
+
current = current.parent
|
|
185
|
+
end
|
|
186
|
+
case current
|
|
187
|
+
when nil then "::#{parts.join('::')}"
|
|
188
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "policy_index"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class Pundit < Rigor::Plugin::Base
|
|
10
|
+
# Walks the configured policy-search paths via the
|
|
11
|
+
# plugin's `IoBoundary`, parses each `.rb` file with
|
|
12
|
+
# Prism, and collects classes whose immediate
|
|
13
|
+
# superclass is one of the configured base classes.
|
|
14
|
+
#
|
|
15
|
+
# For each discovered policy class, the discoverer
|
|
16
|
+
# collects every instance-side `def name?` predicate
|
|
17
|
+
# method. Non-predicate methods (`initialize`,
|
|
18
|
+
# `resolve`, helper methods) are ignored — Pundit's
|
|
19
|
+
# `authorize` only ever calls predicate methods.
|
|
20
|
+
#
|
|
21
|
+
# Limitations (intentional for v0.1.0):
|
|
22
|
+
#
|
|
23
|
+
# - Direct-superclass match only.
|
|
24
|
+
# - Predicate methods are read from the syntactic
|
|
25
|
+
# `def` list. Methods built via `define_method` /
|
|
26
|
+
# inherited from a sibling concern are out of scope.
|
|
27
|
+
class PolicyDiscoverer
|
|
28
|
+
def initialize(io_boundary:, search_paths:, base_classes:)
|
|
29
|
+
@io_boundary = io_boundary
|
|
30
|
+
@search_paths = search_paths
|
|
31
|
+
@base_classes = base_classes.to_set
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [PolicyIndex]
|
|
35
|
+
def discover
|
|
36
|
+
entries = []
|
|
37
|
+
ruby_files_under(@search_paths).each do |path|
|
|
38
|
+
contents = read_safely(path)
|
|
39
|
+
next if contents.nil?
|
|
40
|
+
|
|
41
|
+
tree = Prism.parse(contents).value
|
|
42
|
+
walk_for_policies(tree, []) do |class_name, predicates|
|
|
43
|
+
entries << PolicyIndex::Entry.new(
|
|
44
|
+
policy_class_name: class_name,
|
|
45
|
+
file_path: path,
|
|
46
|
+
predicate_methods: predicates.to_set.freeze
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
PolicyIndex.new(entries)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def read_safely(path)
|
|
56
|
+
@io_boundary.read_file(path)
|
|
57
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ruby_files_under(roots)
|
|
62
|
+
roots.flat_map do |root|
|
|
63
|
+
absolute = File.expand_path(root)
|
|
64
|
+
next [] unless File.directory?(absolute)
|
|
65
|
+
|
|
66
|
+
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def walk_for_policies(node, lexical_path, &)
|
|
71
|
+
return if node.nil?
|
|
72
|
+
|
|
73
|
+
case node
|
|
74
|
+
when Prism::ClassNode then visit_class(node, lexical_path, &)
|
|
75
|
+
when Prism::ModuleNode then visit_module(node, lexical_path, &)
|
|
76
|
+
else
|
|
77
|
+
node.compact_child_nodes.each { |child| walk_for_policies(child, lexical_path, &) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def visit_class(node, lexical_path, &)
|
|
82
|
+
class_local_name = constant_path_name(node.constant_path)
|
|
83
|
+
return if class_local_name.nil?
|
|
84
|
+
|
|
85
|
+
full_name = (lexical_path + [class_local_name]).join("::")
|
|
86
|
+
superclass = constant_path_name(node.superclass) if node.superclass
|
|
87
|
+
if superclass && @base_classes.include?(superclass) && full_name.end_with?("Policy")
|
|
88
|
+
predicates = collect_predicate_methods(node.body)
|
|
89
|
+
yield full_name, predicates
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
inner_path = lexical_path + [class_local_name]
|
|
93
|
+
walk_for_policies(node.body, inner_path, &) if node.body
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def visit_module(node, lexical_path, &)
|
|
97
|
+
module_local_name = constant_path_name(node.constant_path)
|
|
98
|
+
return if module_local_name.nil?
|
|
99
|
+
|
|
100
|
+
inner_path = lexical_path + [module_local_name]
|
|
101
|
+
walk_for_policies(node.body, inner_path, &) if node.body
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def constant_path_name(node)
|
|
105
|
+
return nil if node.nil?
|
|
106
|
+
|
|
107
|
+
case node
|
|
108
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
109
|
+
when Prism::ConstantPathNode
|
|
110
|
+
parts = []
|
|
111
|
+
current = node
|
|
112
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
113
|
+
parts.unshift(current.name.to_s)
|
|
114
|
+
current = current.parent
|
|
115
|
+
end
|
|
116
|
+
case current
|
|
117
|
+
when nil then "::#{parts.join('::')}"
|
|
118
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns symbolic predicate names (`:update?`,
|
|
124
|
+
# `:show?`, …) defined on the policy. Only
|
|
125
|
+
# instance-side names that end in `?` are recorded.
|
|
126
|
+
def collect_predicate_methods(body)
|
|
127
|
+
return [] if body.nil?
|
|
128
|
+
|
|
129
|
+
body.compact_child_nodes.flat_map do |node|
|
|
130
|
+
next [] unless node.is_a?(Prism::DefNode)
|
|
131
|
+
next [] if node.receiver.is_a?(Prism::SelfNode)
|
|
132
|
+
next [] unless node.name.to_s.end_with?("?")
|
|
133
|
+
|
|
134
|
+
[node.name]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
class Pundit < Rigor::Plugin::Base
|
|
6
|
+
# Frozen catalogue of discovered Pundit policy classes
|
|
7
|
+
# keyed by policy class name (e.g. `"PostPolicy"`).
|
|
8
|
+
# Each entry tracks the set of predicate methods
|
|
9
|
+
# defined on the policy (instance-side `def name?`)
|
|
10
|
+
# plus the source file path.
|
|
11
|
+
#
|
|
12
|
+
# The analyzer maps a record's inferred type
|
|
13
|
+
# (`Nominal[Post]`) to the policy class name
|
|
14
|
+
# (`"PostPolicy"`) and looks up the predicate.
|
|
15
|
+
class PolicyIndex
|
|
16
|
+
Entry = Data.define(:policy_class_name, :file_path, :predicate_methods) do
|
|
17
|
+
def includes_method?(method_name)
|
|
18
|
+
predicate_methods.include?(normalize(method_name))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def known_methods
|
|
22
|
+
predicate_methods.to_a.sort
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Normalises an action symbol / string by ensuring
|
|
26
|
+
# a trailing `?`. `:update` and `:update?` both
|
|
27
|
+
# resolve to `update?`.
|
|
28
|
+
def normalize(name)
|
|
29
|
+
string = name.to_s
|
|
30
|
+
string.end_with?("?") ? string.to_sym : :"#{string}?"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :entries
|
|
35
|
+
|
|
36
|
+
def initialize(entries)
|
|
37
|
+
@entries = entries.freeze
|
|
38
|
+
@by_name = entries.to_h { |entry| [entry.policy_class_name, entry] }.freeze
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Entry, nil]
|
|
43
|
+
def find(policy_class_name)
|
|
44
|
+
@by_name[policy_class_name.to_s]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def known?(policy_class_name)
|
|
48
|
+
@by_name.key?(policy_class_name.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def empty?
|
|
52
|
+
@entries.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def size
|
|
56
|
+
@entries.size
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def names
|
|
60
|
+
@by_name.keys
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
require_relative "pundit/policy_index"
|
|
6
|
+
require_relative "pundit/policy_discoverer"
|
|
7
|
+
require_relative "pundit/analyzer"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Plugin
|
|
11
|
+
# rigor-pundit — validates Pundit `authorize` /
|
|
12
|
+
# `policy` / `policy_scope` calls against the project's
|
|
13
|
+
# `app/policies/` tree.
|
|
14
|
+
#
|
|
15
|
+
# Tier 3B of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
|
|
16
|
+
# Statically discovers policy classes by walking
|
|
17
|
+
# `policy_search_paths` and parsing each file with
|
|
18
|
+
# Prism — no `pundit` runtime dependency.
|
|
19
|
+
#
|
|
20
|
+
# ## Configuration
|
|
21
|
+
#
|
|
22
|
+
# plugins:
|
|
23
|
+
# - gem: rigor-pundit
|
|
24
|
+
# config:
|
|
25
|
+
# policy_search_paths: ["app/policies"] # default; optional
|
|
26
|
+
# policy_base_classes: ["ApplicationPolicy"] # default; optional
|
|
27
|
+
#
|
|
28
|
+
# ## What it checks
|
|
29
|
+
#
|
|
30
|
+
# 1. **Policy class existence** — `authorize(record, ...)`
|
|
31
|
+
# looks up `<inferred-type>Policy` in the index.
|
|
32
|
+
# Missing policies emit `unknown-policy-class` with
|
|
33
|
+
# a did-you-mean suggestion.
|
|
34
|
+
# 2. **Predicate method existence** — for the
|
|
35
|
+
# `authorize(record, :action)` form, validates that
|
|
36
|
+
# `<Policy>#<action>?` is defined. Missing methods
|
|
37
|
+
# emit `unknown-policy-method` listing the known
|
|
38
|
+
# predicates.
|
|
39
|
+
#
|
|
40
|
+
# ## Limitations (v0.1.0)
|
|
41
|
+
#
|
|
42
|
+
# - Records whose inferred type is NOT a `Nominal[T]`
|
|
43
|
+
# (untyped local variables, untyped instance
|
|
44
|
+
# variables) are silently passed through. The plugin
|
|
45
|
+
# only validates what it can prove from the static
|
|
46
|
+
# carrier.
|
|
47
|
+
# - The two-argument form
|
|
48
|
+
# `authorize(record, :action_symbol)` is the only
|
|
49
|
+
# one validated. The implicit form
|
|
50
|
+
# `authorize(record)` (which Pundit resolves at
|
|
51
|
+
# runtime against the controller's current action) is
|
|
52
|
+
# passed through with the policy-class check only.
|
|
53
|
+
# - Direct-superclass match for `policy_base_classes`.
|
|
54
|
+
# Indirect inheritance (`AdminPolicy <
|
|
55
|
+
# ApplicationPolicy`) needs `AdminPolicy` listed in
|
|
56
|
+
# `policy_base_classes` if subclasses inherit from
|
|
57
|
+
# it.
|
|
58
|
+
class Pundit < Rigor::Plugin::Base
|
|
59
|
+
manifest(
|
|
60
|
+
id: "pundit",
|
|
61
|
+
version: "0.1.0",
|
|
62
|
+
description: "Validates Pundit policy / authorize calls.",
|
|
63
|
+
config_schema: {
|
|
64
|
+
"policy_search_paths" => :array,
|
|
65
|
+
"policy_base_classes" => :array
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
DEFAULT_POLICY_SEARCH_PATHS = ["app/policies"].freeze
|
|
70
|
+
DEFAULT_POLICY_BASE_CLASSES = %w[ApplicationPolicy].freeze
|
|
71
|
+
|
|
72
|
+
producer :policy_index do |_params|
|
|
73
|
+
PolicyDiscoverer.new(
|
|
74
|
+
io_boundary: io_boundary,
|
|
75
|
+
search_paths: @policy_search_paths,
|
|
76
|
+
base_classes: @policy_base_classes
|
|
77
|
+
).discover
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def init(_services)
|
|
81
|
+
@policy_search_paths = Array(config.fetch("policy_search_paths", DEFAULT_POLICY_SEARCH_PATHS)).map(&:to_s)
|
|
82
|
+
@policy_base_classes = Array(config.fetch("policy_base_classes", DEFAULT_POLICY_BASE_CLASSES)).map(&:to_s)
|
|
83
|
+
@policy_index = nil
|
|
84
|
+
@load_error = nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def diagnostics_for_file(path:, scope:, root:)
|
|
88
|
+
index = policy_index_or_nil
|
|
89
|
+
return [load_error_diagnostic(path)] if index.nil? && @load_error
|
|
90
|
+
return [] if index.nil? || index.empty?
|
|
91
|
+
|
|
92
|
+
Analyzer.diagnose(
|
|
93
|
+
path: path,
|
|
94
|
+
root: root,
|
|
95
|
+
policy_index: index,
|
|
96
|
+
scope: scope
|
|
97
|
+
).map { |diag| build_diagnostic(diag) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def policy_index_or_nil
|
|
103
|
+
return @policy_index if @policy_index
|
|
104
|
+
|
|
105
|
+
@policy_index = cache_for(:policy_index, params: {}).call
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
@load_error = "rigor-pundit: failed to discover policies: #{e.class}: #{e.message}"
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def load_error_diagnostic(path)
|
|
112
|
+
Rigor::Analysis::Diagnostic.new(
|
|
113
|
+
path: path, line: 1, column: 1,
|
|
114
|
+
message: @load_error,
|
|
115
|
+
severity: :warning,
|
|
116
|
+
rule: "load-error"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_diagnostic(diag)
|
|
121
|
+
Rigor::Analysis::Diagnostic.new(
|
|
122
|
+
path: diag.path, line: diag.line, column: diag.column,
|
|
123
|
+
message: diag.message, severity: diag.severity, rule: diag.rule
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
Rigor::Plugin.register(Pundit)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Convenience entry point. `require "rigor-rails"` requires
|
|
4
|
+
# every Tier 1+2 Rails ecosystem plugin in one go, so projects
|
|
5
|
+
# that prefer a single require statement (some `spec_helper`
|
|
6
|
+
# patterns, ad-hoc scripts) do not have to list seven require
|
|
7
|
+
# lines.
|
|
8
|
+
#
|
|
9
|
+
# Note: requiring this entry point does NOT mark every plugin as
|
|
10
|
+
# active. The Rigor plugin loader walks `.rigor.yml`'s `plugins:`
|
|
11
|
+
# list and instantiates only the plugins enumerated there. This
|
|
12
|
+
# is per ADR-12 WD1's "Gemfile-convenience meta-gem" pattern —
|
|
13
|
+
# users still control which plugins participate in analysis via
|
|
14
|
+
# `.rigor.yml`.
|
|
15
|
+
#
|
|
16
|
+
# Sub-plugins ARE registered with `Rigor::Plugin` when this file
|
|
17
|
+
# loads (each gem's entry point side-effects a `Plugin.register`
|
|
18
|
+
# call); the loader's lookup phase finds them by id when listed.
|
|
19
|
+
#
|
|
20
|
+
# Adding the gem to a project's Gemfile without listing any
|
|
21
|
+
# plugin in `.rigor.yml` is harmless: the requires happen on
|
|
22
|
+
# `Bundler.require`, but no plugin's `init` / `prepare` / hooks
|
|
23
|
+
# run.
|
|
24
|
+
|
|
25
|
+
require "rigor-rails-routes"
|
|
26
|
+
require "rigor-rails-i18n"
|
|
27
|
+
require "rigor-actionmailer"
|
|
28
|
+
require "rigor-activejob"
|
|
29
|
+
require "rigor-activerecord"
|
|
30
|
+
require "rigor-actionpack"
|
|
31
|
+
require "rigor-factorybot"
|