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
@@ -5,6 +5,7 @@ require "rigor/source/node_children"
5
5
  require "prism"
6
6
  require "rigor/source/literals"
7
7
 
8
+ require_relative "acronyms"
8
9
  require_relative "helper_table"
9
10
 
10
11
  module Rigor
@@ -58,13 +59,13 @@ module Rigor
58
59
  # draw partial from `config/routes/name.rb`. Returns file contents
59
60
  # or nil when the file is absent.
60
61
  # @return [HelperTable]
61
- def parse(contents, file_reader: nil, custom_helpers: [], grape_prefixes: [])
62
+ def parse(contents, file_reader: nil, custom_helpers: [], grape_prefixes: [], acronyms: [])
62
63
  parse_result = Prism.parse(contents)
63
64
  unless parse_result.errors.empty?
64
65
  return HelperTable.new([], custom_helpers: custom_helpers, grape_prefixes: grape_prefixes)
65
66
  end
66
67
 
67
- context = Context.new(file_reader: file_reader)
68
+ context = Context.new(file_reader: file_reader, acronyms: acronyms)
68
69
  interpret(parse_result.value, context)
69
70
 
70
71
  # Apply name-transform alias rules discovered during the walk (the `direct(name.sub(X, Y)) do
@@ -85,7 +86,8 @@ module Rigor
85
86
  ]
86
87
  end
87
88
  HelperTable.new(paired, custom_helpers: custom_helpers, devise_resources: context.devise_resources,
88
- grape_prefixes: grape_prefixes)
89
+ grape_prefixes: grape_prefixes,
90
+ controllers: context.controller_class_names)
89
91
  end
90
92
 
91
93
  # For every registered alias rule `(from_str, to_str, arity_delta)`, find every existing entry whose
@@ -127,9 +129,10 @@ module Rigor
127
129
  class Context
128
130
  attr_reader :entries, :file_reader, :devise_resources, :alias_rules
129
131
 
130
- def initialize(file_reader: nil)
132
+ def initialize(file_reader: nil, acronyms: [])
131
133
  @entries = []
132
134
  @file_reader = file_reader
135
+ @acronyms = acronyms
133
136
  # Stack of prefix segments. Each entry is one of:
134
137
  # - `{ kind: :namespace, name: "admin" }`
135
138
  # - `{ kind: :scope, parent: "user", arity_segments: [":user_id"] }`
@@ -145,6 +148,47 @@ module Rigor
145
148
  # patterns. Applied after parsing via `apply_alias_rules` to generate substituted-name aliases
146
149
  # for every matching entry — closes GitLab's `namespace_project_*` → `project_*` shorthand idiom.
147
150
  @alias_rules = []
151
+ # ADR-102 WD3 — controller PATHS ("admin/users") in Rails' own `module/controller` spelling,
152
+ # accumulated as routes are interpreted and camelized to class names once at the end. Stored as
153
+ # paths rather than class names so the module chain composes by string join, exactly as Rails
154
+ # composes it, and inflection runs once per unique controller instead of once per route.
155
+ @controllers = []
156
+ end
157
+
158
+ # ADR-102 WD3 — records the controller a route dispatches to.
159
+ #
160
+ # `raw` is the controller as WRITTEN at the route site: a resource name (`users`), an explicit
161
+ # `controller:` value, or the left half of a `"posts#index"` target. Rails resolves it against the
162
+ # enclosing module chain (`namespace :admin` / `scope module: :admin`), and a leading `/` opts out
163
+ # of that chain — both are exactly what this reproduces.
164
+ def record_controller(raw)
165
+ name = raw.to_s.strip
166
+ return if name.empty?
167
+
168
+ if name.start_with?("/")
169
+ @controllers << name.delete_prefix("/")
170
+ return
171
+ end
172
+
173
+ prefix = controller_module_path
174
+ @controllers << (prefix.empty? ? name : "#{prefix}/#{name}")
175
+ end
176
+
177
+ # The `module/` chain in force, from `namespace :admin` and `scope module: :admin` frames. A nested
178
+ # `resources` frame contributes NOTHING here — `resources :users do resources :posts end` serves
179
+ # `PostsController`, not `Users::PostsController` — which is why the module chain is its own frame
180
+ # key rather than being read off the helper-prefix segments.
181
+ def controller_module_path
182
+ @stack.filter_map { |frame| frame[:module] }.join("/")
183
+ end
184
+
185
+ # The accumulated controller paths as Ruby class names. `admin/users` → `Admin::UsersController`,
186
+ # via the ADR-39 shared inflector (the authority Rails itself uses) rather than a local camelizer,
187
+ # then respelled through the project's own declared acronyms ({Acronyms}).
188
+ def controller_class_names
189
+ @controllers.uniq.sort.map do |path|
190
+ Acronyms.apply("#{Rigor::Plugin::Inflector.camelize(path)}Controller", @acronyms)
191
+ end.uniq.sort
148
192
  end
