rigortype 0.3.2 → 0.3.4
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/data/builtins/ruby_core/array.yml +11 -24
- data/data/builtins/ruby_core/enumerable.yml +4 -8
- data/data/builtins/ruby_core/exception.yml +6 -9
- data/data/builtins/ruby_core/hash.yml +13 -20
- data/data/builtins/ruby_core/io.yml +6 -9
- data/data/builtins/ruby_core/numeric.yml +2 -3
- data/data/builtins/ruby_core/proc.yml +4 -8
- data/data/builtins/ruby_core/re.yml +6 -9
- data/data/builtins/ruby_core/set.yml +2 -3
- data/data/builtins/ruby_core/string.yml +4 -7
- data/data/builtins/ruby_core/struct.yml +0 -6
- data/data/builtins/ruby_core/time.yml +3 -6
- data/data/effects/core.yml +843 -0
- data/data/effects/registry.yml +67 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +311 -1
- data/docs/manual/03-configuration.md +84 -1
- data/docs/manual/04-diagnostics.md +14 -2
- data/docs/manual/16-rbs-extended-annotations.md +77 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/plugins/rigor-actionmailer.md +5 -0
- data/docs/manual/plugins/rigor-activejob.md +47 -0
- data/docs/manual/plugins/rigor-factorybot.md +16 -0
- data/docs/manual/plugins/rigor-pundit.md +26 -0
- data/docs/manual/plugins/rigor-rails-routes.md +41 -2
- data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
- data/docs/manual/plugins/rigor-rspec.md +8 -0
- data/docs/manual/plugins/rigor-sidekiq.md +54 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +62 -22
- data/lib/rigor/analysis/incremental_session.rb +140 -6
- data/lib/rigor/analysis/reachability/graph.rb +244 -0
- data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
- data/lib/rigor/analysis/reachability/project_files.rb +58 -0
- data/lib/rigor/analysis/reachability/scan.rb +241 -0
- data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
- data/lib/rigor/analysis/rule_catalog.rb +163 -1
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
- data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
- data/lib/rigor/analysis/runner.rb +420 -10
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/bleeding_edge.rb +22 -0
- data/lib/rigor/cache/incremental_snapshot.rb +24 -5
- data/lib/rigor/cli/check_command.rb +13 -1
- data/lib/rigor/cli/check_runner_factory.rb +4 -1
- data/lib/rigor/cli/effects_command.rb +131 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +54 -0
- data/lib/rigor/cli/effects_report.rb +53 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +16 -0
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +296 -14
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +182 -0
- data/lib/rigor/effects/config_envelopes.rb +185 -0
- data/lib/rigor/effects/discharge.rb +69 -0
- data/lib/rigor/effects/effect_table.rb +92 -0
- data/lib/rigor/effects/entry_points.rb +93 -0
- data/lib/rigor/effects/envelope.rb +86 -0
- data/lib/rigor/effects/envelope_check.rb +172 -0
- data/lib/rigor/effects/envelope_index.rb +157 -0
- data/lib/rigor/effects/file_collection.rb +160 -0
- data/lib/rigor/effects/framework_units.rb +167 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/label.rb +77 -0
- data/lib/rigor/effects/label_intent.rb +73 -0
- data/lib/rigor/effects/label_set.rb +136 -0
- data/lib/rigor/effects/liskov_check.rb +167 -0
- data/lib/rigor/effects/local_ownership.rb +132 -0
- data/lib/rigor/effects/method_key.rb +40 -0
- data/lib/rigor/effects/mutation_classifier.rb +92 -0
- data/lib/rigor/effects/narrowing.rb +202 -0
- data/lib/rigor/effects/origin.rb +66 -0
- data/lib/rigor/effects/path_finder.rb +89 -0
- data/lib/rigor/effects/plugin_facts.rb +335 -0
- data/lib/rigor/effects/propagator.rb +275 -0
- data/lib/rigor/effects/registry.rb +193 -0
- data/lib/rigor/effects/scanner.rb +290 -0
- data/lib/rigor/effects/signature_sources.rb +74 -0
- data/lib/rigor/effects/snapshot.rb +380 -0
- data/lib/rigor/effects/snapshot_diff.rb +265 -0
- data/lib/rigor/effects/summary.rb +154 -0
- data/lib/rigor/effects/taint_cause.rb +38 -0
- data/lib/rigor/effects/unit_scan.rb +572 -0
- data/lib/rigor/effects/unknown_label_check.rb +86 -0
- data/lib/rigor/effects/unknown_label_report.rb +59 -0
- data/lib/rigor/effects.rb +47 -0
- data/lib/rigor/environment/rbs_loader.rb +34 -0
- data/lib/rigor/environment.rb +8 -1
- data/lib/rigor/flow_contribution/element.rb +1 -0
- data/lib/rigor/flow_contribution/merge_result.rb +5 -3
- data/lib/rigor/flow_contribution/merger.rb +16 -1
- data/lib/rigor/flow_contribution.rb +20 -4
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +74 -17
- data/lib/rigor/inference/fork_map.rb +45 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/optimistic_origin.rb +68 -3
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +134 -31
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +25 -12
- data/lib/rigor/plugin/base.rb +24 -0
- data/lib/rigor/plugin/effect_attribution.rb +208 -0
- data/lib/rigor/plugin/effect_edge.rb +101 -0
- data/lib/rigor/plugin/effect_entry_points.rb +51 -0
- data/lib/rigor/plugin/first_party.rb +57 -0
- data/lib/rigor/plugin/isolation.rb +4 -1
- data/lib/rigor/plugin/manifest.rb +103 -3
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +168 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/scope.rb +62 -2
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
- data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
- data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
- data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/rbs_extended.rbs +6 -0
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor.rbs +21 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +83 -1
|
@@ -58,7 +58,8 @@ module Rigor
|
|
|
58
58
|
class Factorybot < Rigor::Plugin::Base
|
|
59
59
|
manifest(
|
|
60
60
|
id: "factorybot",
|
|
61
|
-
|
|
61
|
+
# Bumped 2026-08-16 — publishes `:reachability_references` for `rigor unused` (ADR-102 WD3 / #350).
|
|
62
|
+
version: "0.3.0",
|
|
62
63
|
description: "Validates FactoryBot.create / build / attributes_for call shapes; " \
|
|
63
64
|
"publishes per-factory attribute set + inferred model class as the " \
|
|
64
65
|
":factory_index ADR-9 fact (Pillar 2 Slice 3).",
|
|
@@ -67,7 +68,8 @@ module Rigor
|
|
|
67
68
|
},
|
|
68
69
|
consumes: [
|
|
69
70
|
{ plugin_id: "activerecord", name: :model_index, optional: true }
|
|
70
|
-
]
|
|
71
|
+
],
|
|
72
|
+
produces: [:reachability_references]
|
|
71
73
|
)
|
|
72
74
|
|
|
73
75
|
producer :factory_index, watch: -> { [[@factory_search_paths, "**/*.rb"]] } do |_params|
|
|
@@ -81,6 +83,32 @@ module Rigor
|
|
|
81
83
|
@factory_search_paths = Array(config.fetch("factory_search_paths")).map(&:to_s)
|
|
82
84
|
end
|
|
83
85
|
|
|
86
|
+
# ADR-102 WD3 / #350 — the model classes the factories build, published as `:reachability_references`
|
|
87
|
+
# rather than as `:reachability_roots`.
|
|
88
|
+
#
|
|
89
|
+
# Both halves of that sentence are load-bearing.
|
|
90
|
+
#
|
|
91
|
+
# It is a CONTRIBUTION at all because a factory names its class by a mechanism the constant scan cannot
|
|
92
|
+
# follow: `factory :user, class: "Admin::User"` is a string, and a bare `factory :user` is FactoryBot's
|
|
93
|
+
# own constantization of the factory name. Neither leaves a constant node anywhere in the project.
|
|
94
|
+
#
|
|
95
|
+
# It is a REFERENCE rather than a root because factories live in the test tree. Rooting them would make
|
|
96
|
+
# every factoried class production-reachable and erase ADR-102 WD8's "reachable only from tests"
|
|
97
|
+
# category for exactly the classes it is most likely to be about — a model kept alive by its factory
|
|
98
|
+
# and its spec and nothing else is the archetype of dead production code with a live test, and that is
|
|
99
|
+
# the single most actionable row this report produces. Carrying the `:test` role keeps the finding.
|
|
100
|
+
def prepare(services)
|
|
101
|
+
index = producer_value(:factory_index)
|
|
102
|
+
return if index.nil? || index.empty?
|
|
103
|
+
|
|
104
|
+
references = index.entries.values.filter_map do |entry|
|
|
105
|
+
{ name: entry.model_class, role: :test } if entry.model_class
|
|
106
|
+
end.uniq
|
|
107
|
+
return if references.empty?
|
|
108
|
+
|
|
109
|
+
services.fact_store.publish(plugin_id: manifest.id, name: :reachability_references, value: references)
|
|
110
|
+
end
|
|
111
|
+
|
|
84
112
|
# ADR-37 — per-call factory/attribute validation over the engine-owned walk. Each violation carries
|
|
85
113
|
# its own location (the call's message_loc, or the offending attribute key), so it is positioned
|
|
86
114
|
# via `diagnostic(node, location:)`. No file-level diagnostic remains, so there is no
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/source/node_children"
|
|
4
|
+
|
|
5
|
+
require "prism"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class Pundit < Rigor::Plugin::Base
|
|
10
|
+
# ADR-102 WD3 / #350 — which policy classes does this project's code actually *reach*?
|
|
11
|
+
#
|
|
12
|
+
# A Pundit policy is the textbook case for a plugin-supplied reachability root: `authorize @post` runs
|
|
13
|
+
# `PostPolicy#update?`, and the string `PostPolicy` appears nowhere in the application. A reference
|
|
14
|
+
# index therefore sees a live policy exactly as it sees a dead one, and `rigor unused` reports every
|
|
15
|
+
# policy in the project as a candidate.
|
|
16
|
+
#
|
|
17
|
+
# **What this deliberately does NOT do is publish every class under `policy_search_paths`.** "A file
|
|
18
|
+
# exists under `app/policies`" is not evidence that anything authorizes against it — it is the same
|
|
19
|
+
# non-argument as "a file exists under `app/workers`". Rooting the whole directory would answer the
|
|
20
|
+
# question by refusing to ask it, and an over-claiming root source silently hides real dead code
|
|
21
|
+
# (ADR-102 § Consequences). So this walks the call sites instead and derives the policy each one names.
|
|
22
|
+
#
|
|
23
|
+
# Two derivations, both grounded in Pundit's own `PolicyFinder`, which builds the policy name from the
|
|
24
|
+
# record's class:
|
|
25
|
+
#
|
|
26
|
+
# - a constant argument — `authorize Post`, `policy_scope(Post.all)` — names `PostPolicy` exactly;
|
|
27
|
+
# - a receiverless name — `authorize @post` / `authorize post` — is camelized, because the Rails
|
|
28
|
+
# convention that names the carrier after the record is the same convention Pundit's lookup assumes.
|
|
29
|
+
#
|
|
30
|
+
# The camelization is a plain underscore split rather than `Rigor::Plugin::Inflector` (ADR-39), and the
|
|
31
|
+
# reason it may be: the caller intersects every derived name with the policies {PolicyDiscoverer}
|
|
32
|
+
# actually found, so a name this scan gets wrong matches nothing and is DROPPED. Inflection error can
|
|
33
|
+
# only cost coverage here, never manufacture a root — which is the opposite of the direction ADR-39's
|
|
34
|
+
# no-approximation rule is defending, where a wrong inflection becomes a wrong diagnostic.
|
|
35
|
+
class AuthorizationScan
|
|
36
|
+
# Pundit's entry points. `authorize` resolves and calls a predicate; `policy` and `policy_scope`
|
|
37
|
+
# resolve without calling one. All three reach the policy class, which is what a root records.
|
|
38
|
+
ENTRY_METHODS = %i[authorize policy policy_scope].freeze
|
|
39
|
+
|
|
40
|
+
def initialize(io_boundary:, search_paths:)
|
|
41
|
+
@io_boundary = io_boundary
|
|
42
|
+
@search_paths = search_paths
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Array<String>] policy class names named by an authorization call, sorted and unique. Not
|
|
46
|
+
# yet intersected with the discovered policies — the caller does that.
|
|
47
|
+
def policy_names
|
|
48
|
+
names = Set.new
|
|
49
|
+
ruby_files_under(@search_paths).each do |path|
|
|
50
|
+
contents = read_safely(path)
|
|
51
|
+
next if contents.nil?
|
|
52
|
+
|
|
53
|
+
parsed = Prism.parse(contents)
|
|
54
|
+
next unless parsed.success?
|
|
55
|
+
|
|
56
|
+
walk(parsed.value, names)
|
|
57
|
+
end
|
|
58
|
+
names.to_a.sort
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def read_safely(path)
|
|
64
|
+
@io_boundary.read_file(path)
|
|
65
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ruby_files_under(roots)
|
|
70
|
+
roots.flat_map do |root|
|
|
71
|
+
absolute = File.expand_path(root)
|
|
72
|
+
next [] unless File.directory?(absolute)
|
|
73
|
+
|
|
74
|
+
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def walk(node, names)
|
|
79
|
+
return unless node.is_a?(Prism::Node)
|
|
80
|
+
|
|
81
|
+
record_call(node, names) if node.is_a?(Prism::CallNode)
|
|
82
|
+
node.rigor_each_child { |child| walk(child, names) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def record_call(node, names)
|
|
86
|
+
return unless ENTRY_METHODS.include?(node.name) && node.receiver.nil?
|
|
87
|
+
|
|
88
|
+
record = node.arguments&.arguments&.first
|
|
89
|
+
return if record.nil?
|
|
90
|
+
|
|
91
|
+
name = policy_name_for(record)
|
|
92
|
+
names << name if name
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [String, nil] the policy class the argument names, or nil when the argument is an
|
|
96
|
+
# expression this reading cannot attribute to a record (a method call with arguments, a literal, an
|
|
97
|
+
# index read). Silence, not a guess: an unattributable call contributes nothing.
|
|
98
|
+
def policy_name_for(node)
|
|
99
|
+
record = record_name_for(node)
|
|
100
|
+
record && "#{record}Policy"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def record_name_for(node)
|
|
104
|
+
case node
|
|
105
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then constant_name(node)
|
|
106
|
+
when Prism::InstanceVariableReadNode then camelize(node.name.to_s.delete_prefix("@"))
|
|
107
|
+
when Prism::LocalVariableReadNode then camelize(node.name.to_s)
|
|
108
|
+
when Prism::CallNode then call_record_name(node)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `policy_scope(Post.all)` scopes `PostPolicy::Scope`, so the receiver constant is the record. A
|
|
113
|
+
# receiverless bare name (`authorize post`) is the local-variable case Prism parses as a call when no
|
|
114
|
+
# assignment made it a local — the memoised `def post` helper every Rails controller has.
|
|
115
|
+
def call_record_name(node)
|
|
116
|
+
return constant_name(node.receiver) if node.receiver.is_a?(Prism::ConstantReadNode) ||
|
|
117
|
+
node.receiver.is_a?(Prism::ConstantPathNode)
|
|
118
|
+
return nil unless node.receiver.nil? && node.arguments.nil? && node.block.nil?
|
|
119
|
+
|
|
120
|
+
camelize(node.name.to_s)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def camelize(token)
|
|
124
|
+
return nil if token.empty? || !/\A[a-z_][a-z0-9_]*\z/.match?(token)
|
|
125
|
+
|
|
126
|
+
token.split("_").reject(&:empty?).map { |part| part[0].upcase + part[1..].to_s }.join
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def constant_name(node)
|
|
130
|
+
case node
|
|
131
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
132
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def constant_path_name(node)
|
|
137
|
+
parts = []
|
|
138
|
+
current = node
|
|
139
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
140
|
+
parts.unshift(current.name.to_s)
|
|
141
|
+
current = current.parent
|
|
142
|
+
end
|
|
143
|
+
case current
|
|
144
|
+
when nil then parts.join("::")
|
|
145
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -4,6 +4,7 @@ require "rigor/plugin"
|
|
|
4
4
|
|
|
5
5
|
require_relative "pundit/policy_index"
|
|
6
6
|
require_relative "pundit/policy_discoverer"
|
|
7
|
+
require_relative "pundit/authorization_scan"
|
|
7
8
|
require_relative "pundit/analyzer"
|
|
8
9
|
|
|
9
10
|
module Rigor
|
|
@@ -22,6 +23,7 @@ module Rigor
|
|
|
22
23
|
# config:
|
|
23
24
|
# policy_search_paths: ["app/policies"] # default; optional
|
|
24
25
|
# policy_base_classes: ["ApplicationPolicy"] # default; optional
|
|
26
|
+
# authorization_call_paths: ["app/controllers"] # default; optional
|
|
25
27
|
#
|
|
26
28
|
# ## What it checks
|
|
27
29
|
#
|
|
@@ -44,12 +46,22 @@ module Rigor
|
|
|
44
46
|
class Pundit < Rigor::Plugin::Base
|
|
45
47
|
manifest(
|
|
46
48
|
id: "pundit",
|
|
47
|
-
|
|
49
|
+
# Bumped 2026-08-16 — publishes `:reachability_roots` for `rigor unused` (ADR-102 WD3): the policy
|
|
50
|
+
# classes the project's own `authorize` / `policy` / `policy_scope` calls name by convention.
|
|
51
|
+
version: "0.2.0",
|
|
48
52
|
description: "Validates Pundit policy / authorize calls.",
|
|
49
53
|
config_schema: {
|
|
50
54
|
"policy_search_paths" => { kind: :array, default: ["app/policies"] },
|
|
51
|
-
"policy_base_classes" => { kind: :array, default: %w[ApplicationPolicy] }
|
|
52
|
-
|
|
55
|
+
"policy_base_classes" => { kind: :array, default: %w[ApplicationPolicy] },
|
|
56
|
+
# `authorization_call_paths` — where {AuthorizationScan} looks for the `authorize` / `policy` /
|
|
57
|
+
# `policy_scope` calls that ground the reachability roots. Deliberately narrower than the whole
|
|
58
|
+
# `app/` tree: controllers are where Pundit's entry points overwhelmingly live, the walk is a Prism
|
|
59
|
+
# parse per file on every run, and a project that also authorizes from `app/graphql` or
|
|
60
|
+
# `app/services` widens the list itself. Under-supply is the safe direction here — a policy this
|
|
61
|
+
# never sees stays in the report as a candidate, which is a visible row rather than a silent one.
|
|
62
|
+
"authorization_call_paths" => { kind: :array, default: ["app/controllers"] }
|
|
63
|
+
},
|
|
64
|
+
produces: [:reachability_roots]
|
|
53
65
|
)
|
|
54
66
|
|
|
55
67
|
producer :policy_index, watch: -> { [[@policy_search_paths, "**/*.rb"]] } do |_params|
|
|
@@ -60,9 +72,40 @@ module Rigor
|
|
|
60
72
|
).discover
|
|
61
73
|
end
|
|
62
74
|
|
|
75
|
+
# Cached separately from `:policy_index` because the two invalidate on different trees: adding a
|
|
76
|
+
# controller changes which policies are reached without touching `app/policies` at all.
|
|
77
|
+
producer :authorized_policies, watch: -> { [[@authorization_call_paths, "**/*.rb"]] } do |_params|
|
|
78
|
+
AuthorizationScan.new(
|
|
79
|
+
io_boundary: io_boundary,
|
|
80
|
+
search_paths: @authorization_call_paths
|
|
81
|
+
).policy_names
|
|
82
|
+
end
|
|
83
|
+
|
|
63
84
|
def init(_services)
|
|
64
85
|
@policy_search_paths = Array(config.fetch("policy_search_paths")).map(&:to_s)
|
|
65
86
|
@policy_base_classes = Array(config.fetch("policy_base_classes")).map(&:to_s)
|
|
87
|
+
@authorization_call_paths = Array(config.fetch("authorization_call_paths")).map(&:to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ADR-102 WD3 — publishes the policies the project actually authorizes against, for `rigor unused`.
|
|
91
|
+
#
|
|
92
|
+
# The intersection is the whole design. {AuthorizationScan} says which policy names the code NAMES;
|
|
93
|
+
# {PolicyDiscoverer} says which policy classes EXIST. A root is published only where both agree, so
|
|
94
|
+
# neither an unrecognised call shape nor a mis-camelized record name can root a policy nothing reaches
|
|
95
|
+
# — the failure mode is a missing root, which shows up in the report as a candidate a human can judge,
|
|
96
|
+
# rather than a spurious root, which shows up as nothing at all.
|
|
97
|
+
#
|
|
98
|
+
# Publishing every class under `policy_search_paths` would have been one line and is exactly what this
|
|
99
|
+
# refuses to do: it would report a genuinely orphaned policy as reachable forever.
|
|
100
|
+
def prepare(services)
|
|
101
|
+
index = producer_value(:policy_index)
|
|
102
|
+
named = producer_value(:authorized_policies)
|
|
103
|
+
return if index.nil? || named.nil?
|
|
104
|
+
|
|
105
|
+
roots = named.select { |name| index.known?(name) }
|
|
106
|
+
return if roots.empty?
|
|
107
|
+
|
|
108
|
+
services.fact_store.publish(plugin_id: manifest.id, name: :reachability_roots, value: roots)
|
|
66
109
|
end
|
|
67
110
|
|
|
68
111
|
# File-level only: the load-error emission. The per-call policy validation runs over the engine-owned
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
# whose `target_gems:` are actually in the project's `Gemfile.lock`. If that is rejected, ADR-60 WD1's
|
|
15
15
|
# never-wired-surface criterion applies and this directory goes.
|
|
16
16
|
|
|
17
|
+
require "rigor-railties"
|
|
17
18
|
require "rigor-rails-routes"
|
|
18
19
|
require "rigor-rails-i18n"
|
|
19
20
|
require "rigor-actionmailer"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class RailsI18n < Rigor::Plugin::Base
|
|
8
|
+
# rigor-rails-i18n's effect contract (ADR-103 WD10; design note § 11.2; issue #387).
|
|
9
|
+
#
|
|
10
|
+
# `I18n.t` is not pure, and the reason is worth stating because it looks pure: it reads
|
|
11
|
+
# `I18n.locale`, which is per-fiber mutable process state, and on first use it loads the backend's
|
|
12
|
+
# translations. So a translation lookup is `global.read` — which is exactly the kind of label a
|
|
13
|
+
# project puts in `tolerated:` and then stops thinking about, and exactly why the *meaning* label
|
|
14
|
+
# matters more than the transport here. `rails.i18n.translate` is what a presenter envelope names
|
|
15
|
+
# when it wants to permit translation and nothing else.
|
|
16
|
+
#
|
|
17
|
+
# `I18n.locale=` is a `global.write`, and that one is worth surfacing: a presenter that changes the
|
|
18
|
+
# locale changes it for everything downstream in the request.
|
|
19
|
+
module Effects
|
|
20
|
+
I18N = "I18n"
|
|
21
|
+
READ = ["global.read", "rails.i18n.translate"].freeze
|
|
22
|
+
WRITE = ["global.write"].freeze
|
|
23
|
+
|
|
24
|
+
LOOKUPS = %w[t t! translate translate! l localize].freeze
|
|
25
|
+
WRITERS = %w[locale= default_locale= backend= load_path= with_locale].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def attributions
|
|
30
|
+
LOOKUPS.map do |selector|
|
|
31
|
+
EffectAttribution.new(
|
|
32
|
+
receiver: I18N, method: selector, labels: READ, singleton: true, discharge: true,
|
|
33
|
+
why: "reads `I18n.locale` (per-fiber mutable process state) and lazily loads the backend's " \
|
|
34
|
+
"translations; `rails.i18n.translate` is the meaning a presenter envelope permits"
|
|
35
|
+
)
|
|
36
|
+
end +
|
|
37
|
+
WRITERS.map do |selector|
|
|
38
|
+
EffectAttribution.new(
|
|
39
|
+
receiver: I18N, method: selector, labels: WRITE, singleton: true, discharge: true,
|
|
40
|
+
why: "changes process- or fiber-wide translation state, which every later lookup reads"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -5,6 +5,7 @@ require "rigor/plugin"
|
|
|
5
5
|
require_relative "rails_i18n/locale_index"
|
|
6
6
|
require_relative "rails_i18n/locale_loader"
|
|
7
7
|
require_relative "rails_i18n/analyzer"
|
|
8
|
+
require_relative "rails_i18n/effects"
|
|
8
9
|
|
|
9
10
|
module Rigor
|
|
10
11
|
module Plugin
|
|
@@ -64,7 +65,12 @@ module Rigor
|
|
|
64
65
|
"locale_search_paths" => { kind: :array, default: ["config/locales"] },
|
|
65
66
|
"configured_locales" => { kind: :array, default: ["en"] },
|
|
66
67
|
"view_search_paths" => { kind: :array, default: ["app/views"] }
|
|
67
|
-
}
|
|
68
|
+
},
|
|
69
|
+
# ADR-103 WD10 (#387) — see {Effects}. `rails.i18n.translate` is registered here because this is
|
|
70
|
+
# the plugin that models I18n, even though the label reads like a whole-framework one.
|
|
71
|
+
effect_root: "rails",
|
|
72
|
+
effect_labels: ["rails.i18n.translate"],
|
|
73
|
+
effect_attributions: Effects.attributions
|
|
68
74
|
)
|
|
69
75
|
|
|
70
76
|
# `watch:` covers every `.yml` / `.yaml` file under the locale search paths so the cache invalidates
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require "rigor/source/literals"
|
|
6
|
+
require "rigor/source/node_children"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Plugin
|
|
10
|
+
class RailsRoutes < Rigor::Plugin::Base
|
|
11
|
+
# The project's own inflector acronyms (`inflect.acronym 'ActivityPub'`), read STATICALLY out of
|
|
12
|
+
# `config/initializers/inflections.rb` — never executed, in keeping with ADR-39's note that
|
|
13
|
+
# project-specific inflection rules are ingested by parsing that file rather than by running it.
|
|
14
|
+
#
|
|
15
|
+
# Why the controller-name composition needs them: `scope module: :activitypub` names
|
|
16
|
+
# `ActivityPub::CollectionsController` in a project that registers the acronym, and
|
|
17
|
+
# `Activitypub::CollectionsController` in one that does not. Rigor loads its own
|
|
18
|
+
# `ActiveSupport::Inflector`, not the analysed application's, so the acronym table is empty there and
|
|
19
|
+
# the plain camelization is the wrong name. On Mastodon that is 19 of 280 emitted roots — inert (they
|
|
20
|
+
# match no declaration), but 19 live controllers left unrooted and therefore reported as dead.
|
|
21
|
+
#
|
|
22
|
+
# The correction is a RENAME, never an addition: applying it cannot grow the root set, so it cannot
|
|
23
|
+
# move the report in the over-supply direction ADR-102 § Consequences warns about.
|
|
24
|
+
module Acronyms
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# @param contents [String, nil] the source of `config/initializers/inflections.rb`.
|
|
28
|
+
# @return [Array<String>] declared acronyms in declaration order. Empty for a missing / unparseable
|
|
29
|
+
# file, or one that declares none — which leaves composition exactly as it was.
|
|
30
|
+
def discover(contents)
|
|
31
|
+
return [] if contents.nil? || contents.empty?
|
|
32
|
+
|
|
33
|
+
result = Prism.parse(contents)
|
|
34
|
+
return [] unless result.success?
|
|
35
|
+
|
|
36
|
+
found = []
|
|
37
|
+
walk(result.value) do |node|
|
|
38
|
+
next unless node.is_a?(Prism::CallNode) && node.name == :acronym
|
|
39
|
+
|
|
40
|
+
value = Rigor::Source::Literals.symbol_or_string_name(node.arguments&.arguments&.first)
|
|
41
|
+
found << value if value && !value.empty?
|
|
42
|
+
end
|
|
43
|
+
found.uniq
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Rewrites the camel-case words of an already-camelized name into their acronym spelling.
|
|
47
|
+
#
|
|
48
|
+
# Operating on the camelized RESULT rather than re-implementing camelization keeps
|
|
49
|
+
# `ActiveSupport::Inflector` the authority (ADR-39 forbids a local approximation): the only thing
|
|
50
|
+
# done here is substituting one spelling of a word for another. A word matches when the plain
|
|
51
|
+
# camelization of the acronym appears at a camel-word boundary — `Oauth` in `OauthMetadata` and in
|
|
52
|
+
# `WellKnown::Oauth`, but never the `Oauth` inside a hypothetical `Xoauth`.
|
|
53
|
+
#
|
|
54
|
+
# @param name [String] e.g. `"Activitypub::CollectionsController"`.
|
|
55
|
+
# @param acronyms [Enumerable<String>] as returned by {.discover}.
|
|
56
|
+
# @return [String] e.g. `"ActivityPub::CollectionsController"`.
|
|
57
|
+
def apply(name, acronyms)
|
|
58
|
+
acronyms.reduce(name) do |current, acronym|
|
|
59
|
+
plain = Rigor::Plugin::Inflector.camelize(acronym.to_s.downcase)
|
|
60
|
+
next current if plain == acronym.to_s
|
|
61
|
+
|
|
62
|
+
current.gsub(boundary_pattern(plain), acronym.to_s)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A camel word starts at the beginning, after a `::` separator, or after the lowercase / digit tail
|
|
67
|
+
# of the preceding word; it ends at the end, before a `::`, or before the next word's capital.
|
|
68
|
+
def boundary_pattern(plain)
|
|
69
|
+
/(?<=\A|::|[a-z0-9])#{Regexp.escape(plain)}(?=::|[A-Z]|\z)/
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def walk(node, &)
|
|
73
|
+
return unless node.is_a?(Prism::Node)
|
|
74
|
+
|
|
75
|
+
yield node
|
|
76
|
+
node.rigor_each_child { |child| walk(child, &) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -40,7 +40,11 @@ module Rigor
|
|
|
40
40
|
# (`"api_v4"`), composed by {GrapeApiDiscoverer} from the project's own `prefix` / `version`
|
|
41
41
|
# declarations. The names beyond the prefix come from grape's runtime route table and cannot be
|
|
42
42
|
# enumerated statically, so the namespace is open — same contract as the OmniAuth family above.
|
|
43
|
-
|
|
43
|
+
# @param controllers [Enumerable<String>] fully-qualified controller class names the routes file
|
|
44
|
+
# dispatches to (`"Admin::UsersController"`). Published as the plugin's `:reachability_roots` fact
|
|
45
|
+
# (ADR-102 WD3) — a controller is an entry point nothing in the project references, so without this
|
|
46
|
+
# every routed controller reads as unused. Deliberately NOT consulted by any helper-name check.
|
|
47
|
+
def initialize(entries, custom_helpers: [], devise_resources: [], grape_prefixes: [], controllers: [])
|
|
44
48
|
@entries = entries.freeze
|
|
45
49
|
# Multimap: a single helper name can map to multiple entries when an uncountable-noun resource
|
|
46
50
|
# registers both an arity-0 index helper and an arity-1 show helper under the same `news_path`
|
|
@@ -50,9 +54,17 @@ module Rigor
|
|
|
50
54
|
@custom_helpers = custom_helpers.to_set.freeze
|
|
51
55
|
@devise_resources = devise_resources.to_set(&:to_s).freeze
|
|
52
56
|
@grape_prefixes = grape_prefixes.to_set(&:to_s).freeze
|
|
57
|
+
@controllers = controllers.map(&:to_s).uniq.sort.freeze
|
|
53
58
|
freeze
|
|
54
59
|
end
|
|
55
60
|
|
|
61
|
+
# @return [Array<String>] the routed controller class names (see the `controllers:` parameter). Reads
|
|
62
|
+
# through a nil guard rather than `attr_reader` so a table deserialised from a cache slot written
|
|
63
|
+
# before this field existed answers `[]` instead of `nil`.
|
|
64
|
+
def controllers
|
|
65
|
+
@controllers || []
|
|
66
|
+
end
|
|
67
|
+
|
|
56
68
|
# @return [Entry, nil] First matching entry; for the uncountable-noun case this is the index helper
|
|
57
69
|
# (the show helper is also registered but starts second).
|
|
58
70
|
def find(helper_name)
|