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.
Files changed (179) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -58,7 +58,8 @@ module Rigor
58
58
  class Factorybot < Rigor::Plugin::Base
59
59
  manifest(
60
60
  id: "factorybot",
61
- version: "0.2.0",
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
- version: "0.1.0",
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
- def initialize(entries, custom_helpers: [], devise_resources: [], grape_prefixes: [])
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)