149
193
 
150
194
  def register_alias_rule(from, to)
@@ -163,13 +207,30 @@ module Rigor
163
207
  @concerns[name.to_sym]
164
208
  end
165
209
 
210
+ # `namespace :admin` is Rails shorthand for `scope path: 'admin', module: 'admin', as: 'admin'`, so
211
+ # the frame carries the module segment too — that is what makes `resources :users` inside it read
212
+ # as `Admin::UsersController`.
166
213
  def push_namespace(name)
167
- @stack.push(kind: :namespace, name: name.to_s)
214
+ @stack.push(kind: :namespace, name: name.to_s, module: name.to_s)
168
215
  yield
169
216
  ensure
170
217
  @stack.pop
171
218
  end
172
219
 
220
+ # `scope module: :admin do ... end` — contributes to the CONTROLLER chain only: no helper prefix,
221
+ # no path segment, no arity. Pushed around whatever other frame `handle_scope` decides on, so a
222
+ # `scope path: '/x', module: :admin` composes both.
223
+ def push_module(name)
224
+ return yield if name.nil? || name.to_s.empty?
225
+
226
+ @stack.push(kind: :module_scope, module: name.to_s)
227
+ begin
228
+ yield
229
+ ensure
230
+ @stack.pop
231
+ end
232
+ end
233
+
173
234
  def push_resource(parent_name)
174
235
  singular = singularize(parent_name.to_s)
