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,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config_envelopes"
|
|
4
|
+
require_relative "envelope"
|
|
5
|
+
require_relative "method_key"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "signature_sources"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Effects
|
|
11
|
+
# The envelopes a **call site** may import as a `≤` bound, looked up by the callee the typer named
|
|
12
|
+
# (ADR-103 WD6; #386; normative in `docs/type-specification/effect-labels.md` § The declared lane at
|
|
13
|
+
# call sites).
|
|
14
|
+
#
|
|
15
|
+
# {EnvelopeCheck} asks "what bounds THIS method's body?" and answers it from the project's own
|
|
16
|
+
# declarations alone, because that is the stratum a contract can be checked against. This index asks
|
|
17
|
+
# the other question — "what does the thing I am calling promise?" — and so reads one stratum more:
|
|
18
|
+
# an **accepted signature**'s annotation, from the built RBS environment. WD6's trust ladder puts
|
|
19
|
+
# both on the discharging side, and the two questions are kept in two objects because only the first
|
|
20
|
+
# one may ever produce a finding.
|
|
21
|
+
#
|
|
22
|
+
# Four sources, nearest-first, exactly the check's precedence with the accepted stratum appended:
|
|
23
|
+
#
|
|
24
|
+
# per-method annotation > class-level annotation > `effects.envelopes:` > accepted signature
|
|
25
|
+
#
|
|
26
|
+
# The carrier is **nominal**: the lookup is by the receiver's *static* class name as the typer
|
|
27
|
+
# projected it (or, for an implicit-self call, by the unit's own class), never by walking ancestors.
|
|
28
|
+
# A structural interface erases to `Dynamic[top]` today and has nothing to attach a bound to
|
|
29
|
+
# ([ADR-103](../../../docs/adr/103-effect-labels.md) WD6); an inherited envelope reaches an override
|
|
30
|
+
# through `effect.liskov-widened` instead, which is a judgment rather than an import.
|
|
31
|
+
#
|
|
32
|
+
# Two bounds of the slice, both deliberate:
|
|
33
|
+
#
|
|
34
|
+
# - An `effects.envelopes:` entry selected by **`match:`** does not participate here. A path glob is
|
|
35
|
+
# a fact about where a class is *defined*, which a per-file collection window cannot see without
|
|
36
|
+
# the whole-project class-source table; `namespace:` needs only the name and does participate. The
|
|
37
|
+
# entry still bounds its own classes' methods for {EnvelopeCheck} and the Liskov check.
|
|
38
|
+
# - Lookup is by the *exact* owner. `repo.find` on a receiver typed `PgRepo` does not import
|
|
39
|
+
# `Repo#find`'s bound.
|
|
40
|
+
#
|
|
41
|
+
# Values are Marshal-clean ({Envelope} over frozen Strings and {LabelSet}s), because the index is
|
|
42
|
+
# built per process — the parent and each fork-pool worker build their own from the same
|
|
43
|
+
# configuration and the same signature content, so the two agree without a channel to keep in sync.
|
|
44
|
+
class EnvelopeIndex
|
|
45
|
+
NO_ENVELOPES = {}.freeze
|
|
46
|
+
private_constant :NO_ENVELOPES
|
|
47
|
+
|
|
48
|
+
NO_ENTRIES = [].freeze
|
|
49
|
+
private_constant :NO_ENTRIES
|
|
50
|
+
|
|
51
|
+
# The index a run with no declaration of any kind uses — and the fail-soft answer for a build that
|
|
52
|
+
# raised. Every lookup on it is one `empty?` read.
|
|
53
|
+
def self.empty
|
|
54
|
+
@empty ||= new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reads every stratum this index serves, once per process.
|
|
58
|
+
#
|
|
59
|
+
# @param configuration [Rigor::Configuration]
|
|
60
|
+
# @param plugin_facts [Rigor::Effects::PluginFacts, nil] the loaded plugins' effect contributions
|
|
61
|
+
# (#387); their `effect_labels:` join the vocabulary an annotation is read against, so a gem's
|
|
62
|
+
# `%a{rigor:v1:effect rails.activejob.enqueue}` resolves rather than reading as unknown.
|
|
63
|
+
# @param environment [Rigor::Environment, nil] the run's environment. Its loader supplies the
|
|
64
|
+
# rbs-inline / plugin `virtual_rbs` buffers and the built RBS environment the accepted stratum is
|
|
65
|
+
# read from; without one, both are simply absent (the fail-quiet direction — a missing `≤` bound
|
|
66
|
+
# costs precision, never a finding).
|
|
67
|
+
# @return [EnvelopeIndex]
|
|
68
|
+
def self.build(configuration:, environment: nil, plugin_facts: nil)
|
|
69
|
+
# Required here rather than at the top of the file: the reader pulls in the whole
|
|
70
|
+
# `RbsExtended` surface, and this build runs only under an `effects:` block.
|
|
71
|
+
require_relative "../rbs_extended/envelope_scanner"
|
|
72
|
+
|
|
73
|
+
registry = Registry.for_configuration(configuration, plugin_facts: plugin_facts)
|
|
74
|
+
loader = environment&.rbs_loader
|
|
75
|
+
scan = RbsExtended::EnvelopeScanner.scan(
|
|
76
|
+
sources: SignatureSources.collect(
|
|
77
|
+
signature_paths: configuration.signature_paths, virtual_rbs: loader&.virtual_rbs
|
|
78
|
+
),
|
|
79
|
+
registry: registry
|
|
80
|
+
)
|
|
81
|
+
new(
|
|
82
|
+
method_envelopes: scan.method_envelopes, class_envelopes: scan.class_envelopes,
|
|
83
|
+
config_entries: ConfigEnvelopes.build(entries: configuration.effects_envelopes, registry: registry),
|
|
84
|
+
accepted: accepted_for(loader, registry)
|
|
85
|
+
)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
empty
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The accepted stratum — every `%a{pure}` / `%a{rigor:v1:effect …}` on a method definition in the
|
|
91
|
+
# **built** RBS environment: a gem's shipped signatures, Rigor's bundled overlays, core RBS.
|
|
92
|
+
#
|
|
93
|
+
# It is read-only by construction. Nothing here can bound a project method's body, because the
|
|
94
|
+
# contract check ({EnvelopeCheck}) reads {RbsExtended::EnvelopeScanner}'s project-source tables and
|
|
95
|
+
# never this one; what an accepted signature can do is state what a call into it promises. The
|
|
96
|
+
# project's own signatures are in the built environment too and therefore appear here as well —
|
|
97
|
+
# harmlessly, since the project strata are consulted first and both discharge.
|
|
98
|
+
def self.accepted_for(loader, registry)
|
|
99
|
+
return NO_ENVELOPES if loader.nil?
|
|
100
|
+
|
|
101
|
+
RbsExtended::EnvelopeScanner.from_loader(loader: loader, registry: registry)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
NO_ENVELOPES
|
|
104
|
+
end
|
|
105
|
+
private_class_method :accepted_for
|
|
106
|
+
|
|
107
|
+
def initialize(method_envelopes: NO_ENVELOPES, class_envelopes: NO_ENVELOPES,
|
|
108
|
+
config_entries: NO_ENTRIES, accepted: NO_ENVELOPES)
|
|
109
|
+
@method_envelopes = method_envelopes.freeze
|
|
110
|
+
@class_envelopes = class_envelopes.freeze
|
|
111
|
+
@config_entries = config_entries.reject { |entry| entry.namespace.nil? }.freeze
|
|
112
|
+
@accepted = accepted.freeze
|
|
113
|
+
@config_cache = {}
|
|
114
|
+
freeze
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Whether no stratum has anything to say. The scan's fast path: a project with no envelope of any
|
|
118
|
+
# kind pays one predicate per call site and nothing else.
|
|
119
|
+
def empty?
|
|
120
|
+
@method_envelopes.empty? && @class_envelopes.empty? && @config_entries.empty? && @accepted.empty?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The envelope bounding `owner`'s `selector`, or nil.
|
|
124
|
+
#
|
|
125
|
+
# @param owner [String] the receiver's static class name, as the typer projected it
|
|
126
|
+
# @param singleton [Boolean] whether the call is `Owner.selector` rather than `Owner#selector`
|
|
127
|
+
# @param selector [String]
|
|
128
|
+
# @return [Envelope, nil] never a ⊤ envelope: a bound that bounds nothing is not a bound, and
|
|
129
|
+
# importing it would both add nothing and discharge a taint on the strength of a typo.
|
|
130
|
+
def [](owner, singleton, selector)
|
|
131
|
+
return nil if owner.nil? || empty?
|
|
132
|
+
|
|
133
|
+
key = "#{owner}#{singleton ? '.' : '#'}#{selector}"
|
|
134
|
+
envelope = @method_envelopes[key] || @class_envelopes[owner] || config_envelope(owner) ||
|
|
135
|
+
@accepted[key]
|
|
136
|
+
return nil if envelope.nil? || envelope.top?
|
|
137
|
+
|
|
138
|
+
envelope
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# The first `namespace:` entry selecting `owner`, memoised per class name — one call site's owner
|
|
144
|
+
# is asked for once per site and a project has a handful of entries.
|
|
145
|
+
def config_envelope(owner)
|
|
146
|
+
return nil if @config_entries.empty?
|
|
147
|
+
|
|
148
|
+
if @config_cache.key?(owner)
|
|
149
|
+
@config_cache[owner]
|
|
150
|
+
else
|
|
151
|
+
entry = @config_entries.find { |candidate| ConfigEnvelopes.namespace_match?(candidate.namespace, owner) }
|
|
152
|
+
@config_cache[owner] = entry && ConfigEnvelopes.envelope_for(entry, owner)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "summary"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# What one analyzed file contributes to the project's effect graph (ADR-103 WD12).
|
|
8
|
+
#
|
|
9
|
+
# The collector produces one of these per file and the runner marshals it back from a fork-pool worker
|
|
10
|
+
# with the file's diagnostics, so every field is Marshal-clean: frozen Hashes of Strings, {Summary}
|
|
11
|
+
# values, and `Data` edges. Nothing here holds a Prism node, a `Scope` or an environment.
|
|
12
|
+
#
|
|
13
|
+
# It carries four tables:
|
|
14
|
+
#
|
|
15
|
+
# - {#summaries} — the **direct** summary of each method the file defines, keyed `Class#m` / `Class.m` /
|
|
16
|
+
# `<toplevel>#m` (WD14). A reopening in another file contributes the same key and the two join.
|
|
17
|
+
# - {#edges} — per method key, the calls that must be resolved against the *project* before they become
|
|
18
|
+
# graph edges. Resolution is deferred to the propagator because a file cannot see the whole class
|
|
19
|
+
# graph; the collector only records what the typer decided about the receiver.
|
|
20
|
+
# - {#superclasses} / {#includes} — the ancestry the propagator needs to resolve an edge through
|
|
21
|
+
# inherited methods and to find every project-known override of a call's target (the closed-world
|
|
22
|
+
# join of WD4).
|
|
23
|
+
#
|
|
24
|
+
# Merging is associative and commutative in every table, so folding a run's files in pool-completion
|
|
25
|
+
# order yields exactly the table sequential analysis yields.
|
|
26
|
+
class FileCollection
|
|
27
|
+
# One recorded call, before the project can say which definition it reaches.
|
|
28
|
+
#
|
|
29
|
+
# `receiver_class` is the class name the typer had at the call site (nil when it had none — a Dynamic
|
|
30
|
+
# receiver, or a construct that is not a call). `kind` is `:instance` for a `Nominal` receiver and
|
|
31
|
+
# `:singleton` for a `Singleton` one. `self_call` marks an implicit-self call, which is the only shape
|
|
32
|
+
# whose failure to resolve is an `unresolved-self-call` taint rather than silence.
|
|
33
|
+
#
|
|
34
|
+
# `super_call` marks the edge a `super` contributes (#446). It carries the ENCLOSING unit's class and
|
|
35
|
+
# selector rather than a receiver's, and the propagator resolves it against the ancestry *above* that
|
|
36
|
+
# class with no closed-world override join — a different question from every other edge, which is why
|
|
37
|
+
# it is a field rather than a convention over the other three.
|
|
38
|
+
Edge = Data.define(:receiver_class, :kind, :selector, :self_call, :super_call) do
|
|
39
|
+
# Defaulted because every producer but the `super` one records an ordinary call, and an ordinary
|
|
40
|
+
# call is not a `super`.
|
|
41
|
+
def initialize(super_call: false, **) = super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
NO_TABLE = {}.freeze
|
|
45
|
+
private_constant :NO_TABLE
|
|
46
|
+
|
|
47
|
+
# The collection a file with nothing to say contributes — a parse failure, a file of constants, or a
|
|
48
|
+
# run where the collector was never activated.
|
|
49
|
+
def self.empty(path = nil)
|
|
50
|
+
new(path: path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :path, :summaries, :edges, :superclasses, :includes
|
|
54
|
+
|
|
55
|
+
def initialize(path: nil, summaries: NO_TABLE, edges: NO_TABLE,
|
|
56
|
+
superclasses: NO_TABLE, includes: NO_TABLE, failed: false)
|
|
57
|
+
@path = path
|
|
58
|
+
@summaries = freeze_table(summaries)
|
|
59
|
+
@edges = freeze_edges(edges)
|
|
60
|
+
@superclasses = freeze_table(superclasses)
|
|
61
|
+
@includes = freeze_table(includes)
|
|
62
|
+
@failed = failed ? true : false
|
|
63
|
+
freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Whether the collector gave up on this file entirely (the fail-soft path). Its methods contribute
|
|
67
|
+
# nothing rather than contributing a wrong summary; `rigor check` is unaffected either way.
|
|
68
|
+
def failed?
|
|
69
|
+
@failed
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def empty?
|
|
73
|
+
@summaries.empty? && @edges.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Folds another collection into this one. Summaries join per key, edge lists union, ancestry merges.
|
|
77
|
+
#
|
|
78
|
+
# **Fold a whole run with {merge_all}, not with this in a `reduce`.** Every call here rebuilds and
|
|
79
|
+
# re-freezes the accumulated tables, so folding a run one file at a time costs O(files × methods) —
|
|
80
|
+
# it was 5.1 s of mastodon's 6.4 s collection overhead and the whole of gitlab's superlinear one
|
|
81
|
+
# (`docs/notes/20260817-effect-collection-perf.md`). This stays for a two-collection merge, which is
|
|
82
|
+
# what its cost model fits.
|
|
83
|
+
def merge(other)
|
|
84
|
+
return self if other.empty? && !other.failed?
|
|
85
|
+
|
|
86
|
+
self.class.merge_all([self, other])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Folds a run's collections in one linear pass: each key's summaries join once, each table is built
|
|
90
|
+
# once, and the frozen result is constructed once at the end. Order-independent in every table
|
|
91
|
+
# except `superclasses`, where a later collection's spelling wins exactly as a chain of {merge}
|
|
92
|
+
# calls would leave it, so a path-sorted fold is reproducible.
|
|
93
|
+
#
|
|
94
|
+
# A collection that is {empty?} and not {failed?} contributes nothing, which is {merge}'s own
|
|
95
|
+
# short-circuit spelled once: a file with no methods and no calls has no summary to fold, and its
|
|
96
|
+
# ancestry has no unit to attach to.
|
|
97
|
+
def self.merge_all(collections)
|
|
98
|
+
summaries = {}
|
|
99
|
+
edges = {}
|
|
100
|
+
superclasses = {}
|
|
101
|
+
includes = {}
|
|
102
|
+
failed = false
|
|
103
|
+
|
|
104
|
+
collections.each do |collection|
|
|
105
|
+
failed ||= collection.failed?
|
|
106
|
+
next if collection.empty?
|
|
107
|
+
|
|
108
|
+
fold_summaries(summaries, collection.summaries)
|
|
109
|
+
fold_lists(edges, collection.edges)
|
|
110
|
+
superclasses.update(collection.superclasses)
|
|
111
|
+
fold_lists(includes, collection.includes)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
includes.each_value(&:uniq!)
|
|
115
|
+
new(path: nil, summaries: summaries, edges: edges,
|
|
116
|
+
superclasses: superclasses, includes: includes, failed: failed)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.fold_summaries(into, table)
|
|
120
|
+
table.each do |key, summary|
|
|
121
|
+
existing = into[key]
|
|
122
|
+
into[key] = existing ? existing.join(summary) : summary
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
private_class_method :fold_summaries
|
|
126
|
+
|
|
127
|
+
# De-duplication is deferred to the single pass at the end of {merge_all} — `freeze_edges` uniqs
|
|
128
|
+
# and sorts anyway, and uniqing per file is what made the fold quadratic.
|
|
129
|
+
def self.fold_lists(into, table)
|
|
130
|
+
table.each do |key, list|
|
|
131
|
+
existing = into[key]
|
|
132
|
+
existing ? existing.concat(list) : into[key] = list.dup
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
private_class_method :fold_lists
|
|
136
|
+
|
|
137
|
+
def ==(other)
|
|
138
|
+
other.is_a?(FileCollection) && other.summaries == @summaries && other.edges == @edges &&
|
|
139
|
+
other.superclasses == @superclasses && other.includes == @includes && other.failed? == @failed
|
|
140
|
+
end
|
|
141
|
+
alias eql? ==
|
|
142
|
+
|
|
143
|
+
def hash
|
|
144
|
+
[self.class, @summaries, @edges, @superclasses, @includes, @failed].hash
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def freeze_table(table)
|
|
150
|
+
return NO_TABLE if table.empty?
|
|
151
|
+
|
|
152
|
+
table.transform_values { |value| value.is_a?(Array) ? value.freeze : value }.freeze
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Edge lists are sorted so a marshalled worker collection and a sequential one are `==` and the
|
|
156
|
+
# report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call` and
|
|
157
|
+
# `super_call` are in it because two edges can otherwise agree on every other field (`def emit; super;
|
|
158
|
+
# emit; end` records both), and `sort_by` is not stable.
|
|
159
|
+
def freeze_edges(table)
|
|
160
|
+
return NO_TABLE if table.empty?
|
|
161
|
+
|
|
162
|
+
table.transform_values do |list|
|
|
163
|
+
sorted = list.uniq
|
|
164
|
+
sorted.sort_by! { |edge| edge_order(edge) }
|
|
165
|
+
sorted.freeze
|
|
166
|
+
end.freeze
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def edge_order(edge)
|
|
170
|
+
[edge.receiver_class.to_s, edge.kind.to_s, edge.selector, edge.self_call ? 1 : 0, edge.super_call ? 1 : 0]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "file_collection"
|
|
4
|
+
require_relative "label_set"
|
|
5
|
+
require_relative "origin"
|
|
6
|
+
require_relative "summary"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Effects
|
|
10
|
+
# The framework **edges** a plugin declared, materialised as effect units on the framework class itself
|
|
11
|
+
# (ADR-103 WD10; design note § 11.2 "Framework edges"; #387).
|
|
12
|
+
#
|
|
13
|
+
# `user.save` runs `User`'s `before_save :normalize`; `WelcomeJob.perform_now` runs
|
|
14
|
+
# `WelcomeJob#perform`; `UserMailer.welcome(u)` runs `UserMailer#welcome`. Each is real, synchronous,
|
|
15
|
+
# in-process control flow that the *syntax* does not contain, so without the plugin the caller's
|
|
16
|
+
# summary stops one hop short of the code that actually runs.
|
|
17
|
+
#
|
|
18
|
+
# ## Why units on the class rather than edges at the call site
|
|
19
|
+
#
|
|
20
|
+
# The call site is in another file. `app/controllers/users_controller.rb` writes `@user.save` and knows
|
|
21
|
+
# nothing about `User`'s callbacks — those are in `app/models/user.rb`, and a per-file collection window
|
|
22
|
+
# sees one file at a time. Synthesising `User#save` **where `User` is declared**, with edges to the
|
|
23
|
+
# callback methods, moves the framework knowledge to the file that has it and leaves the call site an
|
|
24
|
+
# ordinary edge. The propagator then resolves `(User, :instance, "save")` to the synthetic unit exactly
|
|
25
|
+
# as it resolves any other, ancestry and closed-world override join included — no second mechanism, no
|
|
26
|
+
# cross-file harvest, and a `save` on a subclass picks up that subclass's own callbacks for free.
|
|
27
|
+
#
|
|
28
|
+
# A project that defines `User#save` itself simply joins with the synthetic unit, which is the honest
|
|
29
|
+
# reading: an override still runs the callbacks unless it skips them.
|
|
30
|
+
#
|
|
31
|
+
# ## What is deliberately not here
|
|
32
|
+
#
|
|
33
|
+
# `perform_later` → `perform`. The deferred body runs in another process on another stack, and ADR-103
|
|
34
|
+
# WD4 fixes attribution to the code rather than to the clock: the enqueue is the effect, and the job's
|
|
35
|
+
# body belongs to the job's own entry point. {Plugin::EffectEdge::TARGETS} has no spelling for it.
|
|
36
|
+
module FrameworkUnits
|
|
37
|
+
IO_DB_READ = LabelSet.new(["io.db.read"]).freeze
|
|
38
|
+
private_constant :IO_DB_READ
|
|
39
|
+
|
|
40
|
+
# ActiveRecord's class-body callback macros, grouped by which persistence selectors run them. The
|
|
41
|
+
# grouping is coarse on purpose: an effect summary is an **upper bound**, and the cost of attributing
|
|
42
|
+
# a `before_create` to `save` (which does run it, when the record is new) is nothing, while the cost of
|
|
43
|
+
# a group fine enough to be wrong somewhere is a missing effect.
|
|
44
|
+
VALIDATION_MACROS = %w[validate before_validation after_validation].freeze
|
|
45
|
+
|
|
46
|
+
SAVE_MACROS = %w[
|
|
47
|
+
before_save around_save after_save before_create around_create after_create
|
|
48
|
+
before_update around_update after_update after_touch
|
|
49
|
+
before_commit after_commit after_rollback
|
|
50
|
+
after_create_commit after_update_commit after_save_commit
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
DESTROY_MACROS = %w[
|
|
54
|
+
before_destroy around_destroy after_destroy after_destroy_commit after_commit after_rollback
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Macros whose literal symbol arguments name methods on the same class. Everything the strategy reads.
|
|
58
|
+
CALLBACK_MACROS = (VALIDATION_MACROS + SAVE_MACROS + DESTROY_MACROS).uniq.freeze
|
|
59
|
+
|
|
60
|
+
# The uniqueness validator is a `SELECT` before the write — the one validation whose effect a reviewer
|
|
61
|
+
# is entitled to see in a summary. Both spellings Rails accepts.
|
|
62
|
+
UNIQUENESS_MACRO = "validates_uniqueness_of"
|
|
63
|
+
VALIDATES_MACRO = "validates"
|
|
64
|
+
UNIQUENESS_OPTION = "uniqueness"
|
|
65
|
+
|
|
66
|
+
# Which selectors run which group. `create` / `create!` are the singleton twins of `save`.
|
|
67
|
+
SAVE_TRIGGERS = %w[save save! update update! update_attribute touch increment! decrement!].freeze
|
|
68
|
+
DESTROY_TRIGGERS = %w[destroy destroy! delete].freeze
|
|
69
|
+
VALIDATION_TRIGGERS = %w[valid? invalid? validate!].freeze
|
|
70
|
+
SINGLETON_SAVE_TRIGGERS = %w[create create!].freeze
|
|
71
|
+
|
|
72
|
+
# `initialize` is not a mailer action, and neither is a method a plugin would want double-counted.
|
|
73
|
+
NON_ACTION_METHODS = %w[initialize].to_set.freeze
|
|
74
|
+
|
|
75
|
+
module_function
|
|
76
|
+
|
|
77
|
+
# Every synthetic unit `class_name` earns, as `[key, Summary, edges]` triples.
|
|
78
|
+
#
|
|
79
|
+
# @param class_name [String]
|
|
80
|
+
# @param instance_methods [Array<String>] the instance methods the class body defines, in source order
|
|
81
|
+
# @param macros [Hash{String=>Array<String>}] receiver-less class-body calls to their literal symbol
|
|
82
|
+
# arguments, as the scanner harvested them
|
|
83
|
+
# @param uniqueness [Boolean] whether the class body declares a uniqueness validator
|
|
84
|
+
# @param plugin_facts [PluginFacts]
|
|
85
|
+
# @param own_units [Hash{String=>Boolean}] the units the class body itself defines, keyed by the
|
|
86
|
+
# suffix a synthetic key carries (`"#save"`, `".create"`), each mapped to whether that body
|
|
87
|
+
# reaches `super`. Read by {.framework_row} and by nothing else.
|
|
88
|
+
def synthesize(class_name:, instance_methods:, macros:, uniqueness:, plugin_facts:, own_units: {})
|
|
89
|
+
units = []
|
|
90
|
+
plugin_facts.edges_for(:activerecord_callbacks).each do |edge|
|
|
91
|
+
next unless plugin_facts.descends_from?(class_name, edge.receiver)
|
|
92
|
+
|
|
93
|
+
units.concat(active_record_units(class_name, macros, uniqueness, plugin_facts, own_units))
|
|
94
|
+
end
|
|
95
|
+
plugin_facts.edges_for(:perform_now).each do |edge|
|
|
96
|
+
next unless plugin_facts.descends_from?(class_name, edge.receiver)
|
|
97
|
+
|
|
98
|
+
# `selector` (the edge's `method:`) names the synthesised selector, defaulting to
|
|
99
|
+
# `perform_now`. The one other value it
|
|
100
|
+
# ever takes is `perform_later` — and ONLY from a plugin that has read the project's own
|
|
101
|
+
# `queue_adapter = :inline`, where Rails really does run the job on the caller's stack. That is a
|
|
102
|
+
# project fact narrowing a transport, not a general edge (ADR-103 WD4).
|
|
103
|
+
units << unit("#{class_name}.#{edge.selector || :perform_now}", [edge_to(class_name, "perform")])
|
|
104
|
+
end
|
|
105
|
+
plugin_facts.edges_for(:mailer_body).each do |edge|
|
|
106
|
+
next unless plugin_facts.descends_from?(class_name, edge.receiver)
|
|
107
|
+
|
|
108
|
+
units.concat(mailer_units(class_name, instance_methods))
|
|
109
|
+
end
|
|
110
|
+
units
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# `save` and friends, edged to the callbacks the class body declared. A trigger with no callbacks and
|
|
114
|
+
# no uniqueness validator is NOT synthesised: an empty unit would put `User#save` in the snapshot for
|
|
115
|
+
# every model in the project and say nothing.
|
|
116
|
+
def active_record_units(class_name, macros, uniqueness, plugin_facts, own_units)
|
|
117
|
+
validation = callbacks(macros, VALIDATION_MACROS)
|
|
118
|
+
save = validation + callbacks(macros, SAVE_MACROS)
|
|
119
|
+
destroy = callbacks(macros, DESTROY_MACROS)
|
|
120
|
+
read = uniqueness ? uniqueness_summary(class_name) : nil
|
|
121
|
+
context = { plugin_facts: plugin_facts, own_units: own_units }
|
|
122
|
+
|
|
123
|
+
units = []
|
|
124
|
+
units.concat(triggers(class_name, SAVE_TRIGGERS, save, read, singleton: false, **context))
|
|
125
|
+
units.concat(triggers(class_name, SINGLETON_SAVE_TRIGGERS, save, read, singleton: true, **context))
|
|
126
|
+
units.concat(triggers(class_name, VALIDATION_TRIGGERS, validation, read, singleton: false, **context))
|
|
127
|
+
units.concat(triggers(class_name, DESTROY_TRIGGERS, destroy, nil, singleton: false, **context))
|
|
128
|
+
units
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ActionMailer's class-method-to-instance mapping: `UserMailer.welcome(u)` instantiates the mailer and
|
|
132
|
+
# runs `#welcome`. One synthetic singleton twin per instance method the mailer defines.
|
|
133
|
+
def mailer_units(class_name, instance_methods)
|
|
134
|
+
instance_methods.reject { |name| NON_ACTION_METHODS.include?(name) }.uniq.map do |name|
|
|
135
|
+
unit("#{class_name}.#{name}", [edge_to(class_name, name)])
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A synthesised trigger exists only when the class body earned it — see {.active_record_units} — but
|
|
140
|
+
# once it exists it stands for the whole of `save`, so it carries the framework's own claim about the
|
|
141
|
+
# selector as well as the callbacks (#440).
|
|
142
|
+
def triggers(class_name, selectors, targets, read, singleton:, plugin_facts:, own_units:)
|
|
143
|
+
return [] if targets.empty? && read.nil?
|
|
144
|
+
|
|
145
|
+
edges = targets.map { |target| edge_to(class_name, target) }
|
|
146
|
+
selectors.map do |selector|
|
|
147
|
+
row = framework_row(class_name, selector, singleton, plugin_facts, own_units)
|
|
148
|
+
unit("#{class_name}#{singleton ? '.' : '#'}#{selector}", edges,
|
|
149
|
+
declared_bundles(read, row), causes(row))
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# What the loaded plugins say `class_name`'s `selector` itself does — `ActiveRecord::Base#save` is
|
|
154
|
+
# `io.db.write` — read off the very table a call site reads (#440).
|
|
155
|
+
#
|
|
156
|
+
# Without it the synthetic unit carried the validator's `SELECT` and nothing else, so a model with a
|
|
157
|
+
# `before_save` or a uniqueness validator reported `AuthSource#save: ≤ io.db.read`: the write was
|
|
158
|
+
# attributed at every *call site* and never on the row that names the method, which is the row a
|
|
159
|
+
# reviewer reads. A `narrow:` row is skipped, because narrowing reads an argument at a call site and
|
|
160
|
+
# there is no call site here.
|
|
161
|
+
def framework_row(class_name, selector, singleton, plugin_facts, own_units)
|
|
162
|
+
return nil if replaced?(own_units, selector, singleton)
|
|
163
|
+
|
|
164
|
+
row = plugin_facts.class_row(class_name, singleton, selector)
|
|
165
|
+
return nil if row.nil? || row.narrow || row.labels.empty?
|
|
166
|
+
|
|
167
|
+
row
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Whether the class spelled the selector out itself and never reaches `super`. Such a body REPLACED
|
|
171
|
+
# the framework's implementation, so the framework's claim no longer describes what runs: a
|
|
172
|
+
# `def save = false` that persists nothing must keep reporting nothing. A body that does reach
|
|
173
|
+
# `super` keeps the claim, which is the case the exemption exists to not break.
|
|
174
|
+
def replaced?(own_units, selector, singleton)
|
|
175
|
+
key = "#{singleton ? '.' : '#'}#{selector}"
|
|
176
|
+
own_units.key?(key) && !own_units[key]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def declared_bundles(read, row)
|
|
180
|
+
bundles = read ? read.dup : {}
|
|
181
|
+
bundles[Origin.plugin(row.key)] = row.labels if row
|
|
182
|
+
bundles
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Mirrors {UnitScan#attribute_plugin}: a row may discharge and still taint, and a row from a plugin
|
|
186
|
+
# the engine does not bundle is a claim that leaves the unit non-exhaustive.
|
|
187
|
+
def causes(row)
|
|
188
|
+
return [] if row.nil?
|
|
189
|
+
|
|
190
|
+
list = []
|
|
191
|
+
list << [row.taint, row.key] if row.taint
|
|
192
|
+
list << ["plugin-attribution", row.key] unless row.discharge?
|
|
193
|
+
list
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def callbacks(macros, names)
|
|
197
|
+
names.flat_map { |name| macros[name] || [] }.uniq
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The uniqueness validator's own query. It rides the DECLARED lane with no taint, exactly as every
|
|
201
|
+
# other first-party plugin contribution does (ADR-103 WD6): the plugin read the app's own
|
|
202
|
+
# `validates … uniqueness: true` and knows what Rails does with it, but the analyzer did not read a
|
|
203
|
+
# body, so this is a trusted claim rather than a proof.
|
|
204
|
+
def uniqueness_summary(class_name)
|
|
205
|
+
{ Origin.plugin("#{class_name}:uniqueness-validator") => IO_DB_READ }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def unit(key, edges, declared = nil, causes = [])
|
|
209
|
+
summary = Summary.new(declared_bundles: declared || {}, exhaustive: causes.empty?, causes: causes)
|
|
210
|
+
[key, summary, edges]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def edge_to(class_name, selector)
|
|
214
|
+
FileCollection::Edge.new(receiver_class: class_name, kind: :instance, selector: selector,
|
|
215
|
+
self_call: false)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private_class_method :active_record_units, :mailer_units, :triggers, :framework_row, :replaced?,
|
|
219
|
+
:declared_bundles, :causes, :callbacks, :uniqueness_summary, :unit, :edge_to
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../cache/descriptor"
|
|
7
|
+
require_relative "catalog"
|
|
8
|
+
require_relative "registry"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
module Effects
|
|
12
|
+
# The **effects cache identity** — the second of ADR-103 WD13's "one cache, two identities, one extra
|
|
13
|
+
# slot" (issue #382; the contract is `docs/internal-spec/effect-summaries.md` § Caching).
|
|
14
|
+
#
|
|
15
|
+
# A run has one identity for its diagnostics and one for its effect summaries. The diagnostics identity
|
|
16
|
+
# is today's and is deliberately untouched by this file: collection is observational, so a diagnostics
|
|
17
|
+
# entry computed with effects on is valid for a run with effects off and vice versa. The effects
|
|
18
|
+
# identity is that identity **plus** the three things that change what a summary means without changing
|
|
19
|
+
# a single analyzed byte:
|
|
20
|
+
#
|
|
21
|
+
# - the **vocabulary version** ({Registry#vocabulary_version}) — a rename or a removal re-reads every
|
|
22
|
+
# persisted label;
|
|
23
|
+
# - the **catalogue identity** ({Catalog#identity}, schema + a digest of `data/effects/core.yml`) — an
|
|
24
|
+
# audited row moving from `io` to `io.fs.write` re-colours summaries the analyzed source never moved;
|
|
25
|
+
# - the **`effects:` block digest** — the same value the snapshot header carries, so a `tolerated:` or
|
|
26
|
+
# `reach:` edit is one visible regeneration event rather than two silently disagreeing digests;
|
|
27
|
+
# - the **plugin fact digest** ({PluginFacts#digest}, #387) — the loaded plugins' labels, attributions,
|
|
28
|
+
# edges and presets. A plugin upgrade that moves `perform_later` from `io` to `io.db.write` re-colours
|
|
29
|
+
# summaries the analyzed source never moved, exactly as a re-audited catalogue row does, and the
|
|
30
|
+
# plugin set is otherwise invisible to a key derived from configuration alone.
|
|
31
|
+
#
|
|
32
|
+
# This module is the ONE place that answers it. {.digest} is the string form (what the ADR-46 snapshot
|
|
33
|
+
# payload carries beside `return_summaries`) and {.descriptor} is the {Cache::Descriptor} form (what the
|
|
34
|
+
# ADR-45 whole-run effects slot is keyed by): the same three inputs, spelled for the two stores.
|
|
35
|
+
#
|
|
36
|
+
# Nothing here is consulted when collection is off — no entry is written, no key is perturbed, and the
|
|
37
|
+
# `effects:` block stays out of `Configuration#to_h` so enabling the feature invalidates no existing
|
|
38
|
+
# project's diagnostics cache.
|
|
39
|
+
module Identity
|
|
40
|
+
# The `configs:` slot the effects identity adds on top of a run's diagnostics key descriptor.
|
|
41
|
+
CONFIG_KEY = "effects.identity"
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# The effects identity as a hex digest — the form a store with no descriptor of its own (the ADR-46
|
|
46
|
+
# incremental snapshot) carries alongside its payload, and compares verbatim on restore.
|
|
47
|
+
#
|
|
48
|
+
# @param configuration [Rigor::Configuration]
|
|
49
|
+
# @param registry [Registry] the vocabulary whose version participates
|
|
50
|
+
# @param catalog [Catalog] the catalogue whose identity participates
|
|
51
|
+
# @return [String] hex SHA-256
|
|
52
|
+
def digest(configuration:, registry: Registry.default, catalog: Catalog.default, plugin_facts: nil)
|
|
53
|
+
Digest::SHA256.hexdigest(
|
|
54
|
+
[
|
|
55
|
+
"vocabulary:#{registry.vocabulary_version}",
|
|
56
|
+
"catalog:#{catalog.identity}",
|
|
57
|
+
"effects:#{config_digest(configuration)}",
|
|
58
|
+
"plugins:#{plugin_facts&.digest || 'none'}"
|
|
59
|
+
].join("\x00")
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The effects identity as a cache KEY descriptor: the run's own diagnostics key descriptor plus one
|
|
64
|
+
# `configs:` entry carrying {.digest}. Composing (rather than rebuilding) is what makes the effects
|
|
65
|
+
# slot inherit every diagnostics-invalidating input — engine version, engine source, RBS libraries,
|
|
66
|
+
# the analyzed-path set — for free, so "the effects identity is the diagnostics identity plus three
|
|
67
|
+
# things" is a property of the code rather than a claim about it.
|
|
68
|
+
#
|
|
69
|
+
# @param base [Cache::Descriptor] the run's diagnostics key descriptor
|
|
70
|
+
# @return [Cache::Descriptor]
|
|
71
|
+
def descriptor(base:, configuration:, registry: Registry.default, catalog: Catalog.default,
|
|
72
|
+
plugin_facts: nil)
|
|
73
|
+
Cache::Descriptor.compose(
|
|
74
|
+
base,
|
|
75
|
+
Cache::Descriptor.new(
|
|
76
|
+
configs: [
|
|
77
|
+
Cache::Descriptor::ConfigEntry.new(
|
|
78
|
+
key: CONFIG_KEY,
|
|
79
|
+
value_hash: digest(configuration: configuration, registry: registry, catalog: catalog,
|
|
80
|
+
plugin_facts: plugin_facts)
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The `effects:` block of `.rigor.yml`, canonicalised (keys sorted at every depth, rendered as JSON)
|
|
88
|
+
# and hashed. {Snapshot.config_digest} is this method — the snapshot header and the cache identity
|
|
89
|
+
# MUST agree, and the cheapest way to guarantee that is for there to be one implementation.
|
|
90
|
+
def config_digest(configuration)
|
|
91
|
+
Digest::SHA256.hexdigest(JSON.generate(canonicalize(configuration.effects || {})))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def canonicalize(value)
|
|
95
|
+
case value
|
|
96
|
+
when Hash then value.map { |key, member| [key.to_s, canonicalize(member)] }.sort_by(&:first).to_h
|
|
97
|
+
when Array then value.map { |member| canonicalize(member) }
|
|
98
|
+
when Symbol then value.to_s
|
|
99
|
+
else value
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|