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,335 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "discharge"
|
|
4
|
+
require_relative "effect_table"
|
|
5
|
+
require_relative "file_collection"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# Closes a run's collected summaries over the call graph (ADR-103 WD12).
|
|
10
|
+
#
|
|
11
|
+
# Two jobs, in order:
|
|
12
|
+
#
|
|
13
|
+
# 1. **Resolve edges.** The collector records a call as `(receiver class, kind, selector)`; a file
|
|
14
|
+
# cannot say which definition that reaches, because the class graph spans the project. Here the
|
|
15
|
+
# merged ancestry resolves it — the receiver's own class first, then its includes, then its
|
|
16
|
+
# superclass chain — and then the **closed world** joins every project-known override of the same
|
|
17
|
+
# selector below the receiver's class (ADR-103 WD4: Ruby has no `final`, and the analyzer already
|
|
18
|
+
# takes this posture for types). A call that resolves to nothing in the project is dropped, not
|
|
19
|
+
# tainted: the taint for an unresolvable call was already decided per site, from the typer's own
|
|
20
|
+
# verdict.
|
|
21
|
+
# 2. **Reach a fixpoint.** Proven labels join along edges, the exhaustiveness bit ANDs, and causes
|
|
22
|
+
# union. The lattice is finite (label sets over a closed vocabulary × one bit × a closed cause
|
|
23
|
+
# enum) and every step is monotone, so iteration terminates on its own — a recursive or mutually
|
|
24
|
+
# recursive cycle simply converges, and no recursion cap is needed or wanted here.
|
|
25
|
+
#
|
|
26
|
+
# Propagation is graph-only: it reads no source, types nothing, and touches no `Scope`. It is
|
|
27
|
+
# fail-soft as a whole — an exception yields the empty table rather than failing the run.
|
|
28
|
+
#
|
|
29
|
+
# **Three label lanes, one fixpoint** (#385), each monotone and each joined along the same edges:
|
|
30
|
+
#
|
|
31
|
+
# - `proven` — what the analyzer established.
|
|
32
|
+
# - `undischarged` — the same closure computed from each unit's *undischarged* direct bundles, i.e.
|
|
33
|
+
# with every origin bundle `effects.tolerated:` discharges dropped at the seed ({Discharge}).
|
|
34
|
+
# Per-origin discharge needs nothing more than that: an origin belongs to exactly one unit's direct
|
|
35
|
+
# summary, so the transitive union of surviving bundles IS the closure of the seeded ones, and the
|
|
36
|
+
# judgment never has to materialise a per-method set of transitive origins. With no `tolerated:`
|
|
37
|
+
# list the policy is inert, the seed is identical, and the lane costs one `equal?`-true join.
|
|
38
|
+
# - `declared` — the `≤` lane. It travels call edges **exactly as the proven lane does** (ADR-103
|
|
39
|
+
# WD1): a controller two hops above an attributed `Net::HTTP.get` reads `≤ io.net.http`, not merely
|
|
40
|
+
# "and possibly more". It is joined into itself and never into `proven`, which is the whole
|
|
41
|
+
# separation — a claim stays a claim however far it propagates.
|
|
42
|
+
module Propagator
|
|
43
|
+
NO_EDGES = [].freeze
|
|
44
|
+
private_constant :NO_EDGES
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# @param collection [FileCollection] the run's merged per-file collections
|
|
49
|
+
# @param discharge [Discharge] the `effects.tolerated:` policy the undischarged lane is computed
|
|
50
|
+
# under; {Discharge.none} makes the two lanes equal.
|
|
51
|
+
# @return [EffectTable]
|
|
52
|
+
def propagate(collection, discharge: Discharge.none)
|
|
53
|
+
return EffectTable.empty if collection.summaries.empty?
|
|
54
|
+
|
|
55
|
+
summaries = collection.summaries
|
|
56
|
+
state = seed(summaries, discharge)
|
|
57
|
+
edges = resolve_edges(collection, state)
|
|
58
|
+
iterate(state, edges)
|
|
59
|
+
EffectTable.new(build_entries(summaries, edges, state))
|
|
60
|
+
rescue StandardError
|
|
61
|
+
EffectTable.empty
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `{caller_key => [callee_key]}`, sorted and de-duplicated. Seeds the `unresolved-super` taint in
|
|
65
|
+
# the same pass, because whether a `super` resolved is exactly what this resolution answers.
|
|
66
|
+
def resolve_edges(collection, state)
|
|
67
|
+
index = Index.new(collection)
|
|
68
|
+
collection.edges.each_with_object({}) do |(caller_key, list), out|
|
|
69
|
+
targets = list.flat_map do |edge|
|
|
70
|
+
resolved = index.targets_for(edge)
|
|
71
|
+
taint_unresolved_super(state, caller_key, edge) if edge.super_call && resolved.empty?
|
|
72
|
+
resolved
|
|
73
|
+
end.uniq.sort
|
|
74
|
+
out[caller_key] = targets.freeze unless targets.empty?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A `super` the project's own ancestry does not answer — the implementation is in a gem, in Ruby's
|
|
79
|
+
# core, or in a module prepended at run time — is the case the taint exists for (#446). Silence
|
|
80
|
+
# would be the one thing the effect model must never say: that the list is complete when a call in
|
|
81
|
+
# the body was not read. An unresolved ORDINARY edge is dropped instead, because most such calls
|
|
82
|
+
# are inherited ones the catalogue simply has no row for and the site's own taint was already
|
|
83
|
+
# decided from the typer's verdict; a `super` has no site verdict to fall back on, and it always
|
|
84
|
+
# dispatches to something.
|
|
85
|
+
#
|
|
86
|
+
# Decided here rather than in the collector because only the merged ancestry can say whether the
|
|
87
|
+
# parent resolves, and written into the SEED rather than into the direct summary so the fixpoint
|
|
88
|
+
# carries it to this method's callers exactly as it carries any other cause. A snapshot's
|
|
89
|
+
# `methods:` table records direct summaries and so does not show it; `reach:`, the report and the
|
|
90
|
+
# judgment all read the closure and do.
|
|
91
|
+
def taint_unresolved_super(state, caller_key, edge)
|
|
92
|
+
entry = state[caller_key]
|
|
93
|
+
return if entry.nil?
|
|
94
|
+
|
|
95
|
+
entry[:exhaustive] = false
|
|
96
|
+
entry[:causes] << ["unresolved-super", edge.selector].freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Causes are carried as a Set through the fixpoint and flattened back to a sorted Array in
|
|
100
|
+
# {build_entries}. A Set is what {absorb} needs: unioning one along an edge must cost the source's
|
|
101
|
+
# size and allocate NOTHING when it adds nothing, and the array-concat-and-uniq it replaces
|
|
102
|
+
# allocated twice on every visit of every edge.
|
|
103
|
+
def seed(summaries, discharge)
|
|
104
|
+
summaries.transform_values do |summary|
|
|
105
|
+
{
|
|
106
|
+
proven: summary.proven,
|
|
107
|
+
undischarged: discharge.inert? ? summary.proven : discharge.undischarged(summary.bundles),
|
|
108
|
+
declared: summary.declared,
|
|
109
|
+
exhaustive: summary.exhaustive?, causes: Set.new(summary.causes)
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A worklist to a fixpoint. `state[key]` changing can only change the methods that CALL `key`, so a
|
|
115
|
+
# pass re-visits exactly those and the round-robin over the whole table is gone — that walk cost
|
|
116
|
+
# O(passes × edges) and the passes are the graph's depth.
|
|
117
|
+
#
|
|
118
|
+
# Each pass still runs in sorted key order, so the answer does not depend on Hash insertion order
|
|
119
|
+
# and a pooled run agrees with a sequential one bit for bit. (The lattice is finite and every step
|
|
120
|
+
# monotone, so the least fixpoint is unique and visit order cannot change it; the sorted pass keeps
|
|
121
|
+
# the *work* reproducible too.)
|
|
122
|
+
def iterate(state, edges)
|
|
123
|
+
order = state.keys.sort
|
|
124
|
+
callers = reverse_edges(edges)
|
|
125
|
+
pending = nil
|
|
126
|
+
|
|
127
|
+
loop do
|
|
128
|
+
dirty = nil
|
|
129
|
+
order.each do |key|
|
|
130
|
+
next if pending && !pending.include?(key)
|
|
131
|
+
|
|
132
|
+
list = edges[key]
|
|
133
|
+
next if list.nil?
|
|
134
|
+
|
|
135
|
+
changed = false
|
|
136
|
+
list.each { |callee| changed = true if absorb(state, key, callee) }
|
|
137
|
+
next unless changed
|
|
138
|
+
|
|
139
|
+
(dirty ||= Set.new).merge(callers[key]) if callers.key?(key)
|
|
140
|
+
end
|
|
141
|
+
break if dirty.nil?
|
|
142
|
+
|
|
143
|
+
pending = dirty
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# `{callee_key => [caller_key]}` — who has to be re-visited when a key's closure moves.
|
|
148
|
+
def reverse_edges(edges)
|
|
149
|
+
edges.each_with_object({}) do |(caller_key, list), out|
|
|
150
|
+
list.each { |callee| (out[callee] ||= []) << caller_key }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def absorb(state, key, callee)
|
|
155
|
+
target = state[key]
|
|
156
|
+
source = state[callee]
|
|
157
|
+
return false if source.nil? || target.equal?(source)
|
|
158
|
+
|
|
159
|
+
# Each lane is named literally rather than looped over an array: this runs once per edge per
|
|
160
|
+
# visit, and a literal array of lane names would allocate one per call for nothing.
|
|
161
|
+
changed = join_lane(target, source, :proven)
|
|
162
|
+
changed = true if join_lane(target, source, :undischarged)
|
|
163
|
+
changed = true if join_lane(target, source, :declared)
|
|
164
|
+
if target[:exhaustive] && !source[:exhaustive]
|
|
165
|
+
target[:exhaustive] = false
|
|
166
|
+
changed = true
|
|
167
|
+
end
|
|
168
|
+
causes = target[:causes]
|
|
169
|
+
source[:causes].each { |cause| changed = true if causes.add?(cause) }
|
|
170
|
+
changed
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Joins one label lane in place along an edge, answering whether it moved. {LabelSet#join} returns
|
|
174
|
+
# the receiver untouched when the source adds nothing, so a converged region costs a comparison and
|
|
175
|
+
# no allocation at all.
|
|
176
|
+
def join_lane(target, source, lane)
|
|
177
|
+
joined = target[lane].join(source[lane])
|
|
178
|
+
moved = joined != target[lane]
|
|
179
|
+
target[lane] = joined if moved
|
|
180
|
+
moved
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def build_entries(summaries, edges, state)
|
|
184
|
+
summaries.each_with_object({}) do |(key, summary), out|
|
|
185
|
+
closed = state.fetch(key)
|
|
186
|
+
out[key] = EffectTable::Entry.new(
|
|
187
|
+
key: key,
|
|
188
|
+
direct: summary,
|
|
189
|
+
proven: closed[:proven],
|
|
190
|
+
undischarged: closed[:undischarged],
|
|
191
|
+
declared: closed[:declared],
|
|
192
|
+
exhaustive: closed[:exhaustive],
|
|
193
|
+
causes: closed[:causes].sort_by { |cause, detail| [cause, detail.to_s] }.freeze,
|
|
194
|
+
edges: edges.fetch(key, NO_EDGES)
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private_class_method :resolve_edges, :taint_unresolved_super, :seed, :iterate, :reverse_edges,
|
|
200
|
+
:absorb, :join_lane, :build_entries
|
|
201
|
+
|
|
202
|
+
# The class graph a run's collections describe, and the edge resolution over it. Built once per
|
|
203
|
+
# propagation; every lookup is a Hash read.
|
|
204
|
+
class Index
|
|
205
|
+
def initialize(collection)
|
|
206
|
+
@summaries = collection.summaries
|
|
207
|
+
@superclasses = collection.superclasses
|
|
208
|
+
@includes = collection.includes
|
|
209
|
+
@classes = build_classes(collection)
|
|
210
|
+
@descendants = build_descendants(collection.superclasses)
|
|
211
|
+
@descendant_closures = {}
|
|
212
|
+
@targets = {}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Every project method key `edge` may reach: the definition its ancestry resolves to, plus every
|
|
216
|
+
# override of the same selector in a project subclass of the receiver's class.
|
|
217
|
+
#
|
|
218
|
+
# Memoised on `(receiver class, kind, selector, super?)` — the answer depends on nothing else, and
|
|
219
|
+
# one such tuple is asked for once per call site in the project. `ApplicationRecord#save` alone is
|
|
220
|
+
# thousands of sites on a Rails app, each of which used to re-walk the whole subclass forest.
|
|
221
|
+
def targets_for(edge)
|
|
222
|
+
@targets[[edge.receiver_class, edge.kind, edge.selector, edge.super_call]] ||= begin
|
|
223
|
+
separator = edge.kind == :singleton ? "." : "#"
|
|
224
|
+
edge.super_call ? super_targets(edge, separator) : call_targets(edge, separator)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def call_targets(edge, separator)
|
|
231
|
+
targets = []
|
|
232
|
+
owner = resolve_owner(edge.receiver_class, separator, edge.selector)
|
|
233
|
+
targets << owner if owner
|
|
234
|
+
descendant_closure(edge.receiver_class).each do |subclass|
|
|
235
|
+
key = "#{subclass}#{separator}#{edge.selector}"
|
|
236
|
+
targets << key if @summaries.key?(key)
|
|
237
|
+
end
|
|
238
|
+
targets.freeze
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A `super` reaches **one** definition, and the closed-world override join every other edge gets
|
|
242
|
+
# is deliberately absent from it (#446). `super` in `C#m` dispatches into the ancestry above `C`
|
|
243
|
+
# in the receiver's chain, and a subclass of `C` is never in it however the receiver was
|
|
244
|
+
# constructed — so joining `D#m` would put a proven label on `C#m` that no execution of `C#m` can
|
|
245
|
+
# produce. Ruby's lack of `final` is the argument for the join at an ordinary call site and says
|
|
246
|
+
# nothing here.
|
|
247
|
+
def super_targets(edge, separator)
|
|
248
|
+
target = resolve_super(edge.receiver_class, separator, edge.selector)
|
|
249
|
+
target ? [target].freeze : NO_TARGETS
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
NO_TARGETS = [].freeze
|
|
253
|
+
private_constant :NO_TARGETS
|
|
254
|
+
|
|
255
|
+
# The transitive subclass closure, memoised per class. A deep hierarchy's root is asked for it
|
|
256
|
+
# once, not once per selector reaching it.
|
|
257
|
+
def descendant_closure(class_name)
|
|
258
|
+
@descendant_closures[class_name] ||= descendants_of(class_name).freeze
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Ancestry order mirrors the engine's: the class itself, the modules it includes, then its
|
|
262
|
+
# superclass, recursively. Cycle-guarded, because a project may declare one. Ancestry names
|
|
263
|
+
# arrive as as-written candidate lists (see `Scanner#lexical_candidates`); every candidate is
|
|
264
|
+
# enqueued and the most-qualified one comes first, so the right constant wins the race and a
|
|
265
|
+
# spelling that names nothing simply matches no key.
|
|
266
|
+
def resolve_owner(class_name, separator, selector)
|
|
267
|
+
walk_ancestors([class_name], Set.new, separator, selector)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Where `super` from `class_name#selector` lands: the same ancestor walk, started one step up —
|
|
271
|
+
# the modules the class includes, then its superclass chain — with the class itself already in
|
|
272
|
+
# `seen`, because a method never `super`s into itself.
|
|
273
|
+
#
|
|
274
|
+
# The includes are instance-side only. `include M` puts `M#m` between the class and its
|
|
275
|
+
# superclass, which is exactly where `super` looks, while `M.m` is a singleton method `include`
|
|
276
|
+
# never contributes; a singleton `super` that really does reach a module went through `extend` or
|
|
277
|
+
# a `class << self` include, neither of which is collected, so it resolves to nothing and taints.
|
|
278
|
+
def resolve_super(class_name, separator, selector)
|
|
279
|
+
queue = separator == "#" ? @includes.fetch(class_name, []).dup : []
|
|
280
|
+
queue.concat(@superclasses.fetch(class_name, []))
|
|
281
|
+
walk_ancestors(queue, Set.new([class_name]), separator, selector)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def walk_ancestors(queue, seen, separator, selector)
|
|
285
|
+
until queue.empty?
|
|
286
|
+
current = queue.shift
|
|
287
|
+
next if current.nil? || !seen.add?(current)
|
|
288
|
+
|
|
289
|
+
key = "#{current}#{separator}#{selector}"
|
|
290
|
+
return key if @summaries.key?(key)
|
|
291
|
+
|
|
292
|
+
queue.concat(@includes.fetch(current, []))
|
|
293
|
+
queue.concat(@superclasses.fetch(current, []))
|
|
294
|
+
end
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def descendants_of(class_name)
|
|
299
|
+
collected = []
|
|
300
|
+
queue = @descendants.fetch(class_name, []).dup
|
|
301
|
+
seen = Set.new
|
|
302
|
+
until queue.empty?
|
|
303
|
+
current = queue.shift
|
|
304
|
+
next unless seen.add?(current)
|
|
305
|
+
|
|
306
|
+
collected << current
|
|
307
|
+
queue.concat(@descendants.fetch(current, []))
|
|
308
|
+
end
|
|
309
|
+
collected
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# The subclass index the closed-world override join walks. Unlike the ancestor walk, this one
|
|
313
|
+
# must pick **one** parent per child: enqueuing every candidate would let `A::Base` and `B::Base`
|
|
314
|
+
# share the short spelling `Base` and join an unrelated class's override into the proven lane.
|
|
315
|
+
# The most-qualified candidate the project actually defines wins; a child whose parent is outside
|
|
316
|
+
# the project keeps its first (most-qualified) spelling and simply matches nothing.
|
|
317
|
+
def build_descendants(superclasses)
|
|
318
|
+
superclasses.each_with_object({}) do |(child, candidates), out|
|
|
319
|
+
parent = candidates.find { |candidate| @classes.include?(candidate) } || candidates.first
|
|
320
|
+
(out[parent] ||= []) << child
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Every class name the project defines a method on — the evidence `build_descendants` resolves an
|
|
325
|
+
# as-written superclass against.
|
|
326
|
+
def build_classes(collection)
|
|
327
|
+
collection.summaries.each_key.with_object(Set.new) do |key, out|
|
|
328
|
+
index = key.index("#") || key.index(".")
|
|
329
|
+
out << key[0, index] if index
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
require_relative "label"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# The effect-label vocabulary: which spellings are recognised, which have been retired, and who
|
|
10
|
+
# may open a new root (ADR-103 WD2; normative in
|
|
11
|
+
# `docs/type-specification/effect-labels.md`).
|
|
12
|
+
#
|
|
13
|
+
# The shared layer is hand-written in `data/effects/registry.yml` — Steins' v1 set verbatim,
|
|
14
|
+
# Ruby's `mutate` leaves, the proposed shared core leaves and the application-meaning roots —
|
|
15
|
+
# so a later slice or a plugin extends the data, not this Ruby. Extensions arrive through
|
|
16
|
+
# {#with}, which is the one place root ownership is enforced.
|
|
17
|
+
#
|
|
18
|
+
# A Registry is a frozen value object; {#with} returns a new one rather than mutating.
|
|
19
|
+
class Registry
|
|
20
|
+
class Error < StandardError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# An added label whose root neither exists already nor belongs to the extender.
|
|
24
|
+
class OwnershipError < Error
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# An added label that is not well-formed under {Label::PATTERN}.
|
|
28
|
+
class InvalidLabelError < Error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
DATA_PATH = File.expand_path("../../../data/effects/registry.yml", __dir__)
|
|
32
|
+
|
|
33
|
+
# How far a misspelling may be from a known label before {#suggest} declines to guess. Two
|
|
34
|
+
# edits catches a transposition or a dropped segment character ("io.nte", "nondet.tim")
|
|
35
|
+
# without proposing an unrelated label for a genuinely new spelling.
|
|
36
|
+
SUGGESTION_DISTANCE_CAP = 2
|
|
37
|
+
|
|
38
|
+
# The shared registry as shipped. Memoised: the YAML parse is a once-per-process cost, and
|
|
39
|
+
# nothing consumes the registry yet, so it stays lazy rather than paying at `require "rigor"`.
|
|
40
|
+
def self.default
|
|
41
|
+
@default ||= load_file(DATA_PATH)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The vocabulary one run works in: the shipped registry plus whatever `effects.labels:` opened
|
|
45
|
+
# (ADR-103 WD2 / #385). The project is the one extender that may open ANY root — listing a label in
|
|
46
|
+
# its own configuration is the vouching act — so this can only fail on a spelling `Configuration`
|
|
47
|
+
# already rejected at load, and a failure degrades to the shipped vocabulary rather than taking the
|
|
48
|
+
# run down.
|
|
49
|
+
#
|
|
50
|
+
# Memoised on the label list, because the answer is a frozen value object and every effects surface
|
|
51
|
+
# in a run asks for the same one: the envelope pass, the unknown-label check, the snapshot header.
|
|
52
|
+
#
|
|
53
|
+
# `plugin_facts` (#387) folds in every loaded plugin's `effect_labels:` FIRST, each under its own
|
|
54
|
+
# owner, so that a project's `effects.labels:` can then name a plugin-opened root and an envelope may
|
|
55
|
+
# bound `rails.activejob.enqueue` without the project having to re-declare the framework's vocabulary.
|
|
56
|
+
# The plugin layer is not memoised on its own — {PluginFacts} is already per-process — and the
|
|
57
|
+
# project layer keeps its memo keyed on the pair.
|
|
58
|
+
def self.for_configuration(configuration, plugin_facts: nil)
|
|
59
|
+
base = plugin_facts.nil? || plugin_facts.empty? ? default : plugin_facts.extend_registry(default)
|
|
60
|
+
labels = configuration.effects_labels
|
|
61
|
+
return base if labels.nil? || labels.empty?
|
|
62
|
+
|
|
63
|
+
(@extended ||= {})[[labels, base.labels]] ||= base.with(labels: labels, owner: nil)
|
|
64
|
+
rescue Error
|
|
65
|
+
default
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Build a registry from a registry-shaped YAML file. Missing or unreadable data degrades to
|
|
69
|
+
# an empty vocabulary rather than raising, matching the built-in catalogues' posture for a
|
|
70
|
+
# bare install that opted data out; every label then reads as unknown, which is fail-open.
|
|
71
|
+
def self.load_file(path)
|
|
72
|
+
raw = File.exist?(path) ? YAML.safe_load_file(path) : nil
|
|
73
|
+
raw = {} unless raw.is_a?(Hash)
|
|
74
|
+
new(
|
|
75
|
+
vocabulary_version: raw.fetch("vocabulary", 0),
|
|
76
|
+
labels: raw.fetch("labels", nil) || [],
|
|
77
|
+
retired: raw.fetch("retired", nil) || {},
|
|
78
|
+
descriptions: raw.fetch("descriptions", nil) || {}
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
attr_reader :vocabulary_version
|
|
83
|
+
|
|
84
|
+
def initialize(vocabulary_version:, labels:, retired: {}, descriptions: {})
|
|
85
|
+
@vocabulary_version = vocabulary_version
|
|
86
|
+
@descriptions = descriptions.to_h { |root, text| [root.to_s, text.to_s.gsub(/\s+/, " ").strip] }.freeze
|
|
87
|
+
@labels = labels.map(&:to_s).uniq.sort.freeze
|
|
88
|
+
@known = build_known(@labels)
|
|
89
|
+
@roots = @known.select { |label| Label.parent(label).nil? }.sort.freeze
|
|
90
|
+
@retired = build_retired(retired)
|
|
91
|
+
freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Every declared label, sorted. Implied ancestors (`email`, because `email.send` is declared)
|
|
95
|
+
# are recognised by {#known?} but are not rows of the vocabulary and are not listed here.
|
|
96
|
+
attr_reader :labels
|
|
97
|
+
|
|
98
|
+
# The roots of the vocabulary — the outermost segments {#with} treats as already owned.
|
|
99
|
+
attr_reader :roots
|
|
100
|
+
|
|
101
|
+
# One line per root, from the data file's `descriptions:` — what `rigor effects --list-labels`
|
|
102
|
+
# prints beside a root (#429). A root nothing describes (a plugin's, a project's) has no entry.
|
|
103
|
+
attr_reader :descriptions
|
|
104
|
+
|
|
105
|
+
# Whether the vocabulary recognises `label`: an exact row, or an ancestor of one. A declared
|
|
106
|
+
# `io` is recognised because `io.net` exists, so a bound may name an interior node the data
|
|
107
|
+
# file never spells out on its own line.
|
|
108
|
+
def known?(label)
|
|
109
|
+
@known.include?(label)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The nearest recognised label to a misspelling, within {SUGGESTION_DISTANCE_CAP} edits, or
|
|
113
|
+
# `nil` when nothing is close enough. A recognised label suggests nothing — ask {#known?}
|
|
114
|
+
# first.
|
|
115
|
+
def suggest(label)
|
|
116
|
+
return nil unless Label.valid?(label)
|
|
117
|
+
return nil if known?(label)
|
|
118
|
+
|
|
119
|
+
best = nil
|
|
120
|
+
best_distance = SUGGESTION_DISTANCE_CAP + 1
|
|
121
|
+
@known.each do |candidate|
|
|
122
|
+
distance = levenshtein(label, candidate, best_distance)
|
|
123
|
+
next unless distance < best_distance
|
|
124
|
+
|
|
125
|
+
best = candidate
|
|
126
|
+
best_distance = distance
|
|
127
|
+
end
|
|
128
|
+
best
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The replacement labels for a retired spelling, or `nil` when the spelling was never
|
|
132
|
+
# retired. A rename or a removal bumps {#vocabulary_version} and records the old spelling
|
|
133
|
+
# here so a snapshot written by an older Rigor still reads.
|
|
134
|
+
def retired(label)
|
|
135
|
+
@retired[label]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A new registry carrying `labels` on top of this one.
|
|
139
|
+
#
|
|
140
|
+
# `owner` is the identity opening a root: a plugin id, or — for a first-party plugin that
|
|
141
|
+
# models a framework — the framework root it owns. `nil` is the project, which may open any
|
|
142
|
+
# root. Every added label must either descend from a root this registry already knows or
|
|
143
|
+
# open a root equal to `owner`.
|
|
144
|
+
def with(labels:, owner:)
|
|
145
|
+
added = labels.map(&:to_s)
|
|
146
|
+
added.each do |label|
|
|
147
|
+
raise InvalidLabelError, "not a well-formed effect label: #{label.inspect}" unless Label.valid?(label)
|
|
148
|
+
|
|
149
|
+
check_ownership(label, owner)
|
|
150
|
+
end
|
|
151
|
+
self.class.new(vocabulary_version: @vocabulary_version, labels: @labels + added, retired: @retired,
|
|
152
|
+
descriptions: @descriptions)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def check_ownership(label, owner)
|
|
158
|
+
root = Label.root(label)
|
|
159
|
+
return if @roots.include?(root)
|
|
160
|
+
return if owner.nil?
|
|
161
|
+
return if owner == root
|
|
162
|
+
|
|
163
|
+
raise OwnershipError,
|
|
164
|
+
"#{owner.inspect} may not open the effect-label root #{root.inspect} (from #{label.inspect}); " \
|
|
165
|
+
"a non-project extender opens only the root it owns"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_known(labels)
|
|
169
|
+
known = labels.to_set
|
|
170
|
+
labels.each { |label| Label.ancestors(label).each { |ancestor| known << ancestor } }
|
|
171
|
+
known.freeze
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def build_retired(retired)
|
|
175
|
+
retired.to_h { |spelling, replacements| [spelling.to_s, Array(replacements).map(&:to_s).freeze] }.freeze
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Bounded Levenshtein. Answers `cap + 1` — "further than the cap" — as soon as the length
|
|
179
|
+
# difference or a whole row of the matrix rules the candidate out, so scanning the vocabulary
|
|
180
|
+
# for a suggestion stays cheap.
|
|
181
|
+
def levenshtein(from, to, cap)
|
|
182
|
+
beyond = cap + 1
|
|
183
|
+
return beyond if (from.length - to.length).abs > cap
|
|
184
|
+
|
|
185
|
+
previous = (0..to.length).to_a
|
|
186
|
+
from.each_char.with_index(1) do |char, row|
|
|
187
|
+
current = [row]
|
|
188
|
+
to.each_char.with_index(1) do |other, column|
|
|
189
|
+
cost = char == other ? 0 : 1
|
|
190
|
+
current << [current[column - 1] + 1, previous[column] + 1, previous[column - 1] + cost].min
|
|
191
|
+
end
|
|
192
|
+
return beyond if current.min > cap
|
|
193
|
+
|
|
194
|
+
previous = current
|
|
195
|
+
end
|
|
196
|
+
previous.last > cap ? beyond : previous.last
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|