175
236
  @stack.push(kind: :scope, parent: singular, parent_plural: parent_name.to_s,
@@ -653,6 +714,16 @@ module Rigor
653
714
  # When `as:` is absent the block is interpreted without any prefix change — helper names are
654
715
  # unaffected by the scope's path, which matches Rails' behaviour for path-only scopes.
655
716
  def handle_scope(node, context)
717
+ options = options_hash(node)
718
+ context.push_module(options[:module]) do
719
+ # `scope(path: 'groups/*id', controller: :groups) do get :edit end` — the scope names the serving
720
+ # controller and the inner routes carry no `to:`, so the root is only visible here.
721
+ context.record_controller(options[:controller]) if options[:controller]
722
+ interpret_scope_frame(node, context)
723
+ end
724
+ end
725
+
726
+ def interpret_scope_frame(node, context)
656
727
  as_name = keyword_symbol(node, :as)
657
728
  # `scope :path_arg, as: :name` (path as a positional arg) vs `scope(path: ':project_id', as:
658
729
  # :project)` (path as a `:path` keyword). GitLab's project routes rely on the latter for the
@@ -695,9 +766,17 @@ module Rigor
695
766
 
696
767
  register_resourceful_helpers(helper_name, actions, base_arity, context, plural: true)
697
768
 
698
- context.push_resource(name) do
699
- replay_concerns_from_options(options, context)
700
- interpret_block_body(node, context)
769
+ # `resources :actions, only: [:create], module: :reports` — `module:` moves the SERVING CONTROLLER
770
+ # into a sub-namespace without touching helper names, paths or arity, which is why the frame wraps
771
+ # only the controller recording and the block. Mastodon uses it 8 times in `config/routes/admin.rb`
772
+ # alone; without it every one of those controllers reads as unused.
773
+ context.push_module(options[:module]) do
774
+ record_resource_controller(context, options, name, plural: true) if routed?(node, actions)
775
+
776
+ context.push_resource(name) do
777
+ replay_concerns_from_options(options, context)
778
+ interpret_block_body(node, context)
779
+ end
701
780
  end
702
781
  end
703
782
 
@@ -714,16 +793,119 @@ module Rigor
714
793
  # `<name>_path` (singular).
715
794
  register_resourceful_helpers(helper_name, actions, base_arity, context, plural: false)
716
795
 
717
- # Push a `:singular_scope` frame so nested declarations pick up the singular resource's name in
718
- # their helper prefix (Mastodon's `resource :instance do; scope module: :instances do; resources
719
- # :domain_blocks; end; end` → `instance_domain_blocks_path`). The singular frame adds NO `:id`
720
- # segment to arity singular resources don't carry one.
721
- context.push_singular_resource(name) do
722
- replay_concerns(node, context)
723
- interpret_block_body(node, context)
796
+ context.push_module(options[:module]) do
797
+ record_resource_controller(context, options, name, plural: false) if routed?(node, actions)
798
+
799
+ # Push a `:singular_scope` frame so nested declarations pick up the singular resource's name in
800
+ # their helper prefix (Mastodon's `resource :instance do; scope module: :instances do; resources
801
+ # :domain_blocks; end; end` → `instance_domain_blocks_path`). The singular frame adds NO `:id`
802
+ # segment to arity — singular resources don't carry one.
803
+ context.push_singular_resource(name) do
804
+ replay_concerns(node, context)
805
+ interpret_block_body(node, context)
806
+ end
724
807
  end
725
808
  end
726
809
 
810
+ # ADR-102 WD3 — the controller a `resources` / `resource` declaration dispatches to.
811
+ #
812
+ # `resources :users` serves `UsersController`; a SINGULAR `resource :profile` serves the PLURAL
813
+ # `ProfilesController` — Rails pluralises the controller even though the routes are singular, and
814
+ # that is the one point where the two shapes disagree. An explicit `controller:` wins outright,
815
+ # including its `controller: "admin/users"` module-path and `controller: "/users"` absolute forms.
816
+ #
817
+ # Whether a `resources` / `resource` declaration actually routes anything to its own controller.
818
+ #
819
+ # `only: []` strips every default action, and the two shapes it appears in mean opposite things:
820
+ # `resource :secret, only: [] do post :rotate end` still routes (Mastodon's
821
+ # `Admin::Webhooks::SecretsController`), while `resources :foo, only: [] do resources :bar end` is a
822
+ # pure nesting wrapper that routes nothing to `FooController`. Distinguishing them is the difference
823
+ # between a missing root and an over-supplied one, so the test is "does the block declare an action
824
+ # of its OWN?" — direct HTTP-verb statements plus those inside a direct `member` / `collection`
825
+ # block — rather than the cheap "does it have a block?".
826
+ def routed?(node, actions)
827
+ return true unless actions.empty?
828
+
829
+ body = node.block&.body
830
+ return false if body.nil?
831
+
832
+ body.child_nodes.compact.any? { |stmt| own_action?(stmt) }
833
+ end
834
+
835
+ ROUTE_VERBS = %i[get post patch put delete match].freeze
836
+
837
+ def own_action?(stmt)
838
+ return false unless stmt.is_a?(Prism::CallNode) && stmt.receiver.nil?
839
+ return true if ROUTE_VERBS.include?(stmt.name)
840
+ return false unless %i[member collection].include?(stmt.name)
841
+
842
+ inner = stmt.block&.body
843
+ return false if inner.nil?
844
+
845
+ inner.child_nodes.compact.any? do |s|
846
+ s.is_a?(Prism::CallNode) && s.receiver.nil? && ROUTE_VERBS.include?(s.name)
847
+ end
848
+ end
849
+
850
+ # `only: []` (or an `except:` that removes every action) declares no routes at all and so names no
851
+ # controller; the callers gate on {#routed?} rather than this method re-deriving it.
852
+ def record_resource_controller(context, options, name, plural:)
853
+ explicit = options[:controller]
854
+ return context.record_controller(explicit) if explicit
855
+
856
+ context.record_controller(plural ? name.to_s : Rigor::Plugin::Inflector.pluralize(name.to_s))
857
+ end
858
+
859
+ # ADR-102 WD3 — the controller named by an explicit route's target, in the spellings Rails accepts:
860
+ # `to: "posts#index"`, the hashrocket `get "help/*path" => "help#show"`, a bare positional
861
+ # `root "welcome#index"`, and a `controller:` option carrying no action. A non-literal target
862
+ # (`to: redirect("/x")`, `to: Foo.action(:show)`) yields nothing — a target this reading cannot
863
+ # resolve MUST name no root rather than a guessed one, because an over-supplied root silently hides
864
+ # real dead code (ADR-102 § Consequences).
865
+ def record_target_controller(node, context)
866
+ controller = route_target_controller(node, context)
867
+ context.record_controller(controller) if controller
868
+ end
869
+
870
+ def route_target_controller(node, context)
871
+ # `effective_options_for`, not `options_hash`: Mastodon routes its streaming endpoint as
872
+ # `with_options to: 'streaming#index' do get '/streaming' end`, where the target lives on the
873
+ # enclosing `with_options` frame and the route call itself carries none.
874
+ options = effective_options_for(node, context)
875
+ target = controller_action_string(options[:to]) || hashrocket_target(node) || positional_target(node)
876
+ return target.split("#", 2).first if target
877
+
878
+ options[:controller]&.to_s
879
+ end
880
+
881
+ # The `"controller#action"` shape, or nil for anything else (a Symbol `to: :index`, a non-literal).
882
+ def controller_action_string(value)
883
+ value.is_a?(String) && value.include?("#") ? value : nil
884
+ end
885
+
886
+ def positional_target(node)
887
+ (node.arguments&.arguments || [])
888
+ .filter_map { |arg| controller_action_string(string_value(arg)) }
889
+ .first
890
+ end
891
+
892
+ # `get 'help/*path' => 'help#show'` — the target is the VALUE of a String-keyed AssocNode in the
893
+ # trailing hash (`hashrocket_path_key` reads the key of the same pair).
894
+ def hashrocket_target(node)
895
+ args = node.arguments&.arguments || []
896
+ last = args.last
897
+ return nil unless last.is_a?(Prism::KeywordHashNode)
898
+
899
+ last.elements.each do |element|
900
+ next unless element.is_a?(Prism::AssocNode)
901
+ next unless element.key.is_a?(Prism::StringNode)
902
+
903
+ target = controller_action_string(string_value(element.value))
904
+ return target if target
905
+ end
906
+ nil
907
+ end
908
+
727
909
  # `concern :account_resources do ... end` registers the body for later replay; we DO NOT interpret
728
910
  # it at the definition site (the body has no parent-resource context yet). Concerns at the top
729
911
  # level land in the Context's `concerns` map by Symbol name.
@@ -769,6 +951,7 @@ module Rigor
769
951
  end
770
952
 
771
953
  def handle_root(node, context)
954
+ record_target_controller(node, context)
772
955
  # `root to: "..."` / `root "..."` — single helper `root_path`, arity 0, GET. Real-world Rails
773
956
  # apps also use `root :to => 'welcome#index', :as => 'home'` (the canonical Redmine idiom across
774
957
  # 230+ call sites), which registers an additional `home_path` / `home_url` alias for the same
@@ -790,6 +973,10 @@ module Rigor
790
973
  end
791
974
 
792
975
  def handle_explicit_route(node, context)
976
+ # Recorded before every early return below: a `get :preview, to: "previews#show"` inside a
977
+ # `member do ... end` block leaves through the shorthand branch, and its target is still a root.
978
+ record_target_controller(node, context)
979
+
793
980
  # Member / collection block shorthand: `post :memorialize` inside `member do ... end` (no path
794
981
  # arg, just a SymbolNode). Rails generates a helper based on the action name + the enclosing
795
982
  # resource: a member action becomes `<action>_<singular_chain>_path(id)`, a collection action
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rigor/plugin"
4
4
 
5
+ require_relative "rails_routes/acronyms"
5
6
  require_relative "rails_routes/helper_table"
6
7
  require_relative "rails_routes/routes_parser"
7
8
  require_relative "rails_routes/helper_discoverer"
@@ -59,7 +60,11 @@ module Rigor
59
60
  # shorten `namespace_project_*` → `project_*`. Bumped 2026-07-10 — recognises the open helper
60
61
  # namespace `grape-path-helpers` generates (`api_v4_*_path`), grounded in the project's own Grape
61
62
  # `prefix` / `version` declarations (see {GrapeApiDiscoverer}).
62
- version: "0.28.0",
63
+ # Bumped 2026-08-15 — the parser now also accumulates the CONTROLLER classes the routes file
64
+ # dispatches to, published as the `:reachability_roots` fact for `rigor unused` (ADR-102 WD3). The
65
+ # bump matters mechanically as well as editorially: the manifest version is part of the producer
66
+ # cache key, so it retires `:helper_table` slots written before the table carried `controllers`.
67
+ version: "0.29.0",
63
68
  description: "Validates Rails route-helper calls against `config/routes.rb`.",
64
69
  config_schema: {
65
70
  "routes_file" => { kind: :string, default: "config/routes.rb" },
@@ -78,7 +83,7 @@ module Rigor
78
83
  # API declares no prefixes and nothing changes for it.
79
84
  "grape_api_paths" => { kind: :array, default: ["lib/api", "app/api"] }
80
85
  },
81
- produces: [:helper_table]
86
+ produces: %i[helper_table reachability_roots]
82
87
  )
