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
|
@@ -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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|