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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Activejob < Rigor::Plugin::Base
|
|
8
|
+
# rigor-activejob's effect contract (ADR-103 WD4 / WD10; design note § 11.2 "Deferred execution";
|
|
9
|
+
# issue #387).
|
|
10
|
+
#
|
|
11
|
+
# ## Attribution follows the code, not the clock
|
|
12
|
+
#
|
|
13
|
+
# `perform_later` is the effect; `perform` is not. The job body runs in another process on another
|
|
14
|
+
# stack, so the caller's code does not contain it and there is **no edge** from `perform_later` to
|
|
15
|
+
# `perform` — {Rigor::Plugin::EffectEdge::TARGETS} has no spelling for one. `perform_now` is an
|
|
16
|
+
# ordinary edge, because it is an ordinary call.
|
|
17
|
+
#
|
|
18
|
+
# `set(wait: 1.hour)` returns a `ConfiguredJob` and does nothing, exactly like a Relation builder; the
|
|
19
|
+
# `.perform_later` on it is the origin, which is what `on_result:` expresses.
|
|
20
|
+
#
|
|
21
|
+
# ## The transport is a project fact
|
|
22
|
+
#
|
|
23
|
+
# Argument-blind, an enqueue is bare `io` — true and useless for policy. But a Rails app declares its
|
|
24
|
+
# adapter once, in `config.active_job.queue_adapter`, and reading it turns the row into something a
|
|
25
|
+
# reviewer can act on: under Solid Queue the enqueue is an `INSERT` into `solid_queue_jobs`, and a
|
|
26
|
+
# "no database on this path" envelope is right to object to it. Under Sidekiq it is a Redis round
|
|
27
|
+
# trip. Under `:async` it is a thread in this process and no transport at all; under `:inline` Rails
|
|
28
|
+
# runs the job on the caller's stack, which — and only which — makes `perform_later` a real edge.
|
|
29
|
+
#
|
|
30
|
+
# This is the configuration-level twin of the argument-dependent narrowing the catalogue does for
|
|
31
|
+
# `File.open`'s mode: the same discipline, one scope wider. An unread or per-environment adapter keeps
|
|
32
|
+
# the honest `io`.
|
|
33
|
+
module Effects
|
|
34
|
+
BASE = "ActiveJob::Base"
|
|
35
|
+
|
|
36
|
+
# Where a Rails app declares its adapter. Per-environment files are read too, because an app that
|
|
37
|
+
# sets the adapter only in `config/environments/production.rb` is common; agreement across every
|
|
38
|
+
# file that names one is what licenses the narrowing (see {.transport_for}).
|
|
39
|
+
CONFIG_PATHS = ["config/application.rb", "config/environments"].freeze
|
|
40
|
+
|
|
41
|
+
SETTING = /queue_adapter\s*=\s*[:"']([a-z_]+)["']?/
|
|
42
|
+
|
|
43
|
+
# What each adapter's enqueue actually touches. The absent adapters are the point: anything not
|
|
44
|
+
# listed keeps bare `io`, which is the truthful upper bound for a transport nobody named.
|
|
45
|
+
TRANSPORTS = {
|
|
46
|
+
"solid_queue" => ["io.db.write"], "delayed_job" => ["io.db.write"], "delayed" => ["io.db.write"],
|
|
47
|
+
"que" => ["io.db.write"], "good_job" => ["io.db.write"], "queue_classic" => ["io.db.write"],
|
|
48
|
+
"sidekiq" => ["io.net"], "resque" => ["io.net"], "sneakers" => ["io.net"],
|
|
49
|
+
"shoryuken" => ["io.net"], "backburner" => ["io.net"], "sucker_punch" => [],
|
|
50
|
+
"async" => [], "inline" => [], "test" => []
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# The meaning half, which is adapter-independent and is what a policy actually names.
|
|
54
|
+
MEANING = ["rails.activejob.enqueue", "job.enqueue"].freeze
|
|
55
|
+
|
|
56
|
+
# `X.perform_later`, `X.perform_all_later`, `X.enqueue` — the class-side enqueues.
|
|
57
|
+
SINGLETON_ENQUEUES = %w[perform_later perform_all_later enqueue enqueue_at].freeze
|
|
58
|
+
|
|
59
|
+
# `job.enqueue`, and the enqueue on whatever `set(…)` returned.
|
|
60
|
+
RESULT_ENQUEUES = %w[perform_later enqueue enqueue_at].freeze
|
|
61
|
+
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
# @param adapter [String, nil] the adapter the project declares, or nil when it declares none
|
|
65
|
+
# (or more than one, across environments)
|
|
66
|
+
def attributions(adapter)
|
|
67
|
+
labels = (TRANSPORTS[adapter] || ["io"]) + MEANING
|
|
68
|
+
why = transport_why(adapter)
|
|
69
|
+
SINGLETON_ENQUEUES.map do |selector|
|
|
70
|
+
EffectAttribution.new(receiver: BASE, method: selector, labels: labels, singleton: true,
|
|
71
|
+
discharge: true, why: why)
|
|
72
|
+
end +
|
|
73
|
+
RESULT_ENQUEUES.map do |selector|
|
|
74
|
+
EffectAttribution.new(
|
|
75
|
+
receiver: BASE, method: selector, labels: labels, on_result: true, discharge: true,
|
|
76
|
+
why: "#{why} Matched on the RESULT of a call to the job class, which is the shape " \
|
|
77
|
+
"`WelcomeJob.set(wait: 1.hour).perform_later` takes: `set` is a builder and returns a " \
|
|
78
|
+
"lazy ConfiguredJob, so the enqueue is one link further out."
|
|
79
|
+
)
|
|
80
|
+
end +
|
|
81
|
+
[EffectAttribution.new(receiver: BASE, method: :enqueue, labels: labels, discharge: true,
|
|
82
|
+
why: why)]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def transport_why(adapter)
|
|
86
|
+
if adapter.nil?
|
|
87
|
+
return "the queue adapter is unread or differs per environment, so the transport stays the " \
|
|
88
|
+
"honest `io`; `rails.activejob.enqueue` is the meaning a policy names."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
transport = TRANSPORTS[adapter]
|
|
92
|
+
if transport.nil?
|
|
93
|
+
return "`config.active_job.queue_adapter = :#{adapter}` is not an adapter this plugin has a " \
|
|
94
|
+
"transport for, so the row keeps bare `io`."
|
|
95
|
+
end
|
|
96
|
+
if transport.empty?
|
|
97
|
+
return "`config.active_job.queue_adapter = :#{adapter}` runs the queue inside this process, so " \
|
|
98
|
+
"the enqueue crosses no transport at all."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
"`config.active_job.queue_adapter = :#{adapter}` makes the enqueue a #{transport.first} — the " \
|
|
102
|
+
"project's own configuration narrowing a transport that is otherwise unknowable."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# `perform_now` is always an edge. Under `:inline`, so is `perform_later`: Rails genuinely runs the
|
|
106
|
+
# job on the caller's stack there, and refusing the edge would understate a controller action that
|
|
107
|
+
# sends the whole email inline.
|
|
108
|
+
def edges(adapter)
|
|
109
|
+
list = [
|
|
110
|
+
EffectEdge.new(receiver: BASE, target: :perform_now,
|
|
111
|
+
why: "`Job.perform_now(...)` runs `Job#perform` synchronously, in this process")
|
|
112
|
+
]
|
|
113
|
+
return list unless adapter == "inline"
|
|
114
|
+
|
|
115
|
+
list << EffectEdge.new(
|
|
116
|
+
receiver: BASE, target: :perform_now, method: :perform_later,
|
|
117
|
+
why: "`config.active_job.queue_adapter = :inline` makes `perform_later` run `perform` on the " \
|
|
118
|
+
"caller's stack; the edge is licensed by the project's own declaration and by nothing else"
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# `reach: [rails-jobs]` — a job's `perform` is an entry point: nothing in the project calls it, and
|
|
123
|
+
# its footprint is what a reviewer asking "what does this job do" wants.
|
|
124
|
+
def entry_points
|
|
125
|
+
[
|
|
126
|
+
EffectEntryPoints.new(
|
|
127
|
+
name: "rails-jobs", globs: ["app/jobs/**/*.rb"],
|
|
128
|
+
why: "ActiveJob jobs — `perform` is invoked by the queue, never by project code"
|
|
129
|
+
)
|
|
130
|
+
]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Reads `config.active_job.queue_adapter` out of the project's configuration. Returns the adapter
|
|
134
|
+
# name when every file that names one agrees, and nil when none does or they disagree — a
|
|
135
|
+
# per-environment split has no single transport, and guessing one would be the wrong kind of
|
|
136
|
+
# precision.
|
|
137
|
+
def detect_adapter(io_boundary, root)
|
|
138
|
+
found = config_files(root).filter_map do |path|
|
|
139
|
+
io_boundary.read_file(path).force_encoding("UTF-8")[SETTING, 1]
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end.uniq
|
|
143
|
+
found.length == 1 ? found.first : nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def config_files(root)
|
|
147
|
+
application = File.join(root, CONFIG_PATHS.first)
|
|
148
|
+
environments = Dir.glob(File.join(root, CONFIG_PATHS.last, "*.rb"))
|
|
149
|
+
([application] + environments).select { |path| File.file?(path) }
|
|
150
|
+
rescue StandardError
|
|
151
|
+
[]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Activejob < Rigor::Plugin::Base
|
|
8
|
+
# ADR-102 WD3 / #369 — which jobs does this project run on a schedule it names them in by STRING?
|
|
9
|
+
#
|
|
10
|
+
# `MyJob.perform_later(...)` writes the job's name as an ordinary constant, so the reachability scan
|
|
11
|
+
# already records that edge and a root would add nothing. Solid Queue's recurring tasks are the
|
|
12
|
+
# opposite case, and they are why this file exists: a recurring job is named only as
|
|
13
|
+
# `class: "SendReminderJob"` in `config/recurring.yml`, so a repository running it every three minutes
|
|
14
|
+
# may contain no `perform_later` for it anywhere. The constant scan sees nothing and `rigor unused`
|
|
15
|
+
# reports a production job as dead code — measured on a real Rails 8 application, where one miss
|
|
16
|
+
# cascaded into a second row because the only caller of a helper class was the job itself.
|
|
17
|
+
#
|
|
18
|
+
# Solid Queue is the default Active Job backend from Rails 8, so this is a mainstream layout rather
|
|
19
|
+
# than a niche one. This is also the ONLY root source this plugin has: #350 declined to publish the
|
|
20
|
+
# discovered job set, because "a file exists under `app/jobs`" is not evidence that anything enqueues
|
|
21
|
+
# it, and an over-claiming root source silently hides real dead code (ADR-102 § Consequences).
|
|
22
|
+
#
|
|
23
|
+
# **Two layouts, one key.** Rails 8 keys `recurring.yml` by environment at the top level
|
|
24
|
+
# (`production:` → task name → entry), unlike `sidekiq-cron`'s flat document. Both depths are read
|
|
25
|
+
# rather than guessed at, and reading both is safe because neither layout yields anything under the
|
|
26
|
+
# other's reading: an environment block's values are entry Hashes whose own `class:` is absent, and a
|
|
27
|
+
# flat entry's values are Strings rather than Hashes. Every environment is read — a job scheduled only
|
|
28
|
+
# in `staging:` is still live code, and picking `production:` would report it dead.
|
|
29
|
+
#
|
|
30
|
+
# **A `command:` entry supplies nothing.** Solid Queue also accepts inline Ruby
|
|
31
|
+
# (`command: "SomeModel.cleanup"`), which names no class the way `class:` does. Parsing a constant out
|
|
32
|
+
# of an arbitrary Ruby snippet would manufacture roots from a string, so `class:` is the only key read.
|
|
33
|
+
#
|
|
34
|
+
# Names are not trusted either: the caller intersects them with the jobs {JobDiscoverer} actually
|
|
35
|
+
# found, so a typo or an out-of-tree class costs coverage rather than manufacturing a root, and the
|
|
36
|
+
# report's `matched no declaration` counter stays meaningful.
|
|
37
|
+
#
|
|
38
|
+
# Fail-soft throughout, because this reads user-authored config Rigor does not own: an absent file, an
|
|
39
|
+
# unreadable one, a YAML syntax error, or a document that is not a Hash contributes nothing rather than
|
|
40
|
+
# raising. Never boots Rails and never loads Solid Queue.
|
|
41
|
+
class RecurringScan
|
|
42
|
+
# The key naming the job class. `command:` is deliberately not read; see the class comment.
|
|
43
|
+
CLASS_KEY = "class"
|
|
44
|
+
|
|
45
|
+
# Errno classes that mean "this path is not readable as a schedule" — swallowed so one bad path does
|
|
46
|
+
# not cost the roots the other paths supply.
|
|
47
|
+
IO_ERRORS = [Errno::ENOENT, Errno::EACCES, Errno::EISDIR].freeze
|
|
48
|
+
|
|
49
|
+
def initialize(io_boundary:, recurring_paths:)
|
|
50
|
+
@io_boundary = io_boundary
|
|
51
|
+
@recurring_paths = recurring_paths
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Array<String>] the class names named by a `class:` key in a recurring-task entry, sorted
|
|
55
|
+
# and unique. NOT yet intersected with the discovered jobs — the caller does that.
|
|
56
|
+
def job_names
|
|
57
|
+
names = Set.new
|
|
58
|
+
@recurring_paths.each do |path|
|
|
59
|
+
document = load_document(path)
|
|
60
|
+
next unless document.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
collect_tasks(document, names)
|
|
63
|
+
document.each_value { |block| collect_tasks(block, names) }
|
|
64
|
+
end
|
|
65
|
+
names.to_a.sort
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def load_document(path)
|
|
71
|
+
absolute = File.expand_path(path.to_s)
|
|
72
|
+
return nil unless File.file?(absolute)
|
|
73
|
+
|
|
74
|
+
contents = read_safely(absolute)
|
|
75
|
+
contents && parse_safely(contents)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read_safely(path)
|
|
79
|
+
@io_boundary.read_file(path)
|
|
80
|
+
rescue Plugin::AccessDeniedError, *IO_ERRORS
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `safe_load` with no permitted classes beyond Symbol: a schedule file is data, and Rigor never loads
|
|
85
|
+
# the Rails environment or the Solid Queue runtime to read it. `aliases: true` because YAML anchors
|
|
86
|
+
# are ordinary style in a hand-maintained schedule that repeats a task across environments.
|
|
87
|
+
def parse_safely(contents)
|
|
88
|
+
YAML.safe_load(contents, aliases: true, permitted_classes: [Symbol])
|
|
89
|
+
rescue Psych::Exception
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A task block maps an arbitrary task NAME to an entry Hash. Only the entry's own `class:` is read —
|
|
94
|
+
# no recursion into the value, so an `args:` payload that happens to carry a `class` key cannot
|
|
95
|
+
# enter.
|
|
96
|
+
def collect_tasks(block, names)
|
|
97
|
+
return unless block.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
block.each_value do |entry|
|
|
100
|
+
next unless entry.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
value = fetch_either(entry, CLASS_KEY)
|
|
103
|
+
names << value if value.is_a?(String) && !value.empty?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fetch_either(hash, key)
|
|
108
|
+
hash.fetch(key) { hash[key.to_sym] }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -5,6 +5,8 @@ require "rigor/plugin"
|
|
|
5
5
|
require_relative "activejob/job_index"
|
|
6
6
|
require_relative "activejob/job_discoverer"
|
|
7
7
|
require_relative "activejob/analyzer"
|
|
8
|
+
require_relative "activejob/effects"
|
|
9
|
+
require_relative "activejob/recurring_scan"
|
|
8
10
|
|
|
9
11
|
module Rigor
|
|
10
12
|
module Plugin
|
|
@@ -22,6 +24,7 @@ module Rigor
|
|
|
22
24
|
# config:
|
|
23
25
|
# job_search_paths: ["app/jobs"] # default; optional
|
|
24
26
|
# job_base_classes: ["ApplicationJob", "ActiveJob::Base"] # default; optional
|
|
27
|
+
# recurring_paths: ["config/recurring.yml"] # default; optional
|
|
25
28
|
#
|
|
26
29
|
# ## Limitations (v0.1.0)
|
|
27
30
|
#
|
|
@@ -34,14 +37,47 @@ module Rigor
|
|
|
34
37
|
class Activejob < Rigor::Plugin::Base
|
|
35
38
|
manifest(
|
|
36
39
|
id: "activejob",
|
|
37
|
-
|
|
40
|
+
# Bumped — publishes `:reachability_roots` for `rigor unused` (ADR-102 WD3): the jobs Solid Queue's
|
|
41
|
+
# recurring schedule runs by name, which no `perform_later` call site writes down.
|
|
42
|
+
version: "0.2.0",
|
|
38
43
|
description: "Validates ActiveJob `Job.perform_later` argument arity.",
|
|
39
44
|
config_schema: {
|
|
40
45
|
"job_search_paths" => { kind: :array, default: ["app/jobs"] },
|
|
41
|
-
"job_base_classes" => { kind: :array, default: %w[ApplicationJob ActiveJob::Base] }
|
|
42
|
-
|
|
46
|
+
"job_base_classes" => { kind: :array, default: %w[ApplicationJob ActiveJob::Base] },
|
|
47
|
+
# `recurring_paths` — the schedule configuration {RecurringScan} reads for the reachability roots
|
|
48
|
+
# below. The default is Solid Queue's conventional location, the Active Job backend Rails ships by
|
|
49
|
+
# default from 8.0. A project that keeps its schedule elsewhere lists the file itself; these are
|
|
50
|
+
# file paths, not directories, because a schedule is a named document rather than a tree.
|
|
51
|
+
"recurring_paths" => { kind: :array, default: ["config/recurring.yml"] }
|
|
52
|
+
},
|
|
53
|
+
produces: [:reachability_roots],
|
|
54
|
+
# ADR-103 WD4 / WD10 (#387). The rows themselves are NOT here: they depend on
|
|
55
|
+
# `config.active_job.queue_adapter`, which is a project fact, so they are built in
|
|
56
|
+
# `#effect_attributions` below. `effect_root: "rails"` is granted because the engine bundles this
|
|
57
|
+
# plugin ({Rigor::Plugin::FirstParty}); a third-party plugin declaring it would open `activejob.*`.
|
|
58
|
+
effect_root: "rails",
|
|
59
|
+
effect_labels: ["rails.activejob.enqueue"],
|
|
60
|
+
effect_entry_points: Effects.entry_points
|
|
43
61
|
)
|
|
44
62
|
|
|
63
|
+
# ADR-103 WD10 — the enqueue's transport, read off the project's own configuration. Overridden rather
|
|
64
|
+
# than declared on the manifest because the answer is per project; memoised because
|
|
65
|
+
# `Plugin::Registry#effect_contributions` is lazy and asks once per process, and only on a run with
|
|
66
|
+
# collection on — a `rigor check` with no `effects:` block never opens `config/application.rb` for this.
|
|
67
|
+
def effect_attributions
|
|
68
|
+
Effects.attributions(detected_queue_adapter)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def effect_edges
|
|
72
|
+
Effects.edges(detected_queue_adapter)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def detected_queue_adapter
|
|
76
|
+
return @detected_queue_adapter if defined?(@detected_queue_adapter)
|
|
77
|
+
|
|
78
|
+
@detected_queue_adapter = Effects.detect_adapter(io_boundary, Dir.pwd)
|
|
79
|
+
end
|
|
80
|
+
|
|
45
81
|
# Cached: discovered job index. `watch:` (ADR-60 WD3) covers every `.rb` under `job_search_paths`
|
|
46
82
|
# so the cache invalidates when a job is added, removed, or edited; the discoverer's in-block
|
|
47
83
|
# `IoBoundary` reads are captured into the record-and-validate dependency descriptor after the
|
|
@@ -54,9 +90,38 @@ module Rigor
|
|
|
54
90
|
).discover
|
|
55
91
|
end
|
|
56
92
|
|
|
93
|
+
# Cached separately from `:job_index` because the two invalidate on different files: editing a
|
|
94
|
+
# schedule changes which jobs are reached without touching `app/jobs` at all. The `watch:` roots the
|
|
95
|
+
# glob at the working directory so that CREATING a schedule file — not just editing one — is seen.
|
|
96
|
+
producer :recurring_jobs, watch: -> { [[".", *@recurring_paths]] } do |_params|
|
|
97
|
+
RecurringScan.new(
|
|
98
|
+
io_boundary: io_boundary,
|
|
99
|
+
recurring_paths: @recurring_paths
|
|
100
|
+
).job_names
|
|
101
|
+
end
|
|
102
|
+
|
|
57
103
|
def init(_services)
|
|
58
104
|
@job_search_paths = Array(config.fetch("job_search_paths")).map(&:to_s)
|
|
59
105
|
@job_base_classes = Array(config.fetch("job_base_classes")).map(&:to_s)
|
|
106
|
+
@recurring_paths = Array(config.fetch("recurring_paths")).map(&:to_s)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ADR-102 WD3 — publishes the jobs Solid Queue's recurring schedule runs BY NAME, for `rigor unused`.
|
|
110
|
+
#
|
|
111
|
+
# The intersection is what keeps the contribution honest, exactly as in `rigor-sidekiq` and
|
|
112
|
+
# `rigor-pundit`. {RecurringScan} says which names the schedule WRITES; {JobIndex} says which jobs
|
|
113
|
+
# EXIST. A `class:` value matching no discovered job — a typo, a renamed class, a job living outside
|
|
114
|
+
# `job_search_paths` — is dropped rather than published, so the failure mode is a missing root (a
|
|
115
|
+
# candidate row a human can judge) instead of a spurious one (silence where dead code used to be).
|
|
116
|
+
def prepare(services)
|
|
117
|
+
index = producer_value(:job_index)
|
|
118
|
+
scheduled = producer_value(:recurring_jobs)
|
|
119
|
+
return if index.nil? || scheduled.nil?
|
|
120
|
+
|
|
121
|
+
roots = scheduled.select { |name| index.known?(name) }
|
|
122
|
+
return if roots.empty?
|
|
123
|
+
|
|
124
|
+
services.fact_store.publish(plugin_id: manifest.id, name: :reachability_roots, value: roots)
|
|
60
125
|
end
|
|
61
126
|
|
|
62
127
|
# File-level only: the load-error emission. Per-call arity validation runs over the engine-owned
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Activerecord < Rigor::Plugin::Base
|
|
8
|
+
# rigor-activerecord's effect contract (ADR-103 WD10; design note § 11.2; issue #387).
|
|
9
|
+
#
|
|
10
|
+
# ## The two channels, and which row goes where
|
|
11
|
+
#
|
|
12
|
+
# The plugin already ships `sig/active_record/relation.rbs`, so **every `ActiveRecord::Relation`
|
|
13
|
+
# method it declares carries its own `%a{…}` annotation there** — the tier-1 channel, read as an
|
|
14
|
+
# accepted signature and therefore discharging (ADR-103 WD6). That file is also where the builder /
|
|
15
|
+
# materializer split lives, because it is the file that already draws it.
|
|
16
|
+
#
|
|
17
|
+
# What is left over lands here, and each item is left over for a reason RBS cannot fix:
|
|
18
|
+
#
|
|
19
|
+
# - **`ActiveRecord::Base`'s own surface.** The plugin ships no `ActiveRecord::Base` signature and
|
|
20
|
+
# should not: an app's models are typed from `db/schema.rb`, per project, and a generic base-class
|
|
21
|
+
# RBS would fight whatever the project's own `rbs collection` supplies.
|
|
22
|
+
# - **The `Enumerable` delegations on a Relation.** `map`, `filter_map`, `each_with_object` and the
|
|
23
|
+
# rest materialise by delegating to `each`, and declaring them in the bundled RBS would change how
|
|
24
|
+
# they *type* (`Enumerable#map`'s block-return element type is the whole point of the include).
|
|
25
|
+
# - **The connection adapter and the migration DSL**, neither of which the plugin types at all.
|
|
26
|
+
#
|
|
27
|
+
# ## Why the reads and writes are worth spelling out at all
|
|
28
|
+
#
|
|
29
|
+
# An `ActiveRecord::Base` row reaches `User.find` through the project's own
|
|
30
|
+
# `User < ApplicationRecord < ActiveRecord::Base` lines, so one row covers every model in the app.
|
|
31
|
+
# That is the whole economy of the framework layer: thirty-odd rows here colour the several thousand
|
|
32
|
+
# database touches a Rails app performs, and none of them needed a line of application code.
|
|
33
|
+
module Effects
|
|
34
|
+
BASE = "ActiveRecord::Base"
|
|
35
|
+
RELATION = "ActiveRecord::Relation"
|
|
36
|
+
ADAPTER = "ActiveRecord::ConnectionAdapters::AbstractAdapter"
|
|
37
|
+
MIGRATION = "ActiveRecord::Migration"
|
|
38
|
+
|
|
39
|
+
READ = ["io.db.read"].freeze
|
|
40
|
+
WRITE = ["io.db.write"].freeze
|
|
41
|
+
TRANSACTION = ["io.db.transaction"].freeze
|
|
42
|
+
SCHEMA_WRITE = ["io.db.write", "rails.schema.write"].freeze
|
|
43
|
+
|
|
44
|
+
# Class-side finders. Every one issues a `SELECT` the moment it is called — that is what separates
|
|
45
|
+
# them from `where`, which returns a relation and issues nothing.
|
|
46
|
+
SINGLETON_READS = %w[
|
|
47
|
+
find find_by find_by! first first! last last! take take! sole find_sole_by
|
|
48
|
+
second third fourth fifth forty_two second_to_last third_to_last
|
|
49
|
+
exists? any? none? one? many? empty? count sum average minimum maximum calculate
|
|
50
|
+
pluck pick ids find_each find_in_batches in_batches find_by_sql count_by_sql
|
|
51
|
+
find_or_initialize_by
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
# Class-side writers.
|
|
55
|
+
SINGLETON_WRITES = %w[
|
|
56
|
+
create create! insert insert! insert_all insert_all! upsert upsert_all
|
|
57
|
+
update update! update_all delete delete_all delete_by destroy destroy_all destroy_by
|
|
58
|
+
find_or_create_by find_or_create_by! create_or_find_by create_or_find_by! touch_all
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
# Instance-side readers. `reload` re-issues the `SELECT` and replaces the record's attributes,
|
|
62
|
+
# which is a receiver mutation as well as a read.
|
|
63
|
+
INSTANCE_READS = %w[valid? invalid? reload].freeze
|
|
64
|
+
|
|
65
|
+
# Instance-side writers. Each is a statement issued now; `save`'s callbacks and validators are the
|
|
66
|
+
# `effect_edges:` half, and arrive as edges rather than labels.
|
|
67
|
+
INSTANCE_WRITES = %w[
|
|
68
|
+
save save! update update! update_attribute update_attributes update_attributes!
|
|
69
|
+
update_column update_columns destroy destroy! delete touch increment! decrement!
|
|
70
|
+
toggle! becomes! insert
|
|
71
|
+
].freeze
|
|
72
|
+
|
|
73
|
+
# The `Enumerable` surface a Relation inherits. Every one of these runs the query, because every
|
|
74
|
+
# one of them calls `each`.
|
|
75
|
+
RELATION_MATERIALIZERS = %w[
|
|
76
|
+
map flat_map filter_map collect collect_concat select filter reject find detect find_all
|
|
77
|
+
each_with_object each_with_index each_entry each_slice each_cons reduce inject
|
|
78
|
+
group_by partition sort sort_by min min_by max max_by minmax tally zip
|
|
79
|
+
take_while drop_while chunk_while slice_when lazy to_set to_h
|
|
80
|
+
all? any? none? one? include? member? first count sum
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
# Raw SQL, narrowed by the statement's own leading verb ({Rigor::Effects::Narrowing} `sql_verb`).
|
|
84
|
+
ADAPTER_SQL = %w[execute exec_query exec_insert exec_update exec_delete select_all select_one
|
|
85
|
+
select_value select_values select_rows query query_value query_values].freeze
|
|
86
|
+
|
|
87
|
+
# The migration DSL. `rails.schema.write` is the meaning a reviewer names ("this changes the
|
|
88
|
+
# schema"); `io.db.write` is the transport that makes the summary honest.
|
|
89
|
+
MIGRATION_DSL = %w[
|
|
90
|
+
create_table drop_table rename_table change_table create_join_table drop_join_table
|
|
91
|
+
add_column remove_column rename_column change_column change_column_null
|
|
92
|
+
change_column_default change_column_comment
|
|
93
|
+
add_index remove_index rename_index add_reference remove_reference
|
|
94
|
+
add_foreign_key remove_foreign_key add_check_constraint remove_check_constraint
|
|
95
|
+
add_timestamps remove_timestamps enable_extension disable_extension
|
|
96
|
+
execute add_belongs_to remove_belongs_to
|
|
97
|
+
].freeze
|
|
98
|
+
|
|
99
|
+
ADAPTER_WHY = "raw SQL; the statement's own leading verb narrows the direction, so a literal " \
|
|
100
|
+
"`execute(\"UPDATE …\")` reads as a write and a computed string keeps the honest " \
|
|
101
|
+
"`io.db`."
|
|
102
|
+
|
|
103
|
+
# Ambient AR calls that are neither a read nor a write of rows.
|
|
104
|
+
TRANSACTIONAL = %w[transaction with_lock lock!].freeze
|
|
105
|
+
|
|
106
|
+
module_function
|
|
107
|
+
|
|
108
|
+
def attributions
|
|
109
|
+
singleton_rows + instance_rows + relation_rows + adapter_rows + migration_rows
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def singleton_rows
|
|
113
|
+
rows(BASE, SINGLETON_READS, READ, singleton: true,
|
|
114
|
+
why: "issues the SELECT at the call — this is the materializing " \
|
|
115
|
+
"half of the builder/materializer split") +
|
|
116
|
+
rows(BASE, SINGLETON_WRITES, WRITE, singleton: true,
|
|
117
|
+
why: "issues an INSERT / UPDATE / DELETE at the call") +
|
|
118
|
+
rows(BASE, TRANSACTIONAL, TRANSACTION, singleton: true,
|
|
119
|
+
why: "opens a transaction; the block's own origins join " \
|
|
120
|
+
"by containment, so the row states only the BEGIN")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def instance_rows
|
|
124
|
+
rows(BASE, INSTANCE_READS, READ,
|
|
125
|
+
why: "re-reads the row (`reload`) or runs the validators, whose uniqueness checks query") +
|
|
126
|
+
rows(BASE, INSTANCE_WRITES, WRITE,
|
|
127
|
+
why: "persists the record — the write a `db: none` envelope is written to catch") +
|
|
128
|
+
rows(BASE, TRANSACTIONAL, TRANSACTION,
|
|
129
|
+
why: "opens a transaction / takes a row lock around the block")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def relation_rows
|
|
133
|
+
rows(RELATION, RELATION_MATERIALIZERS, READ,
|
|
134
|
+
why: "an Enumerable delegation on a Relation: it calls `each`, which runs the query. Not " \
|
|
135
|
+
"declared in the bundled RBS because declaring it would change how it types")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Two rows per selector, because raw SQL is written two ways and only one of them names a type.
|
|
139
|
+
# `adapter.execute(sql)` on a receiver the typer managed to name is the first; the second matches
|
|
140
|
+
# `ActiveRecord::Base.connection.execute(sql)` and `User.connection.exec_query(sql)`, where the
|
|
141
|
+
# connection object has no declared type but the class that handed it over is written in the source.
|
|
142
|
+
def adapter_rows
|
|
143
|
+
ADAPTER_SQL.flat_map do |selector|
|
|
144
|
+
[
|
|
145
|
+
EffectAttribution.new(
|
|
146
|
+
receiver: ADAPTER, method: selector, labels: ["io.db"], narrow: "sql_verb", discharge: true,
|
|
147
|
+
why: ADAPTER_WHY
|
|
148
|
+
),
|
|
149
|
+
EffectAttribution.new(
|
|
150
|
+
receiver: BASE, method: selector, labels: ["io.db"], narrow: "sql_verb", on_result: true,
|
|
151
|
+
discharge: true,
|
|
152
|
+
why: "#{ADAPTER_WHY} Matched on the result of `Model.connection`, which is how raw SQL is " \
|
|
153
|
+
"actually spelled in a Rails app."
|
|
154
|
+
)
|
|
155
|
+
]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def migration_rows
|
|
160
|
+
rows(MIGRATION, MIGRATION_DSL, SCHEMA_WRITE,
|
|
161
|
+
why: "the migration DSL issues DDL: `io.db.write` is the transport, `rails.schema.write` " \
|
|
162
|
+
"the meaning a `db/migrate/**` envelope names")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def rows(receiver, selectors, labels, why:, singleton: false)
|
|
166
|
+
selectors.map do |selector|
|
|
167
|
+
EffectAttribution.new(receiver: receiver, method: selector, labels: labels,
|
|
168
|
+
singleton: singleton, discharge: true, why: why)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ADR-103 WD10 — the one edge ActiveRecord contributes: `save` runs the class body's callbacks and
|
|
173
|
+
# validators. The engine owns the walk; this names the base class whose descendants it applies to.
|
|
174
|
+
def edges
|
|
175
|
+
[
|
|
176
|
+
EffectEdge.new(
|
|
177
|
+
receiver: BASE, target: :activerecord_callbacks,
|
|
178
|
+
why: "`before_save :normalize` / `validate :check` / `after_commit :notify` are real, " \
|
|
179
|
+
"synchronous, in-process calls that no syntax at the call site contains"
|
|
180
|
+
)
|
|
181
|
+
]
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -8,6 +8,7 @@ require_relative "activerecord/structure_sql_parser"
|
|
|
8
8
|
require_relative "activerecord/model_index"
|
|
9
9
|
require_relative "activerecord/model_discoverer"
|
|
10
10
|
require_relative "activerecord/analyzer"
|
|
11
|
+
require_relative "activerecord/effects"
|
|
11
12
|
|
|
12
13
|
module Rigor
|
|
13
14
|
module Plugin
|
|
@@ -72,7 +73,17 @@ module Rigor
|
|
|
72
73
|
# ADR-26 — `ActiveRecord::Relation` is an "open" receiver: it delegates an unbounded set of
|
|
73
74
|
# user-defined scopes / class methods to its model, so `call.undefined-method` must not fire for
|
|
74
75
|
# it. `CheckRules` reads this manifest field and skips the rule for the class.
|
|
75
|
-
open_receivers: ["ActiveRecord::Relation"]
|
|
76
|
+
open_receivers: ["ActiveRecord::Relation"],
|
|
77
|
+
# ADR-103 WD2 / WD10 (#387) — the effect layer. rigor-activerecord models ActiveRecord, which is
|
|
78
|
+
# part of Rails, so it opens the framework's own `rails.*` root rather than one named after the
|
|
79
|
+
# plugin; `effect_root:` is granted only because the engine bundles this plugin
|
|
80
|
+
# ({Rigor::Plugin::FirstParty}). Every row's justification is in {Effects}, and the
|
|
81
|
+
# `ActiveRecord::Relation` half is in the bundled `sig/active_record/relation.rbs` instead —
|
|
82
|
+
# tier 1, because the plugin already ships that signature.
|
|
83
|
+
effect_root: "rails",
|
|
84
|
+
effect_labels: ["rails.schema.write"],
|
|
85
|
+
effect_attributions: Effects.attributions,
|
|
86
|
+
effect_edges: Effects.edges
|
|
76
87
|
)
|
|
77
88
|
|
|
78
89
|
# The class the bundled `sig/active_record/relation.rbs` describes; `dynamic_return` contributes
|