83
88
 
84
89
  # Cached producer — reads `config/routes.rb` through the trusted `IoBoundary` and parses through
@@ -100,11 +105,16 @@ module Rigor
100
105
  end
101
106
  contents = io_boundary.read_file(@routes_file)
102
107
  RoutesParser.parse(contents, file_reader: file_reader, custom_helpers: discover_custom_helpers,
103
- grape_prefixes: discover_grape_prefixes)
108
+ grape_prefixes: discover_grape_prefixes,
109
+ acronyms: discover_acronyms)
104
110
  end
105
111
 
106
112
  def init(_services)
107
113
  @routes_file = config.fetch("routes_file")
114
+ # Derived from `routes_file:` rather than configured separately: both live under the same `config/`
115
+ # tree in every Rails layout, and a second knob for a file the user never has to think about is a
116
+ # setting nobody will set correctly.
117
+ @inflections_file = File.join(File.dirname(@routes_file), "initializers", "inflections.rb")
108
118
  @helper_paths = Array(config.fetch("helper_paths")).map(&:to_s)
109
119
  @grape_api_paths = Array(config.fetch("grape_api_paths")).map(&:to_s)
110
120
  @helper_table = nil
@@ -140,6 +150,15 @@ module Rigor
140
150
  GrapeApiDiscoverer.discover(contents_per_path)
