rigortype 0.3.3 → 0.3.5
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 +860 -0
- data/data/effects/registry.yml +95 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +359 -2
- data/docs/manual/03-configuration.md +101 -1
- data/docs/manual/04-diagnostics.md +8 -3
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +90 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +10 -0
- data/docs/manual/ci-templates/README.md +9 -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/rule_ids.rb +28 -2
- 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 +168 -3
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +38 -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 +93 -10
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +429 -10
- data/lib/rigor/analysis/worker_session.rb +60 -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 +245 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +92 -0
- data/lib/rigor/cli/effects_report.rb +163 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +28 -1
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +322 -28
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +219 -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 +139 -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 +174 -0
- data/lib/rigor/effects/framework_units.rb +222 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/inline_anchor.rb +134 -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 +384 -0
- data/lib/rigor/effects/propagator.rb +335 -0
- data/lib/rigor/effects/registry.rb +200 -0
- data/lib/rigor/effects/scanner.rb +302 -0
- data/lib/rigor/effects/signature_sources.rb +90 -0
- data/lib/rigor/effects/snapshot.rb +396 -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 +39 -0
- data/lib/rigor/effects/unit_scan.rb +647 -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/expression_typer.rb +18 -4
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +15 -3
- data/lib/rigor/plugin/base.rb +28 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -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 +127 -3
- data/lib/rigor/plugin/registry.rb +55 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +188 -0
- data/lib/rigor/reflection.rb +131 -30
- 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 +401 -29
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- 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-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -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.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +84 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Actionpack < Rigor::Plugin::Base
|
|
8
|
+
# rigor-actionpack's effect contract (ADR-103 WD10 / WD14; design note § 11.2; issue #387).
|
|
9
|
+
#
|
|
10
|
+
# A controller action's effects are mostly writes to the response and to per-request state, and the
|
|
11
|
+
# vocabulary distinguishes them because a reviewer does: `rails.response.write` is "this action
|
|
12
|
+
# answers the request", `rails.session.write` is "this action changes who the user is logged in as".
|
|
13
|
+
#
|
|
14
|
+
# ## `mutate.self`, not `io`
|
|
15
|
+
#
|
|
16
|
+
# `render` and `redirect_to` do not write to a socket. They set `@_response_body` and the status on
|
|
17
|
+
# the controller instance; Rack writes it later, outside any project method. So the honest label is
|
|
18
|
+
# `mutate.self` plus the framework meaning — and an envelope forbidding `io` in a service object is
|
|
19
|
+
# not violated by a helper that happens to call `render_to_string`.
|
|
20
|
+
#
|
|
21
|
+
# `render` additionally keeps a **taint**: the template is not an effect unit yet (ADR-103 WD11 /
|
|
22
|
+
# issue #392), so what the view does is genuinely unknown and the summary says so rather than
|
|
23
|
+
# pretending the action stops at the `render` line.
|
|
24
|
+
#
|
|
25
|
+
# ## The self-path rows
|
|
26
|
+
#
|
|
27
|
+
# `session[:user_id] = id` is `[]=` on the result of a receiver-less `session`, and nothing types
|
|
28
|
+
# that result. The `self.session` spelling matches the receiver expression as written, scoped by
|
|
29
|
+
# `within:` to classes whose project ancestry reaches `ActionController::Base` — so a `session`
|
|
30
|
+
# method on some unrelated project class is not mistaken for this one.
|
|
31
|
+
module Effects
|
|
32
|
+
CONTROLLER = "ActionController::Base"
|
|
33
|
+
|
|
34
|
+
RESPONSE = ["mutate.self", "rails.response.write"].freeze
|
|
35
|
+
SESSION_WRITE = ["mutate", "rails.session.write"].freeze
|
|
36
|
+
SESSION_READ = ["io", "rails.session.read"].freeze
|
|
37
|
+
COOKIE_WRITE = ["mutate", "rails.cookie.write"].freeze
|
|
38
|
+
FLASH_WRITE = ["mutate", "rails.flash.write"].freeze
|
|
39
|
+
|
|
40
|
+
# Response writers that say everything about themselves.
|
|
41
|
+
RESPONSE_WRITERS = %w[redirect_to redirect_back redirect_back_or_to head].freeze
|
|
42
|
+
|
|
43
|
+
# The render family. Same labels, plus the `template-not-analysed` taint: what the controller does
|
|
44
|
+
# is fully stated, and what the TEMPLATE does is unknown until views are effect units (ADR-103
|
|
45
|
+
# WD11 / issue #392). A summary that stopped at the `render` line and read exhaustive would be
|
|
46
|
+
# the one genuinely misleading row in the whole Rails layer.
|
|
47
|
+
RENDERERS = %w[render render_to_string render_to_body].freeze
|
|
48
|
+
|
|
49
|
+
# The cookie jars a Rails app writes through.
|
|
50
|
+
COOKIE_JARS = ["self.cookies", "self.cookies.signed", "self.cookies.encrypted",
|
|
51
|
+
"self.cookies.permanent"].freeze
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
def attributions
|
|
56
|
+
response_rows + file_rows + session_rows + cookie_rows + flash_rows
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def response_rows
|
|
60
|
+
RESPONSE_WRITERS.map do |selector|
|
|
61
|
+
EffectAttribution.new(
|
|
62
|
+
receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
|
|
63
|
+
why: "sets the response on the controller instance — Rack writes the socket later, outside " \
|
|
64
|
+
"any project method, so this is `mutate.self` and deliberately not `io`"
|
|
65
|
+
)
|
|
66
|
+
end + render_rows
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render_rows
|
|
70
|
+
RENDERERS.map do |selector|
|
|
71
|
+
EffectAttribution.new(
|
|
72
|
+
receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
|
|
73
|
+
taint: "template-not-analysed",
|
|
74
|
+
why: "sets the response body from a template. The controller half is fully stated; the " \
|
|
75
|
+
"template's own effects are unknown until views become effect units, and the taint " \
|
|
76
|
+
"is how the summary says so rather than reading exhaustive"
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# `send_file` streams from disk; `send_data` does not.
|
|
82
|
+
def file_rows
|
|
83
|
+
[
|
|
84
|
+
EffectAttribution.new(
|
|
85
|
+
receiver: CONTROLLER, method: :send_data, labels: RESPONSE, discharge: true,
|
|
86
|
+
why: "sets the response body from an in-memory string"
|
|
87
|
+
),
|
|
88
|
+
EffectAttribution.new(
|
|
89
|
+
receiver: CONTROLLER, method: :send_file, labels: RESPONSE + ["io.fs.read"], discharge: true,
|
|
90
|
+
why: "sets the response AND reads the named file off disk"
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def session_rows
|
|
96
|
+
[
|
|
97
|
+
EffectAttribution.new(
|
|
98
|
+
receiver: "self.session", method: :[]=, labels: SESSION_WRITE, within: CONTROLLER,
|
|
99
|
+
discharge: true,
|
|
100
|
+
why: "writes per-request state whose store may be a cookie, a cache or the database"
|
|
101
|
+
),
|
|
102
|
+
EffectAttribution.new(
|
|
103
|
+
receiver: "self.session", method: :delete, labels: SESSION_WRITE, within: CONTROLLER,
|
|
104
|
+
discharge: true, why: "same store, same write"
|
|
105
|
+
),
|
|
106
|
+
EffectAttribution.new(
|
|
107
|
+
receiver: "self.session", method: :[], labels: SESSION_READ, within: CONTROLLER,
|
|
108
|
+
discharge: true,
|
|
109
|
+
why: "reads the session store — `io` because a cache- or database-backed store really does " \
|
|
110
|
+
"go out to fetch it"
|
|
111
|
+
),
|
|
112
|
+
EffectAttribution.new(
|
|
113
|
+
receiver: CONTROLLER, method: :reset_session, labels: SESSION_WRITE, discharge: true,
|
|
114
|
+
why: "discards the whole session — the logout write"
|
|
115
|
+
)
|
|
116
|
+
]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def cookie_rows
|
|
120
|
+
COOKIE_JARS.flat_map do |jar|
|
|
121
|
+
%i[[]= delete].map do |selector|
|
|
122
|
+
EffectAttribution.new(
|
|
123
|
+
receiver: jar, method: selector, labels: COOKIE_WRITE, within: CONTROLLER, discharge: true,
|
|
124
|
+
why: "queues a Set-Cookie header on the response — state that outlives the request"
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def flash_rows
|
|
131
|
+
["self.flash", "self.flash.now"].flat_map do |jar|
|
|
132
|
+
%i[[]= alert= notice=].map do |selector|
|
|
133
|
+
EffectAttribution.new(
|
|
134
|
+
receiver: jar, method: selector, labels: FLASH_WRITE, within: CONTROLLER, discharge: true,
|
|
135
|
+
why: "writes the flash, which rides the session into the next request"
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def entry_points
|
|
142
|
+
[
|
|
143
|
+
EffectEntryPoints.new(
|
|
144
|
+
name: "rails-controllers", globs: ["app/controllers/**/*.rb"],
|
|
145
|
+
why: "controller actions — the request entry points; nothing in the project calls them"
|
|
146
|
+
)
|
|
147
|
+
]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "rigor/plugin"
|
|
4
4
|
|
|
5
5
|
require_relative "actionpack/analyzer"
|
|
6
|
+
require_relative "actionpack/effects"
|
|
6
7
|
require_relative "actionpack/controller_discoverer"
|
|
7
8
|
require_relative "actionpack/controller_index"
|
|
8
9
|
|
|
@@ -69,7 +70,14 @@ module Rigor
|
|
|
69
70
|
consumes: [
|
|
70
71
|
{ plugin_id: "rails-routes", name: :helper_table, optional: true },
|
|
71
72
|
{ plugin_id: "activerecord", name: :model_index, optional: true }
|
|
72
|
-
]
|
|
73
|
+
],
|
|
74
|
+
# ADR-103 WD10 / WD14 (#387) — see {Effects} for what each row is and why.
|
|
75
|
+
effect_root: "rails",
|
|
76
|
+
effect_labels: %w[
|
|
77
|
+
rails.response.write rails.session.read rails.session.write rails.cookie.write rails.flash.write
|
|
78
|
+
],
|
|
79
|
+
effect_attributions: Effects.attributions,
|
|
80
|
+
effect_entry_points: Effects.entry_points
|
|
73
81
|
)
|
|
74
82
|
|
|
75
83
|
# Phase 2 cached producer — the controller index built from `controller_search_paths`. `watch:`
|
|
@@ -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
|