rigortype 0.3.3 → 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 +6 -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/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 +158 -0
- 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/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/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 +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/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- 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/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.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +79 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# One row of a plugin's `effect_attributions:` — "a call to this receiver's method contributes these
|
|
6
|
+
# effect labels" (ADR-103 WD6 / WD10; design note § 6.6; the contract is
|
|
7
|
+
# `docs/internal-spec/plugin.md` § Effect contributions).
|
|
8
|
+
#
|
|
9
|
+
# A framework method has no body Rigor reads, so someone must colour it. Three channels can, in
|
|
10
|
+
# descending order of preference:
|
|
11
|
+
#
|
|
12
|
+
# 1. a `%a{rigor:v1:effect …}` annotation in the plugin's own `signature_paths:` RBS — tier 1, read
|
|
13
|
+
# through {Rigor::Effects::EnvelopeIndex}'s accepted stratum, and the right channel whenever the
|
|
14
|
+
# plugin already ships a signature for the method;
|
|
15
|
+
# 2. this field, for the methods RBS cannot name per app — association readers, `find_by_*`, scopes —
|
|
16
|
+
# and for classes the plugin ships no RBS for at all;
|
|
17
|
+
# 3. the project's own `effects.attribution:` table, which is the user's answer for everything left.
|
|
18
|
+
#
|
|
19
|
+
# ## The receiver
|
|
20
|
+
#
|
|
21
|
+
# `receiver` is spelled one of two ways, and the spelling picks the matching rule:
|
|
22
|
+
#
|
|
23
|
+
# - a **class name** (`"ActiveRecord::Base"`, `"I18n"`) matches the class the call's receiver
|
|
24
|
+
# projects to, **through the project's inheritance chain**: an `ActiveRecord::Base` row applies to
|
|
25
|
+
# `User.find` because the project declares `User < ApplicationRecord < ActiveRecord::Base`. This is
|
|
26
|
+
# the difference from a catalogue row, which matches its exact owner and nothing else — Ruby's core
|
|
27
|
+
# classes are leaves in practice and a framework's base class never is.
|
|
28
|
+
# - a **receiver path** (`"Rails.cache"`, `"Time.zone"`, `"Rails.application.credentials"`) matches
|
|
29
|
+
# the receiver *expression* as the syntax spells it. `Rails.cache.read` has no receiver class the
|
|
30
|
+
# typer can name — `Rails.cache` is a call, and its return type is adapter-dependent by design —
|
|
31
|
+
# so the only honest handle on it is the path that was written.
|
|
32
|
+
# - a **self path** (`"self.session"`, `"self.flash.now"`, `"self.cookies.encrypted"`) is the same
|
|
33
|
+
# thing rooted at implicit self, and is what a Rails controller's accessors actually look like:
|
|
34
|
+
# `session[:user_id] = id` is `[]=` on the result of a receiver-less `session`. A self-path row MUST
|
|
35
|
+
# name a `within:` class, because a receiver-less `session` in some other project class is a
|
|
36
|
+
# different `session` — the row applies only inside a class whose project ancestry reaches `within`.
|
|
37
|
+
#
|
|
38
|
+
# `on_result: true` shifts a class-name row one link outwards: it matches a call on **what a call to
|
|
39
|
+
# that class returned**. `UserMailer.welcome(u).deliver_now` and `WelcomeJob.set(wait: 1.hour)
|
|
40
|
+
# .perform_later` are the two idioms that need it — the object in the middle is a lazy
|
|
41
|
+
# `MessageDelivery` / `ConfiguredJob` whose type nothing in the project declares, while the class that
|
|
42
|
+
# produced it is written right there in the source. Without it the send and the enqueue, the two calls
|
|
43
|
+
# a reviewer most wants coloured, would go unattributed in the spelling Rails actually uses.
|
|
44
|
+
#
|
|
45
|
+
# ## Discharge
|
|
46
|
+
#
|
|
47
|
+
# `discharge: true` says the label is derived from the framework's own semantics rather than guessed,
|
|
48
|
+
# so the site is exhaustive rather than tainted. ADR-103 WD6 grants that only to a **first-party
|
|
49
|
+
# bundled** plugin ({FirstParty}), gated by `make check-plugins`; a third-party plugin's `true` is
|
|
50
|
+
# ignored with a load-time warning and the row behaves like the project's `effects.attribution:`
|
|
51
|
+
# table — declared, and carrying a `plugin-attribution` taint.
|
|
52
|
+
#
|
|
53
|
+
# Either way the labels land in the **declared** lane, never the proven one. A discharging row is a
|
|
54
|
+
# trusted claim, exactly like an accepted signature's `%a{…}`: "this is what it does", not "the
|
|
55
|
+
# analyzer read the body and saw this".
|
|
56
|
+
class EffectAttribution
|
|
57
|
+
# A receiver spelled as a `Constant::Path` — the class-name form. Anything else with a `.` in it is
|
|
58
|
+
# read as a receiver path.
|
|
59
|
+
CLASS_NAME = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/
|
|
60
|
+
|
|
61
|
+
# A receiver path: a constant head followed by one or more sends (`Rails.cache`,
|
|
62
|
+
# `Rails.application.credentials`).
|
|
63
|
+
RECEIVER_PATH = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*(\.[a-z_][A-Za-z0-9_]*)+\z/
|
|
64
|
+
|
|
65
|
+
# A self path: implicit self followed by one or more receiver-less sends (`self.flash.now`).
|
|
66
|
+
SELF_PATH = /\Aself(\.[a-z_][A-Za-z0-9_]*)+\z/
|
|
67
|
+
|
|
68
|
+
# What a self path is rooted at, once the `self.` head is stripped.
|
|
69
|
+
SELF_HEAD = "self"
|
|
70
|
+
|
|
71
|
+
attr_reader :receiver, :method, :singleton, :labels, :narrow, :discharge, :within, :on_result,
|
|
72
|
+
:taint, :why
|
|
73
|
+
|
|
74
|
+
# The taint causes a plugin row may name. A closed subset of
|
|
75
|
+
# {Rigor::Effects::TaintCause::ALL}: a plugin may say "and there is more here I cannot see", but
|
|
76
|
+
# only for the two reasons a framework model can honestly have — a template it does not read, and a
|
|
77
|
+
# callable whose body is supplied by the application.
|
|
78
|
+
TAINT_CAUSES = %w[template-not-analysed opaque-callable].freeze
|
|
79
|
+
|
|
80
|
+
# @param receiver [String] a class name or a receiver path (see above)
|
|
81
|
+
# @param method [Symbol, String] the selector this row colours
|
|
82
|
+
# @param singleton [Boolean] whether the row is `Receiver.method` rather than `Receiver#method`.
|
|
83
|
+
# Meaningless — and ignored — for a receiver path, whose head already fixes the receiver object.
|
|
84
|
+
# @param labels [Array<String>] the effect labels the call contributes
|
|
85
|
+
# @param narrow [String, nil] a {Rigor::Effects::Narrowing} handler name, when the call's own
|
|
86
|
+
# argument literals settle a question the row cannot (`connection.execute("SELECT …")`)
|
|
87
|
+
# @param discharge [Boolean] see above; honoured only for a first-party bundled plugin
|
|
88
|
+
# @param why [String] the audit justification, required exactly as `data/effects/core.yml` requires
|
|
89
|
+
# one of every row: a label with no stated reason is a claim nobody can review.
|
|
90
|
+
def initialize(receiver:, method:, labels:, why:, singleton: false, narrow: nil, discharge: false, # rubocop:disable Metrics/ParameterLists
|
|
91
|
+
within: nil, on_result: false, taint: nil)
|
|
92
|
+
@receiver = validate_receiver!(receiver)
|
|
93
|
+
@method = method.to_sym
|
|
94
|
+
@singleton = singleton ? true : false
|
|
95
|
+
@labels = normalize_labels(labels)
|
|
96
|
+
@narrow = narrow.nil? ? nil : narrow.to_s.dup.freeze
|
|
97
|
+
@discharge = discharge ? true : false
|
|
98
|
+
@within = validate_within!(within)
|
|
99
|
+
@on_result = on_result ? true : false
|
|
100
|
+
validate_on_result!
|
|
101
|
+
@taint = validate_taint!(taint)
|
|
102
|
+
@why = validate_why!(why)
|
|
103
|
+
freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether {#receiver} is a receiver path (`Rails.cache`) rather than a class name or a self path.
|
|
107
|
+
def receiver_path?
|
|
108
|
+
@receiver.include?(".") && !self_path?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Whether {#receiver} is a self path (`self.flash.now`).
|
|
112
|
+
def self_path?
|
|
113
|
+
@receiver.start_with?("#{SELF_HEAD}.")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The key an origin and a report spell this row as.
|
|
117
|
+
def key
|
|
118
|
+
return "#{@receiver}.#{@method}" if receiver_path? || self_path?
|
|
119
|
+
return "#{@receiver}()##{@method}" if @on_result
|
|
120
|
+
|
|
121
|
+
"#{@receiver}#{@singleton ? '.' : '#'}#{@method}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def to_h
|
|
125
|
+
{
|
|
126
|
+
"receiver" => @receiver, "method" => @method.to_s, "singleton" => @singleton,
|
|
127
|
+
"labels" => @labels, "narrow" => @narrow, "discharge" => @discharge, "within" => @within,
|
|
128
|
+
"on_result" => @on_result, "taint" => @taint
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def ==(other)
|
|
133
|
+
other.is_a?(EffectAttribution) && to_h == other.to_h
|
|
134
|
+
end
|
|
135
|
+
alias eql? ==
|
|
136
|
+
|
|
137
|
+
def hash
|
|
138
|
+
to_h.hash
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def validate_receiver!(receiver)
|
|
144
|
+
value = receiver.to_s
|
|
145
|
+
unless CLASS_NAME.match?(value) || RECEIVER_PATH.match?(value) || SELF_PATH.match?(value)
|
|
146
|
+
raise ArgumentError,
|
|
147
|
+
"effect attribution receiver must be a class name, a receiver path or a self path, " \
|
|
148
|
+
"got #{receiver.inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
value.dup.freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A self path with no `within:` would colour a receiver-less `session` in any project class at all.
|
|
155
|
+
def validate_within!(within)
|
|
156
|
+
if within.nil?
|
|
157
|
+
raise ArgumentError, "effect attribution #{key} is a self path and must name a `within:` class" if self_path?
|
|
158
|
+
|
|
159
|
+
return nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
value = within.to_s
|
|
163
|
+
raise ArgumentError, "effect attribution `within:` must be a class name, got #{within.inspect}" unless
|
|
164
|
+
CLASS_NAME.match?(value)
|
|
165
|
+
|
|
166
|
+
value.dup.freeze
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def normalize_labels(labels)
|
|
170
|
+
list = Array(labels).map { |label| label.to_s.dup.freeze }
|
|
171
|
+
raise ArgumentError, "effect attribution for #{@receiver} must declare at least one label" if list.empty?
|
|
172
|
+
|
|
173
|
+
list.uniq.sort.freeze
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# `on_result:` shifts the match one link outwards, which only a class-name row can do: a receiver
|
|
177
|
+
# path already names the object, and a self path already names the frame.
|
|
178
|
+
def validate_on_result!
|
|
179
|
+
return unless @on_result
|
|
180
|
+
return unless receiver_path? || self_path?
|
|
181
|
+
|
|
182
|
+
raise ArgumentError, "effect attribution #{key} may not combine `on_result:` with a path receiver"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A row may state a bound AND say the bound is not the whole story. `render` is the case that needs
|
|
186
|
+
# it: what the controller does IS `mutate.self` + `rails.response.write`, and what the TEMPLATE does
|
|
187
|
+
# is unknown until views become effect units (ADR-103 WD11). Reporting only the first would be a
|
|
188
|
+
# summary that reads exhaustive and is not.
|
|
189
|
+
def validate_taint!(taint)
|
|
190
|
+
return nil if taint.nil?
|
|
191
|
+
|
|
192
|
+
value = taint.to_s
|
|
193
|
+
return value.dup.freeze if TAINT_CAUSES.include?(value)
|
|
194
|
+
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
"effect attribution #{key} may only taint with one of #{TAINT_CAUSES.inspect}, " \
|
|
197
|
+
"got #{taint.inspect}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def validate_why!(why)
|
|
201
|
+
value = why.to_s
|
|
202
|
+
raise ArgumentError, "effect attribution #{key} needs a `why:` justification" if value.empty?
|
|
203
|
+
|
|
204
|
+
value.dup.freeze
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# One entry of a plugin's `effect_edges:` — a call graph edge the *syntax* does not contain but the
|
|
6
|
+
# framework does (ADR-103 WD10; design note § 11.2 "Framework edges").
|
|
7
|
+
#
|
|
8
|
+
# `save` runs the class body's `before_save :normalize` and its validators; `Job.perform_now` runs
|
|
9
|
+
# `Job#perform`; `UserMailer.welcome(u)` runs `UserMailer#welcome`. None of those is a call the
|
|
10
|
+
# analyzer can see, and all three are ordinary synchronous in-process control flow — so a plugin that
|
|
11
|
+
# models the framework contributes **edges**, not only labels.
|
|
12
|
+
#
|
|
13
|
+
# What a plugin must NOT contribute is the deferred half: `perform_later` never edges to `perform`,
|
|
14
|
+
# because the body runs in another process on another stack and the caller's code does not contain it
|
|
15
|
+
# (ADR-103 WD4, "attribution follows the code, not the clock"). The enum below has no spelling for it,
|
|
16
|
+
# which is the enforcement.
|
|
17
|
+
#
|
|
18
|
+
# ## Why a fixed enum rather than a callback
|
|
19
|
+
#
|
|
20
|
+
# A plugin supplies **parameters**; the engine owns the strategy. A block would have to run inside the
|
|
21
|
+
# per-file effect scan — the one place ADR-103 WD13 forbids anything that resolves, walks or types —
|
|
22
|
+
# and would not survive the fork-pool / Ractor boundary the collection window crosses. The enum keeps
|
|
23
|
+
# the plugin's contribution declarative, Marshal-clean and reviewable, and keeps every walk on the
|
|
24
|
+
# engine's side of the contract.
|
|
25
|
+
#
|
|
26
|
+
# ## The strategies
|
|
27
|
+
#
|
|
28
|
+
# - `:activerecord_callbacks` — `receiver:` is the ActiveRecord base class. On every project class
|
|
29
|
+
# whose ancestry reaches it, the engine reads the class body's callback and validation macros
|
|
30
|
+
# (`before_save :sym`, `validate :sym`, `after_commit :sym`, …) and synthesises the persistence
|
|
31
|
+
# selectors (`save`, `create!`, `destroy`, `valid?`, …) as effect units edged to those methods.
|
|
32
|
+
# `validates … uniqueness: true` additionally contributes an `io.db.read` origin, because the
|
|
33
|
+
# uniqueness check IS a query.
|
|
34
|
+
# - `:perform_now` — `receiver:` is the job base class. `Job.perform_now(…)` on a project subclass
|
|
35
|
+
# reaches `Job#perform`.
|
|
36
|
+
# - `:mailer_body` — `receiver:` is the mailer base class. `UserMailer.welcome(u)` on a project
|
|
37
|
+
# subclass reaches `UserMailer#welcome`, the class-method-to-instance mapping ActionMailer performs
|
|
38
|
+
# at run time.
|
|
39
|
+
#
|
|
40
|
+
# `method:` and `singleton:` are carried for a future strategy that keys on one selector; the three
|
|
41
|
+
# above read only `receiver:`.
|
|
42
|
+
class EffectEdge
|
|
43
|
+
# Every strategy the engine implements. A `target:` outside this set is a manifest error, so a
|
|
44
|
+
# plugin cannot silently declare an edge nothing honours.
|
|
45
|
+
TARGETS = %i[activerecord_callbacks perform_now mailer_body].freeze
|
|
46
|
+
|
|
47
|
+
attr_reader :receiver, :method, :singleton, :target, :why
|
|
48
|
+
|
|
49
|
+
def initialize(receiver:, target:, why:, method: nil, singleton: false)
|
|
50
|
+
@receiver = validate_receiver!(receiver)
|
|
51
|
+
@target = validate_target!(target)
|
|
52
|
+
@method = method&.to_sym
|
|
53
|
+
@singleton = singleton ? true : false
|
|
54
|
+
@why = validate_why!(why)
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_h
|
|
59
|
+
{
|
|
60
|
+
"receiver" => @receiver, "method" => @method&.to_s, "singleton" => @singleton,
|
|
61
|
+
"target" => @target.to_s
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ==(other)
|
|
66
|
+
other.is_a?(EffectEdge) && to_h == other.to_h
|
|
67
|
+
end
|
|
68
|
+
alias eql? ==
|
|
69
|
+
|
|
70
|
+
def hash
|
|
71
|
+
to_h.hash
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def validate_receiver!(receiver)
|
|
77
|
+
value = receiver.to_s
|
|
78
|
+
unless EffectAttribution::CLASS_NAME.match?(value)
|
|
79
|
+
raise ArgumentError, "effect edge receiver must be a class name, got #{receiver.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
value.dup.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_target!(target)
|
|
86
|
+
value = target.to_sym
|
|
87
|
+
return value if TARGETS.include?(value)
|
|
88
|
+
|
|
89
|
+
raise ArgumentError,
|
|
90
|
+
"effect edge target must be one of #{TARGETS.inspect}, got #{target.inspect}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_why!(why)
|
|
94
|
+
value = why.to_s
|
|
95
|
+
raise ArgumentError, "effect edge #{@receiver} -> #{@target} needs a `why:` justification" if value.empty?
|
|
96
|
+
|
|
97
|
+
value.dup.freeze
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# One entry of a plugin's `effect_entry_points:` — a **named** set of globs that
|
|
6
|
+
# `effects.snapshot.reach:` may adopt by name (ADR-103 WD14).
|
|
7
|
+
#
|
|
8
|
+
# `reach:` is "whose transitive footprint does the snapshot record", and the honest answer for a Rails
|
|
9
|
+
# app — controller actions, `perform`, mailer methods, channel methods — is a fact about the framework
|
|
10
|
+
# rather than about the project. So the framework's plugin names it and the project adopts it:
|
|
11
|
+
#
|
|
12
|
+
# effects:
|
|
13
|
+
# snapshot:
|
|
14
|
+
# reach: [rails]
|
|
15
|
+
#
|
|
16
|
+
# A preset is a **glob** set, matched against the project-relative file a method is defined in, with
|
|
17
|
+
# the `rigor unused --entry-point` semantics {Rigor::Effects::EntryPoints} already implements. Globs
|
|
18
|
+
# rather than a class-ancestry filter, deliberately: `reach:` is resolved from the snapshot's own
|
|
19
|
+
# method table, which carries a defining path per key and no ancestry — and a Rails app's layout IS
|
|
20
|
+
# the ancestry, which is why the convention exists at all.
|
|
21
|
+
class EffectEntryPoints
|
|
22
|
+
attr_reader :name, :globs, :why
|
|
23
|
+
|
|
24
|
+
# @param name [String, Symbol] the token `reach:` adopts. Must satisfy
|
|
25
|
+
# {Rigor::Effects::EntryPoints::NAME_PATTERN}, checked at registration.
|
|
26
|
+
# @param globs [Array<String>] project-relative path globs
|
|
27
|
+
# @param why [String] what the preset stands for, for the plugin's README and `rigor effects`' help
|
|
28
|
+
def initialize(name:, globs:, why: "")
|
|
29
|
+
@name = name.to_s.dup.freeze
|
|
30
|
+
@globs = Array(globs).map { |glob| glob.to_s.dup.freeze }.uniq.sort.freeze
|
|
31
|
+
raise ArgumentError, "effect entry-point preset #{@name.inspect} declares no globs" if @globs.empty?
|
|
32
|
+
|
|
33
|
+
@why = why.to_s.dup.freeze
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
{ "name" => @name, "globs" => @globs }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ==(other)
|
|
42
|
+
other.is_a?(EffectEntryPoints) && to_h == other.to_h
|
|
43
|
+
end
|
|
44
|
+
alias eql? ==
|
|
45
|
+
|
|
46
|
+
def hash
|
|
47
|
+
to_h.hash
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# Which plugin ids the engine itself ships (ADR-103 WD6).
|
|
6
|
+
#
|
|
7
|
+
# WD6's trust ladder grants two things to a **first-party bundled** plugin and to nothing else: it may
|
|
8
|
+
# open the effect-label root of the framework it models rather than one named after itself
|
|
9
|
+
# (`rails.*`, not `activerecord.*`), and its `effect_attributions:` may carry `discharge: true`. Both
|
|
10
|
+
# rest on the same fact — a bundled plugin's framework knowledge is versioned with the engine, reviewed
|
|
11
|
+
# in this repository, and gated by `make check-plugins`, which is exactly the standing a gem's shipped
|
|
12
|
+
# RBS has.
|
|
13
|
+
#
|
|
14
|
+
# The answer is **derived, never listed**: a plugin is first-party when the engine bundles the gem that
|
|
15
|
+
# would register it, which is the same question {Loader.bundled_plugin_path} already answers when it
|
|
16
|
+
# decides whether to require a plugin by its engine-anchored path or by gem name. Every bundled plugin
|
|
17
|
+
# id equals its gem name minus the `rigor-` prefix (pinned by spec), so the id is enough to ask. A list
|
|
18
|
+
# would be a second source of truth to keep in sync with `plugins/`, and the first drift would silently
|
|
19
|
+
# demote a plugin's rows to the tainted lane.
|
|
20
|
+
#
|
|
21
|
+
# This is a trust *ladder*, not a sandbox. ADR-2 settles that plugins are trusted gems the user
|
|
22
|
+
# selected and chooses documentation over forced isolation; a plugin determined to spoof a bundled id
|
|
23
|
+
# is out of scope there and stays out of scope here. What the predicate buys is that an ordinary
|
|
24
|
+
# third-party plugin cannot accidentally claim authority it was never granted.
|
|
25
|
+
module FirstParty
|
|
26
|
+
# The prefix a bundled plugin's gem name carries over its manifest id.
|
|
27
|
+
GEM_PREFIX = "rigor-"
|
|
28
|
+
|
|
29
|
+
@memo = {}
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Whether `id` names a plugin the engine bundles.
|
|
33
|
+
#
|
|
34
|
+
# Memoised: the answer is a `File.file?` on a path fixed for the process, and the effect surfaces
|
|
35
|
+
# ask it once per plugin per run — but a plugin's own specs ask it far more often than that.
|
|
36
|
+
def bundled?(id)
|
|
37
|
+
key = id.to_s
|
|
38
|
+
return false if key.empty?
|
|
39
|
+
|
|
40
|
+
return @memo[key] if @memo.key?(key)
|
|
41
|
+
|
|
42
|
+
# Required here rather than at the top of the file: {Loader} pulls in the plugin {Registry},
|
|
43
|
+
# which pulls in {Manifest}, which asks this module the question — a `require` cycle that would
|
|
44
|
+
# leave `Loader` undefined half-way through load. The require is idempotent and this method is
|
|
45
|
+
# memoised, so it costs one `$LOADED_FEATURES` probe per process.
|
|
46
|
+
require_relative "loader"
|
|
47
|
+
@memo[key] = !Loader.bundled_plugin_path("#{GEM_PREFIX}#{key}").nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Drops the memo. For specs that stub the engine root only.
|
|
51
|
+
def reset!
|
|
52
|
+
@memo = {}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -43,8 +43,11 @@ module Rigor
|
|
|
43
43
|
@target_bundle_root
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Frozen so the value stays `Ractor.shareable?`: a Ractor-pool worker READS this (its Environment
|
|
47
|
+
# build resolves the same root and skips the write — #414), and reading an unshareable value out of
|
|
48
|
+
# a module ivar from a non-main Ractor is the same `Ractor::IsolationError` as writing one.
|
|
46
49
|
def target_bundle_root=(root)
|
|
47
|
-
@target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s)
|
|
50
|
+
@target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s).freeze
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
# Requires `feature`, falling back to the analyzed project's bundler install tree. The fallback
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
require_relative "../inference/hkt_registry"
|
|
4
4
|
require_relative "protocol_contract"
|
|
5
5
|
require_relative "additional_initializer"
|
|
6
|
+
require_relative "effect_attribution"
|
|
7
|
+
require_relative "effect_edge"
|
|
8
|
+
require_relative "effect_entry_points"
|
|
9
|
+
require_relative "first_party"
|
|
6
10
|
|
|
7
11
|
module Rigor
|
|
8
12
|
module Plugin
|
|
@@ -33,11 +37,16 @@ module Rigor
|
|
|
33
37
|
end
|
|
34
38
|
end
|
|
35
39
|
|
|
40
|
+
# ADR-103 WD2 — what a plugin's `effect_root:` may be spelled as: one label segment, the shape
|
|
41
|
+
# {Rigor::Effects::Label} accepts as a root.
|
|
42
|
+
VALID_EFFECT_ROOT = /\A[a-z][a-z0-9_]*\z/
|
|
43
|
+
|
|
36
44
|
attr_reader :id, :version, :description, :config_schema, :config_defaults, :produces, :consumes,
|
|
37
45
|
:owns_receivers, :open_receivers, :type_node_resolvers, :block_as_methods,
|
|
38
46
|
:heredoc_templates, :nested_class_templates, :trait_registries,
|
|
39
47
|
:hkt_registrations, :hkt_definitions, :signature_paths, :protocol_contracts,
|
|
40
|
-
:source_rbs_synthesizer, :additional_initializers
|
|
48
|
+
:source_rbs_synthesizer, :additional_initializers,
|
|
49
|
+
:effect_root, :effect_labels, :effect_attributions, :effect_edges, :effect_entry_points
|
|
41
50
|
|
|
42
51
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
43
52
|
id:, version:,
|
|
@@ -46,7 +55,9 @@ module Rigor
|
|
|
46
55
|
block_as_methods: [], heredoc_templates: [], nested_class_templates: [],
|
|
47
56
|
trait_registries: [],
|
|
48
57
|
hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: [],
|
|
49
|
-
source_rbs_synthesizer: nil, additional_initializers: []
|
|
58
|
+
source_rbs_synthesizer: nil, additional_initializers: [],
|
|
59
|
+
effect_root: nil, effect_labels: [], effect_attributions: [], effect_edges: [],
|
|
60
|
+
effect_entry_points: []
|
|
50
61
|
)
|
|
51
62
|
validate_id!(id)
|
|
52
63
|
validate_version!(version)
|
|
@@ -65,6 +76,11 @@ module Rigor
|
|
|
65
76
|
validate_protocol_contracts!(protocol_contracts)
|
|
66
77
|
validate_source_rbs_synthesizer!(source_rbs_synthesizer)
|
|
67
78
|
validate_additional_initializers!(additional_initializers)
|
|
79
|
+
validate_effect_root!(effect_root)
|
|
80
|
+
validate_effect_labels!(effect_labels)
|
|
81
|
+
validate_effect_attributions!(effect_attributions)
|
|
82
|
+
validate_effect_edges!(effect_edges)
|
|
83
|
+
validate_effect_entry_points!(effect_entry_points)
|
|
68
84
|
|
|
69
85
|
assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
|
|
70
86
|
open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
|
|
@@ -72,6 +88,7 @@ module Rigor
|
|
|
72
88
|
source_rbs_synthesizer)
|
|
73
89
|
assign_nested_class_templates(nested_class_templates)
|
|
74
90
|
assign_additional_initializers(additional_initializers)
|
|
91
|
+
assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges, effect_entry_points)
|
|
75
92
|
freeze
|
|
76
93
|
end
|
|
77
94
|
|
|
@@ -115,6 +132,17 @@ module Rigor
|
|
|
115
132
|
@additional_initializers = additional_initializers.dup.freeze
|
|
116
133
|
end
|
|
117
134
|
private :assign_additional_initializers
|
|
135
|
+
|
|
136
|
+
# ADR-103 WD2 / WD6 / WD10 / WD14 — the effect contract's five fields, assigned together and outside
|
|
137
|
+
# `assign_fields` (which already carries the maximum positional arity).
|
|
138
|
+
def assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges, effect_entry_points)
|
|
139
|
+
@effect_root = effect_root.nil? ? nil : effect_root.to_s.dup.freeze
|
|
140
|
+
@effect_labels = effect_labels.map { |label| label.to_s.dup.freeze }.uniq.sort.freeze
|
|
141
|
+
@effect_attributions = effect_attributions.dup.freeze
|
|
142
|
+
@effect_edges = effect_edges.dup.freeze
|
|
143
|
+
@effect_entry_points = effect_entry_points.dup.freeze
|
|
144
|
+
end
|
|
145
|
+
private :assign_effect_fields
|
|
118
146
|
# rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
|
|
119
147
|
|
|
120
148
|
public
|
|
@@ -161,10 +189,38 @@ module Rigor
|
|
|
161
189
|
"signature_paths" => signature_paths,
|
|
162
190
|
"protocol_contracts" => protocol_contracts.map(&:to_h),
|
|
163
191
|
"source_rbs_synthesizer" => source_rbs_synthesizer&.class&.name,
|
|
164
|
-
"additional_initializers" => additional_initializers.map(&:to_h)
|
|
192
|
+
"additional_initializers" => additional_initializers.map(&:to_h),
|
|
193
|
+
"effect_root" => effect_root,
|
|
194
|
+
"effect_labels" => effect_labels,
|
|
195
|
+
"effect_attributions" => effect_attributions.map(&:to_h),
|
|
196
|
+
"effect_edges" => effect_edges.map(&:to_h),
|
|
197
|
+
"effect_entry_points" => effect_entry_points.map(&:to_h)
|
|
165
198
|
}
|
|
166
199
|
end
|
|
167
200
|
|
|
201
|
+
# ADR-103 WD2 — the identity opening a root when this plugin's labels join the run's vocabulary: the
|
|
202
|
+
# framework root it models for a first-party bundled plugin that declares one, and the plugin id
|
|
203
|
+
# otherwise. A third-party plugin's `effect_root:` is ignored here (and warned about by the registry),
|
|
204
|
+
# so it opens only the root named after itself.
|
|
205
|
+
def effect_owner
|
|
206
|
+
return id if effect_root.nil?
|
|
207
|
+
return id unless FirstParty.bundled?(id)
|
|
208
|
+
|
|
209
|
+
effect_root
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Whether this plugin's `effect_attributions:` may carry `discharge: true` (ADR-103 WD6).
|
|
213
|
+
def effect_discharge_allowed?
|
|
214
|
+
FirstParty.bundled?(id)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Whether the plugin contributes anything to the effect surfaces at all. The compiled per-run tables
|
|
218
|
+
# skip a plugin that answers false, so a project whose plugins predate this contract pays nothing.
|
|
219
|
+
def effects?
|
|
220
|
+
!effect_labels.empty? || !effect_attributions.empty? || !effect_edges.empty? ||
|
|
221
|
+
!effect_entry_points.empty?
|
|
222
|
+
end
|
|
223
|
+
|
|
168
224
|
def ==(other)
|
|
169
225
|
other.is_a?(Manifest) && to_h == other.to_h
|
|
170
226
|
end
|
|
@@ -414,6 +470,50 @@ module Rigor
|
|
|
414
470
|
"got #{synthesizer.inspect}"
|
|
415
471
|
end
|
|
416
472
|
|
|
473
|
+
# ADR-103 WD2 — `effect_root:` names the effect-label root this plugin opens when it is one the
|
|
474
|
+
# engine bundles: rigor-activejob models ActiveJob and therefore opens `rails.*`, not `activejob.*`.
|
|
475
|
+
# Declaring one is only ever a *request*; {#effect_owner} grants it to a first-party plugin and
|
|
476
|
+
# ignores it otherwise, so the field is safe to accept from anyone.
|
|
477
|
+
def validate_effect_root!(root)
|
|
478
|
+
return if root.nil?
|
|
479
|
+
return if root.is_a?(String) && root.match?(VALID_EFFECT_ROOT)
|
|
480
|
+
|
|
481
|
+
raise ArgumentError,
|
|
482
|
+
"plugin manifest effect_root must match #{VALID_EFFECT_ROOT.inspect}, got #{root.inspect}"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# ADR-103 WD2 — `effect_labels:` lists the vocabulary this plugin registers. The engine folds them
|
|
486
|
+
# into the run's {Rigor::Effects::Registry} through `#with(labels:, owner: manifest.effect_owner)`,
|
|
487
|
+
# which is where root ownership is enforced; a label outside the plugin's root is a load-time plugin
|
|
488
|
+
# error, reported like any other. Only the spelling is checked here.
|
|
489
|
+
def validate_effect_labels!(labels)
|
|
490
|
+
validate_array_of!("effect_labels", labels, "non-empty String") { |l| l.is_a?(String) && !l.empty? }
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# ADR-103 WD6 / WD10 — `effect_attributions:` colours calls into the framework this plugin models.
|
|
494
|
+
# Each entry MUST be a {Rigor::Plugin::EffectAttribution}.
|
|
495
|
+
def validate_effect_attributions!(entries)
|
|
496
|
+
validate_array_of!("effect_attributions", entries, "Rigor::Plugin::EffectAttribution instances") do |e|
|
|
497
|
+
e.is_a?(EffectAttribution)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# ADR-103 WD10 — `effect_edges:` declares the framework edges the syntax lacks. Each entry MUST be a
|
|
502
|
+
# {Rigor::Plugin::EffectEdge}, whose `target:` is one of a fixed engine-side strategy enum.
|
|
503
|
+
def validate_effect_edges!(entries)
|
|
504
|
+
validate_array_of!("effect_edges", entries, "Rigor::Plugin::EffectEdge instances") do |e|
|
|
505
|
+
e.is_a?(EffectEdge)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# ADR-103 WD14 — `effect_entry_points:` names the `effects.snapshot.reach:` presets this plugin
|
|
510
|
+
# supplies. Each entry MUST be a {Rigor::Plugin::EffectEntryPoints}.
|
|
511
|
+
def validate_effect_entry_points!(entries)
|
|
512
|
+
validate_array_of!("effect_entry_points", entries, "Rigor::Plugin::EffectEntryPoints instances") do |e|
|
|
513
|
+
e.is_a?(EffectEntryPoints)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
417
517
|
def coerce_consumes(consumes)
|
|
418
518
|
unless consumes.is_a?(Array)
|
|
419
519
|
raise ArgumentError, "plugin manifest consumes must be an Array, got #{consumes.inspect}"
|
|
@@ -244,6 +244,30 @@ module Rigor
|
|
|
244
244
|
# ADR-52 WD4 — the per-run node-rule walk (see {NodeRuleWalk}).
|
|
245
245
|
attr_reader :node_rule_walk
|
|
246
246
|
|
|
247
|
+
# ADR-103 WD2 / WD6 / WD10 / WD14 (#387) — every loaded plugin's effect contribution, in
|
|
248
|
+
# registration order, as {Contribution} rows.
|
|
249
|
+
#
|
|
250
|
+
# **Lazy, unlike the other aggregates.** A plugin MAY compute its `effect_attributions:` from project
|
|
251
|
+
# facts — rigor-activejob reads `config.active_job.queue_adapter` to decide whether an enqueue is a
|
|
252
|
+
# database write or a Redis one — and that is an I/O-boundary read a `rigor check` with no `effects:`
|
|
253
|
+
# block must never pay. Nothing asks for this until {Rigor::Effects::PluginFacts.build} does, and
|
|
254
|
+
# nothing calls that unless collection is on. Memoised into the frozen registry the same way
|
|
255
|
+
# `contracts_for_path` memoises, and for the same reason: the answer is fixed for the run.
|
|
256
|
+
def effect_contributions
|
|
257
|
+
@effect_memo[:contributions] ||= compile_effect_contributions
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# One plugin's effect contribution, flattened off whichever surface answered it (the manifest, or the
|
|
261
|
+
# plugin's own override). Carries the two authority answers with it — `owner`, the label root this
|
|
262
|
+
# plugin may open, and `discharge_allowed`, whether its attributions may discharge — so nothing
|
|
263
|
+
# downstream has to re-derive who is first-party.
|
|
264
|
+
Contribution = Data.define(:id, :owner, :requested_root, :discharge_allowed, :labels, :attributions,
|
|
265
|
+
:edges, :entry_points) do
|
|
266
|
+
def empty?
|
|
267
|
+
labels.empty? && attributions.empty? && edges.empty? && entry_points.empty?
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
247
271
|
# ADR-15 Phase 3 — build a fresh Registry from the supplied blueprint set by replaying
|
|
248
272
|
# {Blueprint#materialize} per entry against `services`. The returned registry carries NEW plugin
|
|
249
273
|
# instances (mutable per-Ractor accumulators included) and the same blueprint set, so a worker can hand
|
|
@@ -379,6 +403,30 @@ module Rigor
|
|
|
379
403
|
@open_receivers_set = @open_receivers.to_set.freeze
|
|
380
404
|
@protocol_contracts = @plugins.flat_map { |p| safe_protocol_contracts(p) }.freeze
|
|
381
405
|
@contracts_by_path = {}
|
|
406
|
+
@effect_memo = {}
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Fail-soft per plugin: a plugin whose `effect_attributions:` override raises (a missing
|
|
410
|
+
# `config/application.rb`, an unreadable path) contributes nothing rather than taking the run down.
|
|
411
|
+
# An effect contribution is an enrichment; ADR-2 isolates plugin failures at the analyzer boundary
|
|
412
|
+
# and this is that boundary.
|
|
413
|
+
def compile_effect_contributions
|
|
414
|
+
@plugins.filter_map { |plugin| effect_contribution_for(plugin) }.freeze
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def effect_contribution_for(plugin)
|
|
418
|
+
manifest = safe_manifest(plugin)
|
|
419
|
+
return nil if manifest.nil?
|
|
420
|
+
|
|
421
|
+
contribution = Contribution.new(
|
|
422
|
+
id: manifest.id, owner: manifest.effect_owner, requested_root: manifest.effect_root,
|
|
423
|
+
discharge_allowed: manifest.effect_discharge_allowed?,
|
|
424
|
+
labels: plugin.effect_labels, attributions: plugin.effect_attributions,
|
|
425
|
+
edges: plugin.effect_edges, entry_points: plugin.effect_entry_points
|
|
426
|
+
)
|
|
427
|
+
contribution.empty? ? nil : contribution
|
|
428
|
+
rescue StandardError
|
|
429
|
+
nil
|
|
382
430
|
end
|
|
383
431
|
|
|
384
432
|
def path_matches_glob?(glob, path)
|