141
151
  end
142
152
 
153
+ # Reads the project's `config/initializers/inflections.rb` through the trusted `IoBoundary` and returns
154
+ # the acronyms it declares, for controller-class-name composition ({Acronyms}). The file is *parsed*,
155
+ # never executed. Absent file → no acronyms → composition behaves exactly as it did before.
156
+ def discover_acronyms
157
+ Acronyms.discover(io_boundary.read_file(@inflections_file))
158
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
159
+ []
160
+ end
161
+
143
162
  def each_helper_file(&) = each_ruby_file(@helper_paths, &)
144
163
 
145
164
  def each_ruby_file(dirs, &)
@@ -153,6 +172,12 @@ module Rigor
153
172
 
154
173
  # Publishes the parsed table to the cross-plugin fact store; `rigor-actionpack` Phase 4 reads it via
155
174
  # `services.fact_store.read`.
175
+ #
176
+ # ADR-102 WD3 — also publishes `:reachability_roots`, the controller classes `config/routes.rb`
177
+ # dispatches to. A controller is an entry point *nothing in the project references*: Rails reaches it
178
+ # by name at request time, so a reference index sees a live controller exactly as it sees a dead one.
179
+ # `rigor unused` reads this fact from every loaded plugin and seeds its mark-and-sweep with the union.
180
+ # Nothing else consumes it, and it never reaches the `rigor check` diagnostic stream (WD1).
156
181
  def prepare(services)
157
182
  table = helper_table_or_nil
158
183
  return if table.nil?
@@ -162,6 +187,7 @@ module Rigor
162
187
  name: :helper_table,
163
188
  value: table.to_h
