rigortype 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/pundit"
@@ -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"