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,384 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
require_relative "entry_points"
|
|
6
|
+
require_relative "label_set"
|
|
7
|
+
require_relative "registry"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Effects
|
|
11
|
+
# The loaded plugins' effect contributions, compiled once per process into the tables the scan and the
|
|
12
|
+
# snapshot read (ADR-103 WD2 / WD6 / WD10 / WD14; #387).
|
|
13
|
+
#
|
|
14
|
+
# A plugin's manifest states its contribution declaratively — `effect_labels:`, `effect_attributions:`,
|
|
15
|
+
# `effect_edges:`, `effect_entry_points:`, all frozen value objects. Nothing here re-reads a manifest at
|
|
16
|
+
# scan time: the per-call lookup is two Hash reads and, for a class-name row, a walk up a superclass
|
|
17
|
+
# table that is a Hash read per level.
|
|
18
|
+
#
|
|
19
|
+
# **Nothing builds one unless collection is on.** {Plugin::Registry#effect_contributions} is itself
|
|
20
|
+
# lazy — a plugin MAY compute its rows from project facts, and rigor-activejob does — and this class is
|
|
21
|
+
# what turns the result into indices. {.build} is called from exactly one place on each side of the fork
|
|
22
|
+
# boundary, behind `configuration.effects_enabled?`, so a `rigor check` with no `effects:` block neither
|
|
23
|
+
# allocates one nor lets a plugin read a file for it.
|
|
24
|
+
#
|
|
25
|
+
# ## Two matching rules, because the framework has two shapes
|
|
26
|
+
#
|
|
27
|
+
# A **class-name** row (`ActiveRecord::Base#save`) matches through the project's inheritance chain:
|
|
28
|
+
# `user.save` on a `User < ApplicationRecord < ActiveRecord::Base` is the whole point, and matching the
|
|
29
|
+
# exact owner (which is what {Catalog} does, correctly, for Ruby's core) would find nothing. The chain
|
|
30
|
+
# walked is the **project's own** superclass table — the cross-file discovery pre-pass's
|
|
31
|
+
# `discovered_superclasses`, the same table `ExpressionTyper` walks for an unresolved implicit-self
|
|
32
|
+
# call. It stops where the project stops, which is exactly right: `ApplicationRecord < ActiveRecord::Base`
|
|
33
|
+
# is a line in `app/models/application_record.rb`, and a class that never names a framework base in
|
|
34
|
+
# project source is not one.
|
|
35
|
+
#
|
|
36
|
+
# A **receiver-path** row (`Rails.cache#read`) matches the receiver *expression* as written. `Rails.cache`
|
|
37
|
+
# returns an adapter-dependent object by design — memory, Redis, the filesystem — so there is no receiver
|
|
38
|
+
# class for a class-name row to key on, and the only stable handle is the path the programmer typed.
|
|
39
|
+
#
|
|
40
|
+
# ## What is deliberately not consulted
|
|
41
|
+
#
|
|
42
|
+
# The RBS ancestor chain. A gem's shipped signatures do carry `class User < ApplicationRecord`-shaped
|
|
43
|
+
# ancestry when the project generates RBS for its models, but reading it here would make a row's reach a
|
|
44
|
+
# function of whether the project happens to run `rbs prototype` — a plugin contribution that appears and
|
|
45
|
+
# disappears with an unrelated tool. The project's own `class … <` lines are the fact; a project whose
|
|
46
|
+
# models are declared only in RBS gets no plugin attribution and no taint, which is the fail-quiet
|
|
47
|
+
# direction.
|
|
48
|
+
class PluginFacts
|
|
49
|
+
# How many ancestors the walk visits before giving up. It bounds *work*, not correctness: cycles are
|
|
50
|
+
# already handled by the visited set, and the walk is memoised per class.
|
|
51
|
+
#
|
|
52
|
+
# It was 24 while the walk climbed superclasses only, where a project deeper than that is
|
|
53
|
+
# pathological. Once included modules joined the walk (#456) the number stopped measuring depth and
|
|
54
|
+
# started measuring breadth, and Rails concerns make breadth ordinary — Mastodon's `Account` includes
|
|
55
|
+
# 21 modules, so `ActiveRecord::Base` fell off the end of a 24-entry chain and every `Account#save`
|
|
56
|
+
# silently lost its `io.db.write`. A cap that can be reached by ordinary code is a correctness bug
|
|
57
|
+
# wearing a budget's clothes.
|
|
58
|
+
ANCESTRY_CAP = 128
|
|
59
|
+
|
|
60
|
+
NO_ROWS = {}.freeze
|
|
61
|
+
private_constant :NO_ROWS
|
|
62
|
+
|
|
63
|
+
# One compiled attribution row.
|
|
64
|
+
#
|
|
65
|
+
# `discharge` is the RESOLVED grant, not the manifest's request: {.build} has already demoted a
|
|
66
|
+
# non-first-party plugin's `true` to `false` and recorded a warning, so nothing downstream has to
|
|
67
|
+
# re-ask who may discharge.
|
|
68
|
+
Row = Data.define(:key, :labels, :narrow, :discharge, :within, :taint, :plugin_id) do
|
|
69
|
+
def discharge? = discharge
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# One compiled framework-edge strategy: the base class a plugin pointed the engine at, and which
|
|
73
|
+
# plugin pointed.
|
|
74
|
+
Edge = Data.define(:target, :receiver, :selector, :plugin_id)
|
|
75
|
+
|
|
76
|
+
def self.empty
|
|
77
|
+
@empty ||= new(contributions: [], superclasses: NO_ROWS, includes: NO_ROWS)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param plugin_registry [Rigor::Plugin::Registry, nil]
|
|
81
|
+
# @param superclasses [Hash{String=>String,Array<String>}] the project's as-written superclass table
|
|
82
|
+
# (`Scope::DiscoveryIndex#discovered_superclasses`). Empty is legal and simply means no row matches
|
|
83
|
+
# through inheritance.
|
|
84
|
+
# @param includes [Hash{String=>Array<String>}] the project's as-written include / prepend table
|
|
85
|
+
# (`Scope::DiscoveryIndex#discovered_includes`), for the frameworks whose contract is a module
|
|
86
|
+
# rather than a base class (#456).
|
|
87
|
+
def self.build(plugin_registry, superclasses: NO_ROWS, includes: NO_ROWS)
|
|
88
|
+
contributions = plugin_registry&.effect_contributions || []
|
|
89
|
+
return empty if contributions.empty?
|
|
90
|
+
|
|
91
|
+
new(contributions: contributions, superclasses: superclasses, includes: includes)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
empty
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Human-readable notes about contributions that were accepted only in part — a third-party plugin's
|
|
97
|
+
# `discharge: true` demoted, a label root refused. Surfaced by `rigor effects`; never a diagnostic,
|
|
98
|
+
# because a plugin the user chose is not the project's mistake to be flagged for.
|
|
99
|
+
attr_reader :warnings
|
|
100
|
+
|
|
101
|
+
# `{owner => [labels]}` — what {#extend_registry} folds into the run's vocabulary.
|
|
102
|
+
attr_reader :labels_by_owner
|
|
103
|
+
|
|
104
|
+
# Every `effect_entry_points:` preset across the loaded set, in registration order.
|
|
105
|
+
attr_reader :entry_points
|
|
106
|
+
|
|
107
|
+
def initialize(contributions:, superclasses:, includes: NO_ROWS)
|
|
108
|
+
@warnings = []
|
|
109
|
+
@class_rows = {}
|
|
110
|
+
@path_rows = {}
|
|
111
|
+
@self_rows = {}
|
|
112
|
+
@result_rows = {}
|
|
113
|
+
@edges = []
|
|
114
|
+
@labels_by_owner = {}
|
|
115
|
+
@entry_points = []
|
|
116
|
+
@declared_ancestry = {}
|
|
117
|
+
contributions.each { |contribution| absorb(contribution) }
|
|
118
|
+
@superclasses = superclasses || NO_ROWS
|
|
119
|
+
@includes = includes || NO_ROWS
|
|
120
|
+
@ancestry = {}
|
|
121
|
+
@digest = compute_digest
|
|
122
|
+
finalize
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A content digest of every compiled plugin fact — labels, attributions, edges and presets, each with
|
|
126
|
+
# the plugin that contributed it. {Identity} folds it in, so upgrading a plugin whose rows moved
|
|
127
|
+
# invalidates the effects slot exactly as a re-audited `data/effects/core.yml` row does. Deliberately
|
|
128
|
+
# independent of the project's superclass table, which is a *project* input the diagnostics identity
|
|
129
|
+
# already covers.
|
|
130
|
+
attr_reader :digest
|
|
131
|
+
|
|
132
|
+
def empty?
|
|
133
|
+
!attributions? && @edges.empty? && @labels_by_owner.empty? && @entry_points.empty?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Whether any attribution row exists at all — the scan's fast path, asked once per call site.
|
|
137
|
+
def attributions?
|
|
138
|
+
!@class_rows.empty? || !@path_rows.empty? || !@self_rows.empty? || !@result_rows.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def edges?
|
|
142
|
+
!@edges.empty?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The row colouring `owner`'s `selector`, found on `owner` itself or on a project ancestor of it.
|
|
146
|
+
#
|
|
147
|
+
# @param owner [String, nil] the receiver's class name as the syntax or the typer named it
|
|
148
|
+
# @param singleton [Boolean] whether the call is `Owner.selector`
|
|
149
|
+
# @return [Row, nil]
|
|
150
|
+
def class_row(owner, singleton, selector)
|
|
151
|
+
return nil if owner.nil? || @class_rows.empty?
|
|
152
|
+
|
|
153
|
+
bucket = @class_rows[singleton]
|
|
154
|
+
return nil if bucket.nil?
|
|
155
|
+
|
|
156
|
+
ancestry(owner).each do |candidate|
|
|
157
|
+
row = bucket[candidate]&.[](selector)
|
|
158
|
+
return row if row
|
|
159
|
+
end
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The row colouring `path`'s `selector`, where `path` is a receiver expression (`"Rails.cache"`).
|
|
164
|
+
# Exact — a receiver path names one object and has no ancestry to walk.
|
|
165
|
+
def path_row(path, selector)
|
|
166
|
+
return nil if path.nil? || @path_rows.empty?
|
|
167
|
+
|
|
168
|
+
@path_rows[path]&.[](selector)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The row colouring `path`'s `selector` for a receiver rooted at implicit self (`"self.flash.now"`),
|
|
172
|
+
# inside a unit whose class is `owner_class`. Answers nil when the row's `within:` class is not on
|
|
173
|
+
# `owner_class`'s project ancestry — a receiver-less `session` outside a controller is a different
|
|
174
|
+
# `session`.
|
|
175
|
+
def self_path_row(path, selector, owner_class)
|
|
176
|
+
return nil if path.nil? || @self_rows.empty?
|
|
177
|
+
|
|
178
|
+
row = @self_rows[path]&.[](selector)
|
|
179
|
+
return nil if row.nil?
|
|
180
|
+
return nil unless descends_from?(owner_class, row.within)
|
|
181
|
+
|
|
182
|
+
row
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The row colouring `selector` on the RESULT of a call to `producer` (or to a project ancestor of it):
|
|
186
|
+
# `UserMailer.welcome(u).deliver_now`, `WelcomeJob.set(wait: 1.hour).perform_later`. The lazy object
|
|
187
|
+
# in between has no declared type; the class that made it is written in the source.
|
|
188
|
+
def result_row(producer, selector)
|
|
189
|
+
return nil if producer.nil? || @result_rows.empty?
|
|
190
|
+
|
|
191
|
+
ancestry(producer).each do |candidate|
|
|
192
|
+
row = @result_rows[candidate]&.[](selector)
|
|
193
|
+
return row if row
|
|
194
|
+
end
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The framework-edge strategies of one kind, e.g. every `:activerecord_callbacks` base class the
|
|
199
|
+
# loaded plugins named.
|
|
200
|
+
def edges_for(target)
|
|
201
|
+
@edges.select { |edge| edge.target == target }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Whether `class_name` is `ancestor`, or reaches it through the project's own `class … <` lines.
|
|
205
|
+
def descends_from?(class_name, ancestor)
|
|
206
|
+
return false if class_name.nil?
|
|
207
|
+
|
|
208
|
+
ancestry(class_name).include?(ancestor)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# `registry` extended with every plugin's `effect_labels:`, each under its own owner so
|
|
212
|
+
# {Registry#with} enforces root ownership per plugin rather than for the set as a whole. A refusal is
|
|
213
|
+
# recorded as a warning and that plugin's labels are dropped; the rest of the run keeps its
|
|
214
|
+
# vocabulary, because one plugin overreaching must not un-name another's labels.
|
|
215
|
+
def extend_registry(registry)
|
|
216
|
+
@labels_by_owner.each do |owner, labels|
|
|
217
|
+
registry = registry.with(labels: labels, owner: owner)
|
|
218
|
+
rescue Registry::Error => e
|
|
219
|
+
@warnings << "effect labels from #{owner.inspect} were not registered: #{e.message}"
|
|
220
|
+
end
|
|
221
|
+
registry
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def absorb(contribution)
|
|
227
|
+
note_root_demotion(contribution)
|
|
228
|
+
(@labels_by_owner[contribution.owner] ||= []).concat(contribution.labels)
|
|
229
|
+
contribution.attributions.each { |entry| absorb_attribution(contribution, entry) }
|
|
230
|
+
contribution.edges.each do |entry|
|
|
231
|
+
@edges << Edge.new(target: entry.target, receiver: entry.receiver, selector: entry.method,
|
|
232
|
+
plugin_id: contribution.id)
|
|
233
|
+
end
|
|
234
|
+
@entry_points.concat(contribution.entry_points)
|
|
235
|
+
contribution.ancestry.each { |entry| absorb_ancestry(contribution, entry) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ADR-103 WD17 (#465) — an ancestry edge the plugin's gem introduces, which the project's own
|
|
239
|
+
# source never writes.
|
|
240
|
+
#
|
|
241
|
+
# **Bundled plugins only**, and this is the one place it is enforced. A claim carries no labels of
|
|
242
|
+
# its own, which is what makes it look harmless: what it does is make *other* plugins' rows
|
|
243
|
+
# reachable, so a third-party plugin asserting `Foo < ActiveRecord::Base` would pull
|
|
244
|
+
# rigor-activerecord's first-party discharging rows onto `Foo`. The grant is the same fact
|
|
245
|
+
# {#discharge_granted?} reads — that the engine bundles this plugin — and the refusal is warned
|
|
246
|
+
# about rather than silent, because a claim that quietly did nothing would read to its author as
|
|
247
|
+
# the rows having vanished.
|
|
248
|
+
def absorb_ancestry(contribution, entry)
|
|
249
|
+
unless contribution.discharge_allowed
|
|
250
|
+
@warnings << "plugin #{contribution.id.inspect} is not bundled with the engine; its " \
|
|
251
|
+
"#{entry.child} < #{entry.parent} ancestry claim is ignored"
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
(@declared_ancestry[entry.child] ||= []) << entry.parent
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# ADR-103 WD2 — a plugin that asked to own a framework root and is not one the engine bundles keeps
|
|
259
|
+
# the root named after itself. Worth saying out loud: the labels still register, just under a
|
|
260
|
+
# different root, and a silent rename would look to the author like the labels vanished.
|
|
261
|
+
def note_root_demotion(contribution)
|
|
262
|
+
return if contribution.requested_root.nil? || contribution.requested_root == contribution.owner
|
|
263
|
+
|
|
264
|
+
@warnings << "plugin #{contribution.id.inspect} is not bundled with the engine and may not open " \
|
|
265
|
+
"the effect-label root #{contribution.requested_root.inspect}; its labels open " \
|
|
266
|
+
"#{contribution.owner.inspect} instead"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def absorb_attribution(contribution, entry)
|
|
270
|
+
row = Row.new(key: entry.key, labels: LabelSet.new(entry.labels), narrow: entry.narrow,
|
|
271
|
+
discharge: discharge_granted?(contribution, entry), within: entry.within,
|
|
272
|
+
taint: entry.taint, plugin_id: contribution.id)
|
|
273
|
+
(bucket_for(entry)[entry.receiver] ||= {})[entry.method.to_s] = row
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Which index a row lands in, from its receiver spelling.
|
|
277
|
+
def bucket_for(entry)
|
|
278
|
+
return @self_rows if entry.self_path?
|
|
279
|
+
return @path_rows if entry.receiver_path?
|
|
280
|
+
return @result_rows if entry.on_result
|
|
281
|
+
|
|
282
|
+
@class_rows[entry.singleton] ||= {}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# ADR-103 WD6 — discharge is a grant, and the granting fact is "the engine bundles this plugin".
|
|
286
|
+
def discharge_granted?(contribution, entry)
|
|
287
|
+
return false unless entry.discharge
|
|
288
|
+
return true if contribution.discharge_allowed
|
|
289
|
+
|
|
290
|
+
@warnings << "plugin #{contribution.id.inspect} is not bundled with the engine; its " \
|
|
291
|
+
"#{entry.key} attribution does not discharge the call site's taint"
|
|
292
|
+
false
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# `[class_name, …ancestors]`, memoised per class. Cycle-guarded and capped: the tables are *as
|
|
296
|
+
# written*, so a project can spell an ancestry that loops.
|
|
297
|
+
#
|
|
298
|
+
# Included and prepended modules are walked beside the superclass, nearer first, because that is
|
|
299
|
+
# Ruby's own lookup order and because a framework's contract is as often a module as a base class:
|
|
300
|
+
# a Sidekiq worker is `class TriggerWebhookWorker; include Sidekiq::Job`, with no base class at all,
|
|
301
|
+
# so a superclass-only walk could not match a row about it however the plugin spelled one (#456).
|
|
302
|
+
# `ExpressionTyper#enqueue_ancestors` already resolves a self-call this way; the two tables
|
|
303
|
+
# disagreeing about what an ancestor is was the bug.
|
|
304
|
+
def ancestry(class_name)
|
|
305
|
+
@ancestry[class_name] ||= begin
|
|
306
|
+
chain = []
|
|
307
|
+
seen = Set.new
|
|
308
|
+
queue = [class_name]
|
|
309
|
+
until queue.empty? || chain.length >= ANCESTRY_CAP
|
|
310
|
+
current = queue.shift
|
|
311
|
+
next unless current && seen.add?(current)
|
|
312
|
+
|
|
313
|
+
chain << current
|
|
314
|
+
queue.concat(Array(@includes[current]).compact)
|
|
315
|
+
parent = Array(@superclasses[current]).first
|
|
316
|
+
queue << parent if parent
|
|
317
|
+
# Last, so a project's own declaration always wins the first-match race against a plugin's
|
|
318
|
+
# claim about a gem (#465).
|
|
319
|
+
queue.concat(Array(@declared_ancestry[current]))
|
|
320
|
+
end
|
|
321
|
+
chain.freeze
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def compute_digest
|
|
326
|
+
payload = [
|
|
327
|
+
@labels_by_owner.sort.map { |owner, labels| [owner, labels.sort] },
|
|
328
|
+
@class_rows.keys.sort_by { |singleton| singleton ? 1 : 0 }
|
|
329
|
+
.map { |singleton| [singleton, sorted(@class_rows[singleton])] },
|
|
330
|
+
sorted(@path_rows), sorted(@self_rows), sorted(@result_rows),
|
|
331
|
+
@edges.map { |edge| [edge.target.to_s, edge.receiver, edge.selector.to_s, edge.plugin_id] }.sort,
|
|
332
|
+
@entry_points.map(&:to_h).sort_by { |preset| preset["name"] },
|
|
333
|
+
@declared_ancestry.sort.map { |child, parents| [child, parents.sort] }
|
|
334
|
+
]
|
|
335
|
+
Digest::SHA256.hexdigest(payload.inspect)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# ADR-103 WD14 — `effects.snapshot.reach: [rails]` adopts a preset BY NAME, so the names have to be
|
|
339
|
+
# registered somewhere between plugin load and snapshot build. Here is that somewhere: this object is
|
|
340
|
+
# built once per process from the loaded plugin set, which is exactly the condition the registration
|
|
341
|
+
# needs. {EntryPoints.register} is idempotent for an identical glob set, so a second run in one
|
|
342
|
+
# process is a no-op; two plugins claiming one name with DIFFERENT globs is a genuine conflict and
|
|
343
|
+
# becomes a warning rather than taking the run down over a `reach:` key nobody may have used.
|
|
344
|
+
def register_entry_points
|
|
345
|
+
return if @entry_points.empty?
|
|
346
|
+
|
|
347
|
+
EntryPoints.register_all(@entry_points)
|
|
348
|
+
rescue EntryPoints::Error => e
|
|
349
|
+
@warnings << "entry-point preset not registered: #{e.message}"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def sorted(bucket)
|
|
353
|
+
bucket.sort.map do |receiver, rows|
|
|
354
|
+
[receiver,
|
|
355
|
+
rows.sort.map do |selector, row|
|
|
356
|
+
[selector, row.labels.to_a, row.narrow, row.discharge, row.within, row.taint]
|
|
357
|
+
end]
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def finalize
|
|
362
|
+
@class_rows.each_value { |bucket| bucket.each_value(&:freeze) }
|
|
363
|
+
@class_rows.each_value(&:freeze)
|
|
364
|
+
@class_rows.freeze
|
|
365
|
+
@path_rows.each_value(&:freeze)
|
|
366
|
+
@path_rows.freeze
|
|
367
|
+
@self_rows.each_value(&:freeze)
|
|
368
|
+
@self_rows.freeze
|
|
369
|
+
@result_rows.each_value(&:freeze)
|
|
370
|
+
@result_rows.freeze
|
|
371
|
+
@edges.freeze
|
|
372
|
+
@labels_by_owner.each_value(&:uniq!)
|
|
373
|
+
@labels_by_owner.reject! { |_, labels| labels.empty? }
|
|
374
|
+
@labels_by_owner.freeze
|
|
375
|
+
@entry_points.freeze
|
|
376
|
+
register_entry_points
|
|
377
|
+
# `@warnings` and `@ancestry` stay mutable: the first collects the registry-extension refusals that
|
|
378
|
+
# can only be known when a vocabulary is folded, and the second is a per-process memo. The object is
|
|
379
|
+
# therefore not frozen — it is process-local by construction (each fork-pool worker builds its own),
|
|
380
|
+
# and nothing marshals it.
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|