164
189
  )
190
+ publish_reachability_roots(services, table)
165
191
  end
166
192
 
167
193
  # File-level only: the once-per-run load-error emission. Per-call helper validation runs over the
@@ -194,6 +220,20 @@ module Rigor
194
220
 
195
221
  private
196
222
 
223
+ # Publishes nothing when the routes file named no controller — an empty root set is indistinguishable
224
+ # from "this plugin contributed nothing", and an absent fact says so without a consumer having to
225
+ # special-case an empty Array.
226
+ def publish_reachability_roots(services, table)
227
+ roots = table.controllers
228
+ return if roots.empty?
229
+
230
+ services.fact_store.publish(
231
+ plugin_id: manifest.id,
232
+ name: :reachability_roots,
233
+ value: roots
234
+ )
235
+ end
236
+
197
237
  # The load-error path used to emit the same warning on every analyzed file in the project. On large
198
238
  # monorepos (Mastodon: 1,302 files; Solidus: ~1,000 files) and on legacy projects without a top-level
199
239
  # `config/routes.rb`, this multiplied a single root cause into 1,000+ identical diagnostics. The
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Railties < Rigor::Plugin::Base
8
+ # rigor-railties' effect contract (ADR-103 WD10; design note § 11.2; issue #387).
9
+ #
10
+ # The rows for the `Rails.` namespace itself — the cache, the logger, the environment, the
11
+ # configuration, the credentials. Every one of them is spelled as a **receiver path**, because that
12
+ # is the only handle there is: `Rails.cache` returns whatever `config.cache_store` names, and
13
+ # `Rails.logger` whatever the app assigned, so the receiver has no class a row could key on. The
14
+ # path the programmer wrote is stable where the type is not.
15
+ #
16
+ # ## `Rails.env` is `global.read`, and that is not pedantry
17
+ #
18
+ # `Rails.env = "test"` is a real thing people do, and a memoisation keyed on `Rails.env` in a class
19
+ # loaded before the environment is set is a real bug. So the label is honest — and then a project
20
+ # writes `tolerated: [rails.config.read]` and stops seeing it, which is the mechanism working
21
+ # correctly: the record stays true, the judgment gets quiet.
22
+ #
23
+ # ## The credentials read is a file read
24
+ #
25
+ # `Rails.application.credentials.secret_key_base` decrypts `config/credentials.yml.enc` on first
26
+ # access. `io.fs.read` plus `global.read` (the decrypted bag is memoised process-wide) plus
27
+ # `rails.credentials.read`, which is the one a "this layer touches no secrets" policy names.
28
+ module Effects
29
+ CACHE = "Rails.cache"
30
+ LOGGER = "Rails.logger"
31
+ ERROR = "Rails.error"
32
+ RAILS = "Rails"
33
+
34
+ CACHE_READ = ["io", "cache.read"].freeze
35
+ CACHE_WRITE = ["io", "cache.write"].freeze
36
+ TELEMETRY = %w[io telemetry].freeze
37
+ CONFIG_READ = ["global.read", "rails.config.read"].freeze
38
+ CREDENTIALS = ["io.fs.read", "global.read", "rails.credentials.read"].freeze
39
+ STATIC_WRITE = ["global.write", "mutate.static"].freeze
40
+
41
+ CACHE_READERS = %w[read read_multi exist? fetch fetch_multi].freeze
42
+ CACHE_WRITERS = %w[write write_multi delete delete_matched delete_multi increment decrement
43
+ clear cleanup].freeze
44
+ LOG_LEVELS = %w[debug info warn error fatal unknown add log tagged silence].freeze
45
+ CONFIG_READERS = %w[env root configuration application logger cache version public_path
46
+ groups autoloaders].freeze
47
+
48
+ # Where a project's own logger lives, in the classes Rails gives one to. Spelled as a self path so
49
+ # a receiver-less `logger` inside a model or a controller is coloured and a `logger` method on some
50
+ # unrelated project class is not.
51
+ LOGGER_HOSTS = %w[ActiveRecord::Base ActionController::Base ActiveJob::Base ActionMailer::Base].freeze
52
+
53
+ module_function
54
+
55
+ def attributions
56
+ cache_rows + logger_rows + error_rows + config_rows + credentials_rows + mutation_rows
57
+ end
58
+
59
+ def cache_rows
60
+ CACHE_READERS.map do |selector|
61
+ path_row(CACHE, selector, CACHE_READ,
62
+ "reads the configured cache store — memory, Redis or the filesystem, which is why " \
63
+ "the transport is bare `io`. `fetch`'s block is this method's own code and joins by " \
64
+ "containment, so a cache miss's cost shows up in the caller either way")
65
+ end +
66
+ CACHE_WRITERS.map do |selector|
67
+ path_row(CACHE, selector, CACHE_WRITE, "writes the configured cache store")
68
+ end
69
+ end
70
+
71
+ def logger_rows
72
+ LOG_LEVELS.map do |selector|
73
+ path_row(LOGGER, selector, TELEMETRY,
74
+ "writes to whatever the app assigned as the logger — a file, stdout, a log " \
75
+ "aggregator. `telemetry` is the label a project tolerates once and then stops " \
76
+ "thinking about, which is exactly what it is for")
77
+ end +
78
+ LOGGER_HOSTS.flat_map do |host|
79
+ LOG_LEVELS.map do |selector|
80
+ EffectAttribution.new(
81
+ receiver: "self.logger", method: selector, labels: TELEMETRY, within: host,
82
+ discharge: true,
83
+ why: "a receiver-less `logger` inside a Rails class is `Rails.logger`; `within:` is what " \
84
+ "keeps a `logger` method on an unrelated project class out of this row"
85
+ )
86
+ end
87
+ end
88
+ end
89
+
90
+ def error_rows
91
+ %w[report handle record].map do |selector|
92
+ path_row(ERROR, selector, TELEMETRY,
93
+ "reports to the error subscribers — Sentry, Honeybadger, a log line; the destination " \
94
+ "is registered at boot and is not statically knowable")
95
+ end
96
+ end
97
+
98
+ def config_rows
99
+ CONFIG_READERS.map do |selector|
100
+ EffectAttribution.new(
101
+ receiver: RAILS, method: selector, labels: CONFIG_READ, singleton: true, discharge: true,
102
+ why: "reads mutable process state. `Rails.env = \"test\"` is a real thing people do, and a " \
103
+ "value memoised from it before boot finishes is a real bug — so the row is honest and " \
104
+ "`tolerated: [rails.config.read]` is how a project makes it quiet"
105
+ )
106
+ end + config_path_rows
107
+ end
108
+
109
+ def config_path_rows
110
+ %w[Rails.configuration Rails.application.config Rails.application.config.x].flat_map do |path|
111
+ %w[[] method_missing].map do |selector|
112
+ EffectAttribution.new(receiver: path, method: selector, labels: CONFIG_READ, discharge: true,
113
+ why: "reads the application configuration object, which is mutable " \
114
+ "process state")
115
+ end
116
+ end
117
+ end
118
+
119
+ def credentials_rows
120
+ %w[Rails.application.credentials Rails.application.secrets].flat_map do |path|
121
+ %w[[] dig fetch config].map do |selector|
122
+ EffectAttribution.new(
123
+ receiver: path, method: selector, labels: CREDENTIALS, discharge: true,
124
+ why: "decrypts `config/credentials.yml.enc` on first access and memoises it process-wide; " \
125
+ "`rails.credentials.read` is what a 'this layer touches no secrets' policy names"
126
+ )
127
+ end
128
+ end
129
+ end
130
+
131
+ def mutation_rows
132
+ [
133
+ EffectAttribution.new(
134
+ receiver: "Rails.application", method: :reload_routes!, labels: STATIC_WRITE, discharge: true,
135
+ why: "rebuilds the route set on the application object — process-global mutation"
136
+ ),
137
+ EffectAttribution.new(
138
+ receiver: "Rails.autoloaders.main", method: :reload, labels: STATIC_WRITE, discharge: true,
139
+ why: "unloads and reloads every autoloaded constant"
140
+ ),
141
+ EffectAttribution.new(
142
+ receiver: "ActiveRecord::Base", method: :establish_connection, labels: STATIC_WRITE,
143
+ singleton: true, discharge: true,
144
+ why: "replaces the connection pool for the class and everything below it"
145
+ )
146
+ ]
147
+ end
148
+
149
+ def path_row(path, selector, labels, why)
150
+ EffectAttribution.new(receiver: path, method: selector, labels: labels, discharge: true, why: why)
151
+ end
152
+
153
+ # The `rails` preset: everything a request or a queue can enter the application through.
154
+ # Expressed as globs rather than as a class-ancestry filter because `reach:` is resolved from the
155
+ # snapshot's own method table, which carries a defining path per key and no ancestry — and in a
156
+ # Rails app the layout IS the ancestry, which is why the convention exists.
157
+ def entry_points
158
+ [
159
+ EffectEntryPoints.new(
160
+ name: "rails",
161
+ globs: ["app/controllers/**/*.rb", "app/jobs/**/*.rb", "app/mailers/**/*.rb",
162
+ "app/channels/**/*.rb"],
163
+ why: "every way the outside world enters a Rails application: controller actions, job " \
164
+ "`perform`, mailer actions, channel callbacks"
165
+ )
166
+ ]
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "railties/effects"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ # rigor-railties — the Rails **framework core** as an effect vocabulary.
10
+ #
11
+ # Unlike its siblings this plugin runs no analysis: it emits no diagnostic, types no receiver and
12
+ # declares no producer. What it contributes is the part of ADR-103's Rails layer that belongs to no
13
+ # single component gem — `Rails.cache`, `Rails.logger`, `Rails.env`, the application configuration and
14
+ # the credentials — plus the `rails` entry-point preset that `effects.snapshot.reach:` adopts by name.
15
+ #
16
+ # ## Why it is its own plugin
17
+ #
18
+ # The rule ADR-103 WD10 works to is "the row lives in the plugin that owns the gem". `Rails.cache` and
19
+ # `Rails.application` come from railties and activesupport, not from Action Pack or Active Record, and
20
+ # putting them in whichever Rails plugin a project happened to enable would make a model's
21
+ # `Rails.logger.info` visible or invisible depending on whether the app has controllers. A project that
22
+ # wants the Rails effect vocabulary lists this plugin; a project that wants only Active Record's rows
23
+ # does not.
24
+ #
25
+ # It is also the natural owner of the `rails` preset. A preset name may be registered once with one
26
+ # glob set ({Rigor::Effects::EntryPoints.register}), and `rails` spans four directories owned by four
27
+ # different component plugins — so exactly one plugin has to declare the union, and it is this one.
28
+ # Each component plugin additionally declares its own narrower preset (`rails-controllers`,
29
+ # `rails-jobs`, `rails-mailers`, `rails-channels`) for a project that wants a slice.
30
+ #
31
+ # plugins:
32
+ # - gem: rigor-railties
33
+ #
34
+ # effects:
35
+ # snapshot:
36
+ # reach: [rails]
37
+ # tolerated: [telemetry, rails.config.read]
38
+ #
39
+ # ## Cost when effects are off
40
+ #
41
+ # None beyond the load. The manifest's effect fields are frozen arrays; nothing reads them unless the
42
+ # project has an `effects:` block ({Rigor::Plugin::Registry#effect_contributions} is lazy), and the
43
+ # plugin implements no per-call contribution path at all, so `ContributionIndex` never consults it.
44
+ class Railties < Rigor::Plugin::Base
45
+ manifest(
46
+ id: "railties",
47
+ version: "0.1.0",
48
+ description: "The Rails framework-core effect vocabulary: cache, logger, environment, " \
49
+ "configuration, credentials, and the `rails` entry-point preset.",
50
+ # ADR-103 WD2 — rigor-railties models Rails itself, so it opens the `rails.*` root. Granted only
51
+ # because the engine bundles this plugin ({Rigor::Plugin::FirstParty}); the same declaration from a
52
+ # third-party gem would open `railties.*` and earn a warning.
53
+ effect_root: "rails",
54
+ effect_labels: %w[rails.config.read rails.credentials.read],
55
+ effect_attributions: Effects.attributions,
56
+ effect_entry_points: Effects.entry_points
57
+ )
58
+ end
59
+
60
+ Rigor::Plugin.register(Railties)
61
+ end
62
